also looking at this
fix(android): hand the launch link to the page that subscribes for it
#232
17 files
+776
-136
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.
| @@ -23,17 +23,22 @@ function pageScript(): string { | ||
| 23 | 23 | |
| 24 | 24 | interface Post { action: string, callbackId?: string, [key: string]: unknown } |
| 25 | 25 | |
| 26 | function loadPage() { | |
| 26 | type Listener = (event: { type: string, detail: unknown }) => void | |
| 27 | ||
| 28 | /** `beforeInject` runs first, the way a page's own script runs before WebKit's didFinish injects the bridge. */ | |
| 29 | function loadPage(beforeInject?: (page: Record<string, any>) => void) { | |
| 27 | 30 | const posts: Post[] = [] |
| 28 | const listeners: Record<string, ((event: { type: string, detail: unknown }) => void)[]> = {} | |
| 31 | const listeners: Record<string, Listener[]> = {} | |
| 29 | 32 | const page: Record<string, any> = { |
| 30 | 33 | webkit: { messageHandlers: { craft: { postMessage: (message: Post) => posts.push(message) } } }, |
| 31 | addEventListener: (type: string, listener: (event: { type: string, detail: unknown }) => void) => { | |
| 34 | addEventListener: (type: string, listener: Listener) => { | |
| 32 | 35 | (listeners[type] ||= []).push(listener) |
| 33 | 36 | }, |
| 34 | removeEventListener: () => {}, | |
| 37 | removeEventListener: (type: string, listener: Listener) => { | |
| 38 | listeners[type] = (listeners[type] || []).filter(existing => existing !== listener) | |
| 39 | }, | |
| 35 | 40 | dispatchEvent: (event: { type: string, detail: unknown }) => { |
| 36 | for (const listener of listeners[event.type] || []) listener(event) | |
| 41 | for (const listener of [...(listeners[event.type] || [])]) listener(event) | |
| 37 | 42 | return true |
| 38 | 43 | }, |
| 39 | 44 | location: { href: 'craft://app/' }, |
| @@ -47,6 +52,7 @@ function loadPage() { | ||
| 47 | 52 | } |
| 48 | 53 | } |
| 49 | 54 | const quiet = { log() {}, warn() {}, error() {} } |
| 55 | beforeInject?.(page) | |
| 50 | 56 | // eslint-disable-next-line no-new-func |
| 51 | 57 | new Function('window', 'document', 'navigator', 'CustomEvent', 'console', pageScript())( |
| 52 | 58 | page, |
| @@ -69,6 +75,9 @@ function loadPage() { | ||
| 69 | 75 | // What Swift's resolveCallback and rejectCallback evaluate. |
| 70 | 76 | answer: (action: string, value: unknown) => craft._resolveCallback(last(action).callbackId, value), |
| 71 | 77 | refuse: (action: string, message: string, code: string) => craft._rejectCallback(last(action).callbackId, message, code), |
| 78 | // What DeepLinkManager.dispatchDeepLink evaluates, once the script has run. | |
| 79 | link: (url: string, initial: boolean) => | |
| 80 | page.dispatchEvent(new CustomEvent('craftDeepLink', { detail: { url, initial } })), | |
| 72 | 81 | } |
| 73 | 82 | } |
| 74 | 83 | |
| @@ -144,4 +153,36 @@ describe('the injected iOS page script', () => { | ||
| 144 | 153 | await expect(settled).rejects.toMatchObject({ code: 'NATIVE_CALL_FAILED' }) |
| 145 | 154 | }) |
| 146 | 155 | } |
| 156 | ||
| 157 | // #198 and #215. The Android page script runs the same block; its own test | |
| 158 | // covers the rest of the contract. | |
| 159 | const tick = () => new Promise(resolve => setTimeout(resolve, 5)) | |
| 160 | ||
| 161 | it('hands the launch link to a page that subscribes after it arrived', async () => { | |
| 162 | const page = loadPage() | |
| 163 | page.link('app://launch', true) | |
| 164 | ||
| 165 | const seen: unknown[] = [] | |
| 166 | page.craft.deepLinks.onLink((detail: unknown) => seen.push(detail)) | |
| 167 | await tick() | |
| 168 | ||
| 169 | expect(seen).toEqual([{ url: 'app://launch', initial: true }]) | |
| 170 | }) | |
| 171 | ||
| 172 | it('delivers the launch link once to a craftReady handler that asks for it both ways', async () => { | |
| 173 | // The handler runs inside the script, before native has dispatched the | |
| 174 | // link at all, so there is nothing held yet for getInitialURL to claim. | |
| 175 | const seen: unknown[] = [] | |
| 176 | const page = loadPage((window) => { | |
| 177 | window.addEventListener('craftReady', () => { | |
| 178 | void window.craft.deepLinks.getInitialURL() | |
| 179 | window.craft.deepLinks.onLink((detail: unknown) => seen.push(detail)) | |
| 180 | }) | |
| 181 | }) | |
| 182 | page.link('app://launch', true) | |
| 183 | await tick() | |
| 184 | ||
| 185 | expect(seen).toEqual([]) | |
| 186 | expect(page.last('getInitialURL').callbackId).toMatch(/^cb_\d+$/) | |
| 187 | }) | |
| 147 | 188 | }) |
| @@ -2907,24 +2907,35 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2907 | 2907 | // |
| 2908 | 2908 | // Held here instead, and handed to the first subscriber on the |
| 2909 | 2909 | // next turn, so an unsubscribe returned in the same tick still |
| 2910 | // applies. The launch link is withdrawn when the page calls | |
| 2911 | // getInitialURL, its other way of receiving it, so a page that | |
| 2912 | // does both in the same tick, in either order, gets it once. | |
| 2910 | // applies. The launch link belongs to getInitialURL once the page | |
| 2911 | // has called it, whether native has dispatched it yet or not, so a | |
| 2912 | // page that does both in the same tick, in either order, gets it | |
| 2913 | // once. That includes a craftReady handler, which runs before | |
| 2914 | // native dispatches anything. A page that asks getInitialURL only | |
| 2915 | // later, after an await, should skip `initial` in onLink. | |
| 2916 | // | |
| 2917 | // The same block runs on Android (#215), where this script can run | |
| 2918 | // twice in one document, so its state lives on window. | |
| 2913 | 2919 | (function installDeepLinkReplay(craft) { |
| 2914 | var undelivered = []; | |
| 2915 | var subscribed = false; | |
| 2916 | window.addEventListener('craftDeepLink', function(e) { | |
| 2917 | if (!subscribed) undelivered.push(e.detail); | |
| 2918 | }); | |
| 2920 | var replay = window.__craftDeepLinkReplay; | |
| 2921 | if (!replay) { | |
| 2922 | replay = window.__craftDeepLinkReplay = {undelivered: [], subscribed: false, initialClaimed: false}; | |
| 2923 | window.addEventListener('craftDeepLink', function(e) { | |
| 2924 | if (!replay.subscribed && !claimed(e.detail)) replay.undelivered.push(e.detail); | |
| 2925 | }); | |
| 2926 | } | |
| 2927 | function claimed(detail) { | |
| 2928 | return replay.initialClaimed && detail && detail.initial; | |
| 2929 | } | |
| 2919 | 2930 | craft._subscribeDeepLinks = function(callback) { |
| 2920 | 2931 | var active = true; |
| 2921 | var listener = function(e) { callback(e.detail); }; | |
| 2932 | var listener = function(e) { if (!claimed(e.detail)) callback(e.detail); }; | |
| 2922 | 2933 | window.addEventListener('craftDeepLink', listener); |
| 2923 | if (!subscribed) { | |
| 2924 | subscribed = true; | |
| 2934 | if (!replay.subscribed) { | |
| 2935 | replay.subscribed = true; | |
| 2925 | 2936 | setTimeout(function() { |
| 2926 | var pending = undelivered; | |
| 2927 | undelivered = []; | |
| 2937 | var pending = replay.undelivered; | |
| 2938 | replay.undelivered = []; | |
| 2928 | 2939 | if (!active) return; |
| 2929 | 2940 | pending.forEach(function(detail) { callback(detail); }); |
| 2930 | 2941 | }, 0); |
| @@ -2935,7 +2946,8 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2935 | 2946 | }; |
| 2936 | 2947 | }; |
| 2937 | 2948 | craft._claimInitialDeepLink = function() { |
| 2938 | undelivered = undelivered.filter(function(detail) { return !(detail && detail.initial); }); | |
| 2949 | replay.initialClaimed = true; | |
| 2950 | replay.undelivered = replay.undelivered.filter(function(detail) { return !(detail && detail.initial); }); | |
| 2939 | 2951 | }; |
| 2940 | 2952 | })(window.craft); |
| 2941 | 2953 | |
| @@ -51,6 +51,9 @@ function setupEventListeners(): void { | ||
| 51 | 51 | // Deep link handling |
| 52 | 52 | if (craft.deepLinks) { |
| 53 | 53 | craft.deepLinks.onLink((data: DeepLinkData) => { |
| 54 | // initializeFeatures() asks getInitialURL() for the launch link after | |
| 55 | // other awaits, so it would arrive here too. | |
| 56 | if (data.initial) return; | |
| 54 | 57 | console.log('Deep link received:', data.url); |
| 55 | 58 | console.log('Host:', data.host); |
| 56 | 59 | console.log('Path:', data.path); |
| @@ -1286,18 +1286,24 @@ export const deepLinks = { | ||
| 1286 | 1286 | async getInitialURL(): Promise<string | null> { |
| 1287 | 1287 | return normalizeDeepLinkURL(await getCraftRoot()?.deepLinks?.getInitialURL?.()) |
| 1288 | 1288 | }, |
| 1289 | onLink(callback: (url: string) => void): () => void { | |
| 1289 | /** | |
| 1290 | * The second argument says whether this is the link that launched the app. | |
| 1291 | * A page that also calls `getInitialURL()` after an await gets that link | |
| 1292 | * from both, and can skip it here when `initial` is true. | |
| 1293 | */ | |
| 1294 | onLink(callback: (url: string, link: { initial: boolean }) => void): () => void { | |
| 1290 | 1295 | const craft = getCraftRoot() |
| 1291 | 1296 | if (craft?.deepLinks?.onLink) { |
| 1292 | 1297 | return craft.deepLinks.onLink((value: unknown) => { |
| 1293 | 1298 | const url = normalizeDeepLinkURL(value) |
| 1294 | if (url) callback(url) | |
| 1299 | if (url) callback(url, { initial: (value as { initial?: unknown } | null)?.initial === true }) | |
| 1295 | 1300 | }) ?? (() => {}) |
| 1296 | 1301 | } |
| 1297 | 1302 | if (typeof globalThis === 'undefined') return () => {} |
| 1298 | 1303 | const listener = (event: Event) => { |
| 1299 | const url = normalizeDeepLinkURL((event as CustomEvent<unknown>).detail) | |
| 1300 | if (url) callback(url) | |
| 1304 | const detail = (event as CustomEvent<unknown>).detail | |
| 1305 | const url = normalizeDeepLinkURL(detail) | |
| 1306 | if (url) callback(url, { initial: (detail as { initial?: unknown } | null)?.initial === true }) | |
| 1301 | 1307 | } |
| 1302 | 1308 | globalThis.addEventListener('craftDeepLink', listener) |
| 1303 | 1309 | return () => globalThis.removeEventListener('craftDeepLink', listener) |
| @@ -833,14 +833,16 @@ export interface CraftBridge { | ||
| 833 | 833 | /** |
| 834 | 834 | * Listen for deep links. |
| 835 | 835 | * |
| 836 | * On iOS the first subscriber is also handed, once, every link that | |
| 837 | * arrived before anything was subscribed, including the link that | |
| 838 | * launched the app (`initial: true`). A page that calls `getInitialURL()` | |
| 839 | * before subscribing, or in the same tick, gets the launch link from there | |
| 840 | * instead and not a second time here. Android does not replay yet (#215). | |
| 836 | * The first subscriber is also handed, once, every link that arrived | |
| 837 | * before anything was subscribed, including the link that launched the | |
| 838 | * app (`initial: true`). A page that calls `getInitialURL()` before | |
| 839 | * subscribing, in the same tick, or in the same `craftReady` handler, gets | |
| 840 | * the launch link from there instead and not a second time here. A page | |
| 841 | * that calls `getInitialURL()` only later, after an await, gets it from | |
| 842 | * both, and should skip `data.initial` here. | |
| 841 | 843 | * |
| 842 | 844 | * @param callback - Called when a deep link is received |
| 843 | * @returns On iOS, a function that unsubscribes | |
| 845 | * @returns A function that unsubscribes, on iOS and Android | |
| 844 | 846 | */ |
| 845 | 847 | onLink(callback: (data: DeepLinkData) => void): (() => void) | void; |
| 846 | 848 | }; |
| @@ -1025,7 +1027,7 @@ export interface DeepLinkData { | ||
| 1025 | 1027 | query: string; |
| 1026 | 1028 | /** Parsed query parameters */ |
| 1027 | 1029 | queryParams?: Record<string, string>; |
| 1028 | /** True for the link that launched the app (iOS) */ | |
| 1030 | /** True for the link that launched the app */ | |
| 1029 | 1031 | initial?: boolean; |
| 1030 | 1032 | } |
| 1031 | 1033 | |