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

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 (46) 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-CjH25hnN.js +2 -0
  15. package/dist/device-install-dialog-W5Xv9kWL.mjs +443 -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-Y1u6vIBB.js +31 -0
  23. package/dist/use-device-install-sDVvby1V.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/storage/browser-storage.ts +26 -1
  38. package/src/core/device-install/types.ts +2 -2
  39. package/src/demo.tsx +93 -10
  40. package/src/hooks/use-device-install.ts +766 -67
  41. package/src/index.ts +19 -1
  42. package/vitest.config.ts +23 -0
  43. package/dist/device-install-dialog-CTwVViYY.js +0 -2
  44. package/dist/device-install-dialog-zzKJu7SM.mjs +0 -328
  45. package/dist/use-device-install-CgrOKKyi.mjs +0 -13042
  46. package/dist/use-device-install-DDKRf6IL.js +0 -23
@@ -1,4 +1,4 @@
1
- import { useId, useState, type ChangeEvent, type ReactNode } from 'react';
1
+ import { useEffect, useId, useState, type ChangeEvent, type ReactNode } from 'react';
2
2
  import { clsx } from 'clsx';
3
3
  import { useDeviceInstall, type UseDeviceInstallOptions } from '../../hooks/use-device-install';
4
4
  import type { DeviceInstallStep, DeviceInstallStepStatus } from '../../core/device-install';
@@ -10,19 +10,19 @@ export type DeviceInstallDialogProps = UseDeviceInstallOptions & {
10
10
 
11
11
  const steps: Array<{ id: DeviceInstallStep; title: string; description: string }> = [
12
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.',
13
+ id: 'signing',
14
+ title: 'Prepare signing',
15
+ description: 'Choose Apple ID login or upload certificates for a registered developer device.',
16
16
  },
17
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.',
18
+ id: 'build',
19
+ title: 'Build for device',
20
+ description: 'Start the signed iPhone build before connecting over USB.',
21
21
  },
22
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.',
23
+ id: 'connect',
24
+ title: 'Connect and pair',
25
+ description: 'After the build succeeds, connect the iPhone with WebUSB and pair this browser.',
26
26
  },
27
27
  {
28
28
  id: 'install',
@@ -31,17 +31,25 @@ const steps: Array<{ id: DeviceInstallStep; title: string; description: string }
31
31
  },
32
32
  ];
33
33
 
