@limrun/ui 0.9.0-rc.2 → 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.
@@ -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,208 +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>
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)
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
+ })
181
180
  }
182
181
  >
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})` : ''}
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 ?? ''}
192
254
  </option>
193
- );
194
- })}
195
- </select>
196
- </label>
255
+ ))}
256
+ </select>
257
+ </label>
258
+ )}
197
259
  {deviceInstall.applePortalSummary && (
198
260
  <p className="lr-device-install__hint">
199
261
  Found {deviceInstall.applePortalSummary.certificateCount} certificates and{' '}
200
262
  {deviceInstall.applePortalSummary.profileCount} provisioning profiles.
201
263
  </p>
202
264
  )}
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)}
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()}
211
275
  >
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>
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 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>
222
321
  )}
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
322
  </div>
275
323
  )}
276
324
 
277
- {step.id === 'usb' && (
325
+ {step.id === 'connect' && (
278
326
  <div className="lr-device-install__step-body">
279
327
  <p>
280
- WebUSB works in Chromium browsers on secure origins. Connect the iPhone over USB and approve
281
- 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.
282
330
  </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>
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>
291
353
  {deviceInstall.device && (
292
354
  <div className="lr-device-install__device">
293
355
  {`${deviceInstall.device.productName ?? 'iPhone'} ${
@@ -295,27 +357,35 @@ export function DeviceInstallDialog({
295
357
  }`.trim()}
296
358
  </div>
297
359
  )}
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>
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>
317
365
  )}
318
- {deviceInstall.device && !deviceInstall.connectedAppleDeviceRegistered && (
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 && (
319
389
  <button
320
390
  type="button"
321
391
  className="lr-device-install__secondary"
@@ -325,47 +395,29 @@ export function DeviceInstallDialog({
325
395
  Register connected iPhone
326
396
  </button>
327
397
  )}
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
398
  <button
353
399
  type="button"
354
400
  className="lr-device-install__primary"
355
- disabled={disabled || !deviceInstall.canPairBrowser}
356
- onClick={() => void deviceInstall.pairBrowser()}
401
+ disabled={disabled || !deviceInstall.canBuild}
402
+ onClick={() => void deviceInstall.startDeviceBuild()}
357
403
  >
358
- {deviceInstall.busyAction === 'pair'
359
- ? 'Pairing...'
360
- : deviceInstall.pairConfirmationRequired
361
- ? 'Confirm pair record'
362
- : 'Pair browser'}
404
+ {deviceInstall.busyAction === 'build' ? 'Starting build...' : 'Start device build'}
363
405
  </button>
364
- <p>
365
- {deviceInstall.hasPairRecord
366
- ? 'Pair record is stored locally. Installation is available.'
367
- : 'Pair this browser once before installing.'}
368
- </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>
369
421
  </div>
370
422
  )}
371
423
 
@@ -407,26 +459,55 @@ function StepCard({
407
459
  index,
408
460
  step,
409
461
  active,
462
+ open,
410
463
  status,
464
+ onToggle,
411
465
  children,
412
466
  }: {
413
467
  index: number;
414
468
  step: { id: DeviceInstallStep; title: string; description: string };
415
469
  active: boolean;
470
+ open: boolean;
416
471
  status: DeviceInstallStepStatus;
472
+ onToggle: () => void;
417
473
  children: ReactNode;
418
474
  }) {
419
475
  return (
420
476
  <article className={clsx('lr-device-install__step', active && 'lr-device-install__step--active')}>
421
- <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
+ >
422
483
  <div className="lr-device-install__step-number">{index}</div>
423
484
  <div>
424
485
  <h3>{step.title}</h3>
425
486
  <p>{step.description}</p>
426
487
  </div>
427
- <span className={clsx('lr-device-install__status', `lr-device-install__status--${status}`)}>{status}</span>
428
- </div>
429
- {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}
430
493
  </article>
431
494
  );
432
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
+ }