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

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 (30) hide show
  1. package/dist/core/device-install/apple/client.d.ts +1 -0
  2. package/dist/core/device-install/apple/provisioning.d.ts +42 -31
  3. package/dist/core/device-install/apple/relay.d.ts +5 -9
  4. package/dist/core/device-install/storage/browser-storage.d.ts +19 -0
  5. package/dist/core/device-install/types.d.ts +2 -2
  6. package/dist/device-install/index.cjs +1 -9
  7. package/dist/device-install/index.js +76 -210
  8. package/dist/device-install/react.cjs +1 -1
  9. package/dist/device-install/react.js +1 -1
  10. package/dist/device-install-dialog-86RDdoK9.js +2 -0
  11. package/dist/device-install-dialog-CnyDWf0q.mjs +462 -0
  12. package/dist/device-install-dialog.css +1 -1
  13. package/dist/hooks/use-device-install.d.ts +21 -3
  14. package/dist/index.cjs +1 -1
  15. package/dist/index.js +3 -3
  16. package/dist/use-device-install-CbGVvwPp.js +31 -0
  17. package/dist/use-device-install-j1Gekpl4.mjs +13623 -0
  18. package/package.json +1 -1
  19. package/src/components/device-install/device-install-dialog.css +82 -1
  20. package/src/components/device-install/device-install-dialog.tsx +337 -187
  21. package/src/core/device-install/apple/client.ts +92 -4
  22. package/src/core/device-install/apple/provisioning.ts +67 -24
  23. package/src/core/device-install/apple/relay.ts +121 -205
  24. package/src/core/device-install/storage/browser-storage.ts +26 -1
  25. package/src/core/device-install/types.ts +2 -2
  26. package/src/hooks/use-device-install.ts +748 -60
  27. package/dist/device-install-dialog-CTwVViYY.js +0 -2
  28. package/dist/device-install-dialog-zzKJu7SM.mjs +0 -328
  29. package/dist/use-device-install-CgrOKKyi.mjs +0 -13042
  30. 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, then confirm the target 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: 'connect',
19
+ title: 'Connect and pair',
20
+ description: 'Connect the iPhone with WebUSB, then pair this browser so installs can use the device.',
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: 'build',
24
+ title: 'Check and build',
25
+ description: 'Verify the device and provisioning profile are ready, then start the signed build.',
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, connect and pair the device, build, 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, 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 the build starts.
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. Connect the iPhone over USB, approve the
329
+ 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,67 @@ 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 the build check.'
369
+ : 'Pair this browser once before building and 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} pendingText="Ready to verify" />
378
+ <StatusLine label="USB device" ready={!!deviceInstall.device} />
379
+ <StatusLine label="Pair record" ready={deviceInstall.hasPairRecord} />
380
+ <StatusLine
381
+ label="Profile includes connected device"
382
+ ready={deviceInstall.connectedDeviceInProfile}
383
+ pendingText="Checked when the build starts"
384
+ />
385
+ </div>
386
+ {deviceInstall.device &&
387
+ deviceInstall.appleTeams.length > 0 &&
388
+ !deviceInstall.connectedAppleDeviceRegistered && (
389
+ <button
390
+ type="button"
391
+ className="lr-device-install__secondary"
392
+ disabled={disabled || !!deviceInstall.busyAction}
393
+ onClick={() => void deviceInstall.registerConnectedAppleDevice()}
394
+ >
395
+ Register connected iPhone
396
+ </button>
397
+ )}
283
398
  <button
284
399
  type="button"
285
400
  className="lr-device-install__primary"
286
- disabled={disabled || !deviceInstall.canPairBrowser}
287
- onClick={() => void deviceInstall.pairBrowser()}
401
+ disabled={disabled || !deviceInstall.canBuild}
402
+ onClick={() => void deviceInstall.startDeviceBuild()}
288
403
  >
289
- {deviceInstall.busyAction === 'pair'
290
- ? 'Pairing...'
291
- : deviceInstall.pairConfirmationRequired
292
- ? 'Confirm pair record'
293
- : 'Pair browser'}
404
+ {deviceInstall.busyAction === 'build' ? 'Starting build...' : 'Start device build'}
294
405
  </button>
295
- <p>
296
- {deviceInstall.hasPairRecord
297
- ? 'Pair record is stored locally. Installation is available.'
298
- : 'Pair this browser once before installing.'}
299
- </p>
406
+ <details
407
+ className="lr-device-install__build-logs"
408
+ open={deviceInstall.buildLogPanelOpen}
409
+ onToggle={(event) => deviceInstall.setBuildLogPanelOpen(event.currentTarget.open)}
410
+ >
411
+ <summary>Build logs ({deviceInstall.buildStatus})</summary>
412
+ <pre>
413
+ {deviceInstall.buildLogs.length > 0
414
+ ? deviceInstall.buildLogs
415
+ .filter((line) => line.type !== 'meta')
416
+ .map((line) => line.data)
417
+ .join('\n')
418
+ : 'Build logs will appear here while the device build is running.'}
419
+ </pre>
420
+ </details>
300
421
  </div>
301
422
  )}
302
423
 
@@ -338,26 +459,55 @@ function StepCard({
338
459
  index,
339
460
  step,
340
461
  active,
462
+ open,
341
463
  status,
464
+ onToggle,
342
465
  children,
343
466
  }: {
344
467
  index: number;
345
468
  step: { id: DeviceInstallStep; title: string; description: string };
346
469
  active: boolean;
470
+ open: boolean;
347
471
  status: DeviceInstallStepStatus;
472
+ onToggle: () => void;
348
473
  children: ReactNode;
349
474
  }) {
350
475
  return (
351
476
  <article className={clsx('lr-device-install__step', active && 'lr-device-install__step--active')}>
352
- <div className="lr-device-install__step-header">
477
+ <button
478
+ type="button"
479
+ className="lr-device-install__step-header"
480
+ aria-expanded={open}
481
+ onClick={onToggle}
482
+ >
353
483
  <div className="lr-device-install__step-number">{index}</div>
354
484
  <div>
355
485
  <h3>{step.title}</h3>
356
486
  <p>{step.description}</p>
357
487
  </div>
358
- <span className={clsx('lr-device-install__status', `lr-device-install__status--${status}`)}>{status}</span>
359
- </div>
360
- {children}
488
+ <span className={clsx('lr-device-install__status', `lr-device-install__status--${status}`)}>
489
+ {status === 'complete' ? '✓ Completed' : status}
490
+ </span>
491
+ </button>
492
+ {open && children}
361
493
  </article>
362
494
  );
363
495
  }
496
+
497
+ function StatusLine({
498
+ label,
499
+ ready,
500
+ pendingText = 'Not ready',
501
+ }: {
502
+ label: string;
503
+ ready?: boolean;
504
+ pendingText?: string;
505
+ }) {
506
+ const text = ready === undefined ? pendingText : ready ? 'Ready' : 'Needs attention';
507
+ return (
508
+ <div className="lr-device-install__check-row">
509
+ <span>{label}</span>
510
+ <strong>{text}</strong>
511
+ </div>
512
+ );
513
+ }