34
+ type SigningSection = 'apple-id' | 'upload';
35
+
34
36
  export function DeviceInstallDialog({
35
37
  disabled,
36
38
  ...hookOptions
37
39
  }: DeviceInstallDialogProps) {
38
40
  const [open, setOpen] = useState(false);
41
+ const [openStep, setOpenStep] = useState<DeviceInstallStep>('signing');
42
+ const [signingSection, setSigningSection] = useState<SigningSection>();
39
43
  const [appleAccountName, setAppleAccountName] = useState('');
40
44
  const [applePassword, setApplePassword] = useState('');
41
45
  const [appleTwoFactorCode, setAppleTwoFactorCode] = useState('');
42
46
  const dialogTitleId = useId();
43
47
  const deviceInstall = useDeviceInstall(hookOptions);
44
48
 
49
+ useEffect(() => {
50
+ setOpenStep(deviceInstall.currentStep);
51
+ }, [deviceInstall.currentStep]);
52
+
45
53
  const updateSigningFiles = (field: 'certificateFile' | 'provisioningProfileFile', event: ChangeEvent<HTMLInputElement>) => {
46
54
  deviceInstall.setSigningFiles({
47
55
  [field]: event.currentTarget.files?.[0],
@@ -70,7 +78,7 @@ export function DeviceInstallDialog({
70
78
  <header className="lr-device-install__header">
71
79
  <div>
72
80
  <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>
81
+ <p>Prepare signing, build for the registered device, connect and pair, then install from this browser.</p>
74
82
  </div>
75
83
  <button type="button" className="lr-device-install__icon-button" onClick={() => setOpen(false)}>
76
84
  Close
@@ -86,182 +94,262 @@ export function DeviceInstallDialog({
86
94
  index={index + 1}
87
95
  step={step}
88
96
  active={deviceInstall.currentStep === step.id}
97
+ open={openStep === step.id}
89
98
  status={deviceInstall.stepStatuses[step.id]}
99
+ onToggle={() => setOpenStep(step.id)}
90
100
  >
91
- {step.id === 'build' && (
101
+ {step.id === 'signing' && (
92
102
  <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">
103
+ <div className="lr-device-install__choice-grid">
126
104
  <button
127
105
  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
- }
106
+ className={clsx(
107
+ 'lr-device-install__choice',
108
+ signingSection === 'apple-id' && 'lr-device-install__choice--active',
109
+ )}
110
+ onClick={() => setSigningSection('apple-id')}
142
111
  >
143
- {deviceInstall.appleSigningStatus === 'authenticating'
144
- ? 'Signing in...'
145
- : 'Sign in with Apple ID'}
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>
146
125
  </button>
147
- <span className="lr-device-install__hint">
148
- Apple password is used only by browser-side SRP. Status: {deviceInstall.appleSigningStatus}
149
- </span>
150
126
  </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>
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
+ )}
163
270
  <button
164
271
  type="button"
165
- className="lr-device-install__secondary"
166
- disabled={!appleTwoFactorCode || deviceInstall.busyAction === 'build'}
167
- onClick={() => void deviceInstall.submitAppleTwoFactorCode(appleTwoFactorCode)}
272
+ className="lr-device-install__primary"
273
+ disabled={disabled || !deviceInstall.canPrepareAppleSigningAssets}
274
+ onClick={() => void deviceInstall.prepareAppleSigningAssets()}
168
275
  >
169
- Submit Apple ID code
276
+ {deviceInstall.appleSigningStatus === 'preparing-assets'
277
+ ? 'Preparing signing assets...'
278
+ : 'Generate certificate and profile'}
170
279
  </button>
171
280
  </div>
172
281
  )}
173
- {deviceInstall.appleTeams.length > 0 && (
174
- <label className="lr-device-install__field">
175
- <span>Apple Developer team</span>
176
- <select
177
- value={deviceInstall.selectedAppleTeamID ?? ''}
178
- onChange={(event) =>
179
- deviceInstall.setSelectedAppleTeamID(event.currentTarget.value || undefined)
180
- }
181
- >
182
- {deviceInstall.appleTeams.map((team, index) => {
183
- const teamID =
184
- team.teamId ??
185
- (team.providerId === undefined ? undefined : String(team.providerId)) ??
186
- team.publicProviderId ??
187
- '';
188
- return (
189
- <option key={`${teamID}-${index}`} value={teamID}>
190
- {team.name ?? 'Apple Developer Team'} {teamID ? `(${teamID})` : ''}
191
- </option>
192
- );
193
- })}
194
- </select>
195
- </label>
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>
196
321
  )}
197
- <div className="lr-device-install__grid">
198
- <label className="lr-device-install__field">
199
- <span>Certificate (.p12)</span>
200
- <input
201
- type="file"
202
- accept=".p12,application/x-pkcs12"
203
- onChange={(event) => updateSigningFiles('certificateFile', event)}
204
- />
205
- </label>
206
- <label className="lr-device-install__field">
207
- <span>Provisioning profile</span>
208
- <input
209
- type="file"
210
- accept=".mobileprovision"
211
- onChange={(event) => updateSigningFiles('provisioningProfileFile', event)}
212
- />
213
- </label>
214
- <label className="lr-device-install__field">
215
- <span>Uploaded .p12 password</span>
216
- <input
217
- type="password"
218
- placeholder="Export password"
219
- onChange={(event) =>
220
- deviceInstall.setSigningFiles({ certificatePassword: event.currentTarget.value })
221
- }
222
- />
223
- </label>
224
- </div>
225
- <button
226
- type="button"
227
- className="lr-device-install__primary"
228
- disabled={disabled || !deviceInstall.canBuild}
229
- onClick={() => void deviceInstall.startDeviceBuild()}
230
- >
231
- {deviceInstall.busyAction === 'build' ? 'Starting build...' : 'Start device build'}
232
- </button>
233
- <details
234
- className="lr-device-install__build-logs"
235
- open={deviceInstall.buildLogPanelOpen}
236
- onToggle={(event) => deviceInstall.setBuildLogPanelOpen(event.currentTarget.open)}
237
- >
238
- <summary>Build logs ({deviceInstall.buildStatus})</summary>
239
- <pre>
240
- {deviceInstall.buildLogs.length > 0
241
- ? deviceInstall.buildLogs
242
- .filter((line) => line.type !== 'meta')
243
- .map((line) => line.data)
244
- .join('\n')
245
- : 'Build logs will appear here while the device build is running.'}
246
- </pre>
247
- </details>
248
322
  </div>
249
323
  )}
250
324
 
251
- {step.id === 'usb' && (
325
+ {step.id === 'connect' && (
252
326
  <div className="lr-device-install__step-body">
253
327
  <p>
254
- WebUSB works in Chromium browsers on secure origins. Connect the iPhone over USB and approve
255
- the browser permission prompt.
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.
256
330
  </p>
257
- <button
258
- type="button"
259
- className="lr-device-install__primary"
260
- disabled={disabled || !deviceInstall.canRequestUSBAccess}
261
- onClick={() => void deviceInstall.requestUSBAccess()}
262
- >
263
- {deviceInstall.busyAction === 'usb' ? 'Selecting iPhone...' : 'Allow USB access'}
264
- </button>
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>
265
353
  {deviceInstall.device && (
266
354
  <div className="lr-device-install__device">
267
355
  {`${deviceInstall.device.productName ?? 'iPhone'} ${
@@ -269,34 +357,49 @@ export function DeviceInstallDialog({
269
357
  }`.trim()}
270
358
  </div>
271
359
  )}
272
- </div>
273
- )}
274
-
275
- {step.id === 'pair' && (
276
- <div className="lr-device-install__step-body">
277
360
  {deviceInstall.pairConfirmationRequired && (
278
361
  <p>
279
362
  Unlock the iPhone and tap <strong>Trust</strong> in the system dialog, then confirm the pair
280
363
  record.
281
364
  </p>
282
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>
283
380
  <button
284
381
  type="button"
285
382
  className="lr-device-install__primary"
286
- disabled={disabled || !deviceInstall.canPairBrowser}
287
- onClick={() => void deviceInstall.pairBrowser()}
383
+ disabled={disabled || !deviceInstall.canBuild}
384
+ onClick={() => void deviceInstall.startDeviceBuild()}
288
385
  >
289
- {deviceInstall.busyAction === 'pair'
290
- ? 'Pairing...'
291
- : deviceInstall.pairConfirmationRequired
292
- ? 'Confirm pair record'
293
- : 'Pair browser'}
386
+ {deviceInstall.busyAction === 'build' ? 'Starting build...' : 'Start device build'}
294
387
  </button>
295
- <p>
296
- {deviceInstall.hasPairRecord
297
- ? 'Pair record is stored locally. Installation is available.'
298
- : 'Pair this browser once before installing.'}
299
- </p>
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>
300
403
  </div>
301
404
  )}
302
405
 
@@ -338,26 +441,55 @@ function StepCard({
338
441
  index,
339
442
  step,
340
443
  active,
444
+ open,
341
445
  status,
446
+ onToggle,
342
447
  children,
343
448
  }: {
344
449
  index: number;
345
450
  step: { id: DeviceInstallStep; title: string; description: string };
346
451
  active: boolean;
452
+ open: boolean;
347
453
  status: DeviceInstallStepStatus;
454
+ onToggle: () => void;
348
455
  children: ReactNode;
349
456
  }) {
350
457
  return (
351
458
  <article className={clsx('lr-device-install__step', active && 'lr-device-install__step--active')}>
352
- <div className="lr-device-install__step-header">
459
+ <button
460
+ type="button"
461
+ className="lr-device-install__step-header"
462
+ aria-expanded={open}
463
+ onClick={onToggle}
464
+ >
353
465
  <div className="lr-device-install__step-number">{index}</div>
354
466
  <div>
355
467
  <h3>{step.title}</h3>
356
468
  <p>{step.description}</p>
357
469
  </div>
358
- <span className={clsx('lr-device-install__status', `lr-device-install__status--${status}`)}>{status}</span>
359
- </div>
360
- {children}
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}
361
475
  </article>
362
476
  );
363
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
+ }