@limrun/ui 0.13.3-rc.1 → 0.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/app-store-relay/index.cjs +4 -4
  2. package/dist/app-store-relay/index.js +175 -119
  3. package/dist/app-store-relay/react.cjs +1 -1
  4. package/dist/app-store-relay/react.js +2 -2
  5. package/dist/{browser-storage-BJ__DGJ6.mjs → browser-storage-Cx9eW-bm.mjs} +48 -45
  6. package/dist/browser-storage-jJaIht4K.js +1 -0
  7. package/dist/{client-CnbCWvCs.js → client-ASLtHFLh.js} +1 -1
  8. package/dist/{client-Bk1N3XIF.mjs → client-CGdh8yoJ.mjs} +33 -27
  9. package/dist/components/remote-control.d.ts +144 -0
  10. package/dist/core/device-install/apple/provisioning.d.ts +4 -4
  11. package/dist/device-build/index.cjs +1 -1
  12. package/dist/device-build/index.js +5 -5
  13. package/dist/device-build/react.cjs +1 -1
  14. package/dist/device-build/react.js +15 -13
  15. package/dist/device-install/demo/demo.d.ts +0 -0
  16. package/dist/device-install/index.cjs +1 -1
  17. package/dist/device-install/index.d.ts +1 -1
  18. package/dist/device-install/index.js +134 -121
  19. package/dist/device-install/react.cjs +1 -1
  20. package/dist/device-install/react.js +55 -50
  21. package/dist/{index-BXg7HdIs.mjs → index-DFdCvi2_.mjs} +3 -7
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.js +1336 -1089
  24. package/dist/limbuild-client-CgEncrgx.js +1 -0
  25. package/dist/{limbuild-client-CFJhYsRx.mjs → limbuild-client-D7U7w342.mjs} +4 -2
  26. package/dist/{provisioning-CdseoMJQ.mjs → provisioning-DKbkBO-p.mjs} +11 -11
  27. package/dist/{provisioning-D2ZZQeyX.js → provisioning-Va0PH4Kk.js} +1 -1
  28. package/package.json +1 -1
  29. package/src/app-store-relay/index.test.ts +1 -5
  30. package/src/app-store-relay/index.ts +109 -42
  31. package/src/app-store-relay/react.ts +6 -2
  32. package/src/components/remote-control.tsx +726 -0
  33. package/src/core/device-install/apple/client.ts +29 -22
  34. package/src/core/device-install/apple/crypto.ts +9 -6
  35. package/src/core/device-install/apple/gsa-srp.ts +3 -7
  36. package/src/core/device-install/apple/provisioning.ts +16 -18
  37. package/src/core/device-install/apple/relay.ts +5 -1
  38. package/src/core/device-install/operations/limbuild-client.ts +8 -1
  39. package/src/core/device-install/operations/operations.ts +3 -1
  40. package/src/core/device-install/operations/relay-client.ts +57 -27
  41. package/src/core/device-install/operations/usbmux.ts +3 -3
  42. package/src/core/device-install/operations/webusb.ts +9 -4
  43. package/src/core/device-install/storage/browser-storage.ts +20 -6
  44. package/src/core/device-install/types.ts +1 -7
  45. package/src/device-build/react.ts +4 -1
  46. package/src/device-install/README.md +423 -0
  47. package/src/device-install/demo/demo.css +493 -0
  48. package/src/device-install/demo/demo.tsx +1069 -0
  49. package/src/device-install/demo/index.html +12 -0
  50. package/src/device-install/index.ts +7 -7
  51. package/src/device-install/react.ts +26 -17
  52. package/src/index.ts +1 -1
  53. package/dist/browser-storage-C1jQLXat.js +0 -1
  54. package/dist/limbuild-client-C_CMNLYV.js +0 -1
@@ -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',
@@ -87,12 +87,18 @@ export function listTeamsRequest(): AppleProvisioningRequest {
87
87
  };
88
88
  }
89
89
 
