@limrun/ui 0.9.0-rc.12 → 0.9.0-rc.14

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 (66) 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/remote-control.d.ts +16 -24
  5. package/dist/core/device-install/apple/client.d.ts +17 -0
  6. package/dist/core/device-install/apple/crypto.d.ts +20 -0
  7. package/dist/core/device-install/apple/gsa-srp.d.ts +26 -0
  8. package/dist/core/device-install/apple/index.d.ts +5 -0
  9. package/dist/core/device-install/apple/provisioning.d.ts +161 -0
  10. package/dist/core/device-install/apple/relay.d.ts +29 -0
  11. package/dist/core/device-install/index.d.ts +4 -0
  12. package/dist/core/device-install/operations/index.d.ts +6 -0
  13. package/dist/core/device-install/operations/limbuild-client.d.ts +28 -0
  14. package/dist/core/device-install/operations/operations.d.ts +32 -0
  15. package/dist/core/device-install/operations/relay-client.d.ts +25 -0
  16. package/dist/core/device-install/operations/relay-protocol.d.ts +27 -0
  17. package/dist/core/device-install/operations/usbmux.d.ts +32 -0
  18. package/dist/core/device-install/operations/webusb.d.ts +21 -0
  19. package/dist/core/device-install/storage/browser-storage.d.ts +44 -0
  20. package/dist/core/device-install/storage/index.d.ts +1 -0
  21. package/dist/core/device-install/types.d.ts +48 -0
  22. package/dist/device-install/index.cjs +1 -0
  23. package/dist/device-install/index.d.ts +3 -0
  24. package/dist/device-install/index.js +78 -0
  25. package/dist/device-install/react.cjs +1 -0
  26. package/dist/device-install/react.d.ts +1 -0
  27. package/dist/device-install/react.js +4 -0
  28. package/dist/device-install-dialog-CjH25hnN.js +2 -0
  29. package/dist/device-install-dialog-W5Xv9kWL.mjs +443 -0
  30. package/dist/device-install-dialog.css +1 -0
  31. package/dist/hooks/index.d.ts +1 -0
  32. package/dist/hooks/use-device-install.d.ts +73 -0
  33. package/dist/index.cjs +1 -1
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.js +942 -900
  36. package/dist/use-device-install-Y1u6vIBB.js +31 -0
  37. package/dist/use-device-install-sDVvby1V.mjs +13627 -0
  38. package/package.json +15 -2
  39. package/src/components/device-install/device-install-dialog.css +325 -0
  40. package/src/components/device-install/device-install-dialog.tsx +495 -0
  41. package/src/components/device-install/index.ts +2 -0
  42. package/src/components/remote-control.tsx +167 -58
  43. package/src/core/device-install/apple/client.ts +152 -0
  44. package/src/core/device-install/apple/crypto.ts +202 -0
  45. package/src/core/device-install/apple/gsa-srp.ts +127 -0
  46. package/src/core/device-install/apple/index.ts +5 -0
  47. package/src/core/device-install/apple/provisioning.ts +298 -0
  48. package/src/core/device-install/apple/relay.ts +221 -0
  49. package/src/core/device-install/index.ts +4 -0
  50. package/src/core/device-install/operations/index.ts +6 -0
  51. package/src/core/device-install/operations/limbuild-client.ts +104 -0
  52. package/src/core/device-install/operations/operations.ts +217 -0
  53. package/src/core/device-install/operations/relay-client.ts +255 -0
  54. package/src/core/device-install/operations/relay-protocol.ts +71 -0
  55. package/src/core/device-install/operations/usbmux.ts +270 -0
  56. package/src/core/device-install/operations/webusb-dom.d.ts +54 -0
  57. package/src/core/device-install/operations/webusb.ts +105 -0
  58. package/src/core/device-install/storage/browser-storage.ts +263 -0
  59. package/src/core/device-install/storage/index.ts +1 -0
  60. package/src/core/device-install/types.ts +65 -0
  61. package/src/device-install/index.ts +3 -0
  62. package/src/device-install/react.ts +1 -0
  63. package/src/hooks/index.ts +1 -0
  64. package/src/hooks/use-device-install.ts +1221 -0
  65. package/src/index.ts +3 -0
  66. package/vite.config.ts +6 -2
