@reclaimprotocol/client 0.1.29 → 0.1.31

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 +1594 -488
  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 +1599 -488
  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,222 @@
1
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
2
+ if (kind === "m") throw new TypeError("Private method is not writable");
3
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
4
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
5
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
6
+ };
7
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
8
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
9
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
+ };
12
+ var _ReclaimFlow_instances, _ReclaimFlow_input, _ReclaimFlow_options, _ReclaimFlow_env, _ReclaimFlow_parsed, _ReclaimFlow_parse, _ReclaimFlow_resolve, _ReclaimFlow_resolveExtensionTarget, _ReclaimFlow_execute, _ReclaimFlow_openNewTab;
13
+ import { appendSessionQuery, resolveDestinationUrl } from "./destination.js";
14
+ import { resolveDevice } from "./device.js";
15
+ import { defaultEnvironment } from "./environment.js";
16
+ import { ReclaimFlowUnavailableError } from "./errors.js";
17
+ import { probeExtension, sendExtensionCancelVerification, sendExtensionStartVerification, } from "./extension.js";
18
+ import { buildAndroidMarketIntentUrl, redirectToAndroid, redirectToAppClip, redirectToIosDeepLink, } from "./mobile.js";
19
+ import { showQrModal } from "./modal.js";
20
+ import { DEFAULT_APP_CLIP_URL, DEFAULT_IOS_APP_DOWNLOAD_URL, DEFAULT_IOS_DEEP_LINK_BASE_URL, resolveAndroidAppDownloadUrl, resolveAndroidDeepLinkPackage, resolveCanUseDeferredDeepLinksFlow, } from "./options.js";
21
+ import { embedPortalIframe, openPortalTab } from "./portal.js";
22
+ import { parseFlowUrl } from "./target.js";
23
+ export { ReclaimFlowUnavailableError, isReclaimFlowUnavailableError, } from "./errors.js";
24
+ function inputVerificationUrl(input) {
25
+ const verificationUrl = typeof input === 'string'
26
+ ? input
27
+ : input.verificationUrl;
28
+ if (!verificationUrl) {
29
+ throw new Error('ReclaimFlow.from() input must be a verificationUrl string, or '
30
+ + 'an object carrying one');
31
+ }
32
+ return verificationUrl;
33
+ }
34
+ export class ReclaimFlow {
35
+ constructor(input, options) {
36
+ _ReclaimFlow_instances.add(this);
37
+ _ReclaimFlow_input.set(this, void 0);
38
+ _ReclaimFlow_options.set(this, void 0);
39
+ _ReclaimFlow_env.set(this, void 0);
40
+ _ReclaimFlow_parsed.set(this, void 0);
41
+ __classPrivateFieldSet(this, _ReclaimFlow_input, input, "f");
42
+ __classPrivateFieldSet(this, _ReclaimFlow_options, options, "f");
43
+ __classPrivateFieldSet(this, _ReclaimFlow_env, options.environment ?? defaultEnvironment(), "f");
44
+ }
45
+ /** The only constructor — no `fromSession`/`fromVerificationUrl`/
46
+ * `fromDescriptor` family. */
47
+ static from(input, options = {}) {
48
+ return new ReclaimFlow(input, options);
49
+ }
50
+ /** The `session`/object this flow was built from, or `undefined` when
51
+ * `from()` was given a bare `verificationUrl` string. */
52
+ get session() {
53
+ return typeof __classPrivateFieldGet(this, _ReclaimFlow_input, "f") === 'string' ? undefined : __classPrivateFieldGet(this, _ReclaimFlow_input, "f");
54
+ }
55
+ /** Merges `options` for later `getUrl()`/`resolveTarget()`/`launch()`
56
+ * calls. Chainable. */
57
+ setOptions(options) {
58
+ __classPrivateFieldSet(this, _ReclaimFlow_options, { ...__classPrivateFieldGet(this, _ReclaimFlow_options, "f"), ...options }, "f");
59
+ if (options.environment) {
60
+ __classPrivateFieldSet(this, _ReclaimFlow_env, options.environment, "f");
61
+ }
62
+ // `verificationClientId` feeds target resolution (./target.ts), so a
63
+ // change invalidates the cached parse.
64
+ __classPrivateFieldSet(this, _ReclaimFlow_parsed, undefined, "f");
65
+ return this;
66
+ }
67
+ /** Resolves the target and returns the claimant URL, without navigating. */
68
+ async getUrl() {
69
+ const parsed = __classPrivateFieldGet(this, _ReclaimFlow_instances, "m", _ReclaimFlow_parse).call(this);
70
+ return resolveDestinationUrl(parsed.target, parsed, __classPrivateFieldGet(this, _ReclaimFlow_options, "f"));
71
+ }
72
+ /** Which client and mechanism this flow will use on this device. A read:
73
+ * reports the mechanism and changes nothing (the one exception is a
74
+ * short-lived `postMessage` probe for an extension-bound session, which
75
+ * navigates nothing and shows no UI). */
76
+ async resolveTarget() {
77
+ return __classPrivateFieldGet(this, _ReclaimFlow_instances, "m", _ReclaimFlow_resolve).call(this);
78
+ }
79
+ /** Opens it. Rejects with `ReclaimFlowUnavailableError` for an
80
+ * extension-bound session this device can't run, rather than silently
81
+ * falling back to another mechanism. */
82
+ async launch() {
83
+ const resolved = await __classPrivateFieldGet(this, _ReclaimFlow_instances, "m", _ReclaimFlow_resolve).call(this);
84
+ if (!resolved.available) {
85
+ throw new ReclaimFlowUnavailableError(resolved.reason ?? 'extension-not-found');
86
+ }
87
+ return __classPrivateFieldGet(this, _ReclaimFlow_instances, "m", _ReclaimFlow_execute).call(this, resolved);
88
+ }
89
+ }
90
+ _ReclaimFlow_input = new WeakMap(), _ReclaimFlow_options = new WeakMap(), _ReclaimFlow_env = new WeakMap(), _ReclaimFlow_parsed = new WeakMap(), _ReclaimFlow_instances = new WeakSet(), _ReclaimFlow_parse = function _ReclaimFlow_parse() {
91
+ __classPrivateFieldSet(this, _ReclaimFlow_parsed, __classPrivateFieldGet(this, _ReclaimFlow_parsed, "f") ?? parseFlowUrl(inputVerificationUrl(__classPrivateFieldGet(this, _ReclaimFlow_input, "f")), __classPrivateFieldGet(this, _ReclaimFlow_options, "f").verificationClientId), "f");
92
+ return __classPrivateFieldGet(this, _ReclaimFlow_parsed, "f");
93
+ }, _ReclaimFlow_resolve = async function _ReclaimFlow_resolve() {
94
+ const parsed = __classPrivateFieldGet(this, _ReclaimFlow_instances, "m", _ReclaimFlow_parse).call(this);
95
+ const { device, confidence } = resolveDevice(__classPrivateFieldGet(this, _ReclaimFlow_env, "f"));
96
+ const client = parsed.target;
97
+ if (client === 'reclaim-browser-extension') {
98
+ return __classPrivateFieldGet(this, _ReclaimFlow_instances, "m", _ReclaimFlow_resolveExtensionTarget).call(this, client, device, confidence);
99
+ }
100
+ if (client === 'verifier-app') {
101
+ let mechanism;
102
+ if (device === 'desktop') {
103
+ mechanism = 'qr-modal';
104
+ }
105
+ else if (device === 'ios') {
106
+ mechanism = __classPrivateFieldGet(this, _ReclaimFlow_options, "f").useAppClip ? 'ios-app-clip' : 'ios-deep-link';
107
+ }
108
+ else {
109
+ mechanism = 'android-deep-link';
110
+ }
111
+ return { client, device, confidence, mechanism, available: true };
112
+ }
113
+ // `builder` | `portals` | undefined (unrecognised path — degrades to a
114
+ // tab). iframe embedding is desktop-only, per the plan's mechanism
115
+ // table — mobile always gets a new tab even with `options.target` set.
116
+ const wantsIframe = !!client && device === 'desktop'
117
+ && !!__classPrivateFieldGet(this, _ReclaimFlow_options, "f").target;
118
+ const mechanism = wantsIframe ? 'iframe' : 'new-tab';
119
+ return { client, device, confidence, mechanism, available: true };
120
+ }, _ReclaimFlow_resolveExtensionTarget = async function _ReclaimFlow_resolveExtensionTarget(client, device, confidence) {
121
+ const unavailable = (reason) => ({
122
+ client,
123
+ device,
124
+ confidence,
125
+ mechanism: 'unavailable',
126
+ available: false,
127
+ reason,
128
+ });
129
+ if (device !== 'desktop') {
130
+ return unavailable('mobile-device');
131
+ }
132
+ if (__classPrivateFieldGet(this, _ReclaimFlow_options, "f").useBrowserExtension === false) {
133
+ return unavailable('extension-disabled');
134
+ }
135
+ const found = await probeExtension(__classPrivateFieldGet(this, _ReclaimFlow_env, "f"), __classPrivateFieldGet(this, _ReclaimFlow_options, "f"));
136
+ if (!found) {
137
+ return unavailable('extension-not-found');
138
+ }
139
+ return {
140
+ client,
141
+ device,
142
+ confidence,
143
+ mechanism: 'extension-handoff',
144
+ available: true,
145
+ };
146
+ }, _ReclaimFlow_execute = function _ReclaimFlow_execute(resolved) {
147
+ const parsed = __classPrivateFieldGet(this, _ReclaimFlow_instances, "m", _ReclaimFlow_parse).call(this);
148
+ switch (resolved.mechanism) {
149
+ case 'extension-handoff': {
150
+ const handoff = {
151
+ sessionId: parsed.sessionId ?? '',
152
+ diag: parsed.diag,
153
+ extensionID: __classPrivateFieldGet(this, _ReclaimFlow_options, "f").extensionID,
154
+ };
155
+ sendExtensionStartVerification(__classPrivateFieldGet(this, _ReclaimFlow_env, "f"), handoff);
156
+ return {
157
+ target: resolved,
158
+ close: () => sendExtensionCancelVerification(__classPrivateFieldGet(this, _ReclaimFlow_env, "f"), handoff),
159
+ };
160
+ }
161
+ case 'qr-modal': {
162
+ const url = resolveDestinationUrl(parsed.target, parsed, __classPrivateFieldGet(this, _ReclaimFlow_options, "f"));
163
+ const modal = showQrModal(__classPrivateFieldGet(this, _ReclaimFlow_env, "f"), url, {
164
+ ...__classPrivateFieldGet(this, _ReclaimFlow_options, "f").modalOptions,
165
+ renderQr: __classPrivateFieldGet(this, _ReclaimFlow_options, "f").renderQr,
166
+ });
167
+ return { target: resolved, close: () => modal.close() };
168
+ }
169
+ case 'ios-app-clip': {
170
+ const appClipUrl = appendSessionQuery(__classPrivateFieldGet(this, _ReclaimFlow_options, "f").appClipUrl ?? DEFAULT_APP_CLIP_URL, parsed);
171
+ const handle = redirectToAppClip(__classPrivateFieldGet(this, _ReclaimFlow_env, "f"), appClipUrl);
172
+ return { target: resolved, close: () => handle.close() };
173
+ }
174
+ case 'ios-deep-link': {
175
+ const deepLinkUrl = appendSessionQuery(__classPrivateFieldGet(this, _ReclaimFlow_options, "f").iosDeepLinkBaseUrl ?? DEFAULT_IOS_DEEP_LINK_BASE_URL, parsed);
176
+ const storeUrl = __classPrivateFieldGet(this, _ReclaimFlow_options, "f").iosAppDownloadUrl
177
+ ?? DEFAULT_IOS_APP_DOWNLOAD_URL;
178
+ const handle = redirectToIosDeepLink(__classPrivateFieldGet(this, _ReclaimFlow_env, "f"), { deepLinkUrl, storeUrl });
179
+ return { target: resolved, close: () => handle.close() };
180
+ }
181
+ case 'android-deep-link': {
182
+ const shareUrl = resolveDestinationUrl(parsed.target, parsed, __classPrivateFieldGet(this, _ReclaimFlow_options, "f"));
183
+ const deepLinkUrl = appendSessionQuery(__classPrivateFieldGet(this, _ReclaimFlow_options, "f").iosDeepLinkBaseUrl ?? DEFAULT_IOS_DEEP_LINK_BASE_URL, parsed);
184
+ const marketIntentUrl = buildAndroidMarketIntentUrl(shareUrl, resolveAndroidDeepLinkPackage(__classPrivateFieldGet(this, _ReclaimFlow_options, "f")));
185
+ const handle = redirectToAndroid(__classPrivateFieldGet(this, _ReclaimFlow_env, "f"), {
186
+ deepLinkUrl,
187
+ marketIntentUrl,
188
+ shareUrl,
189
+ canUseDeferredDeepLinksFlow: resolveCanUseDeferredDeepLinksFlow(__classPrivateFieldGet(this, _ReclaimFlow_options, "f"), 'android'),
190
+ });
191
+ return { target: resolved, close: () => handle.close() };
192
+ }
193
+ case 'iframe': {
194
+ const url = resolveDestinationUrl(parsed.target, parsed, __classPrivateFieldGet(this, _ReclaimFlow_options, "f"));
195
+ const container = __classPrivateFieldGet(this, _ReclaimFlow_options, "f").target;
196
+ if (!container) {
197
+ // `#resolve()` only picks 'iframe' when `options.target` is set;
198
+ // kept total rather than asserting, in case options changed
199
+ // between `resolveTarget()` and `launch()`.
200
+ return __classPrivateFieldGet(this, _ReclaimFlow_instances, "m", _ReclaimFlow_openNewTab).call(this, resolved, url);
201
+ }
202
+ const handle = embedPortalIframe(__classPrivateFieldGet(this, _ReclaimFlow_env, "f"), url, container);
203
+ return {
204
+ target: resolved, iframe: handle.iframe, close: () => handle.close(),
205
+ };
206
+ }
207
+ case 'new-tab': {
208
+ const url = resolveDestinationUrl(parsed.target, parsed, __classPrivateFieldGet(this, _ReclaimFlow_options, "f"));
209
+ return __classPrivateFieldGet(this, _ReclaimFlow_instances, "m", _ReclaimFlow_openNewTab).call(this, resolved, url);
210
+ }
211
+ case 'unavailable':
212
+ // `launch()` already rejected before calling `#execute()`.
213
+ throw new ReclaimFlowUnavailableError(resolved.reason ?? 'extension-not-found');
214
+ }
215
+ }, _ReclaimFlow_openNewTab = function _ReclaimFlow_openNewTab(resolved, url) {
216
+ const handle = openPortalTab(__classPrivateFieldGet(this, _ReclaimFlow_env, "f"), url, __classPrivateFieldGet(this, _ReclaimFlow_options, "f").preOpenedTab);
217
+ return { target: resolved, tab: handle.tab, close: () => handle.close() };
218
+ };
219
+ // Re-exported so a consumer building `androidAppDownloadUrl`/
220
+ // `androidDeepLinkPackage` defaults for display (e.g. an install-link
221
+ // banner) doesn't have to duplicate them.
222
+ export { resolveAndroidAppDownloadUrl, resolveAndroidDeepLinkPackage };
@@ -0,0 +1,16 @@
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 { ReclaimFlow, ReclaimFlowUnavailableError, isReclaimFlowUnavailableError, } from './flow.ts';
11
+ export type { DetectionConfidence, DeviceKind, FlowHandle, FlowInput, FlowOptions, LaunchMechanism, ReclaimFlowUnavailableReason, ResolvedTarget, VerificationTarget, } from './flow.ts';
12
+ export { showQrModal } from './modal.ts';
13
+ export type { ModalHandle, ModalOptions } from './modal.ts';
14
+ export type { RenderQr } from './qr.ts';
15
+ export { defaultEnvironment } from './environment.ts';
16
+ export type { LaunchClipboard, LaunchContainer, LaunchDocument, LaunchElement, LaunchEnvironment, LaunchMessageEvent, LaunchNavigator, LaunchStyleDeclaration, LaunchUserAgentData, LaunchWindow, LaunchWindowHandle, } from './environment.ts';
@@ -0,0 +1,12 @@
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 { ReclaimFlow, ReclaimFlowUnavailableError, isReclaimFlowUnavailableError, } from "./flow.js";
11
+ export { showQrModal } from "./modal.js";
12
+ export { defaultEnvironment } from "./environment.js";
@@ -0,0 +1,77 @@
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
+ export interface MobileRedirectHandle {
22
+ close(): void;
23
+ }
24
+ /** iOS, `useAppClip: true`. A one-way redirect — nothing to probe or fall
25
+ * back from, so `close()` is a no-op. */
26
+ export declare function redirectToAppClip(env: LaunchEnvironment, appClipUrl: string): MobileRedirectHandle;
27
+ /** iOS, `useAppClip: false` (the default): try the custom-scheme deep link,
28
+ * fall back to the App Store after 3s if the tab never backgrounded (i.e.
29
+ * the OS never handed off to the app). */
30
+ export declare function redirectToIosDeepLink(env: LaunchEnvironment, options: {
31
+ deepLinkUrl: string;
32
+ storeUrl: string;
33
+ }): MobileRedirectHandle;
34
+ /** Android. `canUseDeferredDeepLinksFlow` (default `true`) gates the
35
+ * clipboard-write + hidden-iframe probe + Play Store market-intent
36
+ * fallback; `false` is a plain redirect to the share URL. */
37
+ export declare function redirectToAndroid(env: LaunchEnvironment, options: {
38
+ deepLinkUrl: string;
39
+ marketIntentUrl: string;
40
+ shareUrl: string;
41
+ canUseDeferredDeepLinksFlow: boolean;
42
+ }): MobileRedirectHandle;
43
+ /** Builds the Play Store market-intent fallback URL, ported from
44
+ * `redirectToInstantApp`'s `deepLink` construction.
45
+ *
46
+ * The legacy JS SDK put the verification URL in `url=`. Play never read it —
47
+ * Play forwards exactly one parameter to the installed app, `referrer`, via
48
+ * the Install Referrer API, so `url=` has always been inert and only the
49
+ * clipboard carried a deferred install across the Play Store. This emits
50
+ * `referrer=` instead, which `reclaim-verifier-app`'s
51
+ * `checkInstallReferrerForLink` (`lib/widgets/link_handler.dart`) now reads
52
+ * via `PlayInstallReferrer.installReferrer`. `url=` is dropped rather than
53
+ * kept alongside `referrer=`: nothing in this codebase or the verifier app
54
+ * reads it (the app has no `queryParameters['url']` handling anywhere), so
55
+ * it would be dead weight carried forward for no reason.
56
+ *
57
+ * Exactly one layer of `encodeURIComponent` is applied to `shareUrl` here:
58
+ * - `referrer` is itself a query-string value inside this intent URI, so it
59
+ * needs at least one layer to survive the `?`/`&`/`=` characters in
60
+ * `shareUrl` without corrupting the outer intent.
61
+ * - Play decodes exactly one layer of percent-encoding off the referrer
62
+ * string before handing it to the app — the app's
63
+ * `_parseBuilderReferrerUri` documents this and tries the raw string
64
+ * first. One layer here round-trips to the literal share URL on the
65
+ * other side. The app also falls back to `Uri.decodeFull` if the raw
66
+ * string doesn't parse, tolerating an extra layer either way, but this
67
+ * is deliberately exactly one layer rather than leaning on that
68
+ * fallback.
69
+ *
70
+ * Play truncates the referrer string at roughly 1KB. A Builder verification
71
+ * URL is a UUID and two query parameters, comfortably under that — which is
72
+ * what makes this viable where the legacy SDK's base64 `template=` payload
73
+ * never could be (see the launch-api plan's "Install Referrer beats the
74
+ * clipboard"). Keep the clipboard write in `redirectToAndroid` regardless:
75
+ * it is still the only carrier for an install that did not come through
76
+ * Play, and the app still reads it. */
77
+ export declare function buildAndroidMarketIntentUrl(shareUrl: string, androidDeepLinkPackage: string): string;
@@ -0,0 +1,124 @@
1
+ /** How long to wait for the OS to hand off to the app before deciding it is
2
+ * not installed and sending the claimant to the Play Store. The tab
3
+ * backgrounding is the only signal a page gets, and it arrives quickly when
4
+ * it arrives at all. */
5
+ const ANDROID_STORE_FALLBACK_MS = 1500;
6
+ function writeClipboardBestEffort(env, text) {
7
+ env.navigator?.clipboard?.writeText(text)?.catch(() => {
8
+ // Clipboard access can be denied (permissions, insecure context); the
9
+ // deep link/store redirect below is the fallback either way.
10
+ });
11
+ }
12
+ /** iOS, `useAppClip: true`. A one-way redirect — nothing to probe or fall
13
+ * back from, so `close()` is a no-op. */
14
+ export function redirectToAppClip(env, appClipUrl) {
15
+ env.navigate(appClipUrl);
16
+ return { close() { } };
17
+ }
18
+ /** iOS, `useAppClip: false` (the default): try the custom-scheme deep link,
19
+ * fall back to the App Store after 3s if the tab never backgrounded (i.e.
20
+ * the OS never handed off to the app). */
21
+ export function redirectToIosDeepLink(env, options) {
22
+ writeClipboardBestEffort(env, options.deepLinkUrl);
23
+ let appOpened = false;
24
+ const onVisibilityChange = () => {
25
+ if (env.document?.hidden) {
26
+ appOpened = true;
27
+ }
28
+ };
29
+ env.document?.addEventListener('visibilitychange', onVisibilityChange, { once: true });
30
+ env.navigate(options.deepLinkUrl);
31
+ const timeoutHandle = env.setTimeout(() => {
32
+ env.document?.removeEventListener('visibilitychange', onVisibilityChange);
33
+ if (!appOpened) {
34
+ env.navigate(options.storeUrl);
35
+ }
36
+ }, 3000);
37
+ return {
38
+ close() {
39
+ env.document?.removeEventListener('visibilitychange', onVisibilityChange);
40
+ env.clearTimeout(timeoutHandle);
41
+ },
42
+ };
43
+ }
44
+ /** Android. `canUseDeferredDeepLinksFlow` (default `true`) gates the
45
+ * clipboard-write + hidden-iframe probe + Play Store market-intent
46
+ * fallback; `false` is a plain redirect to the share URL. */
47
+ export function redirectToAndroid(env, options) {
48
+ if (!options.canUseDeferredDeepLinksFlow) {
49
+ env.navigate(options.shareUrl);
50
+ return { close() { } };
51
+ }
52
+ writeClipboardBestEffort(env, options.shareUrl);
53
+ // Navigate the tab straight at the custom scheme, rather than probing it
54
+ // from a hidden iframe first. Chrome on Android has blocked custom-scheme
55
+ // navigation from an iframe for years — it fails silently, so the probe
56
+ // never opened the app and the Play Store fallback did all the work, even
57
+ // for someone who already had the app installed. A top-level navigation
58
+ // is the only thing the OS acts on, and it is what the iOS branch above
59
+ // has always done.
60
+ let appOpened = false;
61
+ const onVisibilityChange = () => {
62
+ if (env.document?.hidden) {
63
+ appOpened = true;
64
+ }
65
+ };
66
+ env.document?.addEventListener('visibilitychange', onVisibilityChange, { once: true });
67
+ env.navigate(options.deepLinkUrl);
68
+ // Still here, still visible: the scheme resolved to nothing, so the app is
69
+ // not installed. Send them to the Play Store, carrying the referrer that
70
+ // survives the install.
71
+ const timeoutHandle = env.setTimeout(() => {
72
+ env.document?.removeEventListener('visibilitychange', onVisibilityChange);
73
+ if (!appOpened) {
74
+ env.navigate(options.marketIntentUrl);
75
+ }
76
+ }, ANDROID_STORE_FALLBACK_MS);
77
+ return {
78
+ close() {
79
+ env.document?.removeEventListener('visibilitychange', onVisibilityChange);
80
+ env.clearTimeout(timeoutHandle);
81
+ },
82
+ };
83
+ }
84
+ /** Builds the Play Store market-intent fallback URL, ported from
85
+ * `redirectToInstantApp`'s `deepLink` construction.
86
+ *
87
+ * The legacy JS SDK put the verification URL in `url=`. Play never read it —
88
+ * Play forwards exactly one parameter to the installed app, `referrer`, via
89
+ * the Install Referrer API, so `url=` has always been inert and only the
90
+ * clipboard carried a deferred install across the Play Store. This emits
91
+ * `referrer=` instead, which `reclaim-verifier-app`'s
92
+ * `checkInstallReferrerForLink` (`lib/widgets/link_handler.dart`) now reads
93
+ * via `PlayInstallReferrer.installReferrer`. `url=` is dropped rather than
94
+ * kept alongside `referrer=`: nothing in this codebase or the verifier app
95
+ * reads it (the app has no `queryParameters['url']` handling anywhere), so
96
+ * it would be dead weight carried forward for no reason.
97
+ *
98
+ * Exactly one layer of `encodeURIComponent` is applied to `shareUrl` here:
99
+ * - `referrer` is itself a query-string value inside this intent URI, so it
100
+ * needs at least one layer to survive the `?`/`&`/`=` characters in
101
+ * `shareUrl` without corrupting the outer intent.
102
+ * - Play decodes exactly one layer of percent-encoding off the referrer
103
+ * string before handing it to the app — the app's
104
+ * `_parseBuilderReferrerUri` documents this and tries the raw string
105
+ * first. One layer here round-trips to the literal share URL on the
106
+ * other side. The app also falls back to `Uri.decodeFull` if the raw
107
+ * string doesn't parse, tolerating an extra layer either way, but this
108
+ * is deliberately exactly one layer rather than leaning on that
109
+ * fallback.
110
+ *
111
+ * Play truncates the referrer string at roughly 1KB. A Builder verification
112
+ * URL is a UUID and two query parameters, comfortably under that — which is
113
+ * what makes this viable where the legacy SDK's base64 `template=` payload
114
+ * never could be (see the launch-api plan's "Install Referrer beats the
115
+ * clipboard"). Keep the clipboard write in `redirectToAndroid` regardless:
116
+ * it is still the only carrier for an install that did not come through
117
+ * Play, and the app still reads it. */
118
+ export function buildAndroidMarketIntentUrl(shareUrl, androidDeepLinkPackage) {
119
+ const encodedShareUrl = encodeURIComponent(shareUrl);
120
+ return `intent://details?id=${androidDeepLinkPackage}`
121
+ + `&referrer=${encodedShareUrl}`
122
+ + '#Intent;scheme=market;action=android.intent.action.VIEW;'
123
+ + 'package=com.android.vending;end;';
124
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The desktop `verifier-app` mechanism: a dependency-free QR dialog over the
3
+ * share URL, with the QR itself bundled (see ./qr.ts).
4
+ *
5
+ * The layout follows the reference JS SDK's `utils/modalUtils.ts`
6
+ * `QRCodeModal` — same card, same close affordance, same countdown and
7
+ * progress bar along the bottom — so a consumer moving from `reclaim-js-sdk`
8
+ * sees the dialog they already know. Two things differ on purpose: the QR is
9
+ * an inline SVG from the vendored encoder rather than a `qrcode` dependency,
10
+ * and the countdown can be switched off entirely, because a dialog that
11
+ * closes itself under a claimant who is still reaching for their phone is
12
+ * worse than one that waits.
13
+ */
14
+ import type { LaunchEnvironment } from './environment.ts';
15
+ import type { RenderQr } from './qr.ts';
16
+ export interface ModalOptions {
17
+ title?: string;
18
+ description?: string;
19
+ darkTheme?: boolean;
20
+ /**
21
+ * Seconds to keep the dialog open before closing it on the claimant's
22
+ * behalf, shown as a countdown and a draining progress bar.
23
+ *
24
+ * Pass `0` (or `Infinity`) to keep it open until something closes it —
25
+ * `handle.close()`, the close button, the backdrop, or Escape. The
26
+ * countdown and bar are then omitted rather than frozen, since a timer
27
+ * that never moves reads as broken.
28
+ */
29
+ autoCloseSeconds?: number;
30
+ /** Shows the "install the extension" tip, linking here. */
31
+ extensionUrl?: string;
32
+ /** Called once, whenever and however the dialog closes. */
33
+ onClose?(): void;
34
+ }
35
+ export interface ModalHandle {
36
+ close(): void;
37
+ /** Seconds left before the dialog closes itself, or `null` when it is
38
+ * waiting indefinitely. Reads `0` once closed. */
39
+ readonly remainingSeconds: number | null;
40
+ }
41
+ /**
42
+ * Opens the dialog and returns the handle that closes it again.
43
+ *
44
+ * Exported from the package root as well as used by `launch()`, so a consumer
45
+ * who only wants a QR over a share link can open one directly without going
46
+ * through flow resolution.
47
+ */
48
+ export declare function showQrModal(env: LaunchEnvironment, url: string, options?: ModalOptions & {
49
+ renderQr?: RenderQr;
50
+ }): ModalHandle;