@limrun/ui 0.9.0-rc.9 → 0.13.3

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 (80) hide show
  1. package/README.md +25 -8
  2. package/dist/app-store-relay/index.cjs +9 -0
  3. package/dist/app-store-relay/index.d.ts +102 -0
  4. package/dist/app-store-relay/index.js +486 -0
  5. package/dist/app-store-relay/react.cjs +1 -0
  6. package/dist/app-store-relay/react.d.ts +15 -0
  7. package/dist/app-store-relay/react.js +65 -0
  8. package/dist/browser-storage-Cx9eW-bm.mjs +205 -0
  9. package/dist/browser-storage-jJaIht4K.js +1 -0
  10. package/dist/client-ASLtHFLh.js +1 -0
  11. package/dist/client-CGdh8yoJ.mjs +234 -0
  12. package/dist/components/device-install/index.d.ts +1 -2
  13. package/dist/core/device-install/apple/provisioning.d.ts +58 -7
  14. package/dist/core/device-install/operations/limbuild-client.d.ts +15 -1
  15. package/dist/core/device-install/storage/browser-storage.d.ts +9 -5
  16. package/dist/core/device-install/types.d.ts +4 -1
  17. package/dist/device-build/index.cjs +1 -0
  18. package/dist/device-build/index.d.ts +4 -0
  19. package/dist/device-build/index.js +84 -0
  20. package/dist/device-build/react.cjs +1 -0
  21. package/dist/device-build/react.d.ts +20 -0
  22. package/dist/device-build/react.js +68 -0
  23. package/dist/device-build/signing.d.ts +20 -0
  24. package/dist/device-install/demo/demo.d.ts +0 -0
  25. package/dist/device-install/index.cjs +1 -1
  26. package/dist/device-install/index.d.ts +18 -3
  27. package/dist/device-install/index.js +583 -76
  28. package/dist/device-install/react.cjs +1 -1
  29. package/dist/device-install/react.d.ts +23 -1
  30. package/dist/device-install/react.js +98 -2
  31. package/dist/hooks/index.d.ts +1 -1
  32. package/dist/index-CMeQfhYy.js +22 -0
  33. package/dist/index-DFdCvi2_.mjs +11543 -0
  34. package/dist/index.cjs +1 -1
  35. package/dist/index.d.ts +0 -2
  36. package/dist/index.js +502 -495
  37. package/dist/limbuild-client-CgEncrgx.js +1 -0
  38. package/dist/limbuild-client-D7U7w342.mjs +81 -0
  39. package/dist/provisioning-DKbkBO-p.mjs +239 -0
  40. package/dist/provisioning-Va0PH4Kk.js +1 -0
  41. package/package.json +21 -1
  42. package/src/app-store-relay/index.test.ts +70 -0
  43. package/src/app-store-relay/index.ts +514 -0
  44. package/src/app-store-relay/react.ts +129 -0
  45. package/src/components/device-install/index.ts +1 -2
  46. package/src/core/device-install/apple/client.ts +29 -22
  47. package/src/core/device-install/apple/crypto.ts +9 -6
  48. package/src/core/device-install/apple/gsa-srp.ts +3 -7
  49. package/src/core/device-install/apple/provisioning.test.ts +84 -0
  50. package/src/core/device-install/apple/provisioning.ts +98 -16
  51. package/src/core/device-install/apple/relay.ts +5 -1
  52. package/src/core/device-install/operations/limbuild-client.ts +41 -4
  53. package/src/core/device-install/operations/operations.ts +3 -1
  54. package/src/core/device-install/operations/relay-client.ts +57 -27
  55. package/src/core/device-install/operations/usbmux.ts +3 -3
  56. package/src/core/device-install/operations/webusb.ts +9 -4
  57. package/src/core/device-install/storage/browser-storage.ts +43 -14
  58. package/src/core/device-install/types.ts +6 -8
  59. package/src/device-build/index.ts +42 -0
  60. package/src/device-build/react.ts +131 -0
  61. package/src/device-build/signing.ts +94 -0
  62. package/src/device-install/README.md +423 -0
  63. package/src/device-install/demo/demo.css +493 -0
  64. package/src/device-install/demo/demo.tsx +1069 -0
  65. package/src/device-install/demo/index.html +12 -0
  66. package/src/device-install/index.ts +49 -3
  67. package/src/device-install/react.ts +189 -1
  68. package/src/hooks/index.ts +1 -1
  69. package/src/index.ts +0 -3
  70. package/vite.config.ts +4 -0
  71. package/dist/components/device-install/device-install-dialog.d.ts +0 -5
  72. package/dist/device-install-dialog-CqxDEH85.mjs +0 -443
  73. package/dist/device-install-dialog-jvqQ-fuo.js +0 -2
  74. package/dist/device-install-dialog.css +0 -1
  75. package/dist/hooks/use-device-install.d.ts +0 -73
  76. package/dist/use-device-install-CAPli9MR.js +0 -31
  77. package/dist/use-device-install-H8dqqvbR.mjs +0 -13624
  78. package/src/components/device-install/device-install-dialog.css +0 -325
  79. package/src/components/device-install/device-install-dialog.tsx +0 -495
  80. package/src/hooks/use-device-install.ts +0 -1219