@@ -142,33 +142,25 @@ interface RemoteControlProps {
142
142
  axMaxBackoffMs?: number;
143
143
 
144
144
  /**
145
- * Optional outbound camera input.
145
+ * Fires whenever the iOS simulator's camera demand state changes —
146
+ * i.e. an app inside the sim called
147
+ * `[AVCaptureSession startRunning]` or `[stopRunning]`. The
148
+ * component handles the `navigator.mediaDevices.getUserMedia` prompt
149
+ * and SDP plumbing internally; this callback is purely so the host
150
+ * UI can render a status indicator ("simulator is using your
151
+ * camera", etc.).
146
152
  *
147
- * When provided, every video track on this `MediaStream` is forwarded
148
- * up the same `RTCPeerConnection` that already carries the device
149
- * screen downstream. On iOS instances, the limulator-side injector
150
- * splices those frames into apps that use AVFoundation, so the user's
151
- * browser camera becomes the iOS camera (`AVCaptureDevice.default(for:
152
- * .video)`).
153
+ * `active` reflects whether the sim is currently asking for
154
+ * frames. `granted` is set only on the call that follows a
155
+ * `getUserMedia` attempt: `true` when the user accepted the
156
+ * browser prompt, `false` when they denied or the call failed
157
+ * (in which case the limulator side switches to a black-frame
158
+ * fallback so the app keeps ticking).
153
159
  *
154
- * Today this is only honored by iOS instances launched with the
155
- * camera runtime; Android instances ignore it.
156
- *
157
- * Lifecycle expectations:
158
- * - Pass `null`/`undefined` to skip outbound camera (default).
159
- * - Pass a `MediaStream` obtained from
160
- * `navigator.mediaDevices.getUserMedia({ video: true })` (or any
161
- * other source — screen capture, virtual camera, etc.).
162
- * - Changing the `MediaStream.id` triggers a connection restart so
163
- * the new track is included in the SDP offer. The reference itself
164
- * is allowed to be unstable across renders — we key the reconnect
165
- * on `id`, not object identity, so memoization isn't required.
166
- * - Stopping the tracks (e.g. `track.stop()`) on the parent side is
167
- * sufficient to cut the outbound feed without restarting; the peer
168
- * connection just stops getting frames on that track. To fully
169
- * detach the track from the SDP, swap the prop to `null`.
160
+ * Only iOS instances ever fire this callback; Android instances
161
+ * have no camera-injector path and stay silent.
170
162
  */
171
- cameraStream?: MediaStream | null;
163
+ onCameraDemandChange?: (active: boolean, granted?: boolean) => void;
172
164
  }
173
165
 
174
166
  interface ScreenshotData {
@@ -367,7 +359,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
367
359
  onAxStatusChange,
368
360
  axPollIntervalMs,
369
361
  axMaxBackoffMs,
370
- cameraStream,
362
+ onCameraDemandChange,
371
363
  }: RemoteControlProps,
372
364
  ref,
