@limrun/ui 0.9.0-rc.5 → 0.9.0-rc.7

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 (73) hide show
  1. package/README.md +9 -0
  2. package/dist/components/device-install/device-install-dialog.d.ts +5 -0
  3. package/dist/components/device-install/index.d.ts +2 -0
  4. package/dist/components/inspect-overlay.d.ts +1 -0
  5. package/dist/components/remote-control.d.ts +13 -2
  6. package/dist/core/ax-tree.d.ts +2 -0
  7. package/dist/core/device-install/apple/client.d.ts +17 -0
  8. package/dist/core/device-install/apple/crypto.d.ts +20 -0
  9. package/dist/core/device-install/apple/gsa-srp.d.ts +26 -0
  10. package/dist/core/device-install/apple/index.d.ts +5 -0
  11. package/dist/core/device-install/apple/provisioning.d.ts +161 -0
  12. package/dist/core/device-install/apple/relay.d.ts +29 -0
  13. package/dist/core/device-install/index.d.ts +4 -0
  14. package/dist/core/device-install/operations/index.d.ts +6 -0
  15. package/dist/core/device-install/operations/limbuild-client.d.ts +28 -0
  16. package/dist/core/device-install/operations/operations.d.ts +32 -0
  17. package/dist/core/device-install/operations/relay-client.d.ts +25 -0
  18. package/dist/core/device-install/operations/relay-protocol.d.ts +27 -0
  19. package/dist/core/device-install/operations/usbmux.d.ts +32 -0
  20. package/dist/core/device-install/operations/webusb.d.ts +21 -0
  21. package/dist/core/device-install/storage/browser-storage.d.ts +44 -0
  22. package/dist/core/device-install/storage/index.d.ts +1 -0
  23. package/dist/core/device-install/types.d.ts +48 -0
  24. package/dist/device-install/index.cjs +1 -0
  25. package/dist/device-install/index.d.ts +3 -0
  26. package/dist/device-install/index.js +78 -0
  27. package/dist/device-install/react.cjs +1 -0
  28. package/dist/device-install/react.d.ts +1 -0
  29. package/dist/device-install/react.js +4 -0
  30. package/dist/device-install-dialog-86RDdoK9.js +2 -0
  31. package/dist/device-install-dialog-CnyDWf0q.mjs +462 -0
  32. package/dist/device-install-dialog.css +1 -0
  33. package/dist/hooks/index.d.ts +1 -0
  34. package/dist/hooks/use-device-install.d.ts +73 -0
  35. package/dist/index.cjs +1 -1
  36. package/dist/index.css +1 -1
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.js +737 -703
  39. package/dist/use-device-install-CbGVvwPp.js +31 -0
  40. package/dist/use-device-install-j1Gekpl4.mjs +13623 -0
  41. package/package.json +15 -2
  42. package/src/components/device-install/device-install-dialog.css +325 -0
  43. package/src/components/device-install/device-install-dialog.tsx +513 -0
  44. package/src/components/device-install/index.ts +2 -0
  45. package/src/components/inspect-overlay.css +6 -0
  46. package/src/components/inspect-overlay.tsx +46 -15
  47. package/src/components/remote-control.tsx +16 -2
  48. package/src/core/ax-tree.test.ts +124 -0
  49. package/src/core/ax-tree.ts +107 -0
  50. package/src/core/device-install/apple/client.ts +152 -0
  51. package/src/core/device-install/apple/crypto.ts +202 -0
  52. package/src/core/device-install/apple/gsa-srp.ts +127 -0
  53. package/src/core/device-install/apple/index.ts +5 -0
  54. package/src/core/device-install/apple/provisioning.ts +298 -0
  55. package/src/core/device-install/apple/relay.ts +221 -0
  56. package/src/core/device-install/index.ts +4 -0
  57. package/src/core/device-install/operations/index.ts +6 -0
  58. package/src/core/device-install/operations/limbuild-client.ts +104 -0
  59. package/src/core/device-install/operations/operations.ts +217 -0
  60. package/src/core/device-install/operations/relay-client.ts +255 -0
  61. package/src/core/device-install/operations/relay-protocol.ts +71 -0
  62. package/src/core/device-install/operations/usbmux.ts +270 -0
  63. package/src/core/device-install/operations/webusb-dom.d.ts +54 -0
  64. package/src/core/device-install/operations/webusb.ts +105 -0
  65. package/src/core/device-install/storage/browser-storage.ts +263 -0
  66. package/src/core/device-install/storage/index.ts +1 -0
  67. package/src/core/device-install/types.ts +65 -0
  68. package/src/device-install/index.ts +3 -0
  69. package/src/device-install/react.ts +1 -0
  70. package/src/hooks/index.ts +1 -0
  71. package/src/hooks/use-device-install.ts +1210 -0
  72. package/src/index.ts +4 -0
  73. package/vite.config.ts +6 -2
