@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
package/src/jwks.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* JWKS (RFC 7517) discovery for verifying result/proof signatures. Signers
|
|
3
|
-
* (
|
|
3
|
+
* (Builder, the Attestor) publish their secp256k1 public keys
|
|
4
4
|
* at `<iss>/.well-known/jwks.json` with `kid` = the signer's ETH address.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
b64url,
|
|
8
|
+
base64urlToBytes,
|
|
9
|
+
bytesToHex,
|
|
10
|
+
stripTrailingSlashes,
|
|
11
|
+
} from './encoding.ts'
|
|
7
12
|
import { addressFromUncompressedPubkey, secp } from './secp.ts'
|
|
8
13
|
|
|
9
14
|
export interface Jwk {
|
|
@@ -17,10 +22,6 @@ export interface Jwk {
|
|
|
17
22
|
key_ops?: string[]
|
|
18
23
|
/** Restricts how Builder consumers may use this key. */
|
|
19
24
|
'x-reclaim-purpose'?: 'verification-result' | 'attestor-proof'
|
|
20
|
-
/** Verification Client that owns a result-signing key. */
|
|
21
|
-
'x-reclaim-verification-client-id'?: string
|
|
22
|
-
/** Verification Clients that share a built-in result-signing key. */
|
|
23
|
-
'x-reclaim-verification-client-ids'?: string[]
|
|
24
25
|
/** Attestor proofs use EIP-191 rather than JOSE ES256K. */
|
|
25
26
|
'x-reclaim-signature-scheme'?: 'EIP-191'
|
|
26
27
|
}
|
|
@@ -29,21 +30,18 @@ export interface ReclaimJwks {
|
|
|
29
30
|
keys: Jwk[]
|
|
30
31
|
/** Trusted EIP-191 attestor signer addresses (public-key hashes). */
|
|
31
32
|
'x-reclaim-attestors'?: string[]
|
|
33
|
+
/** Trusted sha256 digests for attestor Confidential Space images. */
|
|
34
|
+
'x-reclaim-attestor-image-digests'?: string[]
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
|
-
* Build the JWK set for an ES256K signing key (
|
|
38
|
+
* Build the JWK set for an ES256K signing key (Builder's managed
|
|
36
39
|
* result-signing key). `kid` is the key's ETH address; `x`/`y` are the
|
|
37
40
|
* uncompressed public-key coordinates. Empty set when no key is configured.
|
|
38
41
|
*/
|
|
39
42
|
export function jwksFromPrivateKey(
|
|
40
43
|
privateKeyHex?: string,
|
|
41
|
-
metadata: Pick<
|
|
42
|
-
Jwk,
|
|
43
|
-
'x-reclaim-purpose'
|
|
44
|
-
| 'x-reclaim-verification-client-id'
|
|
45
|
-
| 'x-reclaim-verification-client-ids'
|
|
46
|
-
> = {},
|
|
44
|
+
metadata: Pick<Jwk, 'x-reclaim-purpose'> = {},
|
|
47
45
|
): { keys: Jwk[] } {
|
|
48
46
|
if(!privateKeyHex) {
|
|
49
47
|
return { keys: [] }
|
|
@@ -76,7 +74,7 @@ export async function fetchJwks(
|
|
|
76
74
|
iss: string,
|
|
77
75
|
fetchImpl: typeof fetch = fetch,
|
|
78
76
|
): Promise<Jwk[]> {
|
|
79
|
-
const base = iss
|
|
77
|
+
const base = stripTrailingSlashes(iss)
|
|
80
78
|
return fetchJwksUrl(`${base}/.well-known/jwks.json`, fetchImpl)
|
|
81
79
|
}
|
|
82
80
|
|
|
@@ -94,6 +92,7 @@ export async function fetchReclaimJwks(
|
|
|
94
92
|
url: string,
|
|
95
93
|
fetchImpl: typeof fetch = fetch,
|
|
96
94
|
): Promise<ReclaimJwks> {
|
|
95
|
+
assertSecureJwksUrl(url)
|
|
97
96
|
const res = await fetchImpl(url)
|
|
98
97
|
if(!res.ok) {
|
|
99
98
|
throw new Error(`JWKS fetch failed for ${url}: HTTP ${res.status}`)
|
|
@@ -105,6 +104,9 @@ export async function fetchReclaimJwks(
|
|
|
105
104
|
'x-reclaim-attestors': validateAttestorAddresses(
|
|
106
105
|
body['x-reclaim-attestors'] ?? [],
|
|
107
106
|
),
|
|
107
|
+
'x-reclaim-attestor-image-digests': validateAttestorImageDigests(
|
|
108
|
+
body['x-reclaim-attestor-image-digests'] ?? [],
|
|
109
|
+
),
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
|
|
@@ -114,16 +116,20 @@ export function selectKey(keys: Jwk[], kid: string): Jwk | undefined {
|
|
|
114
116
|
return keys.find((k) => (k.kid ?? '').toLowerCase() === want)
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
/** Select
|
|
119
|
+
/** Select Builder's result-signing key and validate its JOSE metadata. */
|
|
118
120
|
export function selectVerificationResultKey(
|
|
119
121
|
keys: Jwk[],
|
|
120
122
|
kid: string,
|
|
121
|
-
verificationClientId: string,
|
|
122
123
|
): Jwk | undefined {
|
|
123
124
|
return keys.find((key) => (
|
|
124
125
|
(key.kid ?? '').toLowerCase() === kid.toLowerCase()
|
|
126
|
+
&& key.kty === 'EC'
|
|
127
|
+
&& key.crv === 'secp256k1'
|
|
128
|
+
&& (!key.alg || key.alg === 'ES256K')
|
|
129
|
+
&& (!key.use || key.use === 'sig')
|
|
130
|
+
&& (!key.key_ops || key.key_ops.includes('verify'))
|
|
125
131
|
&& key['x-reclaim-purpose'] !== 'attestor-proof'
|
|
126
|
-
&&
|
|
132
|
+
&& jwkMatchesKid(key)
|
|
127
133
|
))
|
|
128
134
|
}
|
|
129
135
|
|
|
@@ -167,6 +173,18 @@ export function validateAttestorAddresses(addresses: string[]): string[] {
|
|
|
167
173
|
}))]
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
/** Validate and normalize sha256 image digests from a JWKS trust extension. */
|
|
177
|
+
export function validateAttestorImageDigests(digests: string[]): string[] {
|
|
178
|
+
return [...new Set(digests.map((digest) => {
|
|
179
|
+
const normalized = digest.trim().toLowerCase()
|
|
180
|
+
if(!/^sha256:[0-9a-f]{64}$/.test(normalized)) {
|
|
181
|
+
throw new Error('invalid attestor image digest in JWKS trust registry')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return normalized
|
|
185
|
+
}))]
|
|
186
|
+
}
|
|
187
|
+
|
|
170
188
|
/**
|
|
171
189
|
* Reconstruct the uncompressed secp256k1 public key (`0x04` + X + Y) from an
|
|
172
190
|
* EC JWK's base64url `x`/`y` coordinates, for signature verification.
|
|
@@ -185,23 +203,27 @@ export function jwkToUncompressedPublicKey(jwk: Jwk): string {
|
|
|
185
203
|
return '0x' + bytesToHex(out)
|
|
186
204
|
}
|
|
187
205
|
|
|
188
|
-
function
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return
|
|
206
|
+
function jwkMatchesKid(key: Jwk): boolean {
|
|
207
|
+
try {
|
|
208
|
+
const address = addressFromUncompressedPubkey(
|
|
209
|
+
hexPublicKeyBytes(jwkToUncompressedPublicKey(key)),
|
|
210
|
+
)
|
|
211
|
+
return address.toLowerCase() === key.kid?.toLowerCase()
|
|
212
|
+
} catch{
|
|
213
|
+
return false
|
|
196
214
|
}
|
|
215
|
+
}
|
|
197
216
|
|
|
198
|
-
|
|
199
|
-
|
|
217
|
+
function assertSecureJwksUrl(value: string) {
|
|
218
|
+
const url = new URL(value)
|
|
219
|
+
if(url.protocol === 'https:' || (
|
|
220
|
+
url.protocol === 'http:'
|
|
221
|
+
&& ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
|
222
|
+
)) {
|
|
223
|
+
return
|
|
200
224
|
}
|
|
201
225
|
|
|
202
|
-
|
|
203
|
-
// selected from the registered Verification Client record.
|
|
204
|
-
return key['x-reclaim-purpose'] !== 'verification-result'
|
|
226
|
+
throw new Error('JWKS URL must use HTTPS except on loopback')
|
|
205
227
|
}
|
|
206
228
|
|
|
207
229
|
function hexPublicKeyBytes(value: string) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rebuilds the real, openable destination URL for a parsed flow.
|
|
3
|
+
*
|
|
4
|
+
* Since migration `20260917130000000.share-routes-uris.sql`, every built-in
|
|
5
|
+
* client's registered uri is itself a real, openable Builder page (`/share/
|
|
6
|
+
* {name}` — see `builtin-clients.ts` and `SharePage.tsx`), including
|
|
7
|
+
* `portals`, which used to be an attribution-only label Builder served
|
|
8
|
+
* nothing at. That does NOT mean this file's job shrinks to "pass every
|
|
9
|
+
* target through unchanged": which side rewrites is decided per target by
|
|
10
|
+
* who is able to honour an override and who has to keep working against a
|
|
11
|
+
* pre-migration deployment, not by whether Builder happens to have a page
|
|
12
|
+
* there today.
|
|
13
|
+
*
|
|
14
|
+
* - `builder`: registered uri (`/v/{sessionId}` pre-migration, `/share/
|
|
15
|
+
* builder` post-migration) is always real and there is no override to
|
|
16
|
+
* express — Builder's own route owns any redirecting (`/share/builder`
|
|
17
|
+
* 302s to `/v/{sessionId}` server-side). This file never rewrites it.
|
|
18
|
+
* - `portals`: this file keeps rewriting to `options.portalUrl ??
|
|
19
|
+
* DEFAULT_PORTAL_URL`, unconditionally, even though `/share/portals` now
|
|
20
|
+
* redirects there too. Two reasons it can't defer to Builder's hop: (1) a
|
|
21
|
+
* pre-migration `/portals` uri is still a bare label Builder serves
|
|
22
|
+
* nothing at, so passing it through would 404; (2) `options.portalUrl` is
|
|
23
|
+
* an override Builder's fixed server-side redirect has no way to honour.
|
|
24
|
+
* `DEFAULT_PORTAL_URL` here and `SharePage.tsx`'s redirect default are
|
|
25
|
+
* deliberately kept identical, so the common (no-override) case reaches
|
|
26
|
+
* the same destination either hop — Builder's `/share/portals` hop is
|
|
27
|
+
* what makes a *pasted* link (opened without this SDK) still work.
|
|
28
|
+
* - `verifier-app`: registered uri (`/s` or `/share/verifier-app`, both
|
|
29
|
+
* real) is the share link itself, so this file passes it through unless
|
|
30
|
+
* `options.appLinkBaseUrl` overrides it — same shape of reasoning as
|
|
31
|
+
* `portals`, just already correct before this migration.
|
|
32
|
+
* - `reclaim-browser-extension`: has no destination URL at all — the
|
|
33
|
+
* mechanism is a `postMessage` handoff, not a navigation (see
|
|
34
|
+
* ./extension.ts) — so `getUrl()` on that target, like an unrecognised
|
|
35
|
+
* path, returns the original `verificationUrl` unchanged regardless of
|
|
36
|
+
* which shape registered it.
|
|
37
|
+
*/
|
|
38
|
+
import { stripTrailingSlashes } from '../encoding.ts'
|
|
39
|
+
import type { ParsedFlowUrl, VerificationTarget } from './target.ts'
|
|
40
|
+
|
|
41
|
+
export interface DestinationOptions {
|
|
42
|
+
portalUrl?: string
|
|
43
|
+
appLinkBaseUrl?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_PORTAL_URL = 'https://portal.reclaimprotocol.org'
|
|
47
|
+
/** The legacy share page. No longer a default — `verifier-app` sessions now
|
|
48
|
+
* carry a real Builder share URL — but kept as the value a deployment still
|
|
49
|
+
* pointing at `share.reclaimprotocol.org` would pass as `appLinkBaseUrl`. */
|
|
50
|
+
export const LEGACY_APP_LINK_BASE_URL
|
|
51
|
+
= 'https://share.reclaimprotocol.org/verify'
|
|
52
|
+
|
|
53
|
+
/** Copies `api`/`sessionId`/`diag` onto `base`, preserving any query
|
|
54
|
+
* parameters `base` already carries (e.g. an App Clip's `?p=`). */
|
|
55
|
+
export function appendSessionQuery(
|
|
56
|
+
base: string,
|
|
57
|
+
parsed: Pick<ParsedFlowUrl, 'sessionId' | 'diag'>,
|
|
58
|
+
): string {
|
|
59
|
+
const url = new URL(base)
|
|
60
|
+
url.searchParams.set('api', '2')
|
|
61
|
+
if(parsed.sessionId) {
|
|
62
|
+
url.searchParams.set('sessionId', parsed.sessionId)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if(parsed.diag) {
|
|
66
|
+
url.searchParams.set('diag', '1')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return url.toString()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function withoutTrailingSlash(value: string): string {
|
|
73
|
+
return stripTrailingSlashes(value)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveDestinationUrl(
|
|
77
|
+
target: VerificationTarget | undefined,
|
|
78
|
+
parsed: ParsedFlowUrl,
|
|
79
|
+
options: DestinationOptions,
|
|
80
|
+
): string {
|
|
81
|
+
switch (target) {
|
|
82
|
+
case 'builder':
|
|
83
|
+
return parsed.url.toString()
|
|
84
|
+
case 'portals': {
|
|
85
|
+
// Always rewritten here, even now that `/share/portals` also
|
|
86
|
+
// redirects server-side — see the file header for why this file,
|
|
87
|
+
// not Builder's route, has to own it.
|
|
88
|
+
const base = options.portalUrl ?? DEFAULT_PORTAL_URL
|
|
89
|
+
return appendSessionQuery(`${withoutTrailingSlash(base)}/verify`, parsed)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case 'verifier-app': {
|
|
93
|
+
// The registered uri for `verifier-app` is Builder's own share page
|
|
94
|
+
// (`GET /s`), a real openable URL — so the verificationUrl already
|
|
95
|
+
// IS the share link, and rewriting it onto another origin would
|
|
96
|
+
// throw away the app-link association that makes a scan open the
|
|
97
|
+
// app (`build.reclaimprotocol.org` is what the app's entitlements
|
|
98
|
+
// and assetlinks now claim).
|
|
99
|
+
//
|
|
100
|
+
// `appLinkBaseUrl` stays as the override for a deployment that
|
|
101
|
+
// hosts its own share page, or one still pointing at the legacy
|
|
102
|
+
// `share.reclaimprotocol.org/verify`.
|
|
103
|
+
const base = options.appLinkBaseUrl
|
|
104
|
+
return base ? appendSessionQuery(base, parsed) : parsed.url.toString()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'reclaim-browser-extension':
|
|
108
|
+
case undefined:
|
|
109
|
+
return parsed.url.toString()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device detection, ported from the reference JS SDK's `src/utils/device.ts`
|
|
3
|
+
* (`getDeviceType`/`getMobileDeviceType`). Two differences from that source,
|
|
4
|
+
* both required by the launch-API plan:
|
|
5
|
+
*
|
|
6
|
+
* - Every signal is read through a `LaunchEnvironment` (see ./environment.ts)
|
|
7
|
+
* instead of module-scope `navigator`/`window`, so this is testable with
|
|
8
|
+
* plain fakes and safe to import under SSR.
|
|
9
|
+
* - The result carries a `confidence` ('high' | 'low') instead of silently
|
|
10
|
+
* committing to a guess, so a caller can render a chooser instead of
|
|
11
|
+
* assuming. `navigator.userAgentData` (Chromium's low-entropy User-Agent
|
|
12
|
+
* Client Hints) is read as an extra signal where present — it is
|
|
13
|
+
* Chromium-only and absent on every iOS browser, so it augments the
|
|
14
|
+
* heuristics below rather than replacing them, and `getHighEntropyValues`
|
|
15
|
+
* is never called (it's async and equally absent on WebKit).
|
|
16
|
+
*
|
|
17
|
+
* The mobile-vs-desktop scoring mirrors the reference SDK's weights exactly
|
|
18
|
+
* (`CONFIDENCE_THRESHOLD = 3`); the iOS-vs-Android cascade mirrors its rule
|
|
19
|
+
* order, including the iPad-reporting-as-macOS special case.
|
|
20
|
+
*/
|
|
21
|
+
import type { LaunchEnvironment, LaunchWindow } from './environment.ts'
|
|
22
|
+
|
|
23
|
+
export type DeviceKind = 'desktop' | 'ios' | 'android'
|
|
24
|
+
export type DetectionConfidence = 'high' | 'low'
|
|
25
|
+
|
|
26
|
+
export interface DeviceDetection {
|
|
27
|
+
device: DeviceKind
|
|
28
|
+
confidence: DetectionConfidence
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Matches the reference SDK's mobile/desktop scoring cutoff. */
|
|
32
|
+
const MOBILE_SCORE_THRESHOLD = 3
|
|
33
|
+
/** A mobile/desktop score this close to the cutoff is a coin flip, not a
|
|
34
|
+
* confident read — surfaced as `confidence: 'low'`. */
|
|
35
|
+
const MOBILE_SCORE_MARGIN = 2
|
|
36
|
+
|
|
37
|
+
function safeMatchMedia(win: LaunchWindow | undefined, query: string): boolean {
|
|
38
|
+
try {
|
|
39
|
+
return win?.matchMedia?.(query)?.matches ?? false
|
|
40
|
+
} catch{
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function safeCssSupports(
|
|
46
|
+
win: LaunchWindow | undefined,
|
|
47
|
+
property: string,
|
|
48
|
+
value: string,
|
|
49
|
+
): boolean {
|
|
50
|
+
try {
|
|
51
|
+
return win?.CSS?.supports?.(property, value) ?? false
|
|
52
|
+
} catch{
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeQuerySelector(env: LaunchEnvironment, selector: string): boolean {
|
|
58
|
+
try {
|
|
59
|
+
return !!env.document?.querySelector(selector)
|
|
60
|
+
} catch{
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Ported scoring pass. Returns the raw mobile score — positive leans
|
|
66
|
+
* mobile, negative leans desktop. */
|
|
67
|
+
function scoreMobile(env: LaunchEnvironment): number {
|
|
68
|
+
const win = env.window
|
|
69
|
+
const nav = env.navigator
|
|
70
|
+
const userAgent = (nav?.userAgent ?? '').toLowerCase()
|
|
71
|
+
|
|
72
|
+
if(!win || !nav) {
|
|
73
|
+
// SSR / no environment: the reference SDK assumes desktop.
|
|
74
|
+
return -MOBILE_SCORE_THRESHOLD
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let score = 0
|
|
78
|
+
|
|
79
|
+
const screenWidth = win.innerWidth || win.screen?.width || 0
|
|
80
|
+
const screenHeight = win.innerHeight || win.screen?.height || 0
|
|
81
|
+
const hasSmallScreen = screenWidth <= 480 || screenHeight <= 480
|
|
82
|
+
const hasLargeScreen = screenWidth > 1024 && screenHeight > 768
|
|
83
|
+
|
|
84
|
+
const hasTouch = 'ontouchstart' in win || (nav.maxTouchPoints ?? 0) > 0
|
|
85
|
+
const hasPreciseMouse = safeMatchMedia(win, '(pointer: fine)')
|
|
86
|
+
const canHover = safeMatchMedia(win, '(hover: hover)')
|
|
87
|
+
const hasMouseAndTouch = hasTouch && hasPreciseMouse
|
|
88
|
+
|
|
89
|
+
const isWindowsTouchLaptop = /windows/i.test(userAgent)
|
|
90
|
+
&& hasPreciseMouse && hasTouch
|
|
91
|
+
|
|
92
|
+
if(hasTouch && !hasMouseAndTouch) {
|
|
93
|
+
score += 2
|
|
94
|
+
} else if(hasMouseAndTouch) {
|
|
95
|
+
score += 1
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if(hasSmallScreen && !isWindowsTouchLaptop) {
|
|
99
|
+
score += 2
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mobileUaPattern = new RegExp(
|
|
103
|
+
'android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile',
|
|
104
|
+
'i',
|
|
105
|
+
)
|
|
106
|
+
const hasMobileUserAgent = mobileUaPattern.test(userAgent)
|
|
107
|
+
if(hasMobileUserAgent) {
|
|
108
|
+
score += 3
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const hasMobileApis = 'orientation' in win
|
|
112
|
+
|| 'DeviceMotionEvent' in win
|
|
113
|
+
|| 'DeviceOrientationEvent' in win
|
|
114
|
+
const isDesktopSafari = /safari/i.test(userAgent)
|
|
115
|
+
&& !/mobile/i.test(userAgent)
|
|
116
|
+
&& /mac|intel/i.test(userAgent)
|
|
117
|
+
const mobileApisImplyMobile = hasMobileApis
|
|
118
|
+
&& (hasSmallScreen || hasMobileUserAgent)
|
|
119
|
+
&& !isDesktopSafari
|
|
120
|
+
&& !isWindowsTouchLaptop
|
|
121
|
+
if(mobileApisImplyMobile) {
|
|
122
|
+
score += 2
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const hasHighDpi = (win.devicePixelRatio ?? 0) > 1.5
|
|
126
|
+
if(hasHighDpi && hasSmallScreen) {
|
|
127
|
+
score += 1
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const hasViewportMeta = safeQuerySelector(env, 'meta[name="viewport"]')
|
|
131
|
+
if(hasViewportMeta && hasSmallScreen) {
|
|
132
|
+
score += 1
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const isPadProInDesktopMode = userAgent.includes('macintosh') && hasTouch
|
|
136
|
+
if(isPadProInDesktopMode) {
|
|
137
|
+
score += 2
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if(hasLargeScreen && hasPreciseMouse) {
|
|
141
|
+
score -= 3
|
|
142
|
+
} else if(hasLargeScreen && !hasTouch) {
|
|
143
|
+
score -= 2
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if(hasPreciseMouse && canHover) {
|
|
147
|
+
score -= 2
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const isWindowsDesktop = /windows/i.test(userAgent) && !hasMobileUserAgent
|
|
151
|
+
if(isWindowsDesktop) {
|
|
152
|
+
score -= 3
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Chromium's low-entropy UA-CH `mobile` hint: an explicit, high-confidence
|
|
156
|
+
// signal where present, applied on top of the heuristic score above rather
|
|
157
|
+
// than replacing it (it's absent on WebKit/Firefox entirely).
|
|
158
|
+
if(nav.userAgentData?.mobile === true) {
|
|
159
|
+
score += 2
|
|
160
|
+
} else if(nav.userAgentData?.mobile === false) {
|
|
161
|
+
score -= 2
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return score
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface MobileOsGuess {
|
|
168
|
+
os: 'ios' | 'android'
|
|
169
|
+
/** False only for the reference SDK's final fallback: no rule matched
|
|
170
|
+
* either OS, so it defaulted to Android because that's the more common
|
|
171
|
+
* global install base — a guess, not a read. */
|
|
172
|
+
explicit: boolean
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Ported iOS/Android cascade. Only called once `scoreMobile` has decided the
|
|
176
|
+
* device is mobile. */
|
|
177
|
+
function guessMobileOs(env: LaunchEnvironment): MobileOsGuess {
|
|
178
|
+
const win = env.window
|
|
179
|
+
const nav = env.navigator
|
|
180
|
+
const userAgent = nav?.userAgent ?? ''
|
|
181
|
+
const hasTouch = !!win
|
|
182
|
+
&& ('ontouchstart' in win || (nav?.maxTouchPoints ?? 0) > 0)
|
|
183
|
+
|
|
184
|
+
if(/iPad|iPhone|iPod/i.test(userAgent)) {
|
|
185
|
+
return { os: 'ios', explicit: true }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// iPad Pro reports as a Mac (both in the UA string and in UA-CH's
|
|
189
|
+
// `platform` hint) once it gains a keyboard/trackpad, but keeps touch.
|
|
190
|
+
const isMacWithTouch = /Macintosh|MacIntel/i.test(userAgent) && hasTouch
|
|
191
|
+
const isMacOsWithTouch = nav?.userAgentData?.platform === 'macOS' && hasTouch
|
|
192
|
+
if(isMacWithTouch || isMacOsWithTouch) {
|
|
193
|
+
return { os: 'ios', explicit: true }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const hasIosPermissionApi = typeof win?.DeviceMotionEvent?.requestPermission
|
|
197
|
+
=== 'function'
|
|
198
|
+
const hasIosTouchCallout = safeCssSupports(
|
|
199
|
+
win, '-webkit-touch-callout', 'none',
|
|
200
|
+
)
|
|
201
|
+
if(hasIosPermissionApi || hasIosTouchCallout) {
|
|
202
|
+
return { os: 'ios', explicit: true }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const isIosWebKit = /WebKit/i.test(userAgent)
|
|
206
|
+
&& !/Chrome|CriOS|Android/i.test(userAgent)
|
|
207
|
+
&& !/Macintosh|MacIntel/i.test(userAgent)
|
|
208
|
+
if(isIosWebKit) {
|
|
209
|
+
return { os: 'ios', explicit: true }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if(/Android/i.test(userAgent)) {
|
|
213
|
+
return { os: 'android', explicit: true }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if(nav?.userAgentData?.platform === 'Android') {
|
|
217
|
+
return { os: 'android', explicit: true }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Final fallback: no rule matched. Android is the more common global
|
|
221
|
+
// install base, but this is a guess — callers see `confidence: 'low'`.
|
|
222
|
+
return { os: 'android', explicit: false }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Resolves the device this flow is running on, with a confidence a caller
|
|
226
|
+
* can act on instead of a silent guess. */
|
|
227
|
+
export function resolveDevice(env: LaunchEnvironment): DeviceDetection {
|
|
228
|
+
const score = scoreMobile(env)
|
|
229
|
+
const isMobile = score >= MOBILE_SCORE_THRESHOLD
|
|
230
|
+
const borderline
|
|
231
|
+
= Math.abs(score - MOBILE_SCORE_THRESHOLD) < MOBILE_SCORE_MARGIN
|
|
232
|
+
|
|
233
|
+
if(!isMobile) {
|
|
234
|
+
return { device: 'desktop', confidence: borderline ? 'low' : 'high' }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const guess = guessMobileOs(env)
|
|
238
|
+
return {
|
|
239
|
+
device: guess.os,
|
|
240
|
+
confidence: borderline || !guess.explicit ? 'low' : 'high',
|
|
241
|
+
}
|
|
242
|
+
}
|