@@ -29,9 +29,7 @@ export type AppleIDLoginResult = {
29
29
  close: () => Promise<void>;
30
30
  };
31
31
 
32
- type TwoFactorMethod =
33
- | { type: 'trustedDevice' }
34
- | { type: 'phone'; phoneNumberId: number; mode: string };
32
+ type TwoFactorMethod = { type: 'trustedDevice' } | { type: 'phone'; phoneNumberId: number; mode: string };
35
33
 
36
34
  export async function startBrowserOwnedAppleIDLogin({
37
35
  limbuildApiUrl,
@@ -44,7 +42,9 @@ export async function startBrowserOwnedAppleIDLogin({
44
42
  const srp = new AppleGsaSrpClient(accountName);
45
43
  const initResponse = await proxySrpInit(limbuildApiUrl, appleSessionId, await srp.init(), token);
46
44
  if (initResponse.status < 200 || initResponse.status >= 300) {
47
- throw new Error(`Apple SRP init failed: HTTP ${initResponse.status} ${initResponse.rawBody ?? ''}`.trim());
45
+ throw new Error(
46
+ `Apple SRP init failed: HTTP ${initResponse.status} ${initResponse.rawBody ?? ''}`.trim(),
47
+ );
48
48
  }
49
49
  if (!initResponse.body) {
50
50
  throw new Error('Apple SRP init response did not include a body.');
@@ -66,17 +66,20 @@ export async function startBrowserOwnedAppleIDLogin({
66
66
  if (requiresTwoFactor) {
67
67
  twoFactorChallengeResponse = await triggerTrustedDeviceTwoFactor(limbuildApiUrl, appleSessionId, token);
68
68
  const phone = trustedPhoneNumberFromChallenge(twoFactorChallengeResponse.body);
69
- if (phone) {
69
+ // Only route codes through the phone/SMS endpoint when Apple actually
70
+ // falls back to phone verification (HTTP 412). The trusted-device
71
+ // challenge body frequently includes trusted-phone metadata even for a
72
+ // normal device push; routing those codes to the phone endpoint breaks
73
+ // trusted-device login.
74
+ if (twoFactorChallengeResponse.status === 412) {
75
+ if (!phone) {
76
+ throw new Error('Apple requested phone verification but did not include a trusted phone number.');
77
+ }
70
78
  twoFactorMethod = {
71
79
  type: 'phone',
72
80
  phoneNumberId: phone.id,
73
81
  mode: phone.pushMode ?? 'sms',
74
82
  };
75
- }
76
- if (twoFactorChallengeResponse.status === 412) {
77
- if (!phone) {
78
- throw new Error('Apple requested phone verification but did not include a trusted phone number.');
79
- }
80
83
  twoFactorChallengeResponse = await triggerPhoneTwoFactor(
81
84
  limbuildApiUrl,
82
85
  appleSessionId,
@@ -93,7 +96,9 @@ export async function startBrowserOwnedAppleIDLogin({
93
96
  );
94
97
  }
95
98
  } else if (completeResponse.status < 200 || completeResponse.status >= 300) {
96
- throw new Error(`Apple SRP complete failed: HTTP ${completeResponse.status} ${completeResponse.rawBody ?? ''}`.trim());
99
+ throw new Error(
100
+ `Apple SRP complete failed: HTTP ${completeResponse.status} ${completeResponse.rawBody ?? ''}`.trim(),
101
+ );
97
102
  }
98
103
  return {
99
104
  appleSessionId,
@@ -102,18 +107,20 @@ export async function startBrowserOwnedAppleIDLogin({
102
107
  requiresTwoFactor,
103
108
  finishTwoFactor: async (code) => {
104
109
  const response =
105
- twoFactorMethod.type === 'phone'
106
- ? await proxyPhoneTwoFactorCode(
107
- limbuildApiUrl,
108
- appleSessionId,
109
- twoFactorMethod.phoneNumberId,
110
- code,
111
- twoFactorMethod.mode,
112
- token,
113
- )
114
- : await proxyTwoFactorCode(limbuildApiUrl, appleSessionId, code, token);
110
+ twoFactorMethod.type === 'phone' ?
111
+ await proxyPhoneTwoFactorCode(
112
+ limbuildApiUrl,
113
+ appleSessionId,
114
+ twoFactorMethod.phoneNumberId,
115
+ code,
116
+ twoFactorMethod.mode,
117
+ token,
118
+ )
119
+ : await proxyTwoFactorCode(limbuildApiUrl, appleSessionId, code, token);
115
120
  if (response.status < 200 || response.status >= 300) {
116
- throw new Error(`Apple two-factor code failed: HTTP ${response.status} ${response.rawBody ?? ''}`.trim());
121
+ throw new Error(
122
+ `Apple two-factor code failed: HTTP ${response.status} ${response.rawBody ?? ''}`.trim(),
123
+ );
117
124
  }
118
125
  return response;
119
126
  },
@@ -41,7 +41,11 @@ export async function generateAppleSigningKeyAndCSR(input: AppleCSRInput): Promi
41
41
  derContext(0, new Uint8Array()),
42
42
  );
43
43
  const signature = new Uint8Array(
44
- await crypto.subtle.sign('RSASSA-PKCS1-v1_5', keyPair.privateKey, toArrayBuffer(certificationRequestInfo)),
44
+ await crypto.subtle.sign(
45
+ 'RSASSA-PKCS1-v1_5',
46
+ keyPair.privateKey,
47
+ toArrayBuffer(certificationRequestInfo),
48
+ ),
45
49
  );
46
50
  const csrDER = derSequence(
47
51
  certificationRequestInfo,
@@ -62,11 +66,10 @@ export function exportAppleCertificateP12(input: ExportP12Input) {
62
66
  if (!input.certificateBase64 && !input.certificatePEM) {
63
67
  throw new Error('certificateBase64 or certificatePEM is required.');
64
68
  }
65
- const privateKey = forge.pki.privateKeyFromPem(
66
- pemFromBase64('PRIVATE KEY', input.privateKeyPKCS8Base64),
67
- );
68
- const certificate = input.certificatePEM
69
- ? forge.pki.certificateFromPem(input.certificatePEM)
69
+ const privateKey = forge.pki.privateKeyFromPem(pemFromBase64('PRIVATE KEY', input.privateKeyPKCS8Base64));
70
+ const certificate =
71
+ input.certificatePEM ?
72
+ forge.pki.certificateFromPem(input.certificatePEM)
70
73
  : forge.pki.certificateFromAsn1(
71
74
  forge.asn1.fromDer(forge.util.createBuffer(base64ToBinary(input.certificateBase64!))),
72
75
  );
@@ -79,13 +79,9 @@ async function deriveApplePassword(
79
79
  if (protocol === 's2k_fo') {
80
80
  passHash = stringToBytes(util.toHex(passHash));
81
81
  }
82
- const imported = await crypto.subtle.importKey(
83
- 'raw',
84
- toArrayBuffer(passHash),
85
- { name: 'PBKDF2' },
86
- false,
87
- ['deriveBits'],
88
- );
82
+ const imported = await crypto.subtle.importKey('raw', toArrayBuffer(passHash), { name: 'PBKDF2' }, false, [
83
+ 'deriveBits',
84
+ ]);
89
85
  const derived = await crypto.subtle.deriveBits(
90
86
  {
91
87
  name: 'PBKDF2',
@@ -0,0 +1,84 @@
1
+ // @vitest-environment node
2
+
3
+ import { describe, expect, test } from 'vitest';
4
+ import {
5
+ createAdHocProfileRequest,
6
+ createDevelopmentProfileRequest,
7
+ downloadDistributionCertificateRequest,
8
+ findAdHocProfilesRequest,
9
+ findDevelopmentCertificatesRequest,
10
+ findDistributionCertificatesRequest,
11
+ submitDistributionCSRRequest,
12
+ } from './provisioning';
13
+
14
+ describe('Apple provisioning request helpers', () => {
15
+ test('keeps development certificate requests on Apple Development types', () => {
16
+ expect(findDevelopmentCertificatesRequest('TEAM').payload).toMatchObject({
17
+ teamId: 'TEAM',
18
+ types: '83Q87W3TGH,5QPB9NHCEI',
19
+ });
20
+ });
21
+
22
+ test('uses distribution certificate requests for Ad Hoc signing', () => {
23
+ expect(findDistributionCertificatesRequest('TEAM').payload).toMatchObject({
24
+ teamId: 'TEAM',
25
+ types: 'WXV89964HE,R58UK2EWSO',
26
+ });
27
+ expect(submitDistributionCSRRequest({ csrPEM: 'csr', teamID: 'TEAM' }).payload).toMatchObject({
28
+ teamId: 'TEAM',
29
+ type: 'WXV89964HE',
30
+ csrContent: 'csr',
31
+ });
32
+ expect(downloadDistributionCertificateRequest('CERT', 'TEAM').payload).toMatchObject({
33
+ teamId: 'TEAM',
34
+ certificateId: 'CERT',
35
+ type: 'WXV89964HE',
36
+ });
37
+ });
38
+
39
+ test('builds separate development and Ad Hoc profile payloads', () => {
40
+ expect(
41
+ createDevelopmentProfileRequest({
42
+ bundleID: 'com.example.app',
43
+ teamID: 'TEAM',
44
+ appIDID: 'APP',
45
+ certificateID: 'CERT',
46
+ deviceIDs: ['DEVICE'],
47
+ }).payload,
48
+ ).toMatchObject({
49
+ teamId: 'TEAM',
50
+ provisioningProfileName: 'Limrun com.example.app',
51
+ certificateIds: ['CERT'],
52
+ appIdId: 'APP',
53
+ deviceIds: ['DEVICE'],
54
+ distributionType: 'limited',
55
+ subPlatform: 'ios',
56
+ });
57
+
58
+ expect(
59
+ createAdHocProfileRequest({
60
+ bundleID: 'com.example.app',
61
+ teamID: 'TEAM',
62
+ appIDID: 'APP',
63
+ certificateID: 'CERT',
64
+ deviceIDs: ['DEVICE'],
65
+ }).payload,
66
+ ).toMatchObject({
67
+ teamId: 'TEAM',
68
+ provisioningProfileName: 'Limrun Ad Hoc com.example.app',
69
+ certificateIds: ['CERT'],
70
+ appIdId: 'APP',
71
+ deviceIds: ['DEVICE'],
72
+ distributionType: 'adhoc',
73
+ subPlatform: 'ios',
74
+ });
75
+ });
76
+
77
+ test('filters Ad Hoc profile list requests by Ad Hoc distribution type', () => {
78
+ expect(findAdHocProfilesRequest({ bundleID: 'com.example.app', teamID: 'TEAM' }).payload).toMatchObject({
79
+ teamId: 'TEAM',
80
+ search: 'com.example.app',
81
+ distributionType: 'adhoc',
82
+ });
83
+ });
84
+ });
@@ -1,4 +1,4 @@
1
- import type { ProvisioningProfileInfo, StoredSigningAssets } from '../types';
1
+ import type { DeviceInstallSigningMode, ProvisioningProfileInfo, StoredSigningAssets } from '../types';
2
2
  import {
3
3
  getSigningAssets,
4
4
  normalizeBundleID,
@@ -64,15 +64,17 @@ export type AppleSigningAssetCacheInput = {
64
64
  bundleID: string;
65
65
  deviceUDID?: string;
66
66
  teamID?: string;
67
+ signingMode?: DeviceInstallSigningMode;
67
68
  };
68
69
 
69
70
  export type PutAppleGeneratedSigningAssetsInput = {
70
71
  bundleID: string;
71
72
  deviceUDID?: string;
72
73
  teamID?: string;
74
+ signingMode?: DeviceInstallSigningMode;
73
75
  certificateID?: string;
74
76
  certificateP12Base64: string;
75
- certificatePassword: string;
77
+ certificatePassword?: string;
76
78
  provisioningProfileBase64: string;
77
79
  profile: ProvisioningProfileInfo;
78
80
  };
@@ -85,12 +87,18 @@ export function listTeamsRequest(): AppleProvisioningRequest {
85
87
  };
86
88
  }
87
89
 
88
- export function findBundleIDRequest({ bundleID, teamID = '' }: Pick<AppleProvisioningContext, 'bundleID' | 'teamID'>) {
90
+ export function findBundleIDRequest({
91
+ bundleID,
92
+ teamID = '',
93
+ }: Pick<AppleProvisioningContext, 'bundleID' | 'teamID'>) {
89
94
  void bundleID;
90
95
  return pagedRequest('/account/ios/identifiers/listAppIds.action', teamID, { sort: 'name=asc' });
91
96
  }
92
97
 
93
- export function findDeviceRequest({ deviceUDID, teamID = '' }: Pick<AppleProvisioningContext, 'deviceUDID' | 'teamID'>) {
98
+ export function findDeviceRequest({
99
+ deviceUDID,
100
+ teamID = '',
101
+ }: Pick<AppleProvisioningContext, 'deviceUDID' | 'teamID'>) {
94
102
  void deviceUDID;
95
103
  return pagedRequest('/account/ios/device/listDevices.action', teamID, {
96
104
  sort: 'name=asc',
@@ -105,6 +113,13 @@ export function findDevelopmentCertificatesRequest(teamID = '') {
105
113
  });
106
114
  }
107
115
 
116
+ export function findDistributionCertificatesRequest(teamID = '') {
117
+ return pagedRequest('/account/ios/certificate/listCertRequests.action', teamID, {
118
+ sort: 'name=asc',
119
+ types: 'WXV89964HE,R58UK2EWSO',
120
+ });
121
+ }
122
+
108
123
  export function findDevelopmentProfilesRequest({
109
124
  bundleID,
110
125
  teamID = '',
@@ -115,6 +130,17 @@ export function findDevelopmentProfilesRequest({
115
130
  });
116
131
  }
117
132
 
133
+ export function findAdHocProfilesRequest({
134
+ bundleID,
135
+ teamID = '',
136
+ }: Pick<AppleProvisioningContext, 'bundleID' | 'teamID'>) {
137
+ return pagedRequest('/account/ios/profile/listProvisioningProfiles.action', teamID, {
138
+ search: bundleID,
139
+ sort: 'name=asc',
140
+ distributionType: 'adhoc',
141
+ });
142
+ }
143
+
118
144
  export function registerDeviceRequest({
119
145
  deviceUDID,
120
146
  teamID = '',
@@ -150,13 +176,7 @@ export function createBundleIDRequest({
150
176
  } satisfies AppleProvisioningRequest;
151
177
  }
152
178
 
153
- export function submitDevelopmentCSRRequest({
154
- csrPEM,
155
- teamID = '',
156
- }: {
157
- csrPEM: string;
158
- teamID?: string;
159
- }) {
179
+ export function submitDevelopmentCSRRequest({ csrPEM, teamID = '' }: { csrPEM: string; teamID?: string }) {
160
180
  return {
161
181
  method: 'POST',
162
182
  path: '/account/ios/certificate/submitCertificateRequest.action',
@@ -168,6 +188,18 @@ export function submitDevelopmentCSRRequest({
168
188
  } satisfies AppleProvisioningRequest;
169
189
  }
170
190
 
191
+ export function submitDistributionCSRRequest({ csrPEM, teamID = '' }: { csrPEM: string; teamID?: string }) {
192
+ return {
193
+ method: 'POST',
194
+ path: '/account/ios/certificate/submitCertificateRequest.action',
195
+ payload: {
196
+ teamId: teamID,
197
+ type: 'WXV89964HE',
198
+ csrContent: csrPEM,
199
+ },
200
+ } satisfies AppleProvisioningRequest;
201
+ }
202
+
171
203
  export function downloadCertificateRequest(certificateID: string, teamID = '') {
172
204
  return {
173
205
  method: 'GET',
@@ -180,6 +212,18 @@ export function downloadCertificateRequest(certificateID: string, teamID = '') {
180
212
  } satisfies AppleProvisioningRequest;
181
213
  }
182
214
 
215
+ export function downloadDistributionCertificateRequest(certificateID: string, teamID = '') {
216
+ return {
217
+ method: 'GET',
218
+ path: '/account/ios/certificate/downloadCertificateContent.action',
219
+ payload: {
220
+ teamId: teamID,
221
+ certificateId: certificateID,
222
+ type: 'WXV89964HE',
223
+ },
224
+ } satisfies AppleProvisioningRequest;
225
+ }
226
+
183
227
  export function createDevelopmentProfileRequest({
184
228
  bundleID,
185
229
  teamID = '',
@@ -208,6 +252,34 @@ export function createDevelopmentProfileRequest({
208
252
  } satisfies AppleProvisioningRequest;
209
253
  }
210
254
 
255
+ export function createAdHocProfileRequest({
256
+ bundleID,
257
+ teamID = '',
258
+ appIDID,
259
+ certificateID,
260
+ deviceIDs,
261
+ name,
262
+ }: Pick<AppleProvisioningContext, 'bundleID' | 'teamID'> & {
263
+ appIDID: string;
264
+ certificateID: string;
265
+ deviceIDs: string[];
266
+ name?: string;
267
+ }) {
268
+ return {
269
+ method: 'POST',
270
+ path: '/account/ios/profile/createProvisioningProfile.action',
271
+ payload: {
272
+ teamId: teamID,
273
+ provisioningProfileName: name ?? `Limrun Ad Hoc ${bundleID}`,
274
+ certificateIds: [certificateID],
275
+ appIdId: appIDID,
276
+ deviceIds: deviceIDs,
277
+ distributionType: 'adhoc',
278
+ subPlatform: 'ios',
279
+ },
280
+ } satisfies AppleProvisioningRequest;
281
+ }
282
+
211
283
  export function downloadProfileRequest(profileID: string, teamID = '') {
212
284
  return {
213
285
  method: 'GET',
@@ -223,9 +295,10 @@ export async function getReusableAppleSigningAssets({
223
295
  bundleID,
224
296
  deviceUDID,
225
297
  teamID,
298
+ signingMode,
226
299
  }: AppleSigningAssetCacheInput) {
227
- const stored = await getSigningAssets({ bundleID, deviceUDID });
228
- if (!stored || !storedSigningAssetsReusable(stored, { bundleID, deviceUDID, teamID })) {
300
+ const stored = await getSigningAssets({ bundleID, deviceUDID, signingMode });
301
+ if (!stored || !storedSigningAssetsReusable(stored, { bundleID, deviceUDID, teamID, signingMode })) {
229
302
  return undefined;
230
303
  }
231
304
  return stored;
@@ -234,15 +307,24 @@ export async function getReusableAppleSigningAssets({
234
307
  export async function putAppleGeneratedSigningAssets(input: PutAppleGeneratedSigningAssetsInput) {
235
308
  return putSigningAssets({
236
309
  ...input,
237
- certificateFileName: input.certificateID ? `${input.certificateID}.p12` : 'apple-development.p12',
238
- profileFileName: input.profile.uuid ? `${input.profile.uuid}.mobileprovision` : 'apple-development.mobileprovision',
310
+ certificateFileName:
311
+ input.certificateID ? `${input.certificateID}.p12` : `apple-${input.signingMode ?? 'development'}.p12`,
312
+ certificatePassword: input.certificatePassword || undefined,
313
+ signingMode: input.signingMode,
314
+ profileFileName:
315
+ input.profile.uuid ?
316
+ `${input.profile.uuid}.mobileprovision`
317
+ : `${input.signingMode ?? 'development'}.mobileprovision`,
239
318
  });
240
319
  }
241
320
 
242
321
  export function storedSigningAssetsReusable(
243
322
  stored: StoredSigningAssets,
244
- { bundleID, deviceUDID, teamID }: AppleSigningAssetCacheInput,
323
+ { bundleID, deviceUDID, teamID, signingMode = 'development' }: AppleSigningAssetCacheInput,
245
324
  ) {
325
+ if ((stored.signingMode ?? 'development') !== signingMode) {
326
+ return false;
327
+ }
246
328
  if (!profileMatchesBundleID(stored.profile, bundleID)) {
247
329
  return false;
248
330
  }
@@ -26,7 +26,11 @@ export async function createAppleRelaySession(limbuildApiUrl: string, token?: st
26
26
  return (await response.json()) as { appleSessionId: string };
27
27
  }
28
28
 
29
- export async function deleteAppleRelaySession(limbuildApiUrl: string, appleSessionId: string, token?: string) {
29
+ export async function deleteAppleRelaySession(
30
+ limbuildApiUrl: string,
31
+ appleSessionId: string,
32
+ token?: string,
33
+ ) {
30
34
  const response = await fetch(limbuildURL(limbuildApiUrl, '/apple/auth/session/delete', token), {
31
35
  method: 'POST',
32
36
  headers: jsonHeaders(token),
@@ -12,7 +12,7 @@ export type StartSignedDeviceBuildOptions = {
12
12
  limbuildApiUrl: string;
13
13
  token?: string;
14
14
  certificateP12Base64: string;
15
- certificatePassword: string;
15
+ certificatePassword?: string;
16
16
  provisioningProfileBase64: string;
17
17
  };
18
18
 
@@ -25,6 +25,21 @@ export type BuildLogEventsOptions = {
25
25
  onError?: (error: Error) => void;
26
26
  };
27
27
 
28
+ export type IOSOTAInstall = {
29
+ installUrl: string;
30
+ landingUrl: string;
31
+ manifestUrl: string;
32
+ ipaUrl: string;
33
+ bundleId: string;
34
+ displayName: string;
35
+ };
36
+
37
+ export type GetIOSOTAInstallOptions = {
38
+ limbuildApiUrl: string;
39
+ execId: string;
40
+ token?: string;
41
+ };
42
+
28
43
  export async function fetchLimbuildInfo(limbuildApiUrl: string, token?: string) {
29
44
  const url = new URL(`${limbuildApiUrl}/info`);
30
45
  if (token) {
@@ -59,10 +74,10 @@ export async function startSignedDeviceBuild({
59
74
  },
60
75
  body: JSON.stringify({
61
76
  command: 'xcodebuild',
62
- xcodebuild: { sdk: 'iphoneos', configuration: 'Debug' },
77
+ xcodebuild: { sdk: 'iphoneos' },
63
78
  signing: {
64
79
  certificateP12Base64,
65
- certificatePassword,
80
+ ...(certificatePassword ? { certificatePassword } : {}),
66
81
  provisioningProfileBase64,
67
82
  },
68
83
  }),
@@ -74,6 +89,21 @@ export async function startSignedDeviceBuild({
74
89
  return (await response.json()) as { execId?: string };
75
90
  }
76
91
 
92
+ export async function getIOSOTAInstall({ limbuildApiUrl, execId, token }: GetIOSOTAInstallOptions) {
93
+ const url = new URL(`${limbuildApiUrl}/exec/${encodeURIComponent(execId)}/ios/ota`);
94
+ if (token) {
95
+ url.searchParams.set('token', token);
96
+ }
97
+ const response = await fetch(url.toString(), {
98
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
99
+ });
100
+ if (!response.ok) {
101
+ const body = await response.text();
102
+ throw new Error(`OTA install metadata failed: HTTP ${response.status} ${body}`);
103
+ }
104
+ return (await response.json()) as IOSOTAInstall;
105
+ }
106
+
77
107
  export function watchBuildLogEvents({
78
108
  limbuildApiUrl,
79
109
  execId,
@@ -93,11 +123,18 @@ export function watchBuildLogEvents({
93
123
  events.addEventListener('stderr', (event) => onLine({ type: 'stderr', data: event.data }));
94
124
  events.addEventListener('exitCode', (event) => {
95
125
  const code = parseInt(event.data, 10);
96
- onStatus(code === 0 ? 'succeeded' : code < 0 ? 'cancelled' : 'failed');
126
+ onStatus(
127
+ code === 0 ? 'succeeded'
128
+ : code < 0 ? 'cancelled'
129
+ : 'failed',
130
+ );
97
131
  events.close();
98
132
  });
99
133
  events.onerror = () => {
100
134
  events.close();
135
+ // The stream closed without a terminal exitCode event. Mark the build as
136
+ // failed so consumers don't stay stuck on "running" and can retry.
137
+ onStatus('failed');
101
138
  onError?.(new Error('Build log stream closed before completion.'));
102
139
  };
103
140
  return () => events.close();
@@ -155,7 +155,9 @@ async function claimBestUsbmuxCandidate(target: DeviceRelayTarget, log: DeviceIn
155
155
  lastError = error;
156
156
  log(
157
157
  'USB interface claim failed',
158
- `configuration ${candidate.configurationValue}, interface ${candidate.interfaceNumber}: ${errorMessage(error)}`,
158
+ `configuration ${candidate.configurationValue}, interface ${
159
+ candidate.interfaceNumber
160
+ }: ${errorMessage(error)}`,
159
161
  );
160
162
  await resetUSBDevice(target);
161
163
  await sleep(250);
@@ -40,10 +40,17 @@ export class RelayClient {
40
40
  socket.onclose = () => {
41
41
  this.closed = true;
42
42
  this.log('Relay socket closed');
43
- if (this.pairRecordWaiter) {
44
- this.pairRecordWaiter.reject(new Error('Relay socket closed'));
45
- this.pairRecordWaiter = undefined;
46
- }
43
+ // The server often sends PairRecordReady and then closes immediately. In
44
+ // Chromium, onclose can be delivered before the queued onmessage handler
45
+ // has finished processing that final frame. Drain the frame queue before
46
+ // rejecting the waiter so a successful pair is not reported as
47
+ // "Relay socket closed".
48
+ void this.frameQueue.finally(() => {
49
+ if (this.pairRecordWaiter) {
50
+ this.pairRecordWaiter.reject(new Error('Relay socket closed'));
51
+ this.pairRecordWaiter = undefined;
52
+ }
53
+ });
47
54
  };
48
55
  await new Promise<void>((resolve, reject) => {
49
56
  socket.onopen = () => resolve();
@@ -92,9 +99,11 @@ export class RelayClient {
92
99
  }
93
100
 
94
101
  private enqueueFrame(data: Uint8Array) {
95
- this.frameQueue = this.frameQueue.then(() => this.handleFrame(decodeFrame(data))).catch((error) => {
96
- this.log('Relay frame handling failed', error instanceof Error ? error.message : String(error));
97
- });
102
+ this.frameQueue = this.frameQueue
103
+ .then(() => this.handleFrame(decodeFrame(data)))
104
+ .catch((error) => {
105
+ this.log('Relay frame handling failed', error instanceof Error ? error.message : String(error));
106
+ });
98
107
  }
99
108
 
100
109
  private async handleFrame(frame: ReturnType<typeof decodeFrame>) {
@@ -163,7 +172,10 @@ export class RelayClient {
163
172
  this.log(`Opened device stream ${streamId} to port ${port}`);
164
173
  void this.pumpDeviceToServer(streamId, stream);
165
174
  } catch (error) {
166
- this.log(`Open device stream ${streamId} failed`, error instanceof Error ? error.message : String(error));
175
+ this.log(
176
+ `Open device stream ${streamId} failed`,
177
+ error instanceof Error ? error.message : String(error),
178
+ );
167
179
  await this.send({
168
180
  type: RelayMessageType.OpenResult,
169
181
  requestId,
@@ -230,26 +242,44 @@ function formatInstallProgress(message: string) {
230
242
  if (!message.startsWith(prefix)) {
231
243
  return message;
232
244
  }
233
- const xml = message.slice(prefix.length);
234
- const doc = new DOMParser().parseFromString(xml, 'application/xml');
235
- const dict = doc.querySelector('plist > dict');
236
- if (!dict) {
237
- return message;
245
+ const plist = message.slice(prefix.length);
246
+ // The relay message is untrusted device input. Parsing it with DOMParser is
247
+ // both an XSS DOM sink and an XML entity-expansion (billion-laughs) vector,
248
+ // so extract only the fields we render via plain string scanning instead of
249
+ // building a DOM.
250
+ // Surface the real failure reason (e.g. ApplicationVerificationFailed) rather
251
+ // than collapsing it to an opaque numeric code downstream.
252
+ const error = readPlistValue(plist, 'Error');
253
+ if (error) {
254
+ const description = readPlistValue(plist, 'ErrorDescription');
255
+ return `Install error: ${error}${description ? ` — ${description}` : ''}`;
238
256
  }
239
- const values = readPlistDict(dict);
240
- const status = values.Status ?? 'Unknown';
241
- const percent = values.PercentComplete ? `${values.PercentComplete}% ` : '';
242
- return `Install progress: ${percent}${status}`;
257
+ const status = readPlistValue(plist, 'Status') ?? 'Unknown';
258
+ const percent = readPlistValue(plist, 'PercentComplete');
259
+ return `Install progress: ${percent ? `${percent}% ` : ''}${status}`;
243
260
  }
244
261
 
245
- function readPlistDict(dict: Element) {
246
- const result: Record<string, string> = {};
247
- const children = Array.from(dict.children);
248
- for (let index = 0; index < children.length; index += 2) {
249
- const key = children[index];
250
- const value = children[index + 1];
251
- if (!key || key.tagName !== 'key' || !value) continue;
252
- result[key.textContent ?? ''] = value.textContent ?? '';
253
- }
254
- return result;
262
+ // Returns the <string>/<integer>/<real> value immediately following
263
+ // <key>NAME</key> in an Apple plist fragment, without an XML/DOM parser.
264
+ function readPlistValue(plist: string, key: string): string | undefined {
265
+ const pattern = new RegExp(
266
+ `<key>\\s*${escapeRegExp(
267
+ key,
268
+ )}\\s*</key>\\s*<(?:string|integer|real)>([\\s\\S]*?)</(?:string|integer|real)>`,
269
+ );
270
+ const match = pattern.exec(plist);
271
+ return match ? decodeBasicXmlEntities(match[1].trim()) : undefined;
272
+ }
273
+
274
+ function escapeRegExp(value: string) {
275
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
276
+ }
277
+
278
+ function decodeBasicXmlEntities(value: string) {
279
+ return value
280
+ .replace(/&lt;/g, '<')
281
+ .replace(/&gt;/g, '>')
282
+ .replace(/&quot;/g, '"')
283
+ .replace(/&apos;/g, "'")
284
+ .replace(/&amp;/g, '&');
255
285
  }
@@ -150,9 +150,9 @@ async function sendMux(session: UsbmuxSession, protocol: number, payload: Uint8A
150
150
  session.writeChain = session.writeChain.then(async () => {
151
151
  if (session.closed) return;
152
152
  const packet =
153
- session.muxVersion >= 2
154
- ? buildV2Packet(protocol, payload, session.txSeq++, session.rxSeq)
155
- : buildV1Packet(protocol, payload);
153
+ session.muxVersion >= 2 ?
154
+ buildV2Packet(protocol, payload, session.txSeq++, session.rxSeq)
155
+ : buildV1Packet(protocol, payload);
156
156
  await transferOutWithZlp(session.device, session.outEndpoint, packet);
157
157
  });
158
158
  return session.writeChain;