@reclaimprotocol/client 0.1.29 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +255 -234
  2. package/lib/api.d.ts +2 -0
  3. package/lib/api.js +2 -0
  4. package/lib/client.js +2 -0
  5. package/lib/enckeys.d.ts +1 -1
  6. package/lib/enckeys.js +2 -2
  7. package/lib/encoding.d.ts +4 -0
  8. package/lib/encoding.js +10 -0
  9. package/lib/feature-flags.d.ts +9 -0
  10. package/lib/feature-flags.js +19 -0
  11. package/lib/index.d.ts +4 -4
  12. package/lib/index.js +4 -4
  13. package/lib/jwks.d.ts +8 -8
  14. package/lib/jwks.js +38 -17
  15. package/lib/launch/destination.d.ts +14 -0
  16. package/lib/launch/destination.js +89 -0
  17. package/lib/launch/device.d.ts +30 -0
  18. package/lib/launch/device.js +168 -0
  19. package/lib/launch/environment.d.ts +224 -0
  20. package/lib/launch/environment.js +98 -0
  21. package/lib/launch/errors.d.ts +20 -0
  22. package/lib/launch/errors.js +25 -0
  23. package/lib/launch/extension.d.ts +42 -0
  24. package/lib/launch/extension.js +72 -0
  25. package/lib/launch/flow.d.ts +82 -0
  26. package/lib/launch/flow.js +222 -0
  27. package/lib/launch/index.d.ts +16 -0
  28. package/lib/launch/index.js +12 -0
  29. package/lib/launch/mobile.d.ts +77 -0
  30. package/lib/launch/mobile.js +124 -0
  31. package/lib/launch/modal.d.ts +50 -0
  32. package/lib/launch/modal.js +258 -0
  33. package/lib/launch/options.d.ts +74 -0
  34. package/lib/launch/options.js +20 -0
  35. package/lib/launch/portal.d.ts +28 -0
  36. package/lib/launch/portal.js +68 -0
  37. package/lib/launch/qr.d.ts +5 -0
  38. package/lib/launch/qr.js +28 -0
  39. package/lib/launch/target.d.ts +46 -0
  40. package/lib/launch/target.js +128 -0
  41. package/lib/launch/vendor/qrcodegen.d.ts +134 -0
  42. package/lib/launch/vendor/qrcodegen.js +822 -0
  43. package/lib/legacy-proof.d.ts +2 -0
  44. package/lib/legacy-proof.js +37 -7
  45. package/lib/proof-hash.d.ts +5 -5
  46. package/lib/proof-hash.js +1 -1
  47. package/lib/proof-recipe.d.ts +3 -3
  48. package/lib/proof-recipe.js +1 -1
  49. package/lib/proof-types.d.ts +35 -8
  50. package/lib/reclaim.d.ts +81 -70
  51. package/lib/reclaim.js +79 -130
  52. package/lib/request-proof.d.ts +60 -0
  53. package/lib/request-proof.js +241 -0
  54. package/lib/result.d.ts +46 -38
  55. package/lib/result.js +160 -100
  56. package/lib/routes.gen.d.ts +76 -28
  57. package/lib/routes.gen.js +24 -11
  58. package/lib/tee-binding.d.ts +1 -1
  59. package/lib/types/openapi.d.ts +1 -0
  60. package/lib/types/openapi.gen.d.ts +1598 -485
  61. package/lib/types/openapi.gen.js +5 -0
  62. package/lib/verification-clients.d.ts +28 -14
  63. package/lib/verification-clients.js +31 -11
  64. package/lib/verify-attestor-tee.d.ts +19 -2
  65. package/lib/verify-attestor-tee.js +40 -4
  66. package/lib/verify-tee.d.ts +10 -1
  67. package/lib/verify-tee.js +34 -10
  68. package/package.json +5 -1
  69. package/src/api.ts +9 -0
  70. package/src/client.ts +2 -0
  71. package/src/enckeys.ts +2 -2
  72. package/src/encoding.ts +12 -0
  73. package/src/feature-flags.ts +39 -0
  74. package/src/index.ts +13 -14
  75. package/src/jwks.ts +52 -30
  76. package/src/launch/destination.ts +111 -0
  77. package/src/launch/device.ts +242 -0
  78. package/src/launch/environment.ts +337 -0
  79. package/src/launch/errors.ts +49 -0
  80. package/src/launch/extension.ts +124 -0
  81. package/src/launch/flow.ts +369 -0
  82. package/src/launch/index.ts +42 -0
  83. package/src/launch/mobile.ts +184 -0
  84. package/src/launch/modal.ts +378 -0
  85. package/src/launch/options.ts +99 -0
  86. package/src/launch/portal.ts +104 -0
  87. package/src/launch/qr.ts +37 -0
  88. package/src/launch/target.ts +150 -0
  89. package/src/launch/vendor/qrcodegen.ts +1034 -0
  90. package/src/proof-hash.ts +6 -6
  91. package/src/proof-recipe.ts +3 -3
  92. package/src/proof-types.ts +36 -8
  93. package/src/reclaim.ts +185 -218
  94. package/src/{legacy-proof.ts → request-proof.ts} +81 -36
  95. package/src/result.ts +238 -172
  96. package/src/routes.gen.ts +24 -11
  97. package/src/tee-binding.ts +1 -1
  98. package/src/types/openapi.gen.ts +1603 -485
  99. package/src/types/openapi.ts +3 -1
  100. package/src/verification-clients.ts +33 -19
  101. package/src/verify-attestor-tee.ts +66 -8
  102. package/src/verify-tee.ts +44 -13
  103. package/lib/api-client.d.ts +0 -31
  104. package/lib/api-client.js +0 -112
  105. package/lib/api-consts.d.ts +0 -4
  106. package/lib/api-consts.js +0 -4
  107. package/lib/api-errors.d.ts +0 -13
  108. package/lib/api-errors.js +0 -30
  109. package/lib/eip712.d.ts +0 -20
  110. package/lib/eip712.js +0 -108
