@limrun/ui 0.9.0-rc.1 → 0.9.0-rc.10

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 (47) hide show
  1. package/dist/components/inspect-overlay.d.ts +33 -0
  2. package/dist/components/remote-control.d.ts +86 -0
  3. package/dist/core/ax-fetcher.d.ts +49 -0
  4. package/dist/core/ax-tree.d.ts +99 -0
  5. package/dist/core/device-install/apple/client.d.ts +1 -0
  6. package/dist/core/device-install/apple/provisioning.d.ts +42 -31
  7. package/dist/core/device-install/apple/relay.d.ts +5 -9
  8. package/dist/core/device-install/storage/browser-storage.d.ts +19 -0
  9. package/dist/core/device-install/types.d.ts +2 -2
  10. package/dist/device-install/index.cjs +1 -9
  11. package/dist/device-install/index.js +76 -210
  12. package/dist/device-install/react.cjs +1 -1
  13. package/dist/device-install/react.js +1 -1
  14. package/dist/device-install-dialog-DJkKg8y6.mjs +443 -0
  15. package/dist/device-install-dialog-DfRemlSg.js +2 -0
  16. package/dist/device-install-dialog.css +1 -1
  17. package/dist/hooks/use-device-install.d.ts +21 -3
  18. package/dist/index.cjs +1 -1
  19. package/dist/index.css +1 -1
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +1485 -778
  22. package/dist/use-device-install-CS201taa.js +31 -0
  23. package/dist/use-device-install-jz1BMMQx.mjs +13627 -0
  24. package/package.json +7 -3
  25. package/src/components/device-install/device-install-dialog.css +82 -1
  26. package/src/components/device-install/device-install-dialog.tsx +319 -187
  27. package/src/components/inspect-overlay.css +223 -0
  28. package/src/components/inspect-overlay.tsx +437 -0
  29. package/src/components/remote-control.tsx +547 -9
  30. package/src/core/ax-fetcher.test.ts +418 -0
  31. package/src/core/ax-fetcher.ts +377 -0
  32. package/src/core/ax-tree.test.ts +491 -0
  33. package/src/core/ax-tree.ts +416 -0
  34. package/src/core/device-install/apple/client.ts +92 -4
  35. package/src/core/device-install/apple/provisioning.ts +67 -24
  36. package/src/core/device-install/apple/relay.ts +121 -205
  37. package/src/core/device-install/operations/limbuild-client.ts +1 -1
  38. package/src/core/device-install/storage/browser-storage.ts +26 -1
  39. package/src/core/device-install/types.ts +2 -2
  40. package/src/demo.tsx +93 -10
  41. package/src/hooks/use-device-install.ts +766 -67
  42. package/src/index.ts +19 -1
  43. package/vitest.config.ts +23 -0
  44. package/dist/device-install-dialog-CTwVViYY.js +0 -2
  45. package/dist/device-install-dialog-zzKJu7SM.mjs +0 -328
  46. package/dist/use-device-install-CgrOKKyi.mjs +0 -13042
  47. package/dist/use-device-install-DDKRf6IL.js +0 -23
