also looking at this
feat: native trail maps and complete native mobile screens
#1017
60 files
+2505
-823
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -73,8 +73,12 @@ export interface NativeApi { | ||
| 73 | 73 | send: (path: string, method?: string, body?: unknown) => Promise<NativeResponse> |
| 74 | 74 | /** Native FormData carries on-device files through Craft's multipart transport. */ |
| 75 | 75 | upload: (path: string, form: FormData) => Promise<NativeResponse> |
| 76 | /** Keep a protected operation with the screen's loaded account; never retarget it. */ | |
| 77 | bindSession: () => Promise<NativeApi> | |
| 76 | 78 | } |
| 77 | 79 | |
| 80 | interface NativeSessionBinding { account: string, bearer: string } | |
| 81 | ||
| 78 | 82 | /** |
| 79 | 83 | * A token reader and sender bound to one screen. |
| 80 | 84 | * |
| @@ -89,6 +93,15 @@ export function createNativeApi(host: NativeApiHost, origin = nativeApiOrigin(ho | ||
| 89 | 93 | let reading: Promise<string> | null = null |
| 90 | 94 | let session = '' |
| 91 | 95 | const sessionKey = (): string => host.snapshots ? JSON.stringify(host.snapshots.get('wildloop-session')) ?? 'null' : '' |
| 96 | const accountChanged = (): Error => new Error('Your account changed. Please try again.') | |
| 97 | const assertSession = async (binding: NativeSessionBinding): Promise<string> => { | |
| 98 | if (binding.account !== sessionKey()) throw accountChanged() | |
| 99 | const current = await host.secureStorage.get('auth_token') | |
| 100 | // The keychain can rotate before the shared account snapshot is published, | |
| 101 | // and cold starts can have a bearer without any snapshot yet. | |
| 102 | if (binding.account !== sessionKey() || current !== binding.bearer) throw accountChanged() | |
| 103 | return binding.bearer | |
| 104 | } | |
| 92 | 105 | const readToken = async (): Promise<string> => { |
| 93 | 106 | const current = sessionKey() |
| 94 | 107 | if (!host.snapshots || session !== current) { |
| @@ -120,11 +133,11 @@ export function createNativeApi(host: NativeApiHost, origin = nativeApiOrigin(ho | ||
| 120 | 133 | return { 'X-CSRF-Token': token, Cookie: `X-CSRF-Token=${token}` } |
| 121 | 134 | } |
| 122 | 135 | |
| 123 | const request = async (path: string, method: string, body: string | FormData | undefined, json: boolean): Promise<NativeResponse> => { | |
| 124 | const account = sessionKey() | |
| 125 | const bearer = await readToken() | |
| 136 | const request = async (path: string, method: string, body: string | FormData | undefined, json: boolean, binding?: NativeSessionBinding): Promise<NativeResponse> => { | |
| 137 | const account = binding?.account ?? sessionKey() | |
| 138 | const bearer = binding ? await assertSession(binding) : await readToken() | |
| 126 | 139 | const csrf = !bearer && !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()) ? await guestMutationHeaders() : {} |
| 127 | if (account !== sessionKey()) throw new Error('Your account changed. Please try again.') | |
| 140 | if (account !== sessionKey()) throw accountChanged() | |
| 128 | 141 | const response = await fetch(`${origin}${path}`, { |
| 129 | 142 | method, |
| 130 | 143 | headers: { ...nativeHeaders(bearer, json), ...csrf }, |
| @@ -133,14 +146,35 @@ export function createNativeApi(host: NativeApiHost, origin = nativeApiOrigin(ho | ||
| 133 | 146 | // A body that is not JSON is not an exception — an error page is text, and |
| 134 | 147 | // the status is what the screen reads anyway. |
| 135 | 148 | const payload = await response.json().catch(() => null) |
| 136 | if (account !== sessionKey()) throw new Error('Your account changed. Please try again.') | |
| 149 | if (binding) await assertSession(binding) | |
| 150 | else if (account !== sessionKey()) throw accountChanged() | |
| 137 | 151 | return { ok: response.ok, status: response.status, body: payload } |
| 138 | 152 | } |
| 139 | 153 | |
| 154 | const bindSession = async (): Promise<NativeApi> => { | |
| 155 | const account = sessionKey() | |
| 156 | if (reading && session !== account) throw accountChanged() | |
| 157 | // Anchor an already-loaded screen to its last bearer, even on a host | |
| 158 | // without snapshots. Reading a fresh bearer here would retarget its draft. | |
| 159 | const bearer = await (reading ?? readToken()) | |
| 160 | if (account !== sessionKey()) throw accountChanged() | |
| 161 | if (!bearer) throw new Error('Sign in before saving changes.') | |
| 162 | const binding = { account, bearer } | |
| 163 | await assertSession(binding) | |
| 164 | const bound: NativeApi = { | |
| 165 | readToken: () => assertSession(binding), | |
| 166 | send: (path, method = 'GET', body) => request(path, method, body === undefined ? undefined : JSON.stringify(body), body !== undefined, binding), | |
| 167 | upload: (path, form) => request(path, 'POST', form, false, binding), | |
| 168 | bindSession: async () => { await assertSession(binding); return bound }, | |
| 169 | } | |
| 170 | return bound | |
| 171 | } | |
| 172 | ||
| 140 | 173 | return { |
| 141 | 174 | readToken, |
| 142 | 175 | send: (path, method = 'GET', body) => request(path, method, body === undefined ? undefined : JSON.stringify(body), body !== undefined), |
| 143 | 176 | upload: (path, form) => request(path, 'POST', form, false), |
| 177 | bindSession, | |
| 144 | 178 | } |
| 145 | 179 | } |
| 146 | 180 | |