@limrun/ui 0.9.0-rc.12 → 0.9.0-rc.13

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 (64) hide show
  1. package/README.md +9 -0
  2. package/dist/components/device-install/device-install-dialog.d.ts +5 -0
  3. package/dist/components/device-install/index.d.ts +2 -0
  4. package/dist/core/device-install/apple/client.d.ts +17 -0
  5. package/dist/core/device-install/apple/crypto.d.ts +20 -0
  6. package/dist/core/device-install/apple/gsa-srp.d.ts +26 -0
  7. package/dist/core/device-install/apple/index.d.ts +5 -0
  8. package/dist/core/device-install/apple/provisioning.d.ts +161 -0
  9. package/dist/core/device-install/apple/relay.d.ts +29 -0
  10. package/dist/core/device-install/index.d.ts +4 -0
  11. package/dist/core/device-install/operations/index.d.ts +6 -0
  12. package/dist/core/device-install/operations/limbuild-client.d.ts +28 -0
  13. package/dist/core/device-install/operations/operations.d.ts +32 -0
  14. package/dist/core/device-install/operations/relay-client.d.ts +25 -0
  15. package/dist/core/device-install/operations/relay-protocol.d.ts +27 -0
  16. package/dist/core/device-install/operations/usbmux.d.ts +32 -0
  17. package/dist/core/device-install/operations/webusb.d.ts +21 -0
  18. package/dist/core/device-install/storage/browser-storage.d.ts +44 -0
  19. package/dist/core/device-install/storage/index.d.ts +1 -0
  20. package/dist/core/device-install/types.d.ts +48 -0
  21. package/dist/device-install/index.cjs +1 -0
  22. package/dist/device-install/index.d.ts +3 -0
  23. package/dist/device-install/index.js +78 -0
  24. package/dist/device-install/react.cjs +1 -0
  25. package/dist/device-install/react.d.ts +1 -0
  26. package/dist/device-install/react.js +4 -0
  27. package/dist/device-install-dialog-CjH25hnN.js +2 -0
  28. package/dist/device-install-dialog-W5Xv9kWL.mjs +443 -0
  29. package/dist/device-install-dialog.css +1 -0
  30. package/dist/hooks/index.d.ts +1 -0
  31. package/dist/hooks/use-device-install.d.ts +73 -0
  32. package/dist/index.cjs +1 -1
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.js +568 -575
  35. package/dist/use-device-install-Y1u6vIBB.js +31 -0
  36. package/dist/use-device-install-sDVvby1V.mjs +13627 -0
  37. package/package.json +15 -2
  38. package/src/components/device-install/device-install-dialog.css +325 -0
  39. package/src/components/device-install/device-install-dialog.tsx +495 -0
  40. package/src/components/device-install/index.ts +2 -0
  41. package/src/core/device-install/apple/client.ts +152 -0
  42. package/src/core/device-install/apple/crypto.ts +202 -0
  43. package/src/core/device-install/apple/gsa-srp.ts +127 -0
  44. package/src/core/device-install/apple/index.ts +5 -0
  45. package/src/core/device-install/apple/provisioning.ts +298 -0
  46. package/src/core/device-install/apple/relay.ts +221 -0
  47. package/src/core/device-install/index.ts +4 -0
  48. package/src/core/device-install/operations/index.ts +6 -0
  49. package/src/core/device-install/operations/limbuild-client.ts +104 -0
  50. package/src/core/device-install/operations/operations.ts +217 -0
  51. package/src/core/device-install/operations/relay-client.ts +255 -0
  52. package/src/core/device-install/operations/relay-protocol.ts +71 -0
  53. package/src/core/device-install/operations/usbmux.ts +270 -0
  54. package/src/core/device-install/operations/webusb-dom.d.ts +54 -0
  55. package/src/core/device-install/operations/webusb.ts +105 -0
  56. package/src/core/device-install/storage/browser-storage.ts +263 -0
  57. package/src/core/device-install/storage/index.ts +1 -0
  58. package/src/core/device-install/types.ts +65 -0
  59. package/src/device-install/index.ts +3 -0
  60. package/src/device-install/react.ts +1 -0
  61. package/src/hooks/index.ts +1 -0
  62. package/src/hooks/use-device-install.ts +1221 -0
  63. package/src/index.ts +3 -0
  64. package/vite.config.ts +6 -2
