@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.
- package/README.md +255 -234
- package/lib/api.d.ts +2 -0
- package/lib/api.js +2 -0
- package/lib/client.js +2 -0
- package/lib/enckeys.d.ts +1 -1
- package/lib/enckeys.js +2 -2
- package/lib/encoding.d.ts +4 -0
- package/lib/encoding.js +10 -0
- package/lib/feature-flags.d.ts +9 -0
- package/lib/feature-flags.js +19 -0
- package/lib/index.d.ts +4 -4
- package/lib/index.js +4 -4
- package/lib/jwks.d.ts +8 -8
- package/lib/jwks.js +38 -17
- package/lib/launch/destination.d.ts +14 -0
- package/lib/launch/destination.js +89 -0
- package/lib/launch/device.d.ts +30 -0
- package/lib/launch/device.js +168 -0
- package/lib/launch/environment.d.ts +224 -0
- package/lib/launch/environment.js +98 -0
- package/lib/launch/errors.d.ts +20 -0
- package/lib/launch/errors.js +25 -0
- package/lib/launch/extension.d.ts +42 -0
- package/lib/launch/extension.js +72 -0
- package/lib/launch/flow.d.ts +82 -0
- package/lib/launch/flow.js +222 -0
- package/lib/launch/index.d.ts +16 -0
- package/lib/launch/index.js +12 -0
- package/lib/launch/mobile.d.ts +77 -0
- package/lib/launch/mobile.js +124 -0
- package/lib/launch/modal.d.ts +50 -0
- package/lib/launch/modal.js +258 -0
- package/lib/launch/options.d.ts +74 -0
- package/lib/launch/options.js +20 -0
- package/lib/launch/portal.d.ts +28 -0
- package/lib/launch/portal.js +68 -0
- package/lib/launch/qr.d.ts +5 -0
- package/lib/launch/qr.js +28 -0
- package/lib/launch/target.d.ts +46 -0
- package/lib/launch/target.js +128 -0
- package/lib/launch/vendor/qrcodegen.d.ts +134 -0
- package/lib/launch/vendor/qrcodegen.js +822 -0
- package/lib/legacy-proof.d.ts +2 -0
- package/lib/legacy-proof.js +37 -7
- package/lib/proof-hash.d.ts +5 -5
- package/lib/proof-hash.js +1 -1
- package/lib/proof-recipe.d.ts +3 -3
- package/lib/proof-recipe.js +1 -1
- package/lib/proof-types.d.ts +35 -8
- package/lib/reclaim.d.ts +81 -70
- package/lib/reclaim.js +79 -130
- package/lib/request-proof.d.ts +60 -0
- package/lib/request-proof.js +241 -0
- package/lib/result.d.ts +46 -38
- package/lib/result.js +160 -100
- package/lib/routes.gen.d.ts +76 -28
- package/lib/routes.gen.js +24 -11
- package/lib/tee-binding.d.ts +1 -1
- package/lib/types/openapi.d.ts +1 -0
- package/lib/types/openapi.gen.d.ts +1594 -488
- package/lib/types/openapi.gen.js +5 -0
- package/lib/verification-clients.d.ts +28 -14
- package/lib/verification-clients.js +31 -11
- package/lib/verify-attestor-tee.d.ts +19 -2
- package/lib/verify-attestor-tee.js +40 -4
- package/lib/verify-tee.d.ts +10 -1
- package/lib/verify-tee.js +34 -10
- package/package.json +5 -1
- package/src/api.ts +9 -0
- package/src/client.ts +2 -0
- package/src/enckeys.ts +2 -2
- package/src/encoding.ts +12 -0
- package/src/feature-flags.ts +39 -0
- package/src/index.ts +13 -14
- package/src/jwks.ts +52 -30
- package/src/launch/destination.ts +111 -0
- package/src/launch/device.ts +242 -0
- package/src/launch/environment.ts +337 -0
- package/src/launch/errors.ts +49 -0
- package/src/launch/extension.ts +124 -0
- package/src/launch/flow.ts +369 -0
- package/src/launch/index.ts +42 -0
- package/src/launch/mobile.ts +184 -0
- package/src/launch/modal.ts +378 -0
- package/src/launch/options.ts +99 -0
- package/src/launch/portal.ts +104 -0
- package/src/launch/qr.ts +37 -0
- package/src/launch/target.ts +150 -0
- package/src/launch/vendor/qrcodegen.ts +1034 -0
- package/src/proof-hash.ts +6 -6
- package/src/proof-recipe.ts +3 -3
- package/src/proof-types.ts +36 -8
- package/src/reclaim.ts +185 -218
- package/src/{legacy-proof.ts → request-proof.ts} +81 -36
- package/src/result.ts +238 -172
- package/src/routes.gen.ts +24 -11
- package/src/tee-binding.ts +1 -1
- package/src/types/openapi.gen.ts +1599 -488
- package/src/types/openapi.ts +3 -1
- package/src/verification-clients.ts +33 -19
- package/src/verify-attestor-tee.ts +66 -8
- package/src/verify-tee.ts +44 -13
- package/lib/api-client.d.ts +0 -31
- package/lib/api-client.js +0 -112
- package/lib/api-consts.d.ts +0 -4
- package/lib/api-consts.js +0 -4
- package/lib/api-errors.d.ts +0 -13
- package/lib/api-errors.js +0 -30
- package/lib/eip712.d.ts +0 -20
- 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
|
+
}
|