@@ -0,0 +1,513 @@
1
+ import { useEffect, useId, useState, type ChangeEvent, type ReactNode } from 'react';
2
+ import { clsx } from 'clsx';
3
+ import { useDeviceInstall, type UseDeviceInstallOptions } from '../../hooks/use-device-install';
4
+ import type { DeviceInstallStep, DeviceInstallStepStatus } from '../../core/device-install';
5
+ import './device-install-dialog.css';
6
+
7
+ export type DeviceInstallDialogProps = UseDeviceInstallOptions & {
8
+ disabled?: boolean;
9
+ };
10
+
11
+ const steps: Array<{ id: DeviceInstallStep; title: string; description: string }> = [
12
+ {
13
+ id: 'signing',
14
+ title: 'Prepare signing',
15
+ description: 'Choose Apple ID login or upload certificates, then confirm the target developer device.',
16
+ },
17
+ {
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
+ },
22
+ {
23
+ id: 'build',
24
+ title: 'Check and build',
25
+ description: 'Verify the device and provisioning profile are ready, then start the signed build.',
26
+ },
27
+ {
28
+ id: 'install',
29
+ title: 'Start installation',
30
+ description: 'Relay the last successful device build to the paired iPhone.',
31
+ },
32
+ ];
33
+
34
+ type SigningSection = 'apple-id' | 'upload';
35
+
36
+ export function DeviceInstallDialog({
37
+ disabled,
38
+ ...hookOptions
39
+ }: DeviceInstallDialogProps) {
40
+ const [open, setOpen] = useState(false);
41
+ const [openStep, setOpenStep] = useState<DeviceInstallStep>('signing');
42
+ const [signingSection, setSigningSection] = useState<SigningSection>();
43
+ const [appleAccountName, setAppleAccountName] = useState('');
44
+ const [applePassword, setApplePassword] = useState('');
45
+ const [appleTwoFactorCode, setAppleTwoFactorCode] = useState('');
46
+ const dialogTitleId = useId();
47
+ const deviceInstall = useDeviceInstall(hookOptions);
48
+
49
+ useEffect(() => {
50
+ setOpenStep(deviceInstall.currentStep);
51
+ }, [deviceInstall.currentStep]);
52
+
53
+ const updateSigningFiles = (field: 'certificateFile' | 'provisioningProfileFile', event: ChangeEvent<HTMLInputElement>) => {
54
+ deviceInstall.setSigningFiles({
55
+ [field]: event.currentTarget.files?.[0],
56
+ });
57
+ };
58
+
59
+ return (
60
+ <div className="lr-device-install">
61
+ <button
62
+ type="button"
63
+ className="lr-device-install__trigger"
64
+ disabled={disabled || !hookOptions.apiUrl}
65
+ onClick={() => setOpen(true)}
66
+ >
67
+ Install to iPhone
68
+ </button>
69
+
70
+ {open && (
71
+ <div className="lr-device-install__backdrop" role="presentation">
72
+ <section
73
+ aria-labelledby={dialogTitleId}
74
+ aria-modal="true"
75
+ className="lr-device-install__dialog"
76
+ role="dialog"
77
+ >
78
+ <header className="lr-device-install__header">
79
+ <div>
80
+ <h2 id={dialogTitleId}>Install to a real iPhone</h2>
81
+ <p>Prepare signing, connect and pair the device, build, then install from this browser.</p>
82
+ </div>
83
+ <button type="button" className="lr-device-install__icon-button" onClick={() => setOpen(false)}>
84
+ Close
85
+ </button>
86
+ </header>
87
+
88
+ {deviceInstall.error && <div className="lr-device-install__error">{deviceInstall.error}</div>}
89
+
90
+ <div className="lr-device-install__steps">
91
+ {steps.map((step, index) => (
92
+ <StepCard
93
+ key={step.id}
94
+ index={index + 1}
95
+ step={step}
96
+ active={deviceInstall.currentStep === step.id}
97
+ open={openStep === step.id}
98
+ status={deviceInstall.stepStatuses[step.id]}
99
+ onToggle={() => setOpenStep(step.id)}
100
+ >
101
+ {step.id === 'signing' && (
102
+ <div className="lr-device-install__step-body">
103
+ <div className="lr-device-install__choice-grid">
104
+ <button
105
+ type="button"
106
+ className={clsx(
107
+ 'lr-device-install__choice',
108
+ signingSection === 'apple-id' && 'lr-device-install__choice--active',
109
+ )}
110
+ onClick={() => setSigningSection('apple-id')}
111
+ >
112
+ <strong>Apple ID login</strong>
113
+ <span>Sign in, choose team, bundle ID, devices, then generate signing assets.</span>
114
+ </button>
115
+ <button
116
+ type="button"
117
+ className={clsx(
118
+ 'lr-device-install__choice',
119
+ signingSection === 'upload' && 'lr-device-install__choice--active',
120
+ )}
121
+ onClick={() => setSigningSection('upload')}
122
+ >
123
+ <strong>Upload certificates</strong>
124
+ <span>Use an existing .p12 certificate and provisioning profile.</span>
125
+ </button>
126
+ </div>
127
+
128
+ {signingSection === 'apple-id' && (
129
+ <div className="lr-device-install__section-panel">
130
+ <div className="lr-device-install__grid">
131
+ <label className="lr-device-install__field">
132
+ <span>Apple ID</span>
133
+ <input
134
+ type="email"
135
+ autoComplete="username"
136
+ placeholder="name@example.com"
137
+ value={appleAccountName}
138
+ onChange={(event) => setAppleAccountName(event.currentTarget.value)}
139
+ />
140
+ </label>
141
+ <label className="lr-device-install__field">
142
+ <span>Apple ID password</span>
143
+ <input
144
+ type="password"
145
+ autoComplete="current-password"
146
+ placeholder="Password stays in this browser"
147
+ value={applePassword}
148
+ onChange={(event) => setApplePassword(event.currentTarget.value)}
149
+ />
150
+ </label>
151
+ {!deviceInstall.hasReusableAppleCertificate && (
152
+ <label className="lr-device-install__field">
153
+ <span>Generated .p12 password</span>
154
+ <input
155
+ type="password"
156
+ placeholder="Used when exporting Apple certificate"
157
+ onChange={(event) =>
158
+ deviceInstall.setSigningFiles({ certificatePassword: event.currentTarget.value })
159
+ }
160
+ />
161
+ </label>
162
+ )}
163
+ </div>
164
+ <div className="lr-device-install__actions">
165
+ <button
166
+ type="button"
167
+ className="lr-device-install__secondary"
168
+ disabled={
169
+ disabled ||
170
+ !hookOptions.apiUrl ||
171
+ !appleAccountName ||
172
+ !applePassword ||
173
+ deviceInstall.busyAction === 'signing'
174
+ }
175
+ onClick={() =>
176
+ void deviceInstall.startAppleIDLogin({
177
+ accountName: appleAccountName,
178
+ password: applePassword,
179
+ })
180
+ }
181
+ >
182
+ {deviceInstall.appleSigningStatus === 'authenticating'
183
+ ? 'Signing in...'
184
+ : 'Sign in with Apple ID'}
185
+ </button>
186
+ <span className="lr-device-install__hint">
187
+ Apple password is used only by browser-side SRP. Status: {deviceInstall.appleSigningStatus}
188
+ </span>
189
+ </div>
190
+ {deviceInstall.appleSigningStatus === 'two-factor-required' && (
191
+ <div className="lr-device-install__grid">
192
+ <label className="lr-device-install__field">
193
+ <span>Two-factor code</span>
194
+ <input
195
+ type="text"
196
+ inputMode="numeric"
197
+ autoComplete="one-time-code"
198
+ value={appleTwoFactorCode}
199
+ onChange={(event) => setAppleTwoFactorCode(event.currentTarget.value)}
200
+ />
201
+ </label>
202
+ <button
203
+ type="button"
204
+ className="lr-device-install__secondary"
205
+ disabled={!appleTwoFactorCode || deviceInstall.busyAction === 'signing'}
206
+ onClick={() => void deviceInstall.submitAppleTwoFactorCode(appleTwoFactorCode)}
207
+ >
208
+ Submit Apple ID code
209
+ </button>
210
+ </div>
211
+ )}
212
+ {deviceInstall.appleTeams.length > 0 && (
213
+ <label className="lr-device-install__field">
214
+ <span>Apple Developer team</span>
215
+ <select
216
+ value={deviceInstall.selectedAppleTeamID ?? ''}
217
+ onChange={(event) =>
218
+ deviceInstall.setSelectedAppleTeamID(event.currentTarget.value || undefined)
219
+ }
220
+ >
221
+ {deviceInstall.appleTeams.map((team, index) => {
222
+ const teamID =
223
+ team.teamId ??
224
+ (team.providerId === undefined ? undefined : String(team.providerId)) ??
225
+ team.publicProviderId ??
226
+ '';
227
+ return (
228
+ <option key={`${teamID}-${index}`} value={teamID}>
229
+ {team.name ?? 'Apple Developer Team'} {teamID ? `(${teamID})` : ''}
230
+ </option>
231
+ );
232
+ })}
233
+ </select>
234
+ </label>
235
+ )}
236
+ {deviceInstall.appleDevices.length > 0 && (
237
+ <label className="lr-device-install__field">
238
+ <span>Apple Developer devices</span>
239
+ <select
240
+ multiple
241
+ value={deviceInstall.selectedAppleDeviceIDs}
242
+ onChange={(event) =>
243
+ deviceInstall.setSelectedAppleDeviceIDs(
244
+ Array.from(event.currentTarget.selectedOptions).map((option) => option.value),
245
+ )
246
+ }
247
+ >
248
+ {deviceInstall.appleDevices.map((appleDevice) => (
249
+ <option
250
+ key={appleDevice.deviceId ?? appleDevice.deviceNumber}
251
+ value={appleDevice.deviceId ?? ''}
252
+ >
253
+ {appleDevice.name ?? appleDevice.model ?? 'Apple device'} {appleDevice.deviceNumber ?? ''}
254
+ </option>
255
+ ))}
256
+ </select>
257
+ </label>
258
+ )}
259
+ {deviceInstall.applePortalSummary && (
260
+ <p className="lr-device-install__hint">
261
+ Found {deviceInstall.applePortalSummary.certificateCount} certificates and{' '}
262
+ {deviceInstall.applePortalSummary.profileCount} provisioning profiles.
263
+ </p>
264
+ )}
265
+ {deviceInstall.hasReusableAppleCertificate && (
266
+ <p className="lr-device-install__hint">
267
+ Reusing the certificate and private key stored in this browser.
268
+ </p>
269
+ )}
270
+ <button
271
+ type="button"
272
+ className="lr-device-install__primary"
273
+ disabled={disabled || !deviceInstall.canPrepareAppleSigningAssets}
274
+ onClick={() => void deviceInstall.prepareAppleSigningAssets()}
275
+ >
276
+ {deviceInstall.appleSigningStatus === 'preparing-assets'
277
+ ? 'Preparing signing assets...'
278
+ : 'Generate certificate and profile'}
279
+ </button>
280
+ </div>
281
+ )}
282
+
283
+ {signingSection === 'upload' && (
284
+ <div className="lr-device-install__section-panel">
285
+ <div className="lr-device-install__grid">
286
+ <label className="lr-device-install__field">
287
+ <span>Certificate (.p12)</span>
288
+ <input
289
+ type="file"
290
+ accept=".p12,application/x-pkcs12"
291
+ onChange={(event) => updateSigningFiles('certificateFile', event)}
292
+ />
293
+ </label>
294
+ <label className="lr-device-install__field">
295
+ <span>Provisioning profile</span>
296
+ <input
297
+ type="file"
298
+ accept=".mobileprovision"
299
+ onChange={(event) => updateSigningFiles('provisioningProfileFile', event)}
300
+ />
301
+ </label>
302
+ <label className="lr-device-install__field">
303
+ <span>Uploaded .p12 password</span>
304
+ <input
305
+ type="password"
306
+ placeholder="Export password"
307
+ onChange={(event) =>
308
+ deviceInstall.setSigningFiles({ certificatePassword: event.currentTarget.value })
309
+ }
310
+ />
311
+ </label>
312
+ </div>
313
+ <p className="lr-device-install__hint">
314
+ The provisioning profile will be checked against the connected iPhone before 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>
321
+ )}
322
+ </div>
323
+ )}
324
+
325
+ {step.id === 'connect' && (
326
+ <div className="lr-device-install__step-body">
327
+ <p>
328
+ WebUSB works in Chromium browsers on secure origins. Connect the iPhone over USB, approve the
329
+ browser permission prompt, then pair this browser.
330
+ </p>
331
+ <div className="lr-device-install__actions">
332
+ <button
333
+ type="button"
334
+ className="lr-device-install__primary"
335
+ disabled={disabled || !deviceInstall.canRequestUSBAccess}
336
+ onClick={() => void deviceInstall.requestUSBAccess()}
337
+ >
338
+ {deviceInstall.busyAction === 'usb' ? 'Selecting iPhone...' : 'Allow USB access'}
339
+ </button>
340
+ <button
341
+ type="button"
342
+ className="lr-device-install__secondary"
343
+ disabled={disabled || !deviceInstall.canPairBrowser}
344
+ onClick={() => void deviceInstall.pairBrowser()}
345
+ >
346
+ {deviceInstall.busyAction === 'pair'
347
+ ? 'Pairing...'
348
+ : deviceInstall.pairConfirmationRequired
349
+ ? 'Confirm pair record'
350
+ : 'Pair browser'}
351
+ </button>
352
+ </div>
353
+ {deviceInstall.device && (
354
+ <div className="lr-device-install__device">
355
+ {`${deviceInstall.device.productName ?? 'iPhone'} ${
356
+ deviceInstall.device.serialNumber ?? ''
357
+ }`.trim()}
358
+ </div>
359
+ )}
360
+ {deviceInstall.pairConfirmationRequired && (
361
+ <p>
362
+ Unlock the iPhone and tap <strong>Trust</strong> in the system dialog, then confirm the pair
363
+ record.
364
+ </p>
365
+ )}
366
+ <p>
367
+ {deviceInstall.hasPairRecord
368
+ ? 'Pair record is stored locally. Continue to 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
+ )}
398
+ <button
399
+ type="button"
400
+ className="lr-device-install__primary"
401
+ disabled={disabled || !deviceInstall.canBuild}
402
+ onClick={() => void deviceInstall.startDeviceBuild()}
403
+ >
404
+ {deviceInstall.busyAction === 'build' ? 'Starting build...' : 'Start device build'}
405
+ </button>
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>
421
+ </div>
422
+ )}
423
+
424
+ {step.id === 'install' && (
425
+ <div className="lr-device-install__step-body">
426
+ <button
427
+ type="button"
428
+ className="lr-device-install__primary"
429
+ disabled={disabled || !deviceInstall.canInstall}
430
+ onClick={() => void deviceInstall.startInstallation()}
431
+ >
432
+ {deviceInstall.busyAction === 'install' ? 'Installing...' : 'Install last build'}
433
+ </button>
434
+ <button type="button" className="lr-device-install__secondary" onClick={deviceInstall.stopRelay}>
435
+ Stop relay
436
+ </button>
437
+ </div>
438
+ )}
439
+ </StepCard>
440
+ ))}
441
+ </div>
442
+
443
+ <footer className="lr-device-install__logs">
444
+ <h3>Progress</h3>
445
+ <ol>
446
+ {deviceInstall.logs.map((entry, index) => (
447
+ <li key={`${index}-${entry.slice(0, 24)}`}>{entry}</li>
448
+ ))}
449
+ </ol>
450
+ </footer>
451
+ </section>
452
+ </div>
453
+ )}
454
+ </div>
455
+ );
456
+ }
457
+
458
+ function StepCard({
459
+ index,
460
+ step,
461
+ active,
462
+ open,
463
+ status,
464
+ onToggle,
465
+ children,
466
+ }: {
467
+ index: number;
468
+ step: { id: DeviceInstallStep; title: string; description: string };
469
+ active: boolean;
470
+ open: boolean;
471
+ status: DeviceInstallStepStatus;
472
+ onToggle: () => void;
473
+ children: ReactNode;
474
+ }) {
475
+ return (
476
+ <article className={clsx('lr-device-install__step', active && 'lr-device-install__step--active')}>
477
+ <button
478
+ type="button"
479
+ className="lr-device-install__step-header"
480
+ aria-expanded={open}
481
+ onClick={onToggle}
482
+ >
483
+ <div className="lr-device-install__step-number">{index}</div>
484
+ <div>
485
+ <h3>{step.title}</h3>
486
+ <p>{step.description}</p>
487
+ </div>
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}
493
+ </article>
494
+ );
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
+ }
@@ -0,0 +1,2 @@
1
+ export { DeviceInstallDialog, DeviceInstallDialog as DeviceInstallRelay } from './device-install-dialog';
2
+ export type { DeviceInstallDialogProps } from './device-install-dialog';
@@ -9,6 +9,11 @@
9
9
  /* When click-to-select is enabled, the container also captures clicks
10
10
  that fall outside any box so we can clear selection. */