@@ -0,0 +1,495 @@
1
+ import { useEffect, useId, useState, type ChangeEvent, type ReactNode } from 'react';
2
+ import { clsx } from 'clsx';
3
+ import { useDeviceInstall, type UseDeviceInstallOptions } from '../../hooks/use-device-install';
4
+ import type { DeviceInstallStep, DeviceInstallStepStatus } from '../../core/device-install';
5
+ import './device-install-dialog.css';
6
+
7
+ export type DeviceInstallDialogProps = UseDeviceInstallOptions & {
8
+ disabled?: boolean;
9
+ };
10
+
11
+ const steps: Array<{ id: DeviceInstallStep; title: string; description: string }> = [
12
+ {
13
+ id: 'signing',
14
+ title: 'Prepare signing',
15
+ description: 'Choose Apple ID login or upload certificates for a registered developer device.',
16
+ },
17
+ {
18
+ id: 'build',
19
+ title: 'Build for device',
20
+ description: 'Start the signed iPhone build before connecting over USB.',
21
+ },
22
+ {
23
+ id: 'connect',
24
+ title: 'Connect and pair',
25
+ description: 'After the build succeeds, connect the iPhone with WebUSB and pair this browser.',
26
+ },
27
+ {
28
+ id: 'install',
29
+ title: 'Start installation',
30
+ description: 'Relay the last successful device build to the paired iPhone.',
31
+ },
32
+ ];
33
+
34
+ type SigningSection = 'apple-id' | 'upload';
35
+
36
+ export function DeviceInstallDialog({
37
+ disabled,
38
+ ...hookOptions
39
+ }: DeviceInstallDialogProps) {
40
+ const [open, setOpen] = useState(false);
41
+ const [openStep, setOpenStep] = useState<DeviceInstallStep>('signing');
42
+ const [signingSection, setSigningSection] = useState<SigningSection>();
43
+ const [appleAccountName, setAppleAccountName] = useState('');
44
+ const [applePassword, setApplePassword] = useState('');
45
+ const [appleTwoFactorCode, setAppleTwoFactorCode] = useState('');
46
+ const dialogTitleId = useId();
47
+ const deviceInstall = useDeviceInstall(hookOptions);
48
+
49
+ useEffect(() => {
50
+ setOpenStep(deviceInstall.currentStep);
51
+ }, [deviceInstall.currentStep]);
52
+
53
+ const updateSigningFiles = (field: 'certificateFile' | 'provisioningProfileFile', event: ChangeEvent<HTMLInputElement>) => {
54
+ deviceInstall.setSigningFiles({
55
+ [field]: event.currentTarget.files?.[0],
56
+ });
57
+ };
58
+
59
+ return (
60
+ <div className="lr-device-install">
61
+ <button
62
+ type="button"
63
+ className="lr-device-install__trigger"
64
+ disabled={disabled || !hookOptions.apiUrl}
65
+ onClick={() => setOpen(true)}
66
+ >
67
+ Install to iPhone
68
+ </button>
69
+
70
+ {open && (
71
+ <div className="lr-device-install__backdrop" role="presentation">
72
+ <section
73
+ aria-labelledby={dialogTitleId}
74
+ aria-modal="true"
75
+ className="lr-device-install__dialog"
76
+ role="dialog"
77
+ >
78
+ <header className="lr-device-install__header">
79
+ <div>
80
+ <h2 id={dialogTitleId}>Install to a real iPhone</h2>
81
+ <p>Prepare signing, build for the registered device, connect and pair, then install from this browser.</p>
82
+ </div>
83
+ <button type="button" className="lr-device-install__icon-button" onClick={() => setOpen(false)}>
84
+ Close
85
+ </button>
86
+ </header>
87
+
88
+ {deviceInstall.error && <div className="lr-device-install__error">{deviceInstall.error}</div>}
89
+
90
+ <div className="lr-device-install__steps">
91
+ {steps.map((step, index) => (
92
+ <StepCard
93
+ key={step.id}
94
+ index={index + 1}
95
+ step={step}
96
+ active={deviceInstall.currentStep === step.id}
97
+ open={openStep === step.id}
98
+ status={deviceInstall.stepStatuses[step.id]}
99
+ onToggle={() => setOpenStep(step.id)}
100
+ >
101
+ {step.id === 'signing' && (
102
+ <div className="lr-device-install__step-body">
103
+ <div className="lr-device-install__choice-grid">
104
+ <button
105
+ type="button"
106
+ className={clsx(
107
+ 'lr-device-install__choice',
108
+ signingSection === 'apple-id' && 'lr-device-install__choice--active',
109
+ )}
110
+ onClick={() => setSigningSection('apple-id')}
111
+ >
112
+ <strong>Apple ID login</strong>
113
+ <span>Sign in, choose team, bundle ID, and registered devices, then generate signing assets.</span>
114
+ </button>
115
+ <button
116
+ type="button"
117
+ className={clsx(
118
+ 'lr-device-install__choice',
119
+ signingSection === 'upload' && 'lr-device-install__choice--active',
120
+ )}
121
+ onClick={() => setSigningSection('upload')}
122
+ >
123
+ <strong>Upload certificates</strong>
124
+ <span>Use an existing .p12 certificate and provisioning profile.</span>
125
+ </button>
126
+ </div>
127
+
128
+ {signingSection === 'apple-id' && (
129
+ <div className="lr-device-install__section-panel">
130
+ <div className="lr-device-install__grid">
131
+ <label className="lr-device-install__field">
132
+ <span>Apple ID</span>
133
+ <input
134
+ type="email"
135
+ autoComplete="username"
136
+ placeholder="name@example.com"
137
+ value={appleAccountName}
138
+ onChange={(event) => setAppleAccountName(event.currentTarget.value)}
139
+ />
140
+ </label>
141
+ <label className="lr-device-install__field">
142
+ <span>Apple ID password</span>
143
+ <input
144
+ type="password"
145
+ autoComplete="current-password"
146
+ placeholder="Password stays in this browser"
147
+ value={applePassword}
148
+ onChange={(event) => setApplePassword(event.currentTarget.value)}
149
+ />
150
+ </label>
151
+ {!deviceInstall.hasReusableAppleCertificate && (
152
+ <label className="lr-device-install__field">
153
+ <span>Generated .p12 password</span>
154
+ <input
155
+ type="password"
156
+ placeholder="Used when exporting Apple certificate"
157
+ onChange={(event) =>
158
+ deviceInstall.setSigningFiles({ certificatePassword: event.currentTarget.value })
159
+ }
160
+ />
161
+ </label>
162
+ )}
163
+ </div>
164
+ <div className="lr-device-install__actions">
165
+ <button
166
+ type="button"
167
+ className="lr-device-install__secondary"
168
+ disabled={
169
+ disabled ||
170
+ !hookOptions.apiUrl ||
171
+ !appleAccountName ||
172
+ !applePassword ||
173
+ deviceInstall.busyAction === 'signing'
174
+ }
175
+ onClick={() =>
176
+ void deviceInstall.startAppleIDLogin({
177
+ accountName: appleAccountName,
178
+ password: applePassword,
179
+ })
180
+ }
181
+ >
182
+ {deviceInstall.appleSigningStatus === 'authenticating'
183
+ ? 'Signing in...'
184
+ : 'Sign in with Apple ID'}
185
+ </button>
186
+ <span className="lr-device-install__hint">
187
+ Apple password is used only by browser-side SRP. Status: {deviceInstall.appleSigningStatus}
188
+ </span>
189
+ </div>
190
+ {deviceInstall.appleSigningStatus === 'two-factor-required' && (
191
+ <div className="lr-device-install__grid">
192
+ <label className="lr-device-install__field">
193
+ <span>Two-factor code</span>
194
+ <input
195
+ type="text"
196
+ inputMode="numeric"
197
+ autoComplete="one-time-code"
198
+ value={appleTwoFactorCode}
199
+ onChange={(event) => setAppleTwoFactorCode(event.currentTarget.value)}
200
+ />
201
+ </label>
202
+ <button
203
+ type="button"
204
+ className="lr-device-install__secondary"
205
+ disabled={!appleTwoFactorCode || deviceInstall.busyAction === 'signing'}
206
+ onClick={() => void deviceInstall.submitAppleTwoFactorCode(appleTwoFactorCode)}
207
+ >
208
+ Submit Apple ID code
209
+ </button>
210
+ </div>
211
+ )}
212
+ {deviceInstall.appleTeams.length > 0 && (
213
+ <label className="lr-device-install__field">
214
+ <span>Apple Developer team</span>
215
+ <select
216
+ value={deviceInstall.selectedAppleTeamID ?? ''}
217
+ onChange={(event) =>
218
+ deviceInstall.setSelectedAppleTeamID(event.currentTarget.value || undefined)
219
+ }
220
+ >
221
+ {deviceInstall.appleTeams.map((team, index) => {
222
+ const teamID =
223
+ team.teamId ??
224
+ (team.providerId === undefined ? undefined : String(team.providerId)) ??
225
+ team.publicProviderId ??
226
+ '';
227
+ return (
228
+ <option key={`${teamID}-${index}`} value={teamID}>
229
+ {team.name ?? 'Apple Developer Team'} {teamID ? `(${teamID})` : ''}
230
+ </option>
231
+ );
232
+ })}
233
+ </select>
234
+ </label>
235
+ )}
236
+ {deviceInstall.appleDevices.length > 0 && (
237
+ <label className="lr-device-install__field">
238
+ <span>Apple Developer devices</span>
239
+ <select
240
+ multiple
241
+ value={deviceInstall.selectedAppleDeviceIDs}
242
+ onChange={(event) =>
243
+ deviceInstall.setSelectedAppleDeviceIDs(
244
+ Array.from(event.currentTarget.selectedOptions).map((option) => option.value),
245
+ )
246
+ }
247
+ >
248
+ {deviceInstall.appleDevices.map((appleDevice) => (
249
+ <option
250
+ key={appleDevice.deviceId ?? appleDevice.deviceNumber}
251
+ value={appleDevice.deviceId ?? ''}
252
+ >
253
+ {appleDevice.name ?? appleDevice.model ?? 'Apple device'} {appleDevice.deviceNumber ?? ''}
254
+ </option>
255
+ ))}
256
+ </select>
257
+ </label>
258
+ )}
259
+ {deviceInstall.applePortalSummary && (
260
+ <p className="lr-device-install__hint">
261
+ Found {deviceInstall.applePortalSummary.certificateCount} certificates and{' '}
262
+ {deviceInstall.applePortalSummary.profileCount} provisioning profiles.
263
+ </p>
264
+ )}
265
+ {deviceInstall.hasReusableAppleCertificate && (
266
+ <p className="lr-device-install__hint">
267
+ Reusing the certificate and private key stored in this browser.
268
+ </p>
269
+ )}
270
+ <button
271
+ type="button"
272
+ className="lr-device-install__primary"
273
+ disabled={disabled || !deviceInstall.canPrepareAppleSigningAssets}
274
+ onClick={() => void deviceInstall.prepareAppleSigningAssets()}
275
+ >
276
+ {deviceInstall.appleSigningStatus === 'preparing-assets'
277
+ ? 'Preparing signing assets...'
278
+ : 'Generate certificate and profile'}
279
+ </button>
280
+ </div>
281
+ )}
282
+
283
+ {signingSection === 'upload' && (
284
+ <div className="lr-device-install__section-panel">
285
+ <div className="lr-device-install__grid">
286
+ <label className="lr-device-install__field">
287
+ <span>Certificate (.p12)</span>
288
+ <input
289
+ type="file"
290
+ accept=".p12,application/x-pkcs12"
291
+ onChange={(event) => updateSigningFiles('certificateFile', event)}
292
+ />
293
+ </label>
294
+ <label className="lr-device-install__field">
295
+ <span>Provisioning profile</span>
296
+ <input
297
+ type="file"
298
+ accept=".mobileprovision"
299
+ onChange={(event) => updateSigningFiles('provisioningProfileFile', event)}
300
+ />
301
+ </label>
302
+ <label className="lr-device-install__field">
303
+ <span>Uploaded .p12 password</span>
304
+ <input
305
+ type="password"
306
+ placeholder="Export password"
307
+ onChange={(event) =>
308
+ deviceInstall.setSigningFiles({ certificatePassword: event.currentTarget.value })
309
+ }
310
+ />
311
+ </label>
312
+ </div>
313
+ <p className="lr-device-install__hint">
314
+ The provisioning profile will be checked against the connected iPhone before installation.
315
+ </p>
316
+ </div>
317
+ )}
318
+
319
+ {deviceInstall.hasSigningAssets && (
320
+ <p>Signing assets are stored in this browser for the selected bundle and device.</p>
321
+ )}
322
+ </div>
323
+ )}
324
+
325
+ {step.id === 'connect' && (
326
+ <div className="lr-device-install__step-body">
327
+ <p>
328
+ WebUSB works in Chromium browsers on secure origins. Once the build succeeds, connect the iPhone
329
+ over USB, approve the browser permission prompt, then pair this browser.
330
+ </p>
331
+ <div className="lr-device-install__actions">
332
+ <button
333
+ type="button"
334
+ className="lr-device-install__primary"
335
+ disabled={disabled || !deviceInstall.canRequestUSBAccess}
336
+ onClick={() => void deviceInstall.requestUSBAccess()}
337
+ >
338
+ {deviceInstall.busyAction === 'usb' ? 'Selecting iPhone...' : 'Allow USB access'}
339
+ </button>
340
+ <button
341
+ type="button"
342
+ className="lr-device-install__secondary"
343
+ disabled={disabled || !deviceInstall.canPairBrowser}
344
+ onClick={() => void deviceInstall.pairBrowser()}
345
+ >
346
+ {deviceInstall.busyAction === 'pair'
347
+ ? 'Pairing...'
348
+ : deviceInstall.pairConfirmationRequired
349
+ ? 'Confirm pair record'
350
+ : 'Pair browser'}
351
+ </button>
352
+ </div>
353
+ {deviceInstall.device && (
354
+ <div className="lr-device-install__device">
355
+ {`${deviceInstall.device.productName ?? 'iPhone'} ${
356
+ deviceInstall.device.serialNumber ?? ''
357
+ }`.trim()}
358
+ </div>
359
+ )}
360
+ {deviceInstall.pairConfirmationRequired && (
361
+ <p>
362
+ Unlock the iPhone and tap <strong>Trust</strong> in the system dialog, then confirm the pair
363
+ record.
364
+ </p>
365
+ )}
366
+ <p>
367
+ {deviceInstall.hasPairRecord
368
+ ? 'Pair record is stored locally. Continue to installation.'
369
+ : 'Pair this browser once before installing.'}
370
+ </p>
371
+ </div>
372
+ )}
373
+
374
+ {step.id === 'build' && (
375
+ <div className="lr-device-install__step-body">
376
+ <div className="lr-device-install__checklist">
377
+ <StatusLine label="Signing assets" ready={deviceInstall.hasSigningInputs} />
378
+ <StatusLine label="Device build" ready={deviceInstall.buildStatus === 'succeeded' ? true : undefined} pendingText="Not started" />
379
+ </div>
380
+ <button
381
+ type="button"
382
+ className="lr-device-install__primary"
383
+ disabled={disabled || !deviceInstall.canBuild}
384
+ onClick={() => void deviceInstall.startDeviceBuild()}
385
+ >
386
+ {deviceInstall.busyAction === 'build' ? 'Starting build...' : 'Start device build'}
387
+ </button>
388
+ <details
389
+ className="lr-device-install__build-logs"
390
+ open={deviceInstall.buildLogPanelOpen}
391
+ onToggle={(event) => deviceInstall.setBuildLogPanelOpen(event.currentTarget.open)}
392
+ >
393
+ <summary>Build logs ({deviceInstall.buildStatus})</summary>
394
+ <pre>
395
+ {deviceInstall.buildLogs.length > 0
396
+ ? deviceInstall.buildLogs
397
+ .filter((line) => line.type !== 'meta')
398
+ .map((line) => line.data)
399
+ .join('\n')
400
+ : 'Build logs will appear here while the device build is running.'}
401
+ </pre>
402
+ </details>
403
+ </div>
404
+ )}
405
+
406
+ {step.id === 'install' && (
407
+ <div className="lr-device-install__step-body">
408
+ <button
409
+ type="button"
410
+ className="lr-device-install__primary"
411
+ disabled={disabled || !deviceInstall.canInstall}
412
+ onClick={() => void deviceInstall.startInstallation()}
413
+ >
414
+ {deviceInstall.busyAction === 'install' ? 'Installing...' : 'Install last build'}
415
+ </button>
416
+ <button type="button" className="lr-device-install__secondary" onClick={deviceInstall.stopRelay}>
417
+ Stop relay
418
+ </button>
419
+ </div>
420
+ )}
421
+ </StepCard>
422
+ ))}
423
+ </div>
424
+
425
+ <footer className="lr-device-install__logs">
426
+ <h3>Progress</h3>
427
+ <ol>
428
+ {deviceInstall.logs.map((entry, index) => (
429
+ <li key={`${index}-${entry.slice(0, 24)}`}>{entry}</li>
430
+ ))}
431
+ </ol>
432
+ </footer>
433
+ </section>
434
+ </div>
435
+ )}
436
+ </div>
437
+ );
438
+ }
439
+
440
+ function StepCard({
441
+ index,
442
+ step,
443
+ active,
444
+ open,
445
+ status,
446
+ onToggle,
447
+ children,
448
+ }: {
449
+ index: number;
450
+ step: { id: DeviceInstallStep; title: string; description: string };
451
+ active: boolean;
452
+ open: boolean;
453
+ status: DeviceInstallStepStatus;
454
+ onToggle: () => void;
455
+ children: ReactNode;
456
+ }) {
457
+ return (
458
+ <article className={clsx('lr-device-install__step', active && 'lr-device-install__step--active')}>
459
+ <button
460
+ type="button"
461
+ className="lr-device-install__step-header"
462
+ aria-expanded={open}
463
+ onClick={onToggle}
464
+ >
465
+ <div className="lr-device-install__step-number">{index}</div>
466
+ <div>
467
+ <h3>{step.title}</h3>
468
+ <p>{step.description}</p>
469
+ </div>
470
+ <span className={clsx('lr-device-install__status', `lr-device-install__status--${status}`)}>
471
+ {status === 'complete' ? '✓ Completed' : status}
472
+ </span>
473
+ </button>
474
+ {open && children}
475
+ </article>
476
+ );
477
+ }
478
+
479
+ function StatusLine({
480
+ label,
481
+ ready,
482
+ pendingText = 'Not ready',
483
+ }: {
484
+ label: string;
485
+ ready?: boolean;
486
+ pendingText?: string;
487
+ }) {
488
+ const text = ready === undefined ? pendingText : ready ? 'Ready' : 'Needs attention';
489
+ return (
490
+ <div className="lr-device-install__check-row">
491
+ <span>{label}</span>
492
+ <strong>{text}</strong>
493
+ </div>
494
+ );
495
+ }
@@ -0,0 +1,2 @@
1
+ export { DeviceInstallDialog, DeviceInstallDialog as DeviceInstallRelay } from './device-install-dialog';
2
+ export type { DeviceInstallDialogProps } from './device-install-dialog';
@@ -0,0 +1,152 @@
1
+ import { AppleGsaSrpClient } from './gsa-srp';
2
+ import {
3
+ createAppleRelaySession,
4
+ deleteAppleRelaySession,
5
+ fetchAppleAccountSession,
6
+ proxyPhoneTwoFactorCode,
7
+ proxySrpComplete,
8
+ proxySrpInit,
9
+ proxyTwoFactorCode,
10
+ triggerPhoneTwoFactor,
11
+ triggerTrustedDeviceTwoFactor,
12
+ type AppleRelayResponse,
13
+ } from './relay';
14
+
15
+ export type AppleIDLoginInput = {
16
+ limbuildApiUrl: string;
17
+ accountName: string;
18
+ password: string;
19
+ token?: string;
20
+ };
21
+
22
+ export type AppleIDLoginResult = {
23
+ appleSessionId: string;
24
+ completeResponse: AppleRelayResponse;
25
+ twoFactorChallengeResponse?: AppleRelayResponse;
26
+ requiresTwoFactor: boolean;
27
+ finishTwoFactor: (code: string) => Promise<AppleRelayResponse>;
28
+ finalize: () => Promise<AppleRelayResponse>;
29
+ close: () => Promise<void>;
30
+ };
31
+
32
+ type TwoFactorMethod =
33
+ | { type: 'trustedDevice' }
34
+ | { type: 'phone'; phoneNumberId: number; mode: string };
35
+
36
+ export async function startBrowserOwnedAppleIDLogin({
37
+ limbuildApiUrl,
38
+ accountName,
39
+ password,
40
+ token,
41
+ }: AppleIDLoginInput): Promise<AppleIDLoginResult> {
42
+ const { appleSessionId } = await createAppleRelaySession(limbuildApiUrl, token);
43
+ try {
44
+ const srp = new AppleGsaSrpClient(accountName);
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
+ }
49
+ if (!initResponse.body) {
50
+ throw new Error('Apple SRP init response did not include a body.');
51
+ }
52
+ const proof = await srp.complete(password, initResponse.body);
53
+ const completeResponse = await proxySrpComplete(
54
+ limbuildApiUrl,
55
+ appleSessionId,
56
+ {
57
+ ...proof,
58
+ rememberMe: false,
59
+ trustTokens: [],
60
+ },
61
+ token,
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
+ }
98
+ return {
99
+ appleSessionId,
100
+ completeResponse,
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),
121
+ close: () => deleteAppleRelaySession(limbuildApiUrl, appleSessionId, token),
122
+ };
123
+ } catch (error) {
124
+ await deleteAppleRelaySession(limbuildApiUrl, appleSessionId, token).catch(() => undefined);
125
+ throw error;
126
+ }
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
+ }