373
365
  ) => {
@@ -393,13 +385,29 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
393
385
  // Mirrored to a ref so stale closures in event handlers see the latest value.
394
386
  const autoReconnectRef = useRef(autoReconnect);
395
387
  autoReconnectRef.current = autoReconnect;
396
- // Outbound camera input. Held in a ref so the deferred WebRTC setup
397
- // path picks up the latest stream even if React re-renders between
398
- // the connection effect firing and `startAttempt` reaching the
399
- // `addTrack` step. Reconnects are keyed on `MediaStream.id`, not
400
- // reference identity (see the connection effect's deps).
401
- const cameraStreamRef = useRef<MediaStream | null>(cameraStream ?? null);
402
- cameraStreamRef.current = cameraStream ?? null;
388
+ // Demand-driven outbound camera state.
389
+ //
390
+ // The limulator side broadcasts a `cameraRequest` WS message whenever
391
+ // an app inside the simulator opens/closes an `AVCaptureSession`.
392
+ // We respond by calling `navigator.mediaDevices.getUserMedia(...)`
393
+ // and `replaceTrack`ing the result onto a pre-allocated sendonly
394
+ // video transceiver. Pre-allocating the transceiver lets us
395
+ // attach/detach the local camera without renegotiating the SDP
396
+ // (the slot is already in the answer's a=video block, just with
397
+ // `inactive`/empty until we install the track).
398
+ //
399
+ // Refs (not state) because all callers live inside event-handler
400
+ // closures and the ref read happens during message processing, not
401
+ // during render. We keep both the sender and the active local
402
+ // stream so teardown can stop tracks without leaking the camera
403
+ // green light.
404
+ const outboundCameraSenderRef = useRef<RTCRtpSender | null>(null);
405
+ const outboundLocalStreamRef = useRef<MediaStream | null>(null);
406
+ // Mirror the demand-change callback into a ref so the WS message
407
+ // handler always sees the freshest customer callback even when the
408
+ // parent re-renders mid-session.
409
+ const onCameraDemandChangeRef = useRef(onCameraDemandChange);
410
+ onCameraDemandChangeRef.current = onCameraDemandChange;
403
411
  const firstFrameShownRef = useRef(false);
404
412
  const pendingScreenshotResolversRef = useRef<
405
413
  Map<string, (value: ScreenshotData | PromiseLike<ScreenshotData>) => void>
@@ -1472,6 +1480,32 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1472
1480
  setVideoLoaded(true);
1473
1481
  };
1474
1482
 
1483
+ // Stop every track on a MediaStream and release the device handle.
1484
+ // The browser keeps the camera "on" indicator lit until at least
1485
+ // one track on the underlying source is stopped, so we have to
1486
+ // call stop() explicitly — closing the peer connection alone
1487
+ // won't do it.
1488
+ const stopMediaStream = (stream: MediaStream) => {
1489
+ for (const track of stream.getTracks()) {
1490
+ try {
1491
+ track.stop();
1492
+ } catch (err) {
1493
+ debugWarn('track.stop() failed:', err);
1494
+ }
1495
+ }
1496
+ };
1497
+
1498
+ // Stop and forget any currently-attached outbound camera stream.
1499
+ // Safe to call when nothing is attached. Does not touch the
1500
+ // sender — the caller is responsible for `replaceTrack(null)`
1501
+ // separately when it wants the SDP slot itself empty.
1502
+ const stopOutboundLocalStream = () => {
1503
+ const stream = outboundLocalStreamRef.current;
1504
+ if (!stream) return;
1505
+ outboundLocalStreamRef.current = null;
1506
+ stopMediaStream(stream);
1507
+ };
1508
+
1475
1509
  const teardownConnection = () => {
1476
1510
  clearConnectionSuccessTimeout();
1477
1511
  clearIceDisconnectedGrace();
@@ -1480,6 +1514,11 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1480
1514
  axFetcherRef.current.stop();
1481
1515
  axFetcherRef.current = null;
1482
1516
  }
1517
+ // Drop any active outbound camera before the PC dies so the
1518
+ // browser doesn't leave the camera indicator lit between
1519
+ // reconnects.
1520
+ stopOutboundLocalStream();
1521
+ outboundCameraSenderRef.current = null;
1483
1522
  // A scheduled cursor flush would otherwise call setState on a
1484
1523
  // teardown component once the next frame runs.