@@ -0,0 +1,416 @@
1
+ // Accessibility tree types, normalizers, and helpers shared by the inspect
2
+ // overlay and exported for customers building their own panels.
3
+ //
4
+ // We unify two server response shapes into a single AxSnapshot:
5
+ //
6
+ // iOS (limulator): {type:'elementTreeResult', id, json: '<nested-tree-json>'}
7
+ // Android (scrcpy): {type:'getElementTreeResult', id, payload:{nodes:[flat]}}
8
+ // (also emits legacy {type:'elementTreeResult', id, json:'<xml>'})
9
+ //
10
+ // Both are flattened into a single list of AxElement; positions are expressed
11
+ // in a normalized screen coordinate space derived from the root rect so
12
+ // rendering can use plain percentages.
13
+
14
+ export interface AxRect {
15
+ x: number;
16
+ y: number;
17
+ width: number;
18
+ height: number;
19
+ }
20
+
21
+ export interface AxSelectors {
22
+ AXUniqueId?: string;
23
+ AXLabel?: string;
24
+ resourceId?: string;
25
+ contentDesc?: string;
26
+ text?: string;
27
+ className?: string;
28
+ }
29
+
30
+ export interface AxElement {
31
+ // Stable identity for React keys / selection persistence across snapshots.
32
+ // Prefers AXUniqueId / resourceId, falls back to a hierarchical path.
33
+ id: string;
34
+ // Hierarchical path within the source tree (e.g. "0.1.2"). Useful as a
35
+ // fallback identity and for debugging.
36
+ path: string;
37
+ // Human label (AXLabel on iOS, content-desc/text on Android).
38
+ label: string;
39
+ // Value (AXValue on iOS, text on Android inputs).
40
+ value: string;
41
+ // Semantic role (role_description on iOS, className on Android).
42
+ role: string;
43
+ // Element type / class name.
44
+ type: string;
45
+ // Whether the element is interactive.
46
+ enabled: boolean;
47
+ // Whether the element currently has focus.
48
+ focused: boolean;
49
+ // Bounds in the screen coordinate space of AxSnapshot.screen.
50
+ frame: AxRect;
51
+ // Selectors that map back to SDK tapElement / tap calls.
52
+ selectors: AxSelectors;
53
+ // Raw platform-specific node (without children/parsedBounds extras).
54
+ // Exposed so advanced customers can read fields we didn't surface.
55
+ raw: Record<string, unknown>;
56
+ }
57
+
58
+ export type AxPlatform = 'ios' | 'android';
59
+
60
+ export interface AxSnapshot {
61
+ platform: AxPlatform;
62
+ screen: { width: number; height: number };
63
+ elements: AxElement[];
64
+ // Unix epoch ms when the response was decoded on the client.
65
+ capturedAt: number;
66
+ errors?: string[];
67
+ }
68
+
69
+ export const AX_UNAVAILABLE_ERROR = 'Accessibility unavailable on this device.';
70
+
71
+ // Hard cap to keep React render time bounded on enormous trees.
72
+ const MAX_ELEMENTS = 500;
73
+
74
+ const rectsApproxEqual = (a: AxRect, b: AxRect): boolean =>
75
+ Math.abs(a.x - b.x) < 0.5 &&
76
+ Math.abs(a.y - b.y) < 0.5 &&
77
+ Math.abs(a.width - b.width) < 0.5 &&
78
+ Math.abs(a.height - b.height) < 0.5;
79
+
80
+ const rectArea = (r: AxRect): number => Math.max(0, r.width) * Math.max(0, r.height);
81
+
82
+ // ────────────────────────────────────────────────────────────────────────────
83
+ // iOS normalization
84
+ // ────────────────────────────────────────────────────────────────────────────
85
+
86
+ interface RawIosNode {
87
+ AXLabel?: string | null;
88
+ AXValue?: string | null;
89
+ AXUniqueId?: string | null;
90
+ // `frame` is the canonical bounds; the legacy `AXFrame` string field is
91
+ // not consumed.
92
+ frame?: { x: number; y: number; width: number; height: number };
93
+ role?: string;
94
+ role_description?: string;
95
+ type?: string;
96
+ subrole?: string | null;
97
+ title?: string | null;
98
+ enabled?: boolean;
99
+ focused?: boolean;
100
+ pid?: number;
101
+ traits?: string[];
102
+ children?: RawIosNode[];
103
+ // Some serializations carry extras we'll preserve in `raw`.
104
+ [key: string]: unknown;
105
+ }
106
+
107
+ // Picks the "screen rectangle" used as the denominator when expressing
108
+ // element positions as percentages.
109
+ //
110
+ // Apple's `accessibilityElementForFrontmostApplication()` returns the
111
+ // foreground Application element as the (only) root; its `frame` is the
112
+ // device's logical screen — `{x: 0, y: 0, width, height}` in points. The
113
+ // element frames inside the tree are in this same coordinate space, so a
114
+ // child at `(16, 64)` is 16pt from the device's left edge and 64pt from
115
+ // the top edge.
116
+ //
117
+ // We accept any root whose frame has positive dimensions to be robust
118
+ // against the edge case where the foreground app reports a non-zero
119
+ // origin (e.g. status bar excluded). In practice that never happens; if
120
+ // it does, percentages will be slightly off but the overlay will still
121
+ // roughly line up. The defensive console.warn helps us catch this in
122
+ // production telemetry without breaking the feature.
123
+ const iosScreenFrame = (roots: RawIosNode[]): AxRect => {
124
+ const root = roots.find((n) => n.frame && n.frame.width > 0 && n.frame.height > 0);
125
+ if (root?.frame) {
126
+ if (Math.abs(root.frame.x) > 0.5 || Math.abs(root.frame.y) > 0.5) {
127
+ console.warn(
128
+ `[ax-tree] iOS root frame is not anchored at (0,0): ` +
129
+ `(${root.frame.x},${root.frame.y}). Element positions may be ` +
130
+ `slightly off from the rendered video.`,
131
+ );
132
+ }
133
+ return { x: 0, y: 0, width: root.frame.width, height: root.frame.height };
134
+ }
135
+ return { x: 0, y: 0, width: 1, height: 1 };
136
+ };
137
+
138
+ const buildIosSelectors = (node: RawIosNode): AxSelectors => {
139
+ const sel: AxSelectors = {};
140
+ if (typeof node.AXUniqueId === 'string' && node.AXUniqueId.length > 0) sel.AXUniqueId = node.AXUniqueId;
141
+ if (typeof node.AXLabel === 'string' && node.AXLabel.length > 0) sel.AXLabel = node.AXLabel;
142
+ if (typeof node.type === 'string' && node.type.length > 0) sel.className = node.type;
143
+ return sel;
144
+ };
145
+
146
+ const stripIosChildren = (node: RawIosNode): Record<string, unknown> => {
147
+ const out: Record<string, unknown> = {};
148
+ for (const [k, v] of Object.entries(node)) {
149
+ if (k === 'children') continue;
150
+ out[k] = v;
151
+ }
152
+ return out;
153
+ };
154
+
155
+ export function normalizeIosTree(roots: RawIosNode[] | RawIosNode): AxSnapshot {
156
+ const rootArray = Array.isArray(roots) ? roots : [roots];
157
+ const screen = iosScreenFrame(rootArray);
158
+ const elements: AxElement[] = [];
159
+
160
+ const visit = (node: RawIosNode, path: string) => {
161
+ if (elements.length >= MAX_ELEMENTS) return;
162
+ const frame = node.frame;
163
+ if (frame && frame.width > 0 && frame.height > 0 && !rectsApproxEqual(frame, screen)) {
164
+ const role = (node.role_description as string | undefined) || (node.role as string | undefined) || '';
165
+ const label =
166
+ (typeof node.AXLabel === 'string' ? node.AXLabel : '') ||
167
+ (typeof node.title === 'string' ? (node.title as string) : '') ||
168
+ '';
169
+ elements.push({
170
+ id: typeof node.AXUniqueId === 'string' && node.AXUniqueId.length > 0 ? node.AXUniqueId : path,
171
+ path,
172
+ label,
173
+ value: typeof node.AXValue === 'string' ? node.AXValue : '',
174
+ role,
175
+ type: typeof node.type === 'string' ? node.type : '',
176
+ enabled: node.enabled !== false,
177
+ focused: node.focused === true,
178
+ frame: { x: frame.x, y: frame.y, width: frame.width, height: frame.height },
179
+ selectors: buildIosSelectors(node),
180
+ raw: stripIosChildren(node),
181
+ });
182
+ }
183
+ const children = Array.isArray(node.children) ? node.children : [];
184
+ for (let i = 0; i < children.length && elements.length < MAX_ELEMENTS; i++) {
185
+ visit(children[i]!, `${path}.${i}`);
186
+ }
187
+ };
188
+
189
+ for (let i = 0; i < rootArray.length && elements.length < MAX_ELEMENTS; i++) {
190
+ visit(rootArray[i]!, String(i));
191
+ }
192
+
193
+ return {
194
+ platform: 'ios',
195
+ screen: { width: screen.width, height: screen.height },
196
+ elements,
197
+ capturedAt: Date.now(),
198
+ };
199
+ }
200
+
201
+ // ────────────────────────────────────────────────────────────────────────────
202
+ // Android normalization
203
+ // ────────────────────────────────────────────────────────────────────────────
204
+
205
+ interface RawAndroidParsedBounds {
206
+ left: number;
207
+ top: number;
208
+ right: number;
209
+ bottom: number;
210
+ centerX: number;
211
+ centerY: number;
212
+ }
213
+
214
+ interface RawAndroidNode {
215
+ index?: string;
216
+ text?: string;
217
+ resourceId?: string;
218
+ className?: string;
219
+ packageName?: string;
220
+ contentDesc?: string;
221
+ clickable?: boolean;
222
+ enabled?: boolean;
223
+ focusable?: boolean;
224
+ focused?: boolean;
225
+ scrollable?: boolean;
226
+ selected?: boolean;
227
+ bounds?: string;
228
+ parsedBounds?: RawAndroidParsedBounds;
229
+ [key: string]: unknown;
230
+ }
231
+
232
+ const androidScreenFrame = (nodes: RawAndroidNode[]): AxRect => {
233
+ // Take the largest rect — uiautomator's first node is typically the
234
+ // screen-spanning FrameLayout, but tolerate weirdness by scanning.
235
+ let best: AxRect = { x: 0, y: 0, width: 1, height: 1 };
236
+ let bestArea = 0;
237
+ for (const n of nodes) {
238
+ const pb = n.parsedBounds;
239
+ if (!pb) continue;
240
+ const w = pb.right - pb.left;
241
+ const h = pb.bottom - pb.top;
242
+ const area = Math.max(0, w) * Math.max(0, h);
243
+ if (area > bestArea) {
244
+ bestArea = area;
245
+ best = { x: 0, y: 0, width: w, height: h };
246
+ }
247
+ }
248
+ return best;
249
+ };
250
+
251
+ const buildAndroidSelectors = (node: RawAndroidNode): AxSelectors => {
252
+ const sel: AxSelectors = {};
253
+ if (typeof node.resourceId === 'string' && node.resourceId.length > 0) sel.resourceId = node.resourceId;
254
+ if (typeof node.contentDesc === 'string' && node.contentDesc.length > 0) sel.contentDesc = node.contentDesc;
255
+ if (typeof node.text === 'string' && node.text.length > 0) sel.text = node.text;
256
+ if (typeof node.className === 'string' && node.className.length > 0) sel.className = node.className;
257
+ return sel;
258
+ };
259
+
260
+ export function normalizeAndroidTree(nodes: RawAndroidNode[]): AxSnapshot {
261
+ const screen = androidScreenFrame(nodes);
262
+ const elements: AxElement[] = [];
263
+
264
+ for (let i = 0; i < nodes.length && elements.length < MAX_ELEMENTS; i++) {
265
+ const node = nodes[i]!;
266
+ const pb = node.parsedBounds;
267
+ if (!pb) continue;
268
+ const width = Math.max(0, pb.right - pb.left);
269
+ const height = Math.max(0, pb.bottom - pb.top);
270
+ if (width <= 0 || height <= 0) continue;
271
+ const frame: AxRect = { x: pb.left, y: pb.top, width, height };
272
+ if (rectsApproxEqual(frame, { x: 0, y: 0, width: screen.width, height: screen.height })) continue;
273
+
274
+ const label = node.contentDesc || node.text || '';
275
+ const role = node.className || '';
276
+ elements.push({
277
+ id:
278
+ node.resourceId && node.resourceId.length > 0 ? node.resourceId
279
+ : node.contentDesc && node.contentDesc.length > 0 ? `cd:${node.contentDesc}`
280
+ : String(i),
281
+ path: String(i),
282
+ label,
283
+ value: typeof node.text === 'string' ? node.text : '',
284
+ role,
285
+ type: role,
286
+ enabled: node.enabled !== false,
287
+ focused: node.focused === true,
288
+ frame,
289
+ selectors: buildAndroidSelectors(node),
290
+ raw: { ...node },
291
+ });
292
+ }
293
+
294
+ return {
295
+ platform: 'android',
296
+ screen: { width: screen.width, height: screen.height },
297
+ elements,
298
+ capturedAt: Date.now(),
299
+ };
300
+ }
301
+
302
+ // ────────────────────────────────────────────────────────────────────────────
303
+ // Generic helpers (exported for customers building their own panels)
304
+ // ────────────────────────────────────────────────────────────────────────────
305
+
306
+ export function clampAxFrameForScreen(
307
+ frame: AxRect,
308
+ screen: { width: number; height: number },
309
+ ): AxRect | null {
310
+ const x = Math.max(0, frame.x);
311
+ const y = Math.max(0, frame.y);
312
+ const right = Math.min(screen.width, frame.x + frame.width);
313
+ const bottom = Math.min(screen.height, frame.y + frame.height);
314
+ const width = Math.max(0, right - x);
315
+ const height = Math.max(0, bottom - y);
316
+ return width > 0 && height > 0 ? { x, y, width, height } : null;
317
+ }
318
+
319
+ export function axElementsEqual(a: AxElement, b: AxElement): boolean {
320
+ if (a === b) return true;
321
+ if (a.id !== b.id || a.path !== b.path) return false;
322
+ if (a.label !== b.label || a.value !== b.value) return false;
323
+ if (a.role !== b.role || a.type !== b.type) return false;
324
+ if (a.enabled !== b.enabled || a.focused !== b.focused) return false;
325
+ const fa = a.frame;
326
+ const fb = b.frame;
327
+ return fa.x === fb.x && fa.y === fb.y && fa.width === fb.width && fa.height === fb.height;
328
+ }
329
+
330
+ export function axSnapshotsEqual(a: AxSnapshot | null, b: AxSnapshot | null): boolean {
331
+ if (a === b) return true;
332
+ if (!a || !b) return false;
333
+ if (a.platform !== b.platform) return false;
334
+ if (a.screen.width !== b.screen.width || a.screen.height !== b.screen.height) return false;
335
+ if (a.elements.length !== b.elements.length) return false;
336
+ for (let i = 0; i < a.elements.length; i++) {
337
+ if (!axElementsEqual(a.elements[i]!, b.elements[i]!)) return false;
338
+ }
339
+ return true;
340
+ }
341
+
342
+ // Returns the smallest matching element under the given point (in screen
343
+ // coordinate space). Used by the overlay for hit-testing when boxes overlap.
344
+ export function axElementAtPoint(snapshot: AxSnapshot, x: number, y: number): AxElement | null {
345
+ let best: AxElement | null = null;
346
+ let bestArea = Infinity;
347
+ for (const el of snapshot.elements) {
348
+ const f = el.frame;
349
+ if (x < f.x || y < f.y || x > f.x + f.width || y > f.y + f.height) continue;
350
+ const area = rectArea(f);
351
+ if (area < bestArea) {
352
+ bestArea = area;
353
+ best = el;
354
+ }
355
+ }
356
+ return best;
357
+ }
358
+
359
+ // Produces a one-line SDK selector expression that customers can paste into
360
+ // code. Returns null when no usable selector exists.
361
+ export function axElementSelectorExpression(el: AxElement, platform: AxPlatform): string | null {
362
+ if (platform === 'ios') {
363
+ if (el.selectors.AXUniqueId) {
364
+ return `client.tapElement({ AXUniqueId: ${JSON.stringify(el.selectors.AXUniqueId)} })`;
365
+ }
366
+ if (el.selectors.AXLabel) {
367
+ const typeHint = el.selectors.className ? `, type: ${JSON.stringify(el.selectors.className)}` : '';
368
+ return `client.tapElement({ AXLabel: ${JSON.stringify(el.selectors.AXLabel)}${typeHint} })`;
369
+ }
370
+ return null;
371
+ }
372
+ // android
373
+ if (el.selectors.resourceId) {
374
+ return `client.tap({ selector: { resourceId: ${JSON.stringify(el.selectors.resourceId)} } })`;
375
+ }
376
+ if (el.selectors.contentDesc) {
377
+ return `client.tap({ selector: { contentDesc: ${JSON.stringify(el.selectors.contentDesc)} } })`;
378
+ }
379
+ if (el.selectors.text) {
380
+ return `client.tap({ selector: { text: ${JSON.stringify(el.selectors.text)} } })`;
381
+ }
382
+ return null;
383
+ }
384
+
385
+ // Cleans up internal-looking role tokens. iOS's `role_description` can be
386
+ // raw strings like "AXGenericElement" or empty when AppKit doesn't have a
387
+ // description for the role. Android sets role to the className which is
388
+ // often fully-qualified (`android.widget.TextView`); strip the package.
389
+ export function axElementRoleLabel(el: AxElement): string {
390
+ const raw = el.role || el.type || '';
391
+ if (!raw) return 'element';
392
+ // Drop "AX" prefix and split CamelCase into spaced words ("AXTextField" → "Text Field").
393
+ let cleaned = raw.replace(/^AX/, '');
394
+ // For Android fully-qualified names, keep just the last segment.
395
+ if (cleaned.includes('.')) {
396
+ cleaned = cleaned.split('.').pop()!;
397
+ }
398
+ // Reject the generic catch-all bucket; callers can decide to hide it.
399
+ if (cleaned === 'GenericElement') return 'Element';
400
+ // Lightly humanize CamelCase.
401
+ cleaned = cleaned.replace(/([a-z])([A-Z])/g, '$1 $2');
402
+ return cleaned;
403
+ }
404
+
405
+ // A short human-readable summary used in tooltips and the info card title.
406
+ // Truncated to avoid blowing up tooltips on elements with paragraph-length
407
+ // AXLabels.
408
+ const SUMMARY_MAX_LABEL_LEN = 80;
409
+ export function axElementSummary(el: AxElement): string {
410
+ const role = axElementRoleLabel(el);
411
+ const text = el.label || el.value || '';
412
+ if (!text) return role;
413
+ const trimmed =
414
+ text.length > SUMMARY_MAX_LABEL_LEN ? text.slice(0, SUMMARY_MAX_LABEL_LEN).trimEnd() + '…' : text;
415
+ return `${role} · ${trimmed}`;
416
+ }
@@ -2,10 +2,13 @@ import { AppleGsaSrpClient } from './gsa-srp';
2
2
  import {
3
3
  createAppleRelaySession,
4
4
  deleteAppleRelaySession,
5
- finalizeAppleRelaySession,
5
+ fetchAppleAccountSession,
6
+ proxyPhoneTwoFactorCode,
6
7
  proxySrpComplete,
7
8
  proxySrpInit,
8
9
  proxyTwoFactorCode,
10
+ triggerPhoneTwoFactor,
11
+ triggerTrustedDeviceTwoFactor,
9
12
  type AppleRelayResponse,
10
13
  } from './relay';
