@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.
- 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,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, '&')
|
|
375
|
+
.replace(/</g, '<')
|
|
376
|
+
.replace(/>/g, '>')
|
|
377
|
+
.replace(/"/g, '"')
|
|
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
|
+
}
|
package/src/launch/qr.ts
ADDED
|
@@ -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
|
+
}
|