@@ -0,0 +1,369 @@
1
+ /**
2
+ * `ReclaimFlow` — the public entry point. One constructor
3
+ * (`ReclaimFlow.from`), per the launch-API plan: no
4
+ * `fromSession`/`fromVerificationUrl`/`fromDescriptor` family.
5
+ *
6
+ * `resolveTarget()`/`launch()` decide the mechanism from the URL's client
7
+ * (see ./target.ts) and the device (see ./device.ts); `launch()` runs it
8
+ * (./portal.ts, ./mobile.ts, ./extension.ts, ./modal.ts) and hands back a
9
+ * `FlowHandle` that tears down whatever UI it created.
10
+ */
11
+ import type { VerificationSession } from '../types/openapi.ts'
12
+ import { appendSessionQuery, resolveDestinationUrl } from './destination.ts'
13
+ import type { DetectionConfidence, DeviceKind } from './device.ts'
14
+ import { resolveDevice } from './device.ts'
15
+ import type {
16
+ LaunchElement,
17
+ LaunchEnvironment,
18
+ LaunchWindowHandle
19
+ } from './environment.ts'
20
+ import { defaultEnvironment } from './environment.ts'
21
+ import type { ReclaimFlowUnavailableReason } from './errors.ts'
22
+ import { ReclaimFlowUnavailableError } from './errors.ts'
23
+ import {
24
+ probeExtension,
25
+ sendExtensionCancelVerification,
26
+ sendExtensionStartVerification,
27
+ } from './extension.ts'
28
+ import {
29
+ buildAndroidMarketIntentUrl,
30
+ redirectToAndroid,
31
+ redirectToAppClip,
32
+ redirectToIosDeepLink,
33
+ } from './mobile.ts'
34
+ import { showQrModal } from './modal.ts'
35
+ import type { FlowOptions } from './options.ts'
36
+ import {
37
+ DEFAULT_APP_CLIP_URL,
38
+ DEFAULT_IOS_APP_DOWNLOAD_URL,
39
+ DEFAULT_IOS_DEEP_LINK_BASE_URL,
40
+ resolveAndroidAppDownloadUrl,
41
+ resolveAndroidDeepLinkPackage,
42
+ resolveCanUseDeferredDeepLinksFlow,
43
+ } from './options.ts'
44
+ import { embedPortalIframe, openPortalTab } from './portal.ts'
45
+ import type { ParsedFlowUrl, VerificationTarget } from './target.ts'
46
+ import { parseFlowUrl } from './target.ts'
47
+
48
+ export type { FlowOptions } from './options.ts'
49
+ export type { VerificationTarget } from './target.ts'
50
+ export type { DetectionConfidence, DeviceKind } from './device.ts'
51
+ export {
52
+ ReclaimFlowUnavailableError,
53
+ isReclaimFlowUnavailableError,
54
+ } from './errors.ts'
55
+ export type { ReclaimFlowUnavailableReason } from './errors.ts'
56
+
57
+ /** A `verificationUrl` string, or any object carrying one — a full
58
+ * `VerificationSession` satisfies it. */
59
+ export type FlowInput =
60
+ | string
61
+ | VerificationSession
62
+ | { verificationUrl: string }
63
+
64
+ export type LaunchMechanism =
65
+ | 'new-tab'
66
+ | 'iframe'
67
+ | 'extension-handoff'
68
+ | 'qr-modal'
69
+ | 'ios-app-clip'
70
+ | 'ios-deep-link'
71
+ | 'android-deep-link'
72
+ | 'unavailable'
73
+
74
+ /** Which client and mechanism a flow resolves to on this device. Read-only —
75
+ * `resolveTarget()` changes nothing (see the plan's "Targets" section). */
76
+ export interface ResolvedTarget {
77
+ /** The client the URL names, or `undefined` for an unrecognised path
78
+ * (degrades to `mechanism: 'new-tab'`). */
79
+ client: VerificationTarget | undefined
80
+ device: DeviceKind
81
+ /** Exposed so a caller can render a chooser instead of trusting a
82
+ * borderline device-detection guess — see ./device.ts. */
83
+ confidence: DetectionConfidence
84
+ mechanism: LaunchMechanism
85
+ /** `false` only for an extension-bound session this device can't run
86
+ * (see `reason`); `launch()` rejects rather than substituting another
87
+ * mechanism. */
88
+ available: boolean
89
+ reason?: ReclaimFlowUnavailableReason
90
+ }
91
+
92
+ export interface FlowHandle {
93
+ /** Tears down the UI this launch created (tab/iframe/modal, or the
94
+ * extension's in-progress verification). Does not cancel the session —
95
+ * the Builder session outcome still arrives at the consumer's backend
96
+ * callback. */
97
+ close(): void
98
+ readonly target: ResolvedTarget
99
+ readonly iframe?: LaunchElement
100
+ readonly tab?: LaunchWindowHandle | null
101
+ }
102
+
103
+ function inputVerificationUrl(input: FlowInput): string {
104
+ const verificationUrl = typeof input === 'string'
105
+ ? input
106
+ : input.verificationUrl
107
+ if(!verificationUrl) {
108
+ throw new Error(
109
+ 'ReclaimFlow.from() input must be a verificationUrl string, or '
110
+ + 'an object carrying one',
111
+ )
112
+ }
113
+
114
+ return verificationUrl
115
+ }
116
+
117
+ export class ReclaimFlow {
118
+ readonly #input: FlowInput
119
+ #options: FlowOptions
120
+ #env: LaunchEnvironment
121
+ #parsed: ParsedFlowUrl | undefined
122
+
123
+ private constructor(input: FlowInput, options: FlowOptions) {
124
+ this.#input = input
125
+ this.#options = options
126
+ this.#env = options.environment ?? defaultEnvironment()
127
+ }
128
+
129
+ /** The only constructor — no `fromSession`/`fromVerificationUrl`/
130
+ * `fromDescriptor` family. */
131
+ static from(input: FlowInput, options: FlowOptions = {}): ReclaimFlow {
132
+ return new ReclaimFlow(input, options)
133
+ }
134
+
135
+ /** The `session`/object this flow was built from, or `undefined` when
136
+ * `from()` was given a bare `verificationUrl` string. */
137
+ get session(): VerificationSession | { verificationUrl: string } | undefined {
138
+ return typeof this.#input === 'string' ? undefined : this.#input
139
+ }
140
+
141
+ /** Merges `options` for later `getUrl()`/`resolveTarget()`/`launch()`
142
+ * calls. Chainable. */
143
+ setOptions(options: FlowOptions): this {
144
+ this.#options = { ...this.#options, ...options }
145
+ if(options.environment) {
146
+ this.#env = options.environment
147
+ }
148
+
149
+ // `verificationClientId` feeds target resolution (./target.ts), so a
150
+ // change invalidates the cached parse.
151
+ this.#parsed = undefined
152
+ return this
153
+ }
154
+
155
+ /** Resolves the target and returns the claimant URL, without navigating. */
156
+ async getUrl(): Promise<string> {
157
+ const parsed = this.#parse()
158
+ return resolveDestinationUrl(parsed.target, parsed, this.#options)
159
+ }
160
+
161
+ /** Which client and mechanism this flow will use on this device. A read:
162
+ * reports the mechanism and changes nothing (the one exception is a
163
+ * short-lived `postMessage` probe for an extension-bound session, which
164
+ * navigates nothing and shows no UI). */
165
+ async resolveTarget(): Promise<ResolvedTarget> {
166
+ return this.#resolve()
167
+ }
168
+
169
+ /** Opens it. Rejects with `ReclaimFlowUnavailableError` for an
170
+ * extension-bound session this device can't run, rather than silently
171
+ * falling back to another mechanism. */
172
+ async launch(): Promise<FlowHandle> {
173
+ const resolved = await this.#resolve()
174
+ if(!resolved.available) {
175
+ throw new ReclaimFlowUnavailableError(
176
+ resolved.reason ?? 'extension-not-found',
177
+ )
178
+ }
179
+
180
+ return this.#execute(resolved)
181
+ }
182
+
183
+ #parse(): ParsedFlowUrl {
184
+ this.#parsed ??= parseFlowUrl(
185
+ inputVerificationUrl(this.#input),
186
+ this.#options.verificationClientId,
187
+ )
188
+ return this.#parsed
189
+ }
190
+
191
+ async #resolve(): Promise<ResolvedTarget> {
192
+ const parsed = this.#parse()
193
+ const { device, confidence } = resolveDevice(this.#env)
194
+ const client = parsed.target
195
+
196
+ if(client === 'reclaim-browser-extension') {
197
+ return this.#resolveExtensionTarget(client, device, confidence)
198
+ }
199
+
200
+ if(client === 'verifier-app') {
201
+ let mechanism: LaunchMechanism
202
+ if(device === 'desktop') {
203
+ mechanism = 'qr-modal'
204
+ } else if(device === 'ios') {
205
+ mechanism = this.#options.useAppClip ? 'ios-app-clip' : 'ios-deep-link'
206
+ } else {
207
+ mechanism = 'android-deep-link'
208
+ }
209
+
210
+ return { client, device, confidence, mechanism, available: true }
211
+ }
212
+
213
+ // `builder` | `portals` | undefined (unrecognised path — degrades to a
214
+ // tab). iframe embedding is desktop-only, per the plan's mechanism
215
+ // table — mobile always gets a new tab even with `options.target` set.
216
+ const wantsIframe = !!client && device === 'desktop'
217
+ && !!this.#options.target
218
+ const mechanism: LaunchMechanism = wantsIframe ? 'iframe' : 'new-tab'
219
+ return { client, device, confidence, mechanism, available: true }
220
+ }
221
+
222
+ async #resolveExtensionTarget(
223
+ client: VerificationTarget,
224
+ device: DeviceKind,
225
+ confidence: DetectionConfidence,
226
+ ): Promise<ResolvedTarget> {
227
+ const unavailable = (
228
+ reason: ReclaimFlowUnavailableReason,
229
+ ): ResolvedTarget => ({
230
+ client,
231
+ device,
232
+ confidence,
233
+ mechanism: 'unavailable',
234
+ available: false,
235
+ reason,
236
+ })
237
+
238
+ if(device !== 'desktop') {
239
+ return unavailable('mobile-device')
240
+ }
241
+
242
+ if(this.#options.useBrowserExtension === false) {
243
+ return unavailable('extension-disabled')
244
+ }
245
+
246
+ const found = await probeExtension(this.#env, this.#options)
247
+ if(!found) {
248
+ return unavailable('extension-not-found')
249
+ }
250
+
251
+ return {
252
+ client,
253
+ device,
254
+ confidence,
255
+ mechanism: 'extension-handoff',
256
+ available: true,
257
+ }
258
+ }
259
+
260
+ #execute(resolved: ResolvedTarget): FlowHandle {
261
+ const parsed = this.#parse()
262
+
263
+ switch (resolved.mechanism) {
264
+ case 'extension-handoff': {
265
+ const handoff = {
266
+ sessionId: parsed.sessionId ?? '',
267
+ diag: parsed.diag,
268
+ extensionID: this.#options.extensionID,
269
+ }
270
+ sendExtensionStartVerification(this.#env, handoff)
271
+ return {
272
+ target: resolved,
273
+ close: () => sendExtensionCancelVerification(this.#env, handoff),
274
+ }
275
+ }
276
+
277
+ case 'qr-modal': {
278
+ const url = resolveDestinationUrl(parsed.target, parsed, this.#options)
279
+ const modal = showQrModal(this.#env, url, {
280
+ ...this.#options.modalOptions,
281
+ renderQr: this.#options.renderQr,
282
+ })
283
+ return { target: resolved, close: () => modal.close() }
284
+ }
285
+
286
+ case 'ios-app-clip': {
287
+ const appClipUrl = appendSessionQuery(
288
+ this.#options.appClipUrl ?? DEFAULT_APP_CLIP_URL,
289
+ parsed,
290
+ )
291
+ const handle = redirectToAppClip(this.#env, appClipUrl)
292
+ return { target: resolved, close: () => handle.close() }
293
+ }
294
+
295
+ case 'ios-deep-link': {
296
+ const deepLinkUrl = appendSessionQuery(
297
+ this.#options.iosDeepLinkBaseUrl ?? DEFAULT_IOS_DEEP_LINK_BASE_URL,
298
+ parsed,
299
+ )
300
+ const storeUrl = this.#options.iosAppDownloadUrl
301
+ ?? DEFAULT_IOS_APP_DOWNLOAD_URL
302
+ const handle = redirectToIosDeepLink(
303
+ this.#env, { deepLinkUrl, storeUrl },
304
+ )
305
+ return { target: resolved, close: () => handle.close() }
306
+ }
307
+
308
+ case 'android-deep-link': {
309
+ const shareUrl = resolveDestinationUrl(
310
+ parsed.target, parsed, this.#options,
311
+ )
312
+ const deepLinkUrl = appendSessionQuery(
313
+ this.#options.iosDeepLinkBaseUrl ?? DEFAULT_IOS_DEEP_LINK_BASE_URL,
314
+ parsed,
315
+ )
316
+ const marketIntentUrl = buildAndroidMarketIntentUrl(
317
+ shareUrl,
318
+ resolveAndroidDeepLinkPackage(this.#options),
319
+ )
320
+ const handle = redirectToAndroid(this.#env, {
321
+ deepLinkUrl,
322
+ marketIntentUrl,
323
+ shareUrl,
324
+ canUseDeferredDeepLinksFlow: resolveCanUseDeferredDeepLinksFlow(
325
+ this.#options, 'android',
326
+ ),
327
+ })
328
+ return { target: resolved, close: () => handle.close() }
329
+ }
330
+
331
+ case 'iframe': {
332
+ const url = resolveDestinationUrl(parsed.target, parsed, this.#options)
333
+ const container = this.#options.target
334
+ if(!container) {
335
+ // `#resolve()` only picks 'iframe' when `options.target` is set;
336
+ // kept total rather than asserting, in case options changed
337
+ // between `resolveTarget()` and `launch()`.
338
+ return this.#openNewTab(resolved, url)
339
+ }
340
+
341
+ const handle = embedPortalIframe(this.#env, url, container)
342
+ return {
343
+ target: resolved, iframe: handle.iframe, close: () => handle.close(),
344
+ }
345
+ }
346
+
347
+ case 'new-tab': {
348
+ const url = resolveDestinationUrl(parsed.target, parsed, this.#options)
349
+ return this.#openNewTab(resolved, url)
350
+ }
351
+
352
+ case 'unavailable':
353
+ // `launch()` already rejected before calling `#execute()`.
354
+ throw new ReclaimFlowUnavailableError(
355
+ resolved.reason ?? 'extension-not-found',
356
+ )
357
+ }
358
+ }
359
+
360
+ #openNewTab(resolved: ResolvedTarget, url: string): FlowHandle {
361
+ const handle = openPortalTab(this.#env, url, this.#options.preOpenedTab)
362
+ return { target: resolved, tab: handle.tab, close: () => handle.close() }
363
+ }
364
+ }
365
+
366
+ // Re-exported so a consumer building `androidAppDownloadUrl`/
367
+ // `androidDeepLinkPackage` defaults for display (e.g. an install-link
368
+ // banner) doesn't have to duplicate them.
369
+ export { resolveAndroidAppDownloadUrl, resolveAndroidDeepLinkPackage }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `@reclaimprotocol/client/launch` — a browser-only, credential-free
3
+ * launcher. Given a Builder `verificationUrl`, `ReclaimFlow` works out how
4
+ * to open it on this device (browser extension handoff, native app deep
5
+ * link/App Clip, portal/builder tab or iframe, or a QR modal) and hands
6
+ * back a `FlowHandle` to tear the UI down again.
7
+ *
8
+ * See `devtools/plans/client-launch-api.md` for the design this implements.
9
+ */
10
+ export {
11
+ ReclaimFlow,
12
+ ReclaimFlowUnavailableError,
13
+ isReclaimFlowUnavailableError,
14
+ } from './flow.ts'
15
+ export type {
16
+ DetectionConfidence,
17
+ DeviceKind,
18
+ FlowHandle,
19
+ FlowInput,
20
+ FlowOptions,
21
+ LaunchMechanism,
22
+ ReclaimFlowUnavailableReason,
23
+ ResolvedTarget,
24
+ VerificationTarget,
25
+ } from './flow.ts'
26
+ export { showQrModal } from './modal.ts'
27
+ export type { ModalHandle, ModalOptions } from './modal.ts'
28
+ export type { RenderQr } from './qr.ts'
29
+ export { defaultEnvironment } from './environment.ts'
30
+ export type {
31
+ LaunchClipboard,
32
+ LaunchContainer,
33
+ LaunchDocument,
34
+ LaunchElement,
35
+ LaunchEnvironment,
36
+ LaunchMessageEvent,
37
+ LaunchNavigator,
38
+ LaunchStyleDeclaration,
39
+ LaunchUserAgentData,
40
+ LaunchWindow,
41
+ LaunchWindowHandle,
42
+ } from './environment.ts'
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The `verifier-app` target's mobile mechanisms, ported from the reference
3
+ * JS SDK's `redirectToAppClip`/`redirectToiOSApp`/`redirectToInstantApp`
4
+ * (`Reclaim.ts` ~1663/~1752/~1773). Per the launch-API plan's mechanism
5
+ * table (not the reference SDK's own branching, which folds iOS into an
6
+ * Android-shaped fallback in one corner case the plan doesn't carry over):
7
+ *
8
+ * - iOS is exactly two mechanisms: `useAppClip` picks the App Clip, its
9
+ * absence picks the `reclaimverifier://` deep link with a 3s App Store
10
+ * fallback. Both redirect the current tab — there's no popup on a phone.
11
+ * - Android mirrors it: clipboard write, top-level `reclaimverifier://`
12
+ * navigation, Play Store market-intent fallback if the tab never
13
+ * backgrounds. Unless `canUseDeferredDeepLinksFlow` is false, in which
14
+ * case it's a plain redirect to the share URL.
15
+ *
16
+ * All delays go through `env.setTimeout`/`env.clearTimeout` (see
17
+ * ./environment.ts), so tests exercise the fallback paths with a fake
18
+ * scheduler instead of waiting on real 1.5s/3s timers.
19
+ */
20
+ import type { LaunchEnvironment } from './environment.ts'
21
+
22
+ /** How long to wait for the OS to hand off to the app before deciding it is
23
+ * not installed and sending the claimant to the Play Store. The tab
24
+ * backgrounding is the only signal a page gets, and it arrives quickly when
25
+ * it arrives at all. */
26
+ const ANDROID_STORE_FALLBACK_MS = 1500
27
+
28
+ export interface MobileRedirectHandle {
29
+ close(): void
30
+ }
31
+
32
+ function writeClipboardBestEffort(env: LaunchEnvironment, text: string): void {
33
+ env.navigator?.clipboard?.writeText(text)?.catch(() => {
34
+ // Clipboard access can be denied (permissions, insecure context); the
35
+ // deep link/store redirect below is the fallback either way.
36
+ })
37
+ }
38
+
39
+ /** iOS, `useAppClip: true`. A one-way redirect — nothing to probe or fall
40
+ * back from, so `close()` is a no-op. */
41
+ export function redirectToAppClip(
42
+ env: LaunchEnvironment,
43
+ appClipUrl: string,
44
+ ): MobileRedirectHandle {
45
+ env.navigate(appClipUrl)
46
+ return { close() {} }
47
+ }
48
+
49
+ /** iOS, `useAppClip: false` (the default): try the custom-scheme deep link,
50
+ * fall back to the App Store after 3s if the tab never backgrounded (i.e.
51
+ * the OS never handed off to the app). */
52
+ export function redirectToIosDeepLink(
53
+ env: LaunchEnvironment,
54
+ options: { deepLinkUrl: string, storeUrl: string },
55
+ ): MobileRedirectHandle {
56
+ writeClipboardBestEffort(env, options.deepLinkUrl)
57
+
58
+ let appOpened = false
59
+ const onVisibilityChange = () => {
60
+ if(env.document?.hidden) {
61
+ appOpened = true
62
+ }
63
+ }
64
+
65
+ env.document?.addEventListener(
66
+ 'visibilitychange', onVisibilityChange, { once: true },
67
+ )
68
+ env.navigate(options.deepLinkUrl)
69
+
70
+ const timeoutHandle = env.setTimeout(() => {
71
+ env.document?.removeEventListener('visibilitychange', onVisibilityChange)
72
+ if(!appOpened) {
73
+ env.navigate(options.storeUrl)
74
+ }
75
+ }, 3000)
76
+
77
+ return {
78
+ close() {
79
+ env.document?.removeEventListener('visibilitychange', onVisibilityChange)
80
+ env.clearTimeout(timeoutHandle)
81
+ },
82
+ }
83
+ }
84
+
85
+ /** Android. `canUseDeferredDeepLinksFlow` (default `true`) gates the
86
+ * clipboard-write + hidden-iframe probe + Play Store market-intent
87
+ * fallback; `false` is a plain redirect to the share URL. */
88
+ export function redirectToAndroid(
89
+ env: LaunchEnvironment,
90
+ options: {
91
+ deepLinkUrl: string
92
+ marketIntentUrl: string
93
+ shareUrl: string
94
+ canUseDeferredDeepLinksFlow: boolean
95
+ },
96
+ ): MobileRedirectHandle {
97
+ if(!options.canUseDeferredDeepLinksFlow) {
98
+ env.navigate(options.shareUrl)
99
+ return { close() {} }
100
+ }
101
+
102
+ writeClipboardBestEffort(env, options.shareUrl)
103
+
104
+ // Navigate the tab straight at the custom scheme, rather than probing it
105
+ // from a hidden iframe first. Chrome on Android has blocked custom-scheme
106
+ // navigation from an iframe for years — it fails silently, so the probe
107
+ // never opened the app and the Play Store fallback did all the work, even
108
+ // for someone who already had the app installed. A top-level navigation
109
+ // is the only thing the OS acts on, and it is what the iOS branch above
110
+ // has always done.
111
+ let appOpened = false
112
+ const onVisibilityChange = () => {
113
+ if(env.document?.hidden) {
114
+ appOpened = true
115
+ }
116
+ }
117
+
118
+ env.document?.addEventListener(
119
+ 'visibilitychange', onVisibilityChange, { once: true },
120
+ )
121
+ env.navigate(options.deepLinkUrl)
122
+
123
+ // Still here, still visible: the scheme resolved to nothing, so the app is
124
+ // not installed. Send them to the Play Store, carrying the referrer that
125
+ // survives the install.
126
+ const timeoutHandle = env.setTimeout(() => {
127
+ env.document?.removeEventListener('visibilitychange', onVisibilityChange)
128
+ if(!appOpened) {
129
+ env.navigate(options.marketIntentUrl)
130
+ }
131
+ }, ANDROID_STORE_FALLBACK_MS)
132
+
133
+ return {
134
+ close() {
135
+ env.document?.removeEventListener('visibilitychange', onVisibilityChange)
136
+ env.clearTimeout(timeoutHandle)
137
+ },
138
+ }
139
+ }
140
+
141
+ /** Builds the Play Store market-intent fallback URL, ported from
142
+ * `redirectToInstantApp`'s `deepLink` construction.
143
+ *
144
+ * The legacy JS SDK put the verification URL in `url=`. Play never read it —
145
+ * Play forwards exactly one parameter to the installed app, `referrer`, via
146
+ * the Install Referrer API, so `url=` has always been inert and only the
147
+ * clipboard carried a deferred install across the Play Store. This emits
148
+ * `referrer=` instead, which `reclaim-verifier-app`'s
149
+ * `checkInstallReferrerForLink` (`lib/widgets/link_handler.dart`) now reads
150
+ * via `PlayInstallReferrer.installReferrer`. `url=` is dropped rather than
151
+ * kept alongside `referrer=`: nothing in this codebase or the verifier app
152
+ * reads it (the app has no `queryParameters['url']` handling anywhere), so
153
+ * it would be dead weight carried forward for no reason.
154
+ *
155
+ * Exactly one layer of `encodeURIComponent` is applied to `shareUrl` here:
156
+ * - `referrer` is itself a query-string value inside this intent URI, so it
157
+ * needs at least one layer to survive the `?`/`&`/`=` characters in
158
+ * `shareUrl` without corrupting the outer intent.
159
+ * - Play decodes exactly one layer of percent-encoding off the referrer
160
+ * string before handing it to the app — the app's
161
+ * `_parseBuilderReferrerUri` documents this and tries the raw string
162
+ * first. One layer here round-trips to the literal share URL on the
163
+ * other side. The app also falls back to `Uri.decodeFull` if the raw
164
+ * string doesn't parse, tolerating an extra layer either way, but this
165
+ * is deliberately exactly one layer rather than leaning on that
166
+ * fallback.
167
+ *
168
+ * Play truncates the referrer string at roughly 1KB. A Builder verification
169
+ * URL is a UUID and two query parameters, comfortably under that — which is
170
+ * what makes this viable where the legacy SDK's base64 `template=` payload
171
+ * never could be (see the launch-api plan's "Install Referrer beats the
172
+ * clipboard"). Keep the clipboard write in `redirectToAndroid` regardless:
173
+ * it is still the only carrier for an install that did not come through
174
+ * Play, and the app still reads it. */
175
+ export function buildAndroidMarketIntentUrl(
176
+ shareUrl: string,
177
+ androidDeepLinkPackage: string,
178
+ ): string {
179
+ const encodedShareUrl = encodeURIComponent(shareUrl)
180
+ return `intent://details?id=${androidDeepLinkPackage}`
181
+ + `&referrer=${encodedShareUrl}`
182
+ + '#Intent;scheme=market;action=android.intent.action.VIEW;'
183
+ + 'package=com.android.vending;end;'
184
+ }