11
14
 
@@ -19,12 +22,17 @@ export type AppleIDLoginInput = {
19
22
  export type AppleIDLoginResult = {
20
23
  appleSessionId: string;
21
24
  completeResponse: AppleRelayResponse;
25
+ twoFactorChallengeResponse?: AppleRelayResponse;
22
26
  requiresTwoFactor: boolean;
23
27
  finishTwoFactor: (code: string) => Promise<AppleRelayResponse>;
24
28
  finalize: () => Promise<AppleRelayResponse>;
25
29
  close: () => Promise<void>;
26
30
  };
27
31
 
32
+ type TwoFactorMethod =
33
+ | { type: 'trustedDevice' }
34
+ | { type: 'phone'; phoneNumberId: number; mode: string };
35
+
28
36
  export async function startBrowserOwnedAppleIDLogin({
29
37
  limbuildApiUrl,
30
38
  accountName,
@@ -35,6 +43,9 @@ export async function startBrowserOwnedAppleIDLogin({
35
43
  try {
36
44
  const srp = new AppleGsaSrpClient(accountName);
37
45
  const initResponse = await proxySrpInit(limbuildApiUrl, appleSessionId, await srp.init(), token);
46
+ if (initResponse.status < 200 || initResponse.status >= 300) {
47
+ throw new Error(`Apple SRP init failed: HTTP ${initResponse.status} ${initResponse.rawBody ?? ''}`.trim());
48
+ }
38
49
  if (!initResponse.body) {
39
50
  throw new Error('Apple SRP init response did not include a body.');
40
51
  }
@@ -49,12 +60,64 @@ export async function startBrowserOwnedAppleIDLogin({
49
60
  },
50
61
  token,
51
62
  );
63
+ const requiresTwoFactor = completeResponse.status === 409;
64
+ let twoFactorChallengeResponse: AppleRelayResponse | undefined;
65
+ let twoFactorMethod: TwoFactorMethod = { type: 'trustedDevice' };
66
+ if (requiresTwoFactor) {
67
+ twoFactorChallengeResponse = await triggerTrustedDeviceTwoFactor(limbuildApiUrl, appleSessionId, token);
68
+ const phone = trustedPhoneNumberFromChallenge(twoFactorChallengeResponse.body);
69
+ if (phone) {
70
+ twoFactorMethod = {
71
+ type: 'phone',
72
+ phoneNumberId: phone.id,
73
+ mode: phone.pushMode ?? 'sms',
74
+ };
75
+ }
76
+ if (twoFactorChallengeResponse.status === 412) {
77
+ if (!phone) {
78
+ throw new Error('Apple requested phone verification but did not include a trusted phone number.');
79
+ }
80
+ twoFactorChallengeResponse = await triggerPhoneTwoFactor(
81
+ limbuildApiUrl,
82
+ appleSessionId,
83
+ phone.id,
84
+ phone.pushMode ?? 'sms',
85
+ token,
86
+ );
87
+ }
88
+ if (twoFactorChallengeResponse.status < 200 || twoFactorChallengeResponse.status >= 300) {
89
+ throw new Error(
90
+ `Apple two-factor challenge failed: HTTP ${twoFactorChallengeResponse.status} ${
91
+ twoFactorChallengeResponse.rawBody ?? ''
92
+ }`.trim(),
93
+ );
94
+ }
95
+ } else if (completeResponse.status < 200 || completeResponse.status >= 300) {
96
+ throw new Error(`Apple SRP complete failed: HTTP ${completeResponse.status} ${completeResponse.rawBody ?? ''}`.trim());
97
+ }
52
98
  return {
53
99
  appleSessionId,
54
100
  completeResponse,
55
- requiresTwoFactor: completeResponse.status === 409,
56
- finishTwoFactor: (code) => proxyTwoFactorCode(limbuildApiUrl, appleSessionId, code, token),
57
- finalize: () => finalizeAppleRelaySession(limbuildApiUrl, appleSessionId, token),
101
+ twoFactorChallengeResponse,
102
+ requiresTwoFactor,
103
+ finishTwoFactor: async (code) => {
104
+ const response =
105
+ twoFactorMethod.type === 'phone'
106
+ ? await proxyPhoneTwoFactorCode(
107
+ limbuildApiUrl,
108
+ appleSessionId,
109
+ twoFactorMethod.phoneNumberId,
110
+ code,
111
+ twoFactorMethod.mode,
112
+ token,
113
+ )
114
+ : await proxyTwoFactorCode(limbuildApiUrl, appleSessionId, code, token);
115
+ if (response.status < 200 || response.status >= 300) {
116
+ throw new Error(`Apple two-factor code failed: HTTP ${response.status} ${response.rawBody ?? ''}`.trim());
117
+ }
118
+ return response;
119
+ },
120
+ finalize: async () => fetchAppleAccountSession(limbuildApiUrl, appleSessionId, token),
58
121
  close: () => deleteAppleRelaySession(limbuildApiUrl, appleSessionId, token),
59
122
  };
60
123
  } catch (error) {
@@ -62,3 +125,28 @@ export async function startBrowserOwnedAppleIDLogin({
62
125
  throw error;
63
126
  }
64
127
  }
128
+
129
+ function trustedPhoneNumberFromChallenge(body: unknown) {
130
+ if (!isRecord(body)) return undefined;
131
+ const verification = isRecord(body.phoneNumberVerification) ? body.phoneNumberVerification : undefined;
132
+ const trustedPhoneNumber =
133
+ recordValue(verification?.trustedPhoneNumber) ??
134
+ recordValue(body.trustedPhoneNumber) ??
135
+ recordValue(body.phoneNumber);
136
+ if (!trustedPhoneNumber) return undefined;
137
+ const id = trustedPhoneNumber.id;
138
+ if (typeof id !== 'number') return undefined;
139
+ const pushMode =
140
+ typeof trustedPhoneNumber.pushMode === 'string' ? trustedPhoneNumber.pushMode
141
+ : typeof body.mode === 'string' ? body.mode
142
+ : undefined;
143
+ return { id, pushMode };
144
+ }
145
+
146
+ function recordValue(value: unknown) {
147
+ return isRecord(value) ? value : undefined;
148
+ }
149
+
150
+ function isRecord(value: unknown): value is Record<string, unknown> {
151
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
152
+ }