1485
1524
  if (cursorRafIdRef.current !== undefined) {
@@ -1681,26 +1720,18 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1681
1720
  peerConnection.addTransceiver('audio', { direction: 'recvonly' });
1682
1721
  const videoTransceiver = peerConnection.addTransceiver('video', { direction: 'recvonly' });
1683
1722
 
1684
- // Outbound camera input splice the parent-provided MediaStream's
1685
- // video tracks into the same PC. `addTrack` creates an implicit
1686
- // sendonly transceiver and lands the track in the SDP offer; the
1687
- // limulator side picks it up via `peerConnection(_:didAdd:)` and
1688
- // forwards every frame into the camera-injector IOSurface ring.
1689
- // We pass the original MediaStream so the answerer can recover
1690
- // the parent stream id if it cares; today nothing does, but it
1691
- // costs nothing to be correct.
1692
- const outboundCamera = cameraStreamRef.current;
1693
- if (outboundCamera) {
1694
- for (const track of outboundCamera.getVideoTracks()) {
1695
- peerConnection.addTrack(track, outboundCamera);
1696
- }
1697
- debugLog(
1698
- 'Attached outbound camera tracks:',
1699
- outboundCamera.getVideoTracks().length,
1700
- 'streamId:',
1701
- outboundCamera.id,
1702
- );
1703
- }
1723
+ // Pre-allocate a sendonly video slot for the user's camera.
1724
+ // The track stays `null` until the limulator side asks for one
1725
+ // (via the `cameraRequest` WS message), at which point we call
1726
+ // `replaceTrack` with a `getUserMedia` result. Allocating
1727
+ // up-front means we don't have to renegotiate the SDP when the
1728
+ // simulator app actually opens its `AVCaptureSession` the
1729
+ // codec/SSRC negotiation happens once, here, and turning the
1730
+ // camera on/off later is just a track-replace.
1731
+ const outboundCameraTransceiver = peerConnection.addTransceiver('video', {
1732
+ direction: 'sendonly',
1733
+ });
1734
+ outboundCameraSenderRef.current = outboundCameraTransceiver.sender;
1704
1735
 
1705
1736
  // As hardware encoder, we use H265 for iOS and VP9 for Android.
1706
1737
  // We make sure these two are the first ones in the list.
@@ -2030,6 +2061,85 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2030
2061
  pendingScreenshotResolversRef.current.delete(message.id);
2031
2062
  pendingScreenshotRejectersRef.current.delete(message.id);
2032
2063
  break;
2064
+ case 'cameraRequest': {
2065
+ const active = message.active === true;
2066
+ if (!active) {
2067
+ // Sim no longer wants frames. Drop our local track and
2068
+ // shut the browser's camera green light off.
2069
+ const sender = outboundCameraSenderRef.current;
2070
+ if (sender) {
2071
+ try {
2072
+ await sender.replaceTrack(null);
2073
+ } catch (err) {
2074
+ debugWarn('replaceTrack(null) on camera detach failed:', err);
2075
+ }
2076
+ }
2077
+ stopOutboundLocalStream();
2078
+ safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, false);
2079
+ break;
2080
+ }
2081
+ // Sim is asking for camera. Ask the browser; the user's
2082
+ // prompt response is reported back to the host via a
2083
+ // `cameraResult` message so it can swap to a
2084
+ // black-frame fallback on denial.
2085
+ safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true);
2086
+ let stream: MediaStream | null = null;
2087
+ try {
2088
+ stream = await navigator.mediaDevices.getUserMedia({
2089
+ video: true,
2090
+ audio: false,
2091
+ });
2092
+ } catch (err) {
2093
+ debugWarn('getUserMedia denied/failed:', err);
2094
+ }
2095
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
2096
+ if (stream) stopMediaStream(stream);
2097
+ return;
2098
+ }
2099
+ if (!stream) {
2100
+ ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
2101
+ safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
2102
+ break;
2103
+ }
2104
+ // Replace any previous local stream (e.g. an earlier
2105
+ // cameraRequest that resolved with a different device)
2106
+ // before we install the new tracks.
2107
+ stopOutboundLocalStream();
2108
+ outboundLocalStreamRef.current = stream;
2109
+ const sender = outboundCameraSenderRef.current;
2110
+ const videoTrack = stream.getVideoTracks()[0] ?? null;
2111
+ if (!sender || !videoTrack) {
2112
+ if (stream) stopMediaStream(stream);
2113
+ outboundLocalStreamRef.current = null;
2114
+ ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
2115
+ safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
2116
+ break;
2117
+ }
2118
+ try {
2119
+ await sender.replaceTrack(videoTrack);
2120
+ } catch (err) {
2121
+ debugWarn('replaceTrack(videoTrack) failed:', err);
2122
+ stopMediaStream(stream);
2123
+ outboundLocalStreamRef.current = null;
2124
+ ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
2125
+ safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
2126
+ break;
2127
+ }
2128
+ // If the browser revokes the track later (extension,
2129
+ // user clicks Stop in the camera tab UI, etc.), notify
2130
+ // the host so it can switch to black frames.
2131
+ videoTrack.onended = () => {
2132
+ if (outboundLocalStreamRef.current !== stream) return;
2133
+ stopOutboundLocalStream();
2134
+ if (wsRef.current === ws && ws.readyState === WebSocket.OPEN) {
2135
+ ws.send(JSON.stringify({ type: 'cameraResult', granted: false }));
2136
+ }
2137
+ safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, false);
2138
+ };
2139
+ ws.send(JSON.stringify({ type: 'cameraResult', granted: true }));
2140
+ safeInvoke('onCameraDemandChange', onCameraDemandChangeRef.current, true, true);
2141
+ break;
2142
+ }
2033
2143
  case 'terminateAppResult':
