@limrun/ui 0.8.0 → 0.9.0-rc.2
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 +9 -0
- package/dist/components/device-install/device-install-dialog.d.ts +5 -0
- package/dist/components/device-install/index.d.ts +2 -0
- package/dist/core/device-install/apple/client.d.ts +17 -0
- package/dist/core/device-install/apple/crypto.d.ts +20 -0
- package/dist/core/device-install/apple/gsa-srp.d.ts +26 -0
- package/dist/core/device-install/apple/index.d.ts +5 -0
- package/dist/core/device-install/apple/provisioning.d.ts +161 -0
- package/dist/core/device-install/apple/relay.d.ts +29 -0
- package/dist/core/device-install/index.d.ts +4 -0
- package/dist/core/device-install/operations/index.d.ts +6 -0
- package/dist/core/device-install/operations/limbuild-client.d.ts +28 -0
- package/dist/core/device-install/operations/operations.d.ts +32 -0
- package/dist/core/device-install/operations/relay-client.d.ts +25 -0
- package/dist/core/device-install/operations/relay-protocol.d.ts +27 -0
- package/dist/core/device-install/operations/usbmux.d.ts +32 -0
- package/dist/core/device-install/operations/webusb.d.ts +21 -0
- package/dist/core/device-install/storage/browser-storage.d.ts +43 -0
- package/dist/core/device-install/storage/index.d.ts +1 -0
- package/dist/core/device-install/types.d.ts +48 -0
- package/dist/device-install/index.cjs +1 -0
- package/dist/device-install/index.d.ts +3 -0
- package/dist/device-install/index.js +77 -0
- package/dist/device-install/react.cjs +1 -0
- package/dist/device-install/react.d.ts +1 -0
- package/dist/device-install/react.js +4 -0
- package/dist/device-install-dialog-CSwQgbBm.js +2 -0
- package/dist/device-install-dialog-nThj775b.mjs +395 -0
- package/dist/device-install-dialog.css +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/use-device-install.d.ts +70 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +401 -408
- package/dist/use-device-install-C1uVac59.mjs +13541 -0
- package/dist/use-device-install-Ca4jcVKU.js +31 -0
- package/package.json +15 -2
- package/src/components/device-install/device-install-dialog.css +244 -0
- package/src/components/device-install/device-install-dialog.tsx +432 -0
- package/src/components/device-install/index.ts +2 -0
- package/src/core/device-install/apple/client.ts +152 -0
- package/src/core/device-install/apple/crypto.ts +202 -0
- package/src/core/device-install/apple/gsa-srp.ts +127 -0
- package/src/core/device-install/apple/index.ts +5 -0
- package/src/core/device-install/apple/provisioning.ts +298 -0
- package/src/core/device-install/apple/relay.ts +221 -0
- package/src/core/device-install/index.ts +4 -0
- package/src/core/device-install/operations/index.ts +6 -0
- package/src/core/device-install/operations/limbuild-client.ts +104 -0
- package/src/core/device-install/operations/operations.ts +217 -0
- package/src/core/device-install/operations/relay-client.ts +255 -0
- package/src/core/device-install/operations/relay-protocol.ts +71 -0
- package/src/core/device-install/operations/usbmux.ts +270 -0
- package/src/core/device-install/operations/webusb-dom.d.ts +54 -0
- package/src/core/device-install/operations/webusb.ts +105 -0
- package/src/core/device-install/storage/browser-storage.ts +251 -0
- package/src/core/device-install/storage/index.ts +1 -0
- package/src/core/device-install/types.ts +65 -0
- package/src/device-install/index.ts +3 -0
- package/src/device-install/react.ts +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-device-install.ts +1067 -0
- package/src/index.ts +2 -0
- package/vite.config.ts +6 -2
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { 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: 'build',
|
|
14
|
+
title: 'Start a device build',
|
|
15
|
+
description: 'Upload signing assets if needed, then follow the live build logs until the device build succeeds.',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'usb',
|
|
19
|
+
title: 'Access USB procedures',
|
|
20
|
+
description: 'Allow WebUSB access to the connected iPhone from a Chromium browser on a secure origin.',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'pair',
|
|
24
|
+
title: 'Pair with this browser',
|
|
25
|
+
description: 'Pair once and store the pair record locally so future installs can reuse it.',
|
|
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
|
+
export function DeviceInstallDialog({
|
|
35
|
+
disabled,
|
|
36
|
+
...hookOptions
|
|
37
|
+
}: DeviceInstallDialogProps) {
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
const [appleAccountName, setAppleAccountName] = useState('');
|
|
40
|
+
const [applePassword, setApplePassword] = useState('');
|
|
41
|
+
const [appleTwoFactorCode, setAppleTwoFactorCode] = useState('');
|
|
42
|
+
const dialogTitleId = useId();
|
|
43
|
+
const deviceInstall = useDeviceInstall(hookOptions);
|
|
44
|
+
|
|
45
|
+
const updateSigningFiles = (field: 'certificateFile' | 'provisioningProfileFile', event: ChangeEvent<HTMLInputElement>) => {
|
|
46
|
+
deviceInstall.setSigningFiles({
|
|
47
|
+
[field]: event.currentTarget.files?.[0],
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="lr-device-install">
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
className="lr-device-install__trigger"
|
|
56
|
+
disabled={disabled || !hookOptions.apiUrl}
|
|
57
|
+
onClick={() => setOpen(true)}
|
|
58
|
+
>
|
|
59
|
+
Install to iPhone
|
|
60
|
+
</button>
|
|
61
|
+
|
|
62
|
+
{open && (
|
|
63
|
+
<div className="lr-device-install__backdrop" role="presentation">
|
|
64
|
+
<section
|
|
65
|
+
aria-labelledby={dialogTitleId}
|
|
66
|
+
aria-modal="true"
|
|
67
|
+
className="lr-device-install__dialog"
|
|
68
|
+
role="dialog"
|
|
69
|
+
>
|
|
70
|
+
<header className="lr-device-install__header">
|
|
71
|
+
<div>
|
|
72
|
+
<h2 id={dialogTitleId}>Install to a real iPhone</h2>
|
|
73
|
+
<p>Follow each step to build, authorize USB, pair, and install from this browser.</p>
|
|
74
|
+
</div>
|
|
75
|
+
<button type="button" className="lr-device-install__icon-button" onClick={() => setOpen(false)}>
|
|
76
|
+
Close
|
|
77
|
+
</button>
|
|
78
|
+
</header>
|
|
79
|
+
|
|
80
|
+
{deviceInstall.error && <div className="lr-device-install__error">{deviceInstall.error}</div>}
|
|
81
|
+
|
|
82
|
+
<div className="lr-device-install__steps">
|
|
83
|
+
{steps.map((step, index) => (
|
|
84
|
+
<StepCard
|
|
85
|
+
key={step.id}
|
|
86
|
+
index={index + 1}
|
|
87
|
+
step={step}
|
|
88
|
+
active={deviceInstall.currentStep === step.id}
|
|
89
|
+
status={deviceInstall.stepStatuses[step.id]}
|
|
90
|
+
>
|
|
91
|
+
{step.id === 'build' && (
|
|
92
|
+
<div className="lr-device-install__step-body">
|
|
93
|
+
<div className="lr-device-install__grid">
|
|
94
|
+
<label className="lr-device-install__field">
|
|
95
|
+
<span>Apple ID</span>
|
|
96
|
+
<input
|
|
97
|
+
type="email"
|
|
98
|
+
autoComplete="username"
|
|
99
|
+
placeholder="name@example.com"
|
|
100
|
+
value={appleAccountName}
|
|
101
|
+
onChange={(event) => setAppleAccountName(event.currentTarget.value)}
|
|
102
|
+
/>
|
|
103
|
+
</label>
|
|
104
|
+
<label className="lr-device-install__field">
|
|
105
|
+
<span>Apple ID password</span>
|
|
106
|
+
<input
|
|
107
|
+
type="password"
|
|
108
|
+
autoComplete="current-password"
|
|
109
|
+
placeholder="Password stays in this browser"
|
|
110
|
+
value={applePassword}
|
|
111
|
+
onChange={(event) => setApplePassword(event.currentTarget.value)}
|
|
112
|
+
/>
|
|
113
|
+
</label>
|
|
114
|
+
<label className="lr-device-install__field">
|
|
115
|
+
<span>Generated .p12 password</span>
|
|
116
|
+
<input
|
|
117
|
+
type="password"
|
|
118
|
+
placeholder="Used when exporting Apple certificate"
|
|
119
|
+
onChange={(event) =>
|
|
120
|
+
deviceInstall.setSigningFiles({ certificatePassword: event.currentTarget.value })
|
|
121
|
+
}
|
|
122
|
+
/>
|
|
123
|
+
</label>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="lr-device-install__actions">
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
className="lr-device-install__secondary"
|
|
129
|
+
disabled={
|
|
130
|
+
disabled ||
|
|
131
|
+
!hookOptions.apiUrl ||
|
|
132
|
+
!appleAccountName ||
|
|
133
|
+
!applePassword ||
|
|
134
|
+
deviceInstall.busyAction === 'build'
|
|
135
|
+
}
|
|
136
|
+
onClick={() =>
|
|
137
|
+
void deviceInstall.startAppleIDLogin({
|
|
138
|
+
accountName: appleAccountName,
|
|
139
|
+
password: applePassword,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
>
|
|
143
|
+
{deviceInstall.appleSigningStatus === 'authenticating'
|
|
144
|
+
? 'Signing in...'
|
|
145
|
+
: 'Sign in with Apple ID'}
|
|
146
|
+
</button>
|
|
147
|
+
<span className="lr-device-install__hint">
|
|
148
|
+
Apple password is used only by browser-side SRP. Status: {deviceInstall.appleSigningStatus}
|
|
149
|
+
</span>
|
|
150
|
+
</div>
|
|
151
|
+
{deviceInstall.appleSigningStatus === 'two-factor-required' && (
|
|
152
|
+
<div className="lr-device-install__grid">
|
|
153
|
+
<label className="lr-device-install__field">
|
|
154
|
+
<span>Two-factor code</span>
|
|
155
|
+
<input
|
|
156
|
+
type="text"
|
|
157
|
+
inputMode="numeric"
|
|
158
|
+
autoComplete="one-time-code"
|
|
159
|
+
value={appleTwoFactorCode}
|
|
160
|
+
onChange={(event) => setAppleTwoFactorCode(event.currentTarget.value)}
|
|
161
|
+
/>
|
|
162
|
+
</label>
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
className="lr-device-install__secondary"
|
|
166
|
+
disabled={!appleTwoFactorCode || deviceInstall.busyAction === 'build'}
|
|
167
|
+
onClick={() => void deviceInstall.submitAppleTwoFactorCode(appleTwoFactorCode)}
|
|
168
|
+
>
|
|
169
|
+
Submit Apple ID code
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
{deviceInstall.appleTeams.length > 0 && (
|
|
174
|
+
<>
|
|
175
|
+
<label className="lr-device-install__field">
|
|
176
|
+
<span>Apple Developer team</span>
|
|
177
|
+
<select
|
|
178
|
+
value={deviceInstall.selectedAppleTeamID ?? ''}
|
|
179
|
+
onChange={(event) =>
|
|
180
|
+
deviceInstall.setSelectedAppleTeamID(event.currentTarget.value || undefined)
|
|
181
|
+
}
|
|
182
|
+
>
|
|
183
|
+
{deviceInstall.appleTeams.map((team, index) => {
|
|
184
|
+
const teamID =
|
|
185
|
+
team.teamId ??
|
|
186
|
+
(team.providerId === undefined ? undefined : String(team.providerId)) ??
|
|
187
|
+
team.publicProviderId ??
|
|
188
|
+
'';
|
|
189
|
+
return (
|
|
190
|
+
<option key={`${teamID}-${index}`} value={teamID}>
|
|
191
|
+
{team.name ?? 'Apple Developer Team'} {teamID ? `(${teamID})` : ''}
|
|
192
|
+
</option>
|
|
193
|
+
);
|
|
194
|
+
})}
|
|
195
|
+
</select>
|
|
196
|
+
</label>
|
|
197
|
+
{deviceInstall.applePortalSummary && (
|
|
198
|
+
<p className="lr-device-install__hint">
|
|
199
|
+
Found {deviceInstall.applePortalSummary.certificateCount} certificates and{' '}
|
|
200
|
+
{deviceInstall.applePortalSummary.profileCount} provisioning profiles.
|
|
201
|
+
</p>
|
|
202
|
+
)}
|
|
203
|
+
</>
|
|
204
|
+
)}
|
|
205
|
+
{deviceInstall.appleAppIDs.length > 0 && (
|
|
206
|
+
<label className="lr-device-install__field">
|
|
207
|
+
<span>Bundle ID</span>
|
|
208
|
+
<select
|
|
209
|
+
value={deviceInstall.appleBundleID}
|
|
210
|
+
onChange={(event) => deviceInstall.setAppleBundleID(event.currentTarget.value)}
|
|
211
|
+
>
|
|
212
|
+
{deviceInstall.appleAppIDs.map((appID, index) => {
|
|
213
|
+
const bundleID = appID.identifier ?? appID.bundleId ?? '';
|
|
214
|
+
return (
|
|
215
|
+
<option key={`${bundleID}-${index}`} value={bundleID}>
|
|
216
|
+
{appID.name ?? bundleID} {bundleID ? `(${bundleID})` : ''}
|
|
217
|
+
</option>
|
|
218
|
+
);
|
|
219
|
+
})}
|
|
220
|
+
</select>
|
|
221
|
+
</label>
|
|
222
|
+
)}
|
|
223
|
+
<div className="lr-device-install__grid">
|
|
224
|
+
<label className="lr-device-install__field">
|
|
225
|
+
<span>Certificate (.p12)</span>
|
|
226
|
+
<input
|
|
227
|
+
type="file"
|
|
228
|
+
accept=".p12,application/x-pkcs12"
|
|
229
|
+
onChange={(event) => updateSigningFiles('certificateFile', event)}
|
|
230
|
+
/>
|
|
231
|
+
</label>
|
|
232
|
+
<label className="lr-device-install__field">
|
|
233
|
+
<span>Provisioning profile</span>
|
|
234
|
+
<input
|
|
235
|
+
type="file"
|
|
236
|
+
accept=".mobileprovision"
|
|
237
|
+
onChange={(event) => updateSigningFiles('provisioningProfileFile', event)}
|
|
238
|
+
/>
|
|
239
|
+
</label>
|
|
240
|
+
<label className="lr-device-install__field">
|
|
241
|
+
<span>Uploaded .p12 password</span>
|
|
242
|
+
<input
|
|
243
|
+
type="password"
|
|
244
|
+
placeholder="Export password"
|
|
245
|
+
onChange={(event) =>
|
|
246
|
+
deviceInstall.setSigningFiles({ certificatePassword: event.currentTarget.value })
|
|
247
|
+
}
|
|
248
|
+
/>
|
|
249
|
+
</label>
|
|
250
|
+
</div>
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
className="lr-device-install__primary"
|
|
254
|
+
disabled={disabled || !deviceInstall.canBuild}
|
|
255
|
+
onClick={() => void deviceInstall.startDeviceBuild()}
|
|
256
|
+
>
|
|
257
|
+
{deviceInstall.busyAction === 'build' ? 'Starting build...' : 'Start device build'}
|
|
258
|
+
</button>
|
|
259
|
+
<details
|
|
260
|
+
className="lr-device-install__build-logs"
|
|
261
|
+
open={deviceInstall.buildLogPanelOpen}
|
|
262
|
+
onToggle={(event) => deviceInstall.setBuildLogPanelOpen(event.currentTarget.open)}
|
|
263
|
+
>
|
|
264
|
+
<summary>Build logs ({deviceInstall.buildStatus})</summary>
|
|
265
|
+
<pre>
|
|
266
|
+
{deviceInstall.buildLogs.length > 0
|
|
267
|
+
? deviceInstall.buildLogs
|
|
268
|
+
.filter((line) => line.type !== 'meta')
|
|
269
|
+
.map((line) => line.data)
|
|
270
|
+
.join('\n')
|
|
271
|
+
: 'Build logs will appear here while the device build is running.'}
|
|
272
|
+
</pre>
|
|
273
|
+
</details>
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
|
|
277
|
+
{step.id === 'usb' && (
|
|
278
|
+
<div className="lr-device-install__step-body">
|
|
279
|
+
<p>
|
|
280
|
+
WebUSB works in Chromium browsers on secure origins. Connect the iPhone over USB and approve
|
|
281
|
+
the browser permission prompt.
|
|
282
|
+
</p>
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
className="lr-device-install__primary"
|
|
286
|
+
disabled={disabled || !deviceInstall.canRequestUSBAccess}
|
|
287
|
+
onClick={() => void deviceInstall.requestUSBAccess()}
|
|
288
|
+
>
|
|
289
|
+
{deviceInstall.busyAction === 'usb' ? 'Selecting iPhone...' : 'Allow USB access'}
|
|
290
|
+
</button>
|
|
291
|
+
{deviceInstall.device && (
|
|
292
|
+
<div className="lr-device-install__device">
|
|
293
|
+
{`${deviceInstall.device.productName ?? 'iPhone'} ${
|
|
294
|
+
deviceInstall.device.serialNumber ?? ''
|
|
295
|
+
}`.trim()}
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
{deviceInstall.appleDevices.length > 0 && (
|
|
299
|
+
<label className="lr-device-install__field">
|
|
300
|
+
<span>Apple Developer devices</span>
|
|
301
|
+
<select
|
|
302
|
+
multiple
|
|
303
|
+
value={deviceInstall.selectedAppleDeviceIDs}
|
|
304
|
+
onChange={(event) =>
|
|
305
|
+
deviceInstall.setSelectedAppleDeviceIDs(
|
|
306
|
+
Array.from(event.currentTarget.selectedOptions).map((option) => option.value),
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
>
|
|
310
|
+
{deviceInstall.appleDevices.map((appleDevice) => (
|
|
311
|
+
<option key={appleDevice.deviceId ?? appleDevice.deviceNumber} value={appleDevice.deviceId ?? ''}>
|
|
312
|
+
{appleDevice.name ?? appleDevice.model ?? 'Apple device'} {appleDevice.deviceNumber ?? ''}
|
|
313
|
+
</option>
|
|
314
|
+
))}
|
|
315
|
+
</select>
|
|
316
|
+
</label>
|
|
317
|
+
)}
|
|
318
|
+
{deviceInstall.device && !deviceInstall.connectedAppleDeviceRegistered && (
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
className="lr-device-install__secondary"
|
|
322
|
+
disabled={disabled || !!deviceInstall.busyAction}
|
|
323
|
+
onClick={() => void deviceInstall.registerConnectedAppleDevice()}
|
|
324
|
+
>
|
|
325
|
+
Register connected iPhone
|
|
326
|
+
</button>
|
|
327
|
+
)}
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
className="lr-device-install__secondary"
|
|
331
|
+
disabled={disabled || !deviceInstall.canPrepareAppleSigningAssets}
|
|
332
|
+
onClick={() => void deviceInstall.prepareAppleSigningAssets()}
|
|
333
|
+
>
|
|
334
|
+
{deviceInstall.appleSigningStatus === 'preparing-assets'
|
|
335
|
+
? 'Preparing signing assets...'
|
|
336
|
+
: 'Generate certificate and profile'}
|
|
337
|
+
</button>
|
|
338
|
+
{deviceInstall.hasSigningAssets && (
|
|
339
|
+
<p>Signing assets are stored in this browser for the selected bundle and device.</p>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
|
|
344
|
+
{step.id === 'pair' && (
|
|
345
|
+
<div className="lr-device-install__step-body">
|
|
346
|
+
{deviceInstall.pairConfirmationRequired && (
|
|
347
|
+
<p>
|
|
348
|
+
Unlock the iPhone and tap <strong>Trust</strong> in the system dialog, then confirm the pair
|
|
349
|
+
record.
|
|
350
|
+
</p>
|
|
351
|
+
)}
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
className="lr-device-install__primary"
|
|
355
|
+
disabled={disabled || !deviceInstall.canPairBrowser}
|
|
356
|
+
onClick={() => void deviceInstall.pairBrowser()}
|
|
357
|
+
>
|
|
358
|
+
{deviceInstall.busyAction === 'pair'
|
|
359
|
+
? 'Pairing...'
|
|
360
|
+
: deviceInstall.pairConfirmationRequired
|
|
361
|
+
? 'Confirm pair record'
|
|
362
|
+
: 'Pair browser'}
|
|
363
|
+
</button>
|
|
364
|
+
<p>
|
|
365
|
+
{deviceInstall.hasPairRecord
|
|
366
|
+
? 'Pair record is stored locally. Installation is available.'
|
|
367
|
+
: 'Pair this browser once before installing.'}
|
|
368
|
+
</p>
|
|
369
|
+
</div>
|
|
370
|
+
)}
|
|
371
|
+
|
|
372
|
+
{step.id === 'install' && (
|
|
373
|
+
<div className="lr-device-install__step-body">
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
className="lr-device-install__primary"
|
|
377
|
+
disabled={disabled || !deviceInstall.canInstall}
|
|
378
|
+
onClick={() => void deviceInstall.startInstallation()}
|
|
379
|
+
>
|
|
380
|
+
{deviceInstall.busyAction === 'install' ? 'Installing...' : 'Install last build'}
|
|
381
|
+
</button>
|
|
382
|
+
<button type="button" className="lr-device-install__secondary" onClick={deviceInstall.stopRelay}>
|
|
383
|
+
Stop relay
|
|
384
|
+
</button>
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
</StepCard>
|
|
388
|
+
))}
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
<footer className="lr-device-install__logs">
|
|
392
|
+
<h3>Progress</h3>
|
|
393
|
+
<ol>
|
|
394
|
+
{deviceInstall.logs.map((entry, index) => (
|
|
395
|
+
<li key={`${index}-${entry.slice(0, 24)}`}>{entry}</li>
|
|
396
|
+
))}
|
|
397
|
+
</ol>
|
|
398
|
+
</footer>
|
|
399
|
+
</section>
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
</div>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function StepCard({
|
|
407
|
+
index,
|
|
408
|
+
step,
|
|
409
|
+
active,
|
|
410
|
+
status,
|
|
411
|
+
children,
|
|
412
|
+
}: {
|
|
413
|
+
index: number;
|
|
414
|
+
step: { id: DeviceInstallStep; title: string; description: string };
|
|
415
|
+
active: boolean;
|
|
416
|
+
status: DeviceInstallStepStatus;
|
|
417
|
+
children: ReactNode;
|
|
418
|
+
}) {
|
|
419
|
+
return (
|
|
420
|
+
<article className={clsx('lr-device-install__step', active && 'lr-device-install__step--active')}>
|
|
421
|
+
<div className="lr-device-install__step-header">
|
|
422
|
+
<div className="lr-device-install__step-number">{index}</div>
|
|
423
|
+
<div>
|
|
424
|
+
<h3>{step.title}</h3>
|
|
425
|
+
<p>{step.description}</p>
|
|
426
|
+
</div>
|
|
427
|
+
<span className={clsx('lr-device-install__status', `lr-device-install__status--${status}`)}>{status}</span>
|
|
428
|
+
</div>
|
|
429
|
+
{children}
|
|
430
|
+
</article>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
@@ -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
|
+
}
|