90
- export function findBundleIDRequest({ bundleID, teamID = '' }: Pick<AppleProvisioningContext, 'bundleID' | 'teamID'>) {
90
+ export function findBundleIDRequest({
91
+ bundleID,
92
+ teamID = '',
93
+ }: Pick<AppleProvisioningContext, 'bundleID' | 'teamID'>) {
91
94
  void bundleID;
92
95
  return pagedRequest('/account/ios/identifiers/listAppIds.action', teamID, { sort: 'name=asc' });
93
96
  }
94
97
 
95
- export function findDeviceRequest({ deviceUDID, teamID = '' }: Pick<AppleProvisioningContext, 'deviceUDID' | 'teamID'>) {
98
+ export function findDeviceRequest({
99
+ deviceUDID,
100
+ teamID = '',
101
+ }: Pick<AppleProvisioningContext, 'deviceUDID' | 'teamID'>) {
96
102
  void deviceUDID;
97
103
  return pagedRequest('/account/ios/device/listDevices.action', teamID, {
98
104
  sort: 'name=asc',
@@ -170,13 +176,7 @@ export function createBundleIDRequest({
170
176
  } satisfies AppleProvisioningRequest;
171
177
  }
172
178
 
173
- export function submitDevelopmentCSRRequest({
174
- csrPEM,
175
- teamID = '',
176
- }: {
177
- csrPEM: string;
178
- teamID?: string;
179
- }) {
179
+ export function submitDevelopmentCSRRequest({ csrPEM, teamID = '' }: { csrPEM: string; teamID?: string }) {
180
180
  return {
181
181
  method: 'POST',
182
182
  path: '/account/ios/certificate/submitCertificateRequest.action',
@@ -188,13 +188,7 @@ export function submitDevelopmentCSRRequest({
188
188
  } satisfies AppleProvisioningRequest;
189
189
  }
190
190
 
191
- export function submitDistributionCSRRequest({
192
- csrPEM,
193
- teamID = '',
194
- }: {
195
- csrPEM: string;
196
- teamID?: string;
197
- }) {
191
+ export function submitDistributionCSRRequest({ csrPEM, teamID = '' }: { csrPEM: string; teamID?: string }) {
198
192
  return {
199
193
  method: 'POST',
200
194
  path: '/account/ios/certificate/submitCertificateRequest.action',
@@ -313,10 +307,14 @@ export async function getReusableAppleSigningAssets({
313
307
  export async function putAppleGeneratedSigningAssets(input: PutAppleGeneratedSigningAssetsInput) {
314
308
  return putSigningAssets({
315
309
  ...input,
316
- certificateFileName: input.certificateID ? `${input.certificateID}.p12` : `apple-${input.signingMode ?? 'development'}.p12`,
310
+ certificateFileName:
311
+ input.certificateID ? `${input.certificateID}.p12` : `apple-${input.signingMode ?? 'development'}.p12`,
317
312
  certificatePassword: input.certificatePassword || undefined,
318
313
  signingMode: input.signingMode,
319
- profileFileName: input.profile.uuid ? `${input.profile.uuid}.mobileprovision` : `${input.signingMode ?? 'development'}.mobileprovision`,
314
+ profileFileName:
315
+ input.profile.uuid ?
316
+ `${input.profile.uuid}.mobileprovision`
317
+ : `${input.signingMode ?? 'development'}.mobileprovision`,
320
318
  });
321
319
  }
322
320
 
@@ -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),
@@ -123,11 +123,18 @@ export function watchBuildLogEvents({
123
123
  events.addEventListener('stderr', (event) => onLine({ type: 'stderr', data: event.data }));
124
124
  events.addEventListener('exitCode', (event) => {
125
125
  const code = parseInt(event.data, 10);
126
- onStatus(code === 0 ? 'succeeded' : code < 0 ? 'cancelled' : 'failed');
126
+ onStatus(
127
+ code === 0 ? 'succeeded'
128
+ : code < 0 ? 'cancelled'
129
+ : 'failed',
130
+ );
127
131
  events.close();
128
132
  });
129
133
  events.onerror = () => {
130
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');
131
138
  onError?.(new Error('Build log stream closed before completion.'));
132
139
  };
133
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;
@@ -39,7 +39,9 @@ export function findUsbmuxCandidates(device: USBDevice): UsbmuxCandidate[] {
39
39
  return candidates;
40
40
  }
41
41
 
42
- function findConfigurationUsbmuxCandidates(configuration: USBDevice['configurations'][number]): UsbmuxCandidate[] {
42
+ function findConfigurationUsbmuxCandidates(
43
+ configuration: USBDevice['configurations'][number],
44
+ ): UsbmuxCandidate[] {
43
45
  const candidates: UsbmuxCandidate[] = [];
44
46
  for (const usbInterface of configuration.interfaces) {
45
47
  for (const alternate of usbInterface.alternates) {
@@ -68,9 +70,12 @@ export async function claimUsbmux(device: USBDevice, candidate: UsbmuxCandidate)
68
70
  await device.selectConfiguration(candidate.configurationValue);
69
71
  }
70
72
  await device.claimInterface(candidate.interfaceNumber);
71
- if (candidate.alternateSetting !== 0) {
72
- await device.selectAlternateInterface(candidate.interfaceNumber, candidate.alternateSetting);
73
- }
73
+ // Always select the alternate (even alt 0). On macOS Chrome the
74
+ // interface's endpoints are not treated as active until the alternate
75
+ // is explicitly selected, which otherwise surfaces as
76
+ // "endpoint is not part of a claimed and selected alternate interface"
77
+ // on the first transferIn/transferOut.
78
+ await device.selectAlternateInterface(candidate.interfaceNumber, candidate.alternateSetting);
74
79
  }
75
80
 
76
81
  export function getBulkEndpoints(candidate: UsbmuxCandidate) {
@@ -73,12 +73,13 @@ export async function getLatestSigningAssets(signingMode: DeviceInstallSigningMo
73
73
  const all = await getAllSigningAssets();
74
74
  return all
75
75
  .filter((asset) => (asset.signingMode ?? 'development') === signingMode)
76
- .sort(
77
- (left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
78
- )[0];
76
+ .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())[0];
79
77
  }
80
78
 
81
- export async function getLatestSigningAssetsWithCertificate(teamID?: string, signingMode: DeviceInstallSigningMode = 'development') {
79
+ export async function getLatestSigningAssetsWithCertificate(
80
+ teamID?: string,
81
+ signingMode: DeviceInstallSigningMode = 'development',
82
+ ) {
82
83
  const all = await getAllSigningAssets();
83
84
  return all
84
85
  .filter((asset) => {
@@ -116,11 +117,16 @@ export async function putSigningAssets(input: PutSigningAssetsInput) {
116
117
  return stored;
117
118
  }
118
119
 
119
- export async function findSigningAssetsForBundle(bundleID?: string, signingMode: DeviceInstallSigningMode = 'development') {
120
+ export async function findSigningAssetsForBundle(
121
+ bundleID?: string,
122
+ signingMode: DeviceInstallSigningMode = 'development',
123
+ ) {
120
124
  const normalized = normalizeBundleID(bundleID);
121
125
  if (!normalized) return [];
122
126
  const all = await getAllSigningAssets();
123
- return all.filter((asset) => asset.bundleID === normalized && (asset.signingMode ?? 'development') === signingMode);
127
+ return all.filter(
128
+ (asset) => asset.bundleID === normalized && (asset.signingMode ?? 'development') === signingMode,
129
+ );
124
130
  }
125
131
 
126
132
  export function profileContainsDevice(profile: ProvisioningProfileInfo, deviceUDID?: string) {
@@ -160,6 +166,14 @@ export function parseProvisioningProfileBytes(bytes: Uint8Array) {
160
166
  throw new Error('Provisioning profile plist not found.');
161
167
  }
162
168
  const xml = text.slice(start, end + '</plist>'.length);
169
+ // Defensively reject entity declarations before parsing: they are the vector
170
+ // for XML entity-expansion (billion-laughs) attacks and never appear in a
171
+ // real provisioning profile. The standard Apple `<!DOCTYPE plist PUBLIC ...>`
172
+ // (an external DTD reference with no internal entity subset) is benign —
173
+ // browsers' DOMParser does not fetch it — so it is intentionally allowed.
174
+ if (/<!ENTITY/i.test(xml)) {
175
+ throw new Error('Provisioning profile plist contains a disallowed ENTITY declaration.');
176
+ }
163
177
  const doc = new DOMParser().parseFromString(xml, 'application/xml');
164
178
  if (doc.querySelector('parsererror')) {
165
179
  throw new Error('Provisioning profile plist could not be parsed.');
@@ -8,13 +8,7 @@ export type DeviceInstallStepStatus = 'idle' | 'active' | 'complete' | 'error';
8
8
 
9
9
  export type DeviceInstallBusyAction = 'signing' | 'usb' | 'pair' | 'build' | 'install';
10
10
 
11
- export type DeviceInstallBuildStatus =
12
- | 'idle'
13
- | 'queued'
14
- | 'running'
15
- | 'succeeded'
16
- | 'failed'
17
- | 'cancelled';
11
+ export type DeviceInstallBuildStatus = 'idle' | 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
18
12
 
19
13
  export type BuildLogLine = {
20
14
  type: 'command' | 'stdout' | 'stderr' | 'meta';
@@ -100,7 +100,10 @@ export function useDeviceBuild({
100
100
  .catch((caught) => setError(errorMessage(caught)));
101
101
  }
102
102
  },
103
- onError: (caught) => setError(caught.message),
103
+ onError: (caught) => {
104
+ setStatus('failed');
105
+ setError(caught.message);
106
+ },
104
107
  });
105
108
  return result.execId;
106
109
  } catch (caught) {