@reclaimprotocol/client 0.1.28 → 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,378 @@
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 {
15
+ LaunchDocument,
16
+ LaunchElement,
17
+ LaunchEnvironment,
18
+ LaunchKeyboardEvent,
19
+ LaunchMouseEvent,
20
+ LaunchStyleDeclaration,
21
+ } from './environment.ts'
22
+ import type { RenderQr } from './qr.ts'
23
+ import { defaultRenderQr } from './qr.ts'
24
+
25
+ export interface ModalOptions {
26
+ title?: string
27
+ description?: string
28
+ darkTheme?: boolean
29
+ /**
30
+ * Seconds to keep the dialog open before closing it on the claimant's
31
+ * behalf, shown as a countdown and a draining progress bar.
32
+ *
33
+ * Pass `0` (or `Infinity`) to keep it open until something closes it —
34
+ * `handle.close()`, the close button, the backdrop, or Escape. The
35
+ * countdown and bar are then omitted rather than frozen, since a timer
36
+ * that never moves reads as broken.
37
+ */
38
+ autoCloseSeconds?: number
39
+ /** Shows the "install the extension" tip, linking here. */
40
+ extensionUrl?: string
41
+ /** Called once, whenever and however the dialog closes. */
42
+ onClose?(): void
43
+ }
44
+
45
+ export interface ModalHandle {
46
+ close(): void
47
+ /** Seconds left before the dialog closes itself, or `null` when it is
48
+ * waiting indefinitely. Reads `0` once closed. */
49
+ readonly remainingSeconds: number | null
50
+ }
51
+
52
+ const DEFAULT_TITLE = 'Verify with Reclaim'
53
+ const DEFAULT_DESCRIPTION
54
+ = 'Scan the QR code with your phone to complete verification.'
55
+ const DEFAULT_AUTO_CLOSE_SECONDS = 60
56
+ const MODAL_FONT_FAMILY
57
+ = '-apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif'
58
+ const TICK_MS = 1000
59
+
60
+ /**
61
+ * Opens the dialog and returns the handle that closes it again.
62
+ *
63
+ * Exported from the package root as well as used by `launch()`, so a consumer
64
+ * who only wants a QR over a share link can open one directly without going
65
+ * through flow resolution.
66
+ */
67
+ export function showQrModal(
68
+ env: LaunchEnvironment,
69
+ url: string,
70
+ options: ModalOptions & { renderQr?: RenderQr } = {},
71
+ ): ModalHandle {
72
+ const doc = env.document
73
+ const body = doc?.body
74
+ if(!doc || !body) {
75
+ return { close() {}, remainingSeconds: null }
76
+ }
77
+
78
+ // Captured with its narrowed type: `close` below is a hoisted function
79
+ // declaration, and TypeScript will not carry a narrowing into one.
80
+ const activeDoc: LaunchDocument = doc
81
+ const theme = themeFor(!!options.darkTheme)
82
+ const total = normalizeSeconds(options.autoCloseSeconds)
83
+ let remaining = total
84
+ let closed = false
85
+ let timer: unknown
86
+
87
+ const overlay = doc.createElement('div')
88
+ setStyle(overlay, {
89
+ position: 'fixed', inset: '0', display: 'flex', alignItems: 'center',
90
+ justifyContent: 'center', padding: '16px', boxSizing: 'border-box',
91
+ background: theme.backdrop, zIndex: '2147483647',
92
+ })
93
+
94
+ const card = doc.createElement('div')
95
+ card.setAttribute('role', 'dialog')
96
+ card.setAttribute('aria-modal', 'true')
97
+ card.setAttribute('aria-label', options.title ?? DEFAULT_TITLE)
98
+ setStyle(card, {
99
+ position: 'relative', background: theme.card, color: theme.title,
100
+ borderRadius: '12px', padding: '32px', maxWidth: '400px', width: '100%',
101
+ boxSizing: 'border-box', textAlign: 'center',
102
+ fontFamily: MODAL_FONT_FAMILY, boxShadow: theme.shadow,
103
+ })
104
+ overlay.appendChild(card)
105
+
106
+ card.appendChild(closeButton(doc, theme, () => close()))
107
+
108
+ const title = doc.createElement('h2')
109
+ title.innerHTML = escapeHtml(options.title ?? DEFAULT_TITLE)
110
+ setStyle(title, {
111
+ margin: '0 0 16px 0', fontSize: '24px', fontWeight: '600',
112
+ color: theme.title,
113
+ })
114
+ card.appendChild(title)
115
+
116
+ const description = doc.createElement('p')
117
+ description.innerHTML = escapeHtml(options.description ?? DEFAULT_DESCRIPTION)
118
+ setStyle(description, {
119
+ margin: '0 0 24px 0', fontSize: '14px', lineHeight: '1.5',
120
+ color: theme.text,
121
+ })
122
+ card.appendChild(description)
123
+
124
+ const qrSlot = doc.createElement('div')
125
+ setStyle(qrSlot, {
126
+ margin: '0 auto 24px auto', display: 'inline-block', padding: '8px',
127
+ background: theme.qr, borderRadius: '8px',
128
+ })
129
+ card.appendChild(qrSlot)
130
+ void (options.renderQr ?? defaultRenderQr)(url, qrSlot)
131
+
132
+ if(options.extensionUrl) {
133
+ card.appendChild(extensionTip(doc, theme, options.extensionUrl))
134
+ }
135
+
136
+ const copyLink = doc.createElement('button')
137
+ copyLink.setAttribute('type', 'button')
138
+ copyLink.innerHTML = 'Copy link'
139
+ setStyle(copyLink, {
140
+ background: theme.button, color: theme.buttonText, border: 'none',
141
+ borderRadius: '6px', padding: '8px 16px', fontSize: '13px',
142
+ cursor: 'pointer',
143
+ })
144
+ card.appendChild(copyLink)
145
+ copyLink.addEventListener('click', () => {
146
+ void env.navigator?.clipboard?.writeText(url).catch(() => {})
147
+ })
148
+
149
+ // Only built when there is a countdown to show. An unmoving timer and a
150
+ // full progress bar would both claim something is being measured.
151
+ let countdownText: LaunchElement | undefined
152
+ let progressBar: LaunchElement | undefined
153
+ if(total !== null) {
154
+ const wrap = doc.createElement('div')
155
+ setStyle(wrap, { marginTop: '16px' })
156
+
157
+ countdownText = doc.createElement('div')
158
+ setStyle(countdownText, {
159
+ fontSize: '12px', color: theme.countdown, marginBottom: '8px',
160
+ })
161
+ wrap.appendChild(countdownText)
162
+
163
+ const track = doc.createElement('div')
164
+ setStyle(track, {
165
+ width: '100%', height: '4px', backgroundColor: theme.progressTrack,
166
+ borderRadius: '2px', overflow: 'hidden',
167
+ })
168
+ progressBar = doc.createElement('div')
169
+ setStyle(progressBar, {
170
+ width: '100%', height: '100%', background: theme.progressFill,
171
+ borderRadius: '2px', transition: 'width 1s linear',
172
+ })
173
+ track.appendChild(progressBar)
174
+ wrap.appendChild(track)
175
+ card.appendChild(wrap)
176
+ }
177
+
178
+ overlay.addEventListener('click', onBackdropClick)
179
+ doc.addEventListener('keydown', onKeydown)
180
+ body.appendChild(overlay)
181
+
182
+ if(total !== null) {
183
+ renderCountdown()
184
+ timer = env.setTimeout(tick, TICK_MS)
185
+ }
186
+
187
+ return {
188
+ close,
189
+ get remainingSeconds() {
190
+ return remaining
191
+ },
192
+ }
193
+
194
+ function tick() {
195
+ if(closed || remaining === null) {
196
+ return
197
+ }
198
+
199
+ remaining -= 1
200
+ renderCountdown()
201
+ if(remaining <= 0) {
202
+ close()
203
+ return
204
+ }
205
+
206
+ timer = env.setTimeout(tick, TICK_MS)
207
+ }
208
+
209
+ function renderCountdown() {
210
+ if(remaining === null || total === null) {
211
+ return
212
+ }
213
+
214
+ const minutes = Math.floor(remaining / 60)
215
+ const seconds = remaining % 60
216
+ if(countdownText) {
217
+ countdownText.innerHTML = escapeHtml(
218
+ `Closes in ${minutes}:${String(seconds).padStart(2, '0')}`,
219
+ )
220
+ }
221
+
222
+ if(progressBar) {
223
+ progressBar.style.width = `${Math.max(0, remaining / total) * 100}%`
224
+ }
225
+ }
226
+
227
+ // `e.target === overlay` is what separates a click on the backdrop from
228
+ // one that bubbled up out of the card — without it, every click inside
229
+ // the dialog would close it.
230
+ function onBackdropClick(event?: LaunchMouseEvent) {
231
+ if(!event || event.target === overlay) {
232
+ close()
233
+ }
234
+ }
235
+
236
+ function onKeydown(event: LaunchKeyboardEvent) {
237
+ if(event.key === 'Escape') {
238
+ close()
239
+ }
240
+ }
241
+
242
+ function close() {
243
+ if(closed) {
244
+ return
245
+ }
246
+
247
+ closed = true
248
+ remaining = total === null ? null : 0
249
+ if(timer !== undefined) {
250
+ env.clearTimeout(timer)
251
+ timer = undefined
252
+ }
253
+
254
+ overlay.removeEventListener('click', onBackdropClick)
255
+ activeDoc.removeEventListener('keydown', onKeydown)
256
+ overlay.remove()
257
+ options.onClose?.()
258
+ }
259
+ }
260
+
261
+ /** `undefined` means the default countdown; `0`/negative/non-finite means
262
+ * wait indefinitely, which the caller sees as `null` throughout. */
263
+ function normalizeSeconds(value: number | undefined): number | null {
264
+ const seconds = value ?? DEFAULT_AUTO_CLOSE_SECONDS
265
+ if(!Number.isFinite(seconds) || seconds <= 0) {
266
+ return null
267
+ }
268
+
269
+ return Math.ceil(seconds)
270
+ }
271
+
272
+ function closeButton(
273
+ doc: LaunchDocument,
274
+ theme: ModalTheme,
275
+ onClick: () => void,
276
+ ): LaunchElement {
277
+ const button = doc.createElement('button')
278
+ button.setAttribute('type', 'button')
279
+ button.setAttribute('aria-label', 'Close')
280
+ button.setAttribute('title', 'Close')
281
+ setStyle(button, {
282
+ position: 'absolute', top: '16px', right: '16px', width: '32px',
283
+ height: '32px', background: 'none', border: 'none', cursor: 'pointer',
284
+ borderRadius: '6px', display: 'flex', alignItems: 'center',
285
+ justifyContent: 'center', padding: '4px',
286
+ })
287
+ button.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" '
288
+ + 'fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
289
+ + `<path d="M12 4L4 12M4 4L12 12" stroke="${theme.buttonText}" `
290
+ + 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
291
+ + '</svg>'
292
+ button.addEventListener('click', onClick)
293
+ return button
294
+ }
295
+
296
+ function extensionTip(
297
+ doc: LaunchDocument,
298
+ theme: ModalTheme,
299
+ extensionUrl: string,
300
+ ): LaunchElement {
301
+ const tip = doc.createElement('div')
302
+ setStyle(tip, {
303
+ marginBottom: '24px', padding: '16px', background: theme.tip,
304
+ border: `1px solid ${theme.tipBorder}`, borderRadius: '8px',
305
+ textAlign: 'left',
306
+ })
307
+ tip.innerHTML = '<p style="margin:0 0 8px 0;font-size:14px;font-weight:500;'
308
+ + `color:${theme.tipText}">For a faster route</p>`
309
+ + '<p style="margin:0 0 12px 0;font-size:13px;line-height:1.4;'
310
+ + `color:${theme.tipText}">Install the browser extension to verify `
311
+ + 'here, without a phone.</p>'
312
+ + `<a href="${escapeHtml(extensionUrl)}" target="_blank" `
313
+ + 'rel="noopener noreferrer" style="display:inline-block;'
314
+ + `background:${theme.accent};color:#fff;text-decoration:none;`
315
+ + 'padding:8px 16px;border-radius:6px;font-size:12px;font-weight:500">'
316
+ + 'Install extension</a>'
317
+ return tip
318
+ }
319
+
320
+ interface ModalTheme {
321
+ backdrop: string
322
+ card: string
323
+ shadow: string
324
+ title: string
325
+ text: string
326
+ qr: string
327
+ button: string
328
+ buttonText: string
329
+ countdown: string
330
+ progressTrack: string
331
+ progressFill: string
332
+ tip: string
333
+ tipBorder: string
334
+ tipText: string
335
+ accent: string
336
+ }
337
+
338
+ function themeFor(dark: boolean): ModalTheme {
339
+ return dark
340
+ ? {
341
+ backdrop: 'rgba(0,0,0,0.8)', card: '#1f2937',
342
+ shadow: '0 20px 25px -5px rgba(0,0,0,0.5)',
343
+ title: '#f9fafb', text: '#d1d5db', qr: '#374151',
344
+ button: '#374151', buttonText: '#f9fafb', countdown: '#6b7280',
345
+ progressTrack: '#4b5563',
346
+ progressFill:
347
+ 'linear-gradient(90deg,#3b82f6 0%,#2563eb 50%,#1d4ed8 100%)',
348
+ tip: '#1e3a8a', tipBorder: '#1e40af', tipText: '#dbeafe',
349
+ accent: '#2563eb',
350
+ }
351
+ : {
352
+ backdrop: 'rgba(0,0,0,0.5)', card: '#fff',
353
+ shadow: '0 20px 25px -5px rgba(0,0,0,0.1),'
354
+ + '0 10px 10px -5px rgba(0,0,0,0.04)',
355
+ title: '#1f2937', text: '#6b7280', qr: '#f9fafb',
356
+ button: '#f3f4f6', buttonText: '#374151', countdown: '#9ca3af',
357
+ progressTrack: '#e5e7eb',
358
+ progressFill:
359
+ 'linear-gradient(90deg,#2563eb 0%,#1d4ed8 50%,#1e40af 100%)',
360
+ tip: '#f0f9ff', tipBorder: '#e0f2fe', tipText: '#0369a1',
361
+ accent: '#2563eb',
362
+ }
363
+ }
364
+
365
+ /** Every property `LaunchStyleDeclaration` declares is a real
366
+ * `CSSStyleDeclaration` property name, so this needs no cast — unlike a
367
+ * generic `Record<string, string>` target, which would. */
368
+ function setStyle(el: LaunchElement, declarations: LaunchStyleDeclaration) {
369
+ Object.assign(el.style, declarations)
370
+ }
371
+
372
+ function escapeHtml(value: string): string {
373
+ return value
374
+ .replace(/&/g, '&amp;')
375
+ .replace(/</g, '&lt;')
376
+ .replace(/>/g, '&gt;')
377
+ .replace(/"/g, '&quot;')
378
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `FlowOptions` — the launch-API plan's "Options" table. Option NAMES match
3
+ * the reference JS SDK's so existing call sites port unchanged; everything
4
+ * here is optional, and every default lives in `DEFAULT_FLOW_OPTIONS`/the
5
+ * `resolve*Options` helpers below rather than scattered through `flow.ts`.
6
+ */
7
+ import type {
8
+ LaunchContainer,
9
+ LaunchEnvironment,
10
+ LaunchWindowHandle
11
+ } from './environment.ts'
12
+ import type { ModalOptions } from './modal.ts'
13
+ import type { RenderQr } from './qr.ts'
14
+
15
+ export interface FlowOptions {
16
+ /** Whether to probe for the browser extension at all. Default `true`. */
17
+ useBrowserExtension?: boolean
18
+ /** Strict-mode match against `chrome.runtime.id`. Omitted means any
19
+ * extension answers. */
20
+ extensionID?: string
21
+ /** Availability probe budget, in ms. Default `200`. */
22
+ extensionTimeout?: number
23
+ /** Portal origin; `/verify` is appended. Default
24
+ * `https://portal.reclaimprotocol.org`. */
25
+ portalUrl?: string
26
+ /** Universal link for the verifier app. Default
27
+ * `https://share.reclaimprotocol.org/verify`. */
28
+ appLinkBaseUrl?: string
29
+ /** iOS custom-scheme deep link base, used both for the direct iOS deep
30
+ * link and (with the same custom scheme) the Android probe. Default
31
+ * `reclaimverifier://org.reclaimprotocol.app`. */
32
+ iosDeepLinkBaseUrl?: string
33
+ /** Android package id, used to build the Play Store market-intent
34
+ * fallback. Default `org.reclaimprotocol.app`. */
35
+ androidDeepLinkPackage?: string
36
+ /** iOS App Store fallback. Default the Reclaim Verifier App Store page. */
37
+ iosAppDownloadUrl?: string
38
+ /** Android Play Store fallback. Defaults to the Play Store page for
39
+ * `androidDeepLinkPackage`. */
40
+ androidAppDownloadUrl?: string
41
+ /** Prefer the App Clip on iOS over the direct deep link. Default `false`. */
42
+ useAppClip?: boolean
43
+ /** App Clip destination. Default
44
+ * `https://appclip.apple.com/id?p=org.reclaimprotocol.app.clip`. */
45
+ appClipUrl?: string
46
+ /** Android: clipboard write + deep-link probe + market-intent fallback
47
+ * (`true`, the default) vs. a plain redirect to the share URL (`false`).
48
+ * iOS: no effect — the iOS mechanism is chosen by `useAppClip` alone.
49
+ * Defaults to `true` on Android, `false` on iOS. */
50
+ canUseDeferredDeepLinksFlow?: boolean
51
+ /** DOM element to embed the portal/builder page into as an iframe,
52
+ * instead of opening a new tab (desktop only). */
53
+ target?: LaunchContainer
54
+ /** QR dialog title, description, theme, auto-close countdown, optional
55
+ * extension tip, and close handler. */
56
+ modalOptions?: ModalOptions
57
+ /** Overrides the bundled QR renderer. */
58
+ renderQr?: RenderQr
59
+ /** A tab already opened via `window.open('about:blank')` inside the
60
+ * click handler, so the network round trip before `launch()` doesn't
61
+ * lose the user gesture a fresh `window.open` would need. */
62
+ preOpenedTab?: LaunchWindowHandle | null
63
+ /** Escape hatch for a staff-registered custom client whose URL doesn't
64
+ * match the seeded path convention — see `./target.ts`. Never required. */
65
+ verificationClientId?: string
66
+ /** Overrides the real browser globals — see ./environment.ts. Tests pass
67
+ * a fake; browser code never needs to set this. */
68
+ environment?: LaunchEnvironment
69
+ }
70
+
71
+ export const DEFAULT_USE_BROWSER_EXTENSION = true
72
+ export const DEFAULT_EXTENSION_TIMEOUT_MS = 200
73
+ // `portalUrl`/`appLinkBaseUrl` defaults live in ./destination.ts, next to the
74
+ // `resolveDestinationUrl()` that's the only place they're read.
75
+ export const DEFAULT_IOS_DEEP_LINK_BASE_URL
76
+ = 'reclaimverifier://org.reclaimprotocol.app'
77
+ export const DEFAULT_ANDROID_DEEP_LINK_PACKAGE = 'org.reclaimprotocol.app'
78
+ export const DEFAULT_IOS_APP_DOWNLOAD_URL
79
+ = 'itms-apps://apps.apple.com/in/app/reclaim-verifier/id6503247508'
80
+ export const DEFAULT_USE_APP_CLIP = false
81
+ export const DEFAULT_APP_CLIP_URL
82
+ = 'https://appclip.apple.com/id?p=org.reclaimprotocol.app.clip'
83
+
84
+ export function resolveAndroidAppDownloadUrl(options: FlowOptions): string {
85
+ const androidPackage = resolveAndroidDeepLinkPackage(options)
86
+ return options.androidAppDownloadUrl
87
+ ?? `https://play.google.com/store/apps/details?id=${androidPackage}`
88
+ }
89
+
90
+ export function resolveAndroidDeepLinkPackage(options: FlowOptions): string {
91
+ return options.androidDeepLinkPackage ?? DEFAULT_ANDROID_DEEP_LINK_PACKAGE
92
+ }
93
+
94
+ export function resolveCanUseDeferredDeepLinksFlow(
95
+ options: FlowOptions,
96
+ device: 'ios' | 'android',
97
+ ): boolean {
98
+ return options.canUseDeferredDeepLinksFlow ?? (device === 'android')
99
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The `new-tab`/`iframe` mechanisms shared by the `builder` and `portals`
3
+ * targets. Ported from the reference JS SDK's `openPortalTab`/
4
+ * `embedPortalIframe` (`Reclaim.ts` ~1159/~1191).
5
+ */
6
+ import type {
7
+ LaunchContainer,
8
+ LaunchElement,
9
+ LaunchEnvironment,
10
+ LaunchWindowHandle
11
+ } from './environment.ts'
12
+
13
+ export interface PortalTabHandle {
14
+ tab: LaunchWindowHandle | null
15
+ close(): void
16
+ }
17
+
18
+ /**
19
+ * Opens `url` in a new tab. Popup blockers kill a `window.open` that happens
20
+ * after a network round trip, so a caller that already opened
21
+ * `window.open('about:blank')` synchronously inside the click handler passes
22
+ * it as `preOpenedTab` — this just navigates it, preserving the user
23
+ * gesture. Falls back to opening fresh (and recovers if the pre-opened tab
24
+ * never actually navigated, e.g. a blocker allowed the open but not the
25
+ * navigation) exactly as the reference SDK does.
26
+ */
27
+ export function openPortalTab(
28
+ env: LaunchEnvironment,
29
+ url: string,
30
+ preOpenedTab?: LaunchWindowHandle | null,
31
+ ): PortalTabHandle {
32
+ // Matches the reference SDK exactly: the fresh path also opens
33
+ // `about:blank` first rather than the real URL directly, so the recovery
34
+ // check below (which looks for a tab stuck at `about:blank`) applies
35
+ // uniformly to both paths.
36
+ const tab = preOpenedTab ?? env.open('about:blank', '_blank')
37
+ if(!tab) {
38
+ return { tab: null, close() {} }
39
+ }
40
+
41
+ tab.navigate(url)
42
+
43
+ // Verify navigation actually happened; recover by opening fresh if not.
44
+ const recoveryHandle = env.setTimeout(() => {
45
+ try {
46
+ if(tab.location.href === 'about:blank') {
47
+ tab.close()
48
+ env.open(url, '_blank')
49
+ }
50
+ } catch{
51
+ // Cross-origin after navigation means it worked.
52
+ }
53
+ }, 500)
54
+
55
+ return {
56
+ tab,
57
+ close() {
58
+ env.clearTimeout(recoveryHandle)
59
+ try {
60
+ tab.close()
61
+ } catch{
62
+ // Cross-origin — tab may have navigated away.
63
+ }
64
+ },
65
+ }
66
+ }
67
+
68
+ export interface PortalIframeHandle {
69
+ iframe: LaunchElement | undefined
70
+ close(): void
71
+ }
72
+
73
+ /** Embeds `url` inside `container` (`options.target`). Appends
74
+ * `embedded=true` so the portal skips extension detection, matching the
75
+ * reference SDK. */
76
+ export function embedPortalIframe(
77
+ env: LaunchEnvironment,
78
+ url: string,
79
+ container: LaunchContainer,
80
+ ): PortalIframeHandle {
81
+ if(!env.document) {
82
+ return { iframe: undefined, close() {} }
83
+ }
84
+
85
+ const embedUrl = new URL(url)
86
+ embedUrl.searchParams.set('embedded', 'true')
87
+
88
+ const iframe = env.document.createElement('iframe')
89
+ iframe.src = embedUrl.toString()
90
+ iframe.style.width = '100%'
91
+ iframe.style.height = '100%'
92
+ iframe.style.border = 'none'
93
+ iframe.setAttribute('allow', 'clipboard-write')
94
+ iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms')
95
+
96
+ container.appendChild(iframe)
97
+
98
+ return {
99
+ iframe,
100
+ close() {
101
+ iframe.remove()
102
+ },
103
+ }
104
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Default QR renderer — bundled so the desktop `verifier-app` modal works
3
+ * with zero configuration (`options.renderQr` overrides it for a branded
4
+ * code). Renders an inline SVG (no `<canvas>`/image needed) from the vendored
5
+ * encoder in ./vendor/qrcodegen.ts.
6
+ */
7
+ import { QrCode, QrCodeEcc } from './vendor/qrcodegen.ts'
8
+ import type { LaunchElement } from './environment.ts'
9
+
10
+ export type RenderQr = (
11
+ url: string,
12
+ container: LaunchElement,
13
+ ) => void | Promise<void>
14
+
15
+ /** Renders `url` as an inline SVG QR code into `container`. Quiet zone of 4
16
+ * modules, as the QR spec recommends. */
17
+ export const defaultRenderQr: RenderQr = (url, container) => {
18
+ const qr = QrCode.encodeText(url, QrCodeEcc.MEDIUM)
19
+ const quietZone = 4
20
+ const dimension = qr.size + quietZone * 2
21
+
22
+ let path = ''
23
+ for(let y = 0; y < qr.size; y++) {
24
+ for(let x = 0; x < qr.size; x++) {
25
+ if(qr.getModule(x, y)) {
26
+ path += `M${x + quietZone},${y + quietZone}h1v1h-1z`
27
+ }
28
+ }
29
+ }
30
+
31
+ const viewBox = `0 0 ${dimension} ${dimension}`
32
+ container.innerHTML = `<svg viewBox="${viewBox}" width="200" height="200" `
33
+ + 'role="img" aria-label="QR code" xmlns="http://www.w3.org/2000/svg">'
34
+ + `<rect width="${dimension}" height="${dimension}" fill="#fff"/>`
35
+ + `<path d="${path}" fill="#000"/>`
36
+ + '</svg>'
37
+ }