@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.
@@ -73,6 +73,18 @@ export async function getLatestSigningAssets() {
73
73
  )[0];
74
74
  }
75
75
 
76
+ export async function getLatestSigningAssetsWithCertificate(teamID?: string) {
77
+ const all = await getAllSigningAssets();
78
+ return all
79
+ .filter((asset) => {
80
+ if (!asset.certificateID || !asset.certificateP12Base64 || !asset.certificatePassword) {
81
+ return false;
82
+ }
83
+ return !teamID || !asset.teamID || asset.teamID === teamID;
84
+ })
85
+ .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())[0];
86
+ }
87
+
76
88
  export async function putSigningAssets(input: PutSigningAssetsInput) {
77
89
  const normalizedBundleID = normalizeBundleID(input.bundleID);
78
90
  if (!normalizedBundleID) {
@@ -1,10 +1,10 @@
1
1
  export type DeviceInstallLog = (message: string, detail?: string) => void;
2
2
 
3
- export type DeviceInstallStep = 'build' | 'usb' | 'pair' | 'install';
3
+ export type DeviceInstallStep = 'signing' | 'connect' | 'build' | 'install';
4
4
 
5
5
  export type DeviceInstallStepStatus = 'idle' | 'active' | 'complete' | 'error';
6
6
 
7
- export type DeviceInstallBusyAction = 'build' | 'usb' | 'pair' | 'install';
7
+ export type DeviceInstallBusyAction = 'signing' | 'usb' | 'pair' | 'build' | 'install';
8
8
 
9
9
  export type DeviceInstallBuildStatus =
10
10
  | 'idle'
@@ -14,6 +14,7 @@ import {
14
14
  generateAppleSigningKeyAndCSR,
15
15
  getPairRecord,
16
16
  getLatestSigningAssets,
17
+ getLatestSigningAssetsWithCertificate,
17
18
  getReusableAppleSigningAssets,
18
19
  listTeamsRequest,
19
20
  parseProvisioningProfile,
@@ -49,6 +50,13 @@ import type { RelayClient } from '../core/device-install/operations';
49
50
 
50
51
  type DeviceInstallStepStatuses = Record<DeviceInstallStep, DeviceInstallStepStatus>;
51
52
 
53
+ type ReusableAppleCertificate = Pick<
54
+ StoredSigningAssets,
55
+ 'certificateID' | 'certificateP12Base64' | 'certificatePassword' | 'teamID'
56
+ > & {
57
+ certificateID: string;
58
+ };
59
+
52
60
  export type UseDeviceInstallOptions = {
53
61
  apiUrl?: string;
54
62
  token?: string;
@@ -60,6 +68,7 @@ export type UseDeviceInstallResult = {
60
68
  device?: DeviceInstallDevice;
61
69
  hasPairRecord: boolean;
62
70
  hasSigningAssets: boolean;
71
+ hasSigningInputs: boolean;
63
72
  pairConfirmationRequired: boolean;
64
73
  logs: string[];
65
74
  buildLogs: BuildLogLine[];
@@ -72,6 +81,8 @@ export type UseDeviceInstallResult = {
72
81
  selectedAppleTeamID?: string;
73
82
  selectedAppleDeviceIDs: string[];
74
83
  connectedAppleDeviceRegistered: boolean;
84
+ connectedDeviceInProfile?: boolean;
85
+ hasReusableAppleCertificate: boolean;
75
86
  appleBundleID: string;
76
87
  buildLogPanelOpen: boolean;
77
88
  busyAction?: DeviceInstallBusyAction;
@@ -131,9 +142,9 @@ export type ApplePortalSummary = {
131
142
  };
132
143
 
133
144
  const initialStepStatuses: DeviceInstallStepStatuses = {
145
+ signing: 'idle',
146
+ connect: 'idle',
134
147
  build: 'idle',
135
- usb: 'idle',
136
- pair: 'idle',
137
148
  install: 'idle',
138
149
  };
139
150
 
@@ -141,13 +152,13 @@ export function useDeviceInstall({
141
152
  apiUrl,
142
153
  token,
143
154
  }: UseDeviceInstallOptions): UseDeviceInstallResult {
144
- const [currentStep, setCurrentStep] = useState<DeviceInstallStep>('build');
155
+ const [currentStep, setCurrentStep] = useState<DeviceInstallStep>('signing');
145
156
  const [stepStatuses, setStepStatuses] = useState<DeviceInstallStepStatuses>(initialStepStatuses);
146
157
  const [selectedDevice, setSelectedDevice] = useState<DeviceRelayTarget | undefined>();
147
158
  const [pairRecord, setPairRecord] = useState<StoredPairRecord | undefined>();
148
159
  const [signingAssets, setSigningAssets] = useState<StoredSigningAssets | undefined>();
149
160
  const [logs, setLogs] = useState<string[]>([
150
- 'Ready. Start a signed device build, allow USB access, pair this browser, then install.',
161
+ 'Ready. Prepare signing assets, connect and pair the iPhone, build, then install.',
151
162
  ]);
152
163
  const [buildLogs, setBuildLogs] = useState<BuildLogLine[]>([]);
153
164
  const [buildStatus, setBuildStatus] = useState<DeviceInstallBuildStatus>('idle');
@@ -159,6 +170,7 @@ export function useDeviceInstall({
159
170
  const [applePortalSummary, setApplePortalSummary] = useState<ApplePortalSummary | undefined>();
160
171
  const [selectedAppleTeamID, setSelectedAppleTeamID] = useState<string | undefined>();
161
172
  const [appleBundleID, setAppleBundleID] = useState('');
173
+ const [reusableAppleCertificate, setReusableAppleCertificate] = useState<ReusableAppleCertificate | undefined>();
162
174
  const [buildLogPanelOpen, setBuildLogPanelOpen] = useState(false);
163
175
  const [busyAction, setBusyAction] = useState<DeviceInstallBusyAction | undefined>();
164
176
  const [error, setError] = useState<string | undefined>();
@@ -179,18 +191,67 @@ export function useDeviceInstall({
179
191
  }, []);
180
192
 
181
193
  const setSigningFiles = useCallback((files: DeviceInstallSigningFiles) => {
182
- setSigningFilesState((current) => ({ ...current, ...files }));
194
+ setSigningFilesState((current) => {
195
+ const next = { ...current, ...files };
196
+ const ready = !!next.certificateFile && !!next.provisioningProfileFile && !!next.certificatePassword;
197
+ setStepStatus('signing', ready ? 'complete' : 'active');
198
+ if (ready) {
199
+ setAppleSigningStatus('assets-ready');
200
+ setCurrentStep('connect');
201
+ }
202
+ return next;
203
+ });
183
204
  setSigningAssets(undefined);
184
- }, []);
205
+ }, [setStepStatus]);
206
+
207
+ useEffect(() => {
208
+ let cancelled = false;
209
+ void getLatestSigningAssets().then((stored) => {
210
+ if (cancelled || !stored) return;
211
+ setSigningAssets(stored);
212
+ setAppleBundleID(stored.bundleID);
213
+ setAppleSigningStatus('using-cached-profile');
214
+ setStepStatus('signing', 'complete');
215
+ setCurrentStep('connect');
216
+ log('Using stored signing assets', stored.bundleID);
217
+ });
218
+ return () => {
219
+ cancelled = true;
220
+ };
221
+ }, [log, setStepStatus]);
185
222
 
186
223
  const selectedDeveloperTeamID = useCallback(() => {
187
224
  return developerPortalTeamID(appleTeams.find((team) => appleTeamSelectionID(team) === selectedAppleTeamID));
188
225
  }, [appleTeams, selectedAppleTeamID]);
189
226
 
227
+ useEffect(() => {
228
+ const teamID = selectedDeveloperTeamID();
229
+ if (!teamID) {
230
+ setReusableAppleCertificate(undefined);
231
+ return;
232
+ }
233
+ let cancelled = false;
234
+ void findReusableAppleCertificate(teamID).then((certificate) => {
235
+ if (!cancelled) {
236
+ setReusableAppleCertificate(certificate);
237
+ }
238
+ });
239
+ return () => {
240
+ cancelled = true;
241
+ };
242
+ }, [selectedDeveloperTeamID]);
243
+
190
244
  const connectedAppleDevice = selectedDevice?.hello.serialNumber
191
245
  ? appleDevices.find((device) => normalizeAppleUDID(device.deviceNumber) === normalizeAppleUDID(selectedDevice.hello.serialNumber))
192
246
  : undefined;
193
247
  const connectedAppleDeviceRegistered = !!connectedAppleDevice?.deviceId;
248
+ const manualSigningFilesReady =
249
+ !!signingFiles.certificateFile && !!signingFiles.provisioningProfileFile && !!signingFiles.certificatePassword;
250
+ const signingInputsReady = !!signingAssets || manualSigningFilesReady;
251
+ const connectedDeviceInProfile =
252
+ selectedDevice?.hello.serialNumber && signingAssets
253
+ ? profileContainsDevice(signingAssets.profile, selectedDevice.hello.serialNumber)
254
+ : undefined;
194
255
 
195
256
  useEffect(() => {
196
257
  selectedDeviceRef.current = selectedDevice;
@@ -269,8 +330,10 @@ export function useDeviceInstall({
269
330
  const startAppleIDLogin = useCallback(
270
331
  async ({ accountName, password }: DeviceInstallAppleIDLoginInput) => {
271
332
  if (!apiUrl) return;
272
- setBusyAction('build');
333
+ setBusyAction('signing');
273
334
  setError(undefined);
335
+ setCurrentStep('signing');
336
+ setStepStatus('signing', 'active');
274
337
  setAppleSigningStatus('authenticating');
275
338
  try {
276
339
  await appleIDLoginRef.current?.close().catch(() => undefined);
@@ -287,6 +350,17 @@ export function useDeviceInstall({
287
350
  accountSession?.body as AppleDeveloperPortalResponse | undefined,
288
351
  );
289
352
  await refreshAppleAppIDs(apiUrl, session.appleSessionId, token, teamID, setAppleAppIDs, setAppleBundleID);
353
+ if (teamID) {
354
+ await refreshAppleDevices({
355
+ apiUrl,
356
+ token,
357
+ appleSessionId: session.appleSessionId,
358
+ teamID,
359
+ setAppleDevices,
360
+ setSelectedAppleDeviceIDs,
361
+ log,
362
+ });
363
+ }
290
364
  await refreshApplePortalSummary(apiUrl, session.appleSessionId, token, teamID, setApplePortalSummary, log);
291
365
  }
292
366
  setAppleSigningStatus(session.requiresTwoFactor ? 'two-factor-required' : 'authenticated');
@@ -303,7 +377,7 @@ export function useDeviceInstall({
303
377
  setBusyAction(undefined);
304
378
  }
305
379
  },
306
- [apiUrl, log, token],
380
+ [apiUrl, log, setStepStatus, token],
307
381
  );
308
382
 
309
383
  const submitAppleTwoFactorCode = useCallback(
@@ -312,8 +386,10 @@ export function useDeviceInstall({
312
386
  if (!session) {
313
387
  throw new Error('Start Apple ID login before submitting a two-factor code.');
314
388
  }
315
- setBusyAction('build');
389
+ setBusyAction('signing');
316
390
  setError(undefined);
391
+ setCurrentStep('signing');
392
+ setStepStatus('signing', 'active');
317
393
  try {
318
394
  await session.finishTwoFactor(code);
319
395
  if (apiUrl) {
@@ -327,6 +403,17 @@ export function useDeviceInstall({
327
403
  accountSession?.body as AppleDeveloperPortalResponse | undefined,
328
404
  );
329
405
  await refreshAppleAppIDs(apiUrl, session.appleSessionId, token, teamID, setAppleAppIDs, setAppleBundleID);
406
+ if (teamID) {
407
+ await refreshAppleDevices({
408
+ apiUrl,
409
+ token,
410
+ appleSessionId: session.appleSessionId,
411
+ teamID,
412
+ setAppleDevices,
413
+ setSelectedAppleDeviceIDs,
414
+ log,
415
+ });
416
+ }
330
417
  await refreshApplePortalSummary(apiUrl, session.appleSessionId, token, teamID, setApplePortalSummary, log);
331
418
  }
332
419
  setAppleSigningStatus('authenticated');
@@ -340,7 +427,7 @@ export function useDeviceInstall({
340
427
  setBusyAction(undefined);
341
428
  }
342
429
  },
343
- [apiUrl, log, token],
430
+ [apiUrl, log, setStepStatus, token],
344
431
  );
345
432
 
346
433
  const clearAppleIDLogin = useCallback(() => {
@@ -352,9 +439,11 @@ export function useDeviceInstall({
352
439
  setSelectedAppleDeviceIDs([]);
353
440
  setApplePortalSummary(undefined);
354
441
  setSelectedAppleTeamID(undefined);
442
+ setReusableAppleCertificate(undefined);
355
443
  setAppleSigningStatus('idle');
444
+ setStepStatus('signing', 'idle');
356
445
  log('Apple ID login state cleared');
357
- }, [log]);
446
+ }, [log, setStepStatus]);
358
447
 
359
448
  const selectAppleTeam = useCallback(
360
449
  (teamID: string | undefined) => {
@@ -373,18 +462,16 @@ export function useDeviceInstall({
373
462
  try {
374
463
  await refreshAppleAppIDs(apiUrl, session.appleSessionId, token, developerTeamID, setAppleAppIDs, setAppleBundleID);
375
464
  await refreshApplePortalSummary(apiUrl, session.appleSessionId, token, developerTeamID, setApplePortalSummary, log);
376
- if (selectedDeviceRef.current?.hello.serialNumber) {
377
- await refreshAppleDevices({
378
- apiUrl,
379
- token,
380
- appleSessionId: session.appleSessionId,
381
- teamID: developerTeamID,
382
- connectedUDID: selectedDeviceRef.current.hello.serialNumber,
383
- setAppleDevices,
384
- setSelectedAppleDeviceIDs,
385
- log,
386
- });
387
- }
465
+ await refreshAppleDevices({
466
+ apiUrl,
467
+ token,
468
+ appleSessionId: session.appleSessionId,
469
+ teamID: developerTeamID,
470
+ connectedUDID: selectedDeviceRef.current?.hello.serialNumber,
471
+ setAppleDevices,
472
+ setSelectedAppleDeviceIDs,
473
+ log,
474
+ });
388
475
  } catch (caught) {
389
476
  const message = errorMessage(caught);
390
477
  setError(message);
@@ -398,7 +485,7 @@ export function useDeviceInstall({
398
485
  const registerConnectedAppleDevice = useCallback(async () => {
399
486
  const teamID = selectedDeveloperTeamID();
400
487
  if (!apiUrl || !appleIDLoginRef.current || !selectedDevice?.hello.serialNumber || !teamID) return;
401
- setBusyAction('build');
488
+ setBusyAction('signing');
402
489
  setError(undefined);
403
490
  try {
404
491
  const normalizedUDID = normalizeAppleUDID(selectedDevice.hello.serialNumber);
@@ -434,7 +521,7 @@ export function useDeviceInstall({
434
521
  }, [apiUrl, log, selectedDeveloperTeamID, selectedDevice?.hello.productName, selectedDevice?.hello.serialNumber, token]);
435
522
 
436
523
  const prepareAppleSigningAssets = useCallback(async () => {
437
- if (!apiUrl || !appleIDLoginRef.current || !selectedDevice?.hello.serialNumber) return;
524
+ if (!apiUrl || !appleIDLoginRef.current) return;
438
525
  const bundleID = appleBundleID.trim();
439
526
  if (!bundleID) {
440
527
  throw new Error('Enter a bundle ID before preparing signing assets.');
@@ -449,21 +536,30 @@ export function useDeviceInstall({
449
536
  if (selectedAppleDeviceIDs.length === 0) {
450
537
  throw new Error('Select at least one Apple Developer device before preparing signing assets.');
451
538
  }
452
- if (!signingFiles.certificatePassword) {
539
+ if (!reusableAppleCertificate && !signingFiles.certificatePassword) {
453
540
  throw new Error('Enter a .p12 password before preparing signing assets.');
454
541
  }
455
- setBusyAction('build');
542
+ const selectedPortalDevice = appleDevices.find((device) => selectedAppleDeviceIDs.includes(device.deviceId ?? ''));
543
+ const signingDeviceUDID = selectedDevice?.hello.serialNumber ?? selectedPortalDevice?.deviceNumber;
544
+ if (!signingDeviceUDID) {
545
+ throw new Error('Select an Apple Developer device before preparing signing assets.');
546
+ }
547
+ setBusyAction('signing');
456
548
  setError(undefined);
549
+ setCurrentStep('signing');
550
+ setStepStatus('signing', 'active');
457
551
  setAppleSigningStatus('preparing-assets');
458
552
  try {
459
553
  const cached = await getReusableAppleSigningAssets({
460
554
  bundleID,
461
- deviceUDID: selectedDevice.hello.serialNumber,
555
+ deviceUDID: signingDeviceUDID,
462
556
  teamID,
463
557
  });
464
558
  if (cached) {
465
559
  setSigningAssets(cached);
466
560
  setAppleSigningStatus('assets-ready');
561
+ setStepStatus('signing', 'complete');
562
+ setCurrentStep('connect');
467
563
  log('Using cached Apple signing assets', bundleID);
468
564
  return;
469
565
  }
@@ -473,13 +569,16 @@ export function useDeviceInstall({
473
569
  appleSessionId: appleIDLoginRef.current.appleSessionId,
474
570
  teamID,
475
571
  bundleID,
476
- deviceUDID: selectedDevice.hello.serialNumber,
572
+ deviceUDID: signingDeviceUDID,
477
573
  deviceIDs: selectedAppleDeviceIDs,
478
574
  certificatePassword: signingFiles.certificatePassword,
575
+ reusableCertificate: reusableAppleCertificate,
479
576
  });
480
577
  setSigningAssets(assets);
481
578
  setAppleSigningStatus('assets-ready');
482
- log('Apple signing assets stored locally', `${bundleID} for ${selectedDevice.hello.serialNumber}`);
579
+ setStepStatus('signing', 'complete');
580
+ setCurrentStep('connect');
581
+ log('Apple signing assets stored locally', `${bundleID} for ${signingDeviceUDID}`);
483
582
  } catch (caught) {
484
583
  const message = errorMessage(caught);
485
584
  setError(message);
@@ -491,13 +590,15 @@ export function useDeviceInstall({
491
590
  }, [
492
591
  apiUrl,
493
592
  appleBundleID,
593
+ appleDevices,
494
594
  appleTeams,
495
595
  log,
496
596
  selectedAppleTeamID,
497
597
  selectedAppleDeviceIDs,
498
598
  selectedDeveloperTeamID,
499
- selectedDevice?.hello.productName,
500
599
  selectedDevice?.hello.serialNumber,
600
+ setStepStatus,
601
+ reusableAppleCertificate,
501
602
  signingFiles.certificatePassword,
502
603
  token,
503
604
  ]);
@@ -535,7 +636,7 @@ export function useDeviceInstall({
535
636
  setBuildStatus(status);
536
637
  if (status === 'succeeded') {
537
638
  setStepStatus('build', 'complete');
538
- setCurrentStep(selectedDeviceRef.current ? (pairRecord ? 'install' : 'pair') : 'usb');
639
+ setCurrentStep('install');
539
640
  } else if (status === 'failed' || status === 'cancelled') {
540
641
  setStepStatus('build', 'error');
541
642
  }
@@ -560,8 +661,8 @@ export function useDeviceInstall({
560
661
  const requestUSBAccess = useCallback(async () => {
561
662
  setBusyAction('usb');
562
663
  setError(undefined);
563
- setCurrentStep('usb');
564
- setStepStatus('usb', 'active');
664
+ setCurrentStep('connect');
665
+ setStepStatus('connect', 'active');
565
666
  try {
566
667
  await cleanupDeviceAccess();
567
668
  const target = await requestDeviceUSBAccess({ log });
@@ -569,7 +670,7 @@ export function useDeviceInstall({
569
670
  setPairConfirmationRequired(false);
570
671
  const storedPairRecord = await getPairRecord(target.hello.serialNumber);
571
672
  setPairRecord(storedPairRecord);
572
- const storedSigningAssets = await getLatestSigningAssets();
673
+ const storedSigningAssets = manualSigningFilesReady ? undefined : await getLatestSigningAssets();
573
674
  if (storedSigningAssets) {
574
675
  if (!profileContainsDevice(storedSigningAssets.profile, target.hello.serialNumber)) {
575
676
  throw new Error('Stored provisioning profile does not include the selected iPhone.');
@@ -591,26 +692,26 @@ export function useDeviceInstall({
591
692
  });
592
693
  }
593
694
  }
594
- setStepStatus('usb', 'complete');
595
- setCurrentStep(storedPairRecord ? 'install' : 'pair');
695
+ setStepStatus('connect', storedPairRecord ? 'complete' : 'active');
696
+ setCurrentStep(storedPairRecord ? 'build' : 'connect');
596
697
  log(storedPairRecord ? 'Pair record found' : 'No pair record found', target.hello.serialNumber);
597
698
  } catch (caught) {
598
699
  const message = errorMessage(caught);
599
700
  setError(message);
600
- setStepStatus('usb', 'error');
701
+ setStepStatus('connect', 'error');
601
702
  log('USB access failed', message);
602
703
  } finally {
603
704
  setBusyAction(undefined);
604
705
  }
605
- }, [apiUrl, cleanupDeviceAccess, log, selectedDeveloperTeamID, setStepStatus, token]);
706
+ }, [apiUrl, cleanupDeviceAccess, log, manualSigningFilesReady, selectedDeveloperTeamID, setStepStatus, token]);
606
707
 
607
708
  const pairBrowser = useCallback(async () => {
608
709
  if (!apiUrl || !selectedDevice) return;
609
710
  setBusyAction('pair');
610
711
  setError(undefined);
611
712
  setPairConfirmationRequired(false);
612
- setCurrentStep('pair');
613
- setStepStatus('pair', 'active');
713
+ setCurrentStep('connect');
714
+ setStepStatus('connect', 'active');
614
715
  try {
615
716
  await cleanupDeviceAccess();
616
717
  const result = await startPairingRelay({
@@ -626,15 +727,15 @@ export function useDeviceInstall({
626
727
  await closeDeviceRelayTarget(selectedDevice, log);
627
728
  setPairRecord(stored);
628
729
  setPairConfirmationRequired(false);
629
- setStepStatus('pair', 'complete');
630
- setCurrentStep('install');
730
+ setStepStatus('connect', 'complete');
731
+ setCurrentStep('build');
631
732
  log('Device paired', 'The pair record was stored locally in this browser.');
632
733
  } catch (caught) {
633
734
  await closeDeviceRelayTarget(selectedDevice, log);
634
735
  const message = errorMessage(caught);
635
736
  setPairConfirmationRequired(true);
636
737
  setError('Unlock the iPhone, tap Trust, then confirm the pair record.');
637
- setStepStatus('pair', 'error');
738
+ setStepStatus('connect', 'error');
638
739
  log('Device pairing failed', message);
639
740
  } finally {
640
741
  setBusyAction(undefined);
@@ -680,6 +781,7 @@ export function useDeviceInstall({
680
781
  device: selectedDevice?.hello,
681
782
  hasPairRecord: !!pairRecord,
682
783
  hasSigningAssets: !!signingAssets,
784
+ hasSigningInputs: signingInputsReady,
683
785
  pairConfirmationRequired,
684
786
  logs,
685
787
  buildLogs,
@@ -692,21 +794,28 @@ export function useDeviceInstall({
692
794
  selectedAppleTeamID,
693
795
  selectedAppleDeviceIDs,
694
796
  connectedAppleDeviceRegistered,
797
+ connectedDeviceInProfile,
798
+ hasReusableAppleCertificate: !!reusableAppleCertificate,
695
799
  appleBundleID,
696
800
  buildLogPanelOpen,
697
801
  busyAction,
698
802
  error,
699
- canBuild: !!apiUrl && !busyAction && !!signingAssets,
803
+ canBuild:
804
+ !!apiUrl &&
805
+ !busyAction &&
806
+ !!selectedDevice &&
807
+ !!pairRecord &&
808
+ signingInputsReady &&
809
+ connectedDeviceInProfile !== false,
700
810
  canPrepareAppleSigningAssets:
701
811
  !!apiUrl &&
702
812
  !busyAction &&
703
813
  !!appleIDLoginRef.current &&
704
- !!selectedDevice &&
705
814
  !!appleBundleID.trim() &&
706
815
  !!selectedDeveloperTeamID() &&
707
816
  selectedAppleDeviceIDs.length > 0 &&
708
- !!signingFiles.certificatePassword,
709
- canRequestUSBAccess: !busyAction && appleSigningStatus === 'authenticated' && !!appleBundleID.trim(),
817
+ (!!reusableAppleCertificate || !!signingFiles.certificatePassword),
818
+ canRequestUSBAccess: !busyAction && signingInputsReady,
710
819
  canPairBrowser: !!apiUrl && !busyAction && !!selectedDevice,
711
820
  canInstall: !!apiUrl && !busyAction && !!selectedDevice && !!pairRecord,
712
821
  setSigningFiles,
@@ -731,6 +840,19 @@ async function fetchStoredBuildInfo(apiUrl: string, token?: string) {
731
840
  return fetchLimbuildInfo(apiUrl, token);
732
841
  }
733
842
 
843
+ async function findReusableAppleCertificate(teamID: string): Promise<ReusableAppleCertificate | undefined> {
844
+ const stored = await getLatestSigningAssetsWithCertificate(teamID);
845
+ if (!stored?.certificateID || !stored.certificateP12Base64 || !stored.certificatePassword) {
846
+ return undefined;
847
+ }
848
+ return {
849
+ certificateID: stored.certificateID,
850
+ certificateP12Base64: stored.certificateP12Base64,
851
+ certificatePassword: stored.certificatePassword,
852
+ teamID: stored.teamID,
853
+ };
854
+ }
855
+
734
856
  async function refreshAppleTeams(
735
857
  apiUrl: string,
736
858
  appleSessionId: string,
@@ -791,6 +913,11 @@ async function refreshAppleAppIDs(
791
913
  const firstBundleID = bundleIDFromAppleAppID(appIDs[0]);
792
914
  if (firstBundleID) {
793
915
  setAppleBundleID(firstBundleID);
916
+ return;
917
+ }
918
+ const info = await fetchStoredBuildInfo(apiUrl, token).catch(() => undefined);
919
+ if (info?.lastBuildConfig?.bundleId) {
920
+ setAppleBundleID(info.lastBuildConfig.bundleId);
794
921
  }
795
922
  }
796
923
 
@@ -857,12 +984,18 @@ async function refreshAppleDevices({
857
984
  assertApplePortalResponseOK(response.body, 'Apple device list');
858
985
  const devices = response.body?.devices ?? [];
859
986
  setAppleDevices(devices);
987
+ const firstDeviceID = devices.find((device) => !!device.deviceId)?.deviceId;
988
+ if (!connectedUDID) {
989
+ setSelectedAppleDeviceIDs(firstDeviceID ? [firstDeviceID] : []);
990
+ log('Apple Developer devices fetched', `${devices.length} devices`);
991
+ return;
992
+ }
860
993
  const connected = devices.find((device) => normalizeAppleUDID(device.deviceNumber) === normalizeAppleUDID(connectedUDID));
861
994
  if (connected?.deviceId) {
862
995
  setSelectedAppleDeviceIDs([connected.deviceId]);
863
996
  log('Connected iPhone found in Apple Developer devices', connected.name ?? connected.deviceNumber);
864
997
  } else {
865
- setSelectedAppleDeviceIDs([]);
998
+ setSelectedAppleDeviceIDs(firstDeviceID ? [firstDeviceID] : []);
866
999
  log('Connected iPhone is not registered with Apple Developer', connectedUDID);
867
1000
  }
868
1001
  }
@@ -876,6 +1009,7 @@ async function prepareAppleSigningAssetsForDevice({
876
1009
  deviceUDID,
877
1010
  certificatePassword,
878
1011
  deviceIDs,
1012
+ reusableCertificate,
879
1013
  }: {
880
1014
  apiUrl: string;
881
1015
  token?: string;
@@ -884,13 +1018,10 @@ async function prepareAppleSigningAssetsForDevice({
884
1018
  bundleID: string;
885
1019
  deviceUDID: string;
886
1020
  deviceIDs: string[];
887
- certificatePassword: string;
1021
+ certificatePassword?: string;
1022
+ reusableCertificate?: ReusableAppleCertificate;
888
1023
  }) {
889
1024
  const normalizedUDID = deviceUDID.replace(/-/g, '').replace(/[^a-fA-F0-9]/g, '');
890
- const keyMaterial = await generateAppleSigningKeyAndCSR({
891
- commonName: `Limrun ${bundleID}`,
892
- });
893
-
894
1025
  const appIDID = await findOrCreateAppleBundleID({
895
1026
  apiUrl,
896
1027
  token,
@@ -899,37 +1030,49 @@ async function prepareAppleSigningAssetsForDevice({
899
1030
  bundleID,
900
1031
  });
901
1032
 
902
- const certificateResponse = await proxyProvisioningRequest<AppleDeveloperPortalResponse>(
903
- apiUrl,
904
- appleSessionId,
905
- submitDevelopmentCSRRequest({ csrPEM: keyMaterial.csrPEM, teamID }),
906
- token,
907
- );
908
- assertApplePortalResponseOK(certificateResponse.body, 'Apple Development certificate creation');
909
- const certificateID =
910
- stringField(certificateResponse.body?.certRequest, 'certificateId') ??
911
- stringField(certificateResponse.body?.certRequest, 'certRequestId') ??
912
- stringField(certificateResponse.body, 'certificateId') ??
913
- stringField(certificateResponse.body, 'certRequestId');
914
- if (!certificateID) {
915
- throw new Error('Apple certificate creation did not return a certificate ID.');
916
- }
1033
+ let certificateID = reusableCertificate?.certificateID;
1034
+ let certificateP12Base64 = reusableCertificate?.certificateP12Base64;
1035
+ let storedCertificatePassword = reusableCertificate?.certificatePassword;
1036
+ if (!certificateID || !certificateP12Base64 || !storedCertificatePassword) {
1037
+ if (!certificatePassword) {
1038
+ throw new Error('Enter a .p12 password before preparing signing assets.');
1039
+ }
1040
+ const keyMaterial = await generateAppleSigningKeyAndCSR({
1041
+ commonName: `Limrun ${bundleID}`,
1042
+ });
1043
+ const certificateResponse = await proxyProvisioningRequest<AppleDeveloperPortalResponse>(
1044
+ apiUrl,
1045
+ appleSessionId,
1046
+ submitDevelopmentCSRRequest({ csrPEM: keyMaterial.csrPEM, teamID }),
1047
+ token,
1048
+ );
1049
+ assertApplePortalResponseOK(certificateResponse.body, 'Apple Development certificate creation');
1050
+ certificateID =
1051
+ stringField(certificateResponse.body?.certRequest, 'certificateId') ??
1052
+ stringField(certificateResponse.body?.certRequest, 'certRequestId') ??
1053
+ stringField(certificateResponse.body, 'certificateId') ??
1054
+ stringField(certificateResponse.body, 'certRequestId');
1055
+ if (!certificateID) {
1056
+ throw new Error('Apple certificate creation did not return a certificate ID.');
1057
+ }
917
1058
 
918
- const downloadedCertificate = await proxyProvisioningRequest(
919
- apiUrl,
920
- appleSessionId,
921
- downloadCertificateRequest(certificateID, teamID),
922
- token,
923
- );
924
- if (downloadedCertificate.status < 200 || downloadedCertificate.status >= 300 || !downloadedCertificate.rawBodyBase64) {
925
- throw new Error(`Apple certificate download failed: HTTP ${downloadedCertificate.status}`);
1059
+ const downloadedCertificate = await proxyProvisioningRequest(
1060
+ apiUrl,
1061
+ appleSessionId,
1062
+ downloadCertificateRequest(certificateID, teamID),
1063
+ token,
1064
+ );
1065
+ if (downloadedCertificate.status < 200 || downloadedCertificate.status >= 300 || !downloadedCertificate.rawBodyBase64) {
1066
+ throw new Error(`Apple certificate download failed: HTTP ${downloadedCertificate.status}`);
1067
+ }
1068
+ certificateP12Base64 = exportAppleCertificateP12({
1069
+ privateKeyPKCS8Base64: keyMaterial.privateKeyPKCS8Base64,
1070
+ certificateBase64: downloadedCertificate.rawBodyBase64,
1071
+ password: certificatePassword,
1072
+ friendlyName: `Apple Development ${bundleID}`,
1073
+ });
1074
+ storedCertificatePassword = certificatePassword;
926
1075
  }
927
- const certificateP12Base64 = exportAppleCertificateP12({
928
- privateKeyPKCS8Base64: keyMaterial.privateKeyPKCS8Base64,
929
- certificateBase64: downloadedCertificate.rawBodyBase64,
930
- password: certificatePassword,
931
- friendlyName: `Apple Development ${bundleID}`,
932
- });
933
1076
 
934
1077
  const profileName = `Limrun ${bundleID}`;
935
1078
  const profileResponse = await proxyProvisioningRequest<AppleDeveloperPortalResponse>(
@@ -971,7 +1114,7 @@ async function prepareAppleSigningAssetsForDevice({
971
1114
  teamID,
972
1115
  certificateID,
973
1116
  certificateP12Base64,
974
- certificatePassword,
1117
+ certificatePassword: storedCertificatePassword,
975
1118
  provisioningProfileBase64,
976
1119
  profile,
977
1120
  });