11
11
  pointer-events: auto;
12
+ /* Match Chrome DevTools' crosshair cursor while inspect mode is active.
13
+ The merged `.rc-inspect-overlay-select .rc-inspect-box` rule below
14
+ restates the cursor on each inner box, since `.rc-inspect-box` sets
15
+ its own `cursor: pointer`. */
16
+ cursor: crosshair;
12
17
  }
13
18
 
14
19
  .rc-inspect-box {
@@ -32,6 +37,7 @@
32
37
 
33
38
  .rc-inspect-overlay-select .rc-inspect-box {
34
39
  pointer-events: auto;
40
+ cursor: crosshair;
35
41
  }
36
42
 
37
43
  .rc-inspect-box-disabled {
@@ -7,8 +7,9 @@ import {
7
7
  AxElement,
8
8
  AxPlatform,
9
9
  AxSnapshot,
10
+ axCliTapCommand,
10
11
  axElementRoleLabel,
11
- axElementSelectorExpression,
12
+ axElementSelectorObject,
12
13
  axElementSummary,
13
14
  axElementsEqual,
14
15
  clampAxFrameForScreen,
@@ -37,6 +38,10 @@ export interface InspectOverlayProps {
37
38
  highlightedId: string | null;
38
39
  selectedId: string | null;
39
40
  mode: InspectMode;
41
+ // Optional instance id used to render the "Copy command" CLI snippet
42
+ // (e.g. `lim ios tap-element --ax-label 'Sign in' --id <instanceId>`)
43
+ // in the info card. When omitted the Copy command button is hidden.
44
+ instanceId?: string;
40
45
  // Current pointer position in viewport coordinates (clientX/Y). Drives the
41
46
  // cursor-anchored preview card while hovering. null when the pointer is
42
47
  // outside the device.
@@ -117,6 +122,10 @@ const InspectBox = memo(
117
122
  if (!selectable) return;
118
123
  e.preventDefault();
119
124
  e.stopPropagation();
125
+ // Chrome DevTools parity: clicking the already-selected element
126
+ // is a no-op. Re-firing the selection change callback would
127
+ // needlessly reset the card anchor and consumer-side state.
128
+ if (selected) return;
120
129
  onClick(element, { x: e.clientX, y: e.clientY });
121
130
  }}
122
131
  style={{
@@ -151,6 +160,9 @@ interface InfoCardProps {
151
160
  anchor: { x: number; y: number };
152
161
  cursorAnchored: boolean;
153
162
  showActions: boolean;
163
+ // Used to render the "Copy command" CLI snippet. When absent the button
164
+ // is hidden.
165
+ instanceId?: string;
154
166
  // Receives the element AND the viewport-space coordinate to tap at
155
167
  // (the frozen click position). Tapping at the click point — rather than
156
168
  // the element's frame center — preserves the user's aim when the
@@ -180,6 +192,7 @@ const InfoCard = memo(function InfoCard({
180
192
  anchor,
181
193
  cursorAnchored,
182
194
  showActions,
195
+ instanceId,
183
196
  onTap,
184
197
  }: InfoCardProps) {
185
198
  const [copied, setCopied] = useState<string | null>(null);
@@ -235,11 +248,26 @@ const InfoCard = memo(function InfoCard({
235
248
  return { left: `${left}px`, top: `${top}px`, transform };
236
249
  }, [anchor.x, anchor.y]);
237
250
 
238
- const selectorExpr = useMemo(() => axElementSelectorExpression(element, platform), [element, platform]);
251
+ // "Copy selector" returns the raw selector hash as JSON (the same shape
252
+ // accepted by the CLI / instance API). Every available selector field is
253
+ // included so the consumer can disambiguate (e.g. `AXLabel + type`).
254
+ const selectorObject = useMemo(() => axElementSelectorObject(element, platform), [element, platform]);
255
+ const selectorJson = useMemo(
256
+ () => (selectorObject ? JSON.stringify(selectorObject, null, 2) : null),
257
+ [selectorObject],
258
+ );
259
+ // "Copy command" emits a selector-based `lim … tap-element …` invocation
260
+ // that targets this exact element on this exact instance, robust to
261
+ // layout changes that would invalidate a coordinate-based tap.
262
+ const cliCommand = useMemo(
263
+ () => (instanceId ? axCliTapCommand(element, platform, instanceId) : null),
264
+ [element, platform, instanceId],
265
+ );
266
+
239
267
  const primaryIdField = platform === 'ios' ? element.selectors.AXUniqueId : element.selectors.resourceId;
240
268
  const primaryIdLabel = platform === 'ios' ? 'AXUniqueId' : 'resourceId';
241
269
 
242
- const handleCopy = useCallback(async (text: string | undefined, key: string) => {
270
+ const handleCopy = useCallback(async (text: string | null, key: string) => {
243
271
  if (!text) return;
244
272
  const ok = await copyToClipboard(text);
245
273
  if (ok) setCopied(key);
@@ -308,21 +336,22 @@ const InfoCard = memo(function InfoCard({
308
336
  <button
309
337
  type="button"
310
338
  className={clsx('rc-inspect-card-btn', copied === 'selector' && 'rc-inspect-card-btn-copied')}
311
- disabled={!selectorExpr}
312
- title={selectorExpr ?? 'No usable selector for this element'}
313
- onClick={() => handleCopy(selectorExpr ?? undefined, 'selector')}
339
+ disabled={!selectorJson}
340
+ title={selectorJson ?? 'No usable selector for this element'}
341
+ onClick={() => handleCopy(selectorJson, 'selector')}
314
342
  >
315
343
  {copied === 'selector' ? 'Copied!' : 'Copy selector'}
316
344
  </button>
317
- <button
318
- type="button"
319
- className={clsx('rc-inspect-card-btn', copied === 'id' && 'rc-inspect-card-btn-copied')}
320
- disabled={!primaryIdField}
321
- title={primaryIdField ?? `No ${primaryIdLabel}`}
322
- onClick={() => handleCopy(primaryIdField, 'id')}
323
- >
324
- {copied === 'id' ? 'Copied!' : `Copy ${primaryIdLabel}`}
325
- </button>
345
+ {cliCommand && (
346
+ <button
347
+ type="button"
348
+ className={clsx('rc-inspect-card-btn', copied === 'command' && 'rc-inspect-card-btn-copied')}
349
+ title={cliCommand}
350
+ onClick={() => handleCopy(cliCommand, 'command')}
351
+ >
352
+ {copied === 'command' ? 'Copied!' : 'Copy command'}
353
+ </button>
354
+ )}
326
355
  </div>
327
356
  )}
328
357
  </div>,
@@ -340,6 +369,7 @@ export const InspectOverlay = memo(function InspectOverlay({
340
369
  highlightedId,
341
370
  selectedId,
342
371
  mode,
372
+ instanceId,
343
373
  cursorPosition,
344
374
  frozenCursorPosition,
345
375
  onSelectChange,
@@ -429,6 +459,7 @@ export const InspectOverlay = memo(function InspectOverlay({
429
459
  anchor={anchor}
430
460
  cursorAnchored={cursorAnchored}
431
461
  showActions={showActions}
462
+ instanceId={instanceId}
432
463
  onTap={onTapElement}
433
464
  />
434
465
  )}