2034
2144
  if (typeof message.id !== 'string') {
2035
2145
  debugWarn('Received invalid terminateApp result message:', message);
@@ -2138,13 +2248,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2138
2248
  stop();
2139
2249
  document.removeEventListener('visibilitychange', handleVisibilityChange);
2140
2250
  };
2141
- // We key reconnects on `MediaStream.id` rather than the prop's
2142
- // object identity so callers don't have to memoize the stream —
2143
- // turning the camera on/off swaps the underlying MediaStream and
2144
- // its id, but rerenders that happen to re-create the parent's
2145
- // wrapping object don't trigger churn.
2251
+ // Camera attach/detach happens entirely inside the WS message
2252
+ // loop now (sendonly transceiver + `replaceTrack`), so the
2253
+ // connection effect doesn't need to bounce when the camera
2254
+ // turns on/off no SDP-affecting change.
2146
2255
  // eslint-disable-next-line react-hooks/exhaustive-deps
2147
- }, [url, token, propSessionId, cameraStream?.id]);
2256
+ }, [url, token, propSessionId]);
2148
2257
 
2149
2258
  // Recompute the inspect-overlay geometry (container-local pixel rect of
2150
2259
  // the actually-rendered video content) from the current mapping context.
@@ -0,0 +1,152 @@
1
+ import { AppleGsaSrpClient } from './gsa-srp';
2
+ import {
3
+ createAppleRelaySession,
4
+ deleteAppleRelaySession,
5
+ fetchAppleAccountSession,
6
+ proxyPhoneTwoFactorCode,
7
+ proxySrpComplete,
8
+ proxySrpInit,
9
+ proxyTwoFactorCode,
10
+ triggerPhoneTwoFactor,
11
+ triggerTrustedDeviceTwoFactor,
12
+ type AppleRelayResponse,
13
+ } from './relay';
14
+
15
+ export type AppleIDLoginInput = {
16
+ limbuildApiUrl: string;
17
+ accountName: string;
18
+ password: string;
19
+ token?: string;
20
+ };
21
+
22
+ export type AppleIDLoginResult = {
23
+ appleSessionId: string;
24
+ completeResponse: AppleRelayResponse;
25
+ twoFactorChallengeResponse?: AppleRelayResponse;
26
+ requiresTwoFactor: boolean;
27
+ finishTwoFactor: (code: string) => Promise<AppleRelayResponse>;
28
+ finalize: () => Promise<AppleRelayResponse>;
29
+ close: () => Promise<void>;
30
+ };
31
+
32
+ type TwoFactorMethod =
33
+ | { type: 'trustedDevice' }
34
+ | { type: 'phone'; phoneNumberId: number; mode: string };
35
+
36
+ export async function startBrowserOwnedAppleIDLogin({
37
+ limbuildApiUrl,
38
+ accountName,
39
+ password,
40
+ token,
41
+ }: AppleIDLoginInput): Promise<AppleIDLoginResult> {
42
+ const { appleSessionId } = await createAppleRelaySession(limbuildApiUrl, token);
43
+ try {
44
+ const srp = new AppleGsaSrpClient(accountName);
45
+ const initResponse = await proxySrpInit(limbuildApiUrl, appleSessionId, await srp.init(), token);
46
+ if (initResponse.status < 200 || initResponse.status >= 300) {
47
+ throw new Error(`Apple SRP init failed: HTTP ${initResponse.status} ${initResponse.rawBody ?? ''}`.trim());
48
+ }
49
+ if (!initResponse.body) {
50
+ throw new Error('Apple SRP init response did not include a body.');
51
+ }
52
+ const proof = await srp.complete(password, initResponse.body);
53
+ const completeResponse = await proxySrpComplete(
54
+ limbuildApiUrl,
55
+ appleSessionId,
56
+ {
57
+ ...proof,
58
+ rememberMe: false,
59
+ trustTokens: [],
60
+ },
61
+ token,
62
+ );
63
+ const requiresTwoFactor = completeResponse.status === 409;
64
+ let twoFactorChallengeResponse: AppleRelayResponse | undefined;
65
+ let twoFactorMethod: TwoFactorMethod = { type: 'trustedDevice' };
66
+ if (requiresTwoFactor) {
67
+ twoFactorChallengeResponse = await triggerTrustedDeviceTwoFactor(limbuildApiUrl, appleSessionId, token);
68
+ const phone = trustedPhoneNumberFromChallenge(twoFactorChallengeResponse.body);
69
+ if (phone) {
70
+ twoFactorMethod = {
71
+ type: 'phone',
72
+ phoneNumberId: phone.id,
73
+ mode: phone.pushMode ?? 'sms',
74
+ };
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
+ twoFactorChallengeResponse = await triggerPhoneTwoFactor(
81
+ limbuildApiUrl,
82
+ appleSessionId,
83
+ phone.id,
84
+ phone.pushMode ?? 'sms',
85
+ token,
86
+ );
87
+ }
88
+ if (twoFactorChallengeResponse.status < 200 || twoFactorChallengeResponse.status >= 300) {
89
+ throw new Error(
90
+ `Apple two-factor challenge failed: HTTP ${twoFactorChallengeResponse.status} ${
91
+ twoFactorChallengeResponse.rawBody ?? ''
92
+ }`.trim(),
93
+ );
94
+ }
95
+ } else if (completeResponse.status < 200 || completeResponse.status >= 300) {
96
+ throw new Error(`Apple SRP complete failed: HTTP ${completeResponse.status} ${completeResponse.rawBody ?? ''}`.trim());
97
+ }
98
+ return {
99
+ appleSessionId,
100
+ completeResponse,
101
+ twoFactorChallengeResponse,
102
+ requiresTwoFactor,
103
+ finishTwoFactor: async (code) => {
104
+ 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);
115
+ if (response.status < 200 || response.status >= 300) {
116
+ throw new Error(`Apple two-factor code failed: HTTP ${response.status} ${response.rawBody ?? ''}`.trim());
117
+ }
118
+ return response;
119
+ },
120
+ finalize: async () => fetchAppleAccountSession(limbuildApiUrl, appleSessionId, token),
121
+ close: () => deleteAppleRelaySession(limbuildApiUrl, appleSessionId, token),
122
+ };
123
+ } catch (error) {
124
+ await deleteAppleRelaySession(limbuildApiUrl, appleSessionId, token).catch(() => undefined);
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ function trustedPhoneNumberFromChallenge(body: unknown) {
130
+ if (!isRecord(body)) return undefined;
131
+ const verification = isRecord(body.phoneNumberVerification) ? body.phoneNumberVerification : undefined;
132
+ const trustedPhoneNumber =
133
+ recordValue(verification?.trustedPhoneNumber) ??
134
+ recordValue(body.trustedPhoneNumber) ??
135
+ recordValue(body.phoneNumber);
136
+ if (!trustedPhoneNumber) return undefined;
137
+ const id = trustedPhoneNumber.id;
138
+ if (typeof id !== 'number') return undefined;
139
+ const pushMode =
140
+ typeof trustedPhoneNumber.pushMode === 'string' ? trustedPhoneNumber.pushMode
141
+ : typeof body.mode === 'string' ? body.mode
142
+ : undefined;
143
+ return { id, pushMode };
144
+ }
145
+
146
+ function recordValue(value: unknown) {
147
+ return isRecord(value) ? value : undefined;
148
+ }
149
+
150
+ function isRecord(value: unknown): value is Record<string, unknown> {
151
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
152
+ }
@@ -0,0 +1,202 @@
1
+ import forge from 'node-forge';
2
+
3
+ export type AppleSigningKeyMaterial = {
4
+ privateKey: CryptoKey;
5
+ privateKeyPKCS8Base64: string;
6
+ publicKeySPKIBase64: string;
7
+ csrPEM: string;
8
+ csrBase64: string;
9
+ };
10
+
11
+ export type AppleCSRInput = {
12
+ commonName: string;
13
+ emailAddress?: string;
14
+ };
15
+
16
+ export type ExportP12Input = {
17
+ privateKeyPKCS8Base64: string;
18
+ certificateBase64?: string;
19
+ certificatePEM?: string;
20
+ password: string;
21
+ friendlyName?: string;
22
+ };
23
+
24
+ const rsaAlgorithm: RsaHashedKeyGenParams = {
25
+ name: 'RSASSA-PKCS1-v1_5',
26
+ modulusLength: 2048,
27
+ publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
28
+ hash: 'SHA-256',
29
+ };
30
+
31
+ export async function generateAppleSigningKeyAndCSR(input: AppleCSRInput): Promise<AppleSigningKeyMaterial> {
32
+ if (!crypto.subtle) {
33
+ throw new Error('WebCrypto is not available in this browser.');
34
+ }
35
+ const keyPair = await crypto.subtle.generateKey(rsaAlgorithm, true, ['sign', 'verify']);
36
+ const publicKeySPKI = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
37
+ const certificationRequestInfo = derSequence(
38
+ derInteger(0),
39
+ derName(input),
40
+ publicKeySPKI,
41
+ derContext(0, new Uint8Array()),
42
+ );
43
+ const signature = new Uint8Array(
44
+ await crypto.subtle.sign('RSASSA-PKCS1-v1_5', keyPair.privateKey, toArrayBuffer(certificationRequestInfo)),
45
+ );
46
+ const csrDER = derSequence(
47
+ certificationRequestInfo,
48
+ derSequence(derOID('1.2.840.113549.1.1.11'), derNull()),
49
+ derBitString(signature),
50
+ );
51
+ const privateKeyPKCS8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
52
+ return {
53
+ privateKey: keyPair.privateKey,
54
+ privateKeyPKCS8Base64: bytesToBase64(privateKeyPKCS8),
55
+ publicKeySPKIBase64: bytesToBase64(publicKeySPKI),
56
+ csrPEM: pemBlock('CERTIFICATE REQUEST', csrDER),
57
+ csrBase64: bytesToBase64(csrDER),
58
+ };
59
+ }
60
+
61
+ export function exportAppleCertificateP12(input: ExportP12Input) {
62
+ if (!input.certificateBase64 && !input.certificatePEM) {
63
+ throw new Error('certificateBase64 or certificatePEM is required.');
64
+ }
65
+ const privateKey = forge.pki.privateKeyFromPem(
66
+ pemFromBase64('PRIVATE KEY', input.privateKeyPKCS8Base64),
67
+ );
68
+ const certificate = input.certificatePEM
69
+ ? forge.pki.certificateFromPem(input.certificatePEM)
70
+ : forge.pki.certificateFromAsn1(
71
+ forge.asn1.fromDer(forge.util.createBuffer(base64ToBinary(input.certificateBase64!))),
72
+ );
73
+ const p12 = forge.pkcs12.toPkcs12Asn1(privateKey, [certificate], input.password, {
74
+ algorithm: '3des',
75
+ friendlyName: input.friendlyName,
76
+ });
77
+ const der = forge.asn1.toDer(p12).getBytes();
78
+ return binaryToBase64(der);
79
+ }
80
+
81
+ function derName(input: AppleCSRInput) {
82
+ const attributes = [derAttribute('2.5.4.3', derUTF8String(input.commonName))];
83
+ if (input.emailAddress) {
84
+ attributes.push(derAttribute('1.2.840.113549.1.9.1', derIA5String(input.emailAddress)));
85
+ }
86
+ return derSequence(...attributes);
87
+ }
88
+
89
+ function derAttribute(oid: string, value: Uint8Array) {
90
+ return derSet(derSequence(derOID(oid), value));
91
+ }
92
+
93
+ function derSequence(...values: Uint8Array[]) {
94
+ return derTLV(0x30, concatBytes(...values));
95
+ }
96
+
97
+ function derSet(...values: Uint8Array[]) {
98
+ return derTLV(0x31, concatBytes(...values));
99
+ }
100
+
101
+ function derContext(tag: number, value: Uint8Array) {
102
+ return derTLV(0xa0 + tag, value);
103
+ }
104
+
105
+ function derInteger(value: number) {
106
+ return derTLV(0x02, new Uint8Array([value]));
107
+ }
108
+
109
+ function derNull() {
110
+ return new Uint8Array([0x05, 0x00]);
111
+ }
112
+
113
+ function derUTF8String(value: string) {
114
+ return derTLV(0x0c, new TextEncoder().encode(value));
115
+ }
116
+
117
+ function derIA5String(value: string) {
118
+ return derTLV(0x16, new TextEncoder().encode(value));
119
+ }
120
+
121
+ function derBitString(value: Uint8Array) {
122
+ return derTLV(0x03, concatBytes(new Uint8Array([0]), value));
123
+ }
124
+
125
+ function derOID(oid: string) {
126
+ const parts = oid.split('.').map((part) => parseInt(part, 10));
127
+ if (parts.length < 2 || parts.some((part) => !Number.isFinite(part))) {
128
+ throw new Error(`Invalid OID: ${oid}`);
129
+ }
130
+ const encoded = [parts[0] * 40 + parts[1]];
131
+ for (const part of parts.slice(2)) {
132
+ const stack = [part & 0x7f];
133
+ let value = part >> 7;
134
+ while (value > 0) {
135
+ stack.unshift((value & 0x7f) | 0x80);
136
+ value >>= 7;
137
+ }
138
+ encoded.push(...stack);
139
+ }
140
+ return derTLV(0x06, new Uint8Array(encoded));
141
+ }
142
+
143
+ function derTLV(tag: number, value: Uint8Array) {
144
+ return concatBytes(new Uint8Array([tag]), derLength(value.byteLength), value);
145
+ }
146
+
147
+ function derLength(length: number) {
148
+ if (length < 0x80) {
149
+ return new Uint8Array([length]);
150
+ }
151
+ const bytes: number[] = [];
152
+ let value = length;
153
+ while (value > 0) {
154
+ bytes.unshift(value & 0xff);
155
+ value >>= 8;
156
+ }
157
+ return new Uint8Array([0x80 | bytes.length, ...bytes]);
158
+ }
159
+
160
+ function concatBytes(...chunks: Uint8Array[]) {
161
+ const length = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
162
+ const out = new Uint8Array(length);
163
+ let offset = 0;
164
+ for (const chunk of chunks) {
165
+ out.set(chunk, offset);
166
+ offset += chunk.byteLength;
167
+ }
168
+ return out;
169
+ }
170
+
171
+ function pemBlock(label: string, der: Uint8Array) {
172
+ const base64 = bytesToBase64(der);
173
+ const lines = base64.match(/.{1,64}/g) ?? [];
174
+ return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
175
+ }
176
+
177
+ function pemFromBase64(label: string, base64: string) {
178
+ const lines = base64.match(/.{1,64}/g) ?? [];
179
+ return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
180
+ }
181
+
182
+ function bytesToBase64(bytes: Uint8Array) {
183
+ let binary = '';
184
+ for (const byte of bytes) {
185
+ binary += String.fromCharCode(byte);
186
+ }
187
+ return btoa(binary);
188
+ }
189
+
190
+ function binaryToBase64(binary: string) {
191
+ return btoa(binary);
192
+ }
193
+
194
+ function base64ToBinary(value: string) {
195
+ return atob(value);
196
+ }
197
+
198
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
199
+ const copy = new Uint8Array(bytes.byteLength);
200
+ copy.set(bytes);
201
+ return copy.buffer;
202
+ }