@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.
- package/README.md +9 -0
- package/dist/components/device-install/device-install-dialog.d.ts +5 -0
- package/dist/components/device-install/index.d.ts +2 -0
- package/dist/components/remote-control.d.ts +16 -24
- package/dist/core/device-install/apple/client.d.ts +17 -0
- package/dist/core/device-install/apple/crypto.d.ts +20 -0
- package/dist/core/device-install/apple/gsa-srp.d.ts +26 -0
- package/dist/core/device-install/apple/index.d.ts +5 -0
- package/dist/core/device-install/apple/provisioning.d.ts +161 -0
- package/dist/core/device-install/apple/relay.d.ts +29 -0
- package/dist/core/device-install/index.d.ts +4 -0
- package/dist/core/device-install/operations/index.d.ts +6 -0
- package/dist/core/device-install/operations/limbuild-client.d.ts +28 -0
- package/dist/core/device-install/operations/operations.d.ts +32 -0
- package/dist/core/device-install/operations/relay-client.d.ts +25 -0
- package/dist/core/device-install/operations/relay-protocol.d.ts +27 -0
- package/dist/core/device-install/operations/usbmux.d.ts +32 -0
- package/dist/core/device-install/operations/webusb.d.ts +21 -0
- package/dist/core/device-install/storage/browser-storage.d.ts +44 -0
- package/dist/core/device-install/storage/index.d.ts +1 -0
- package/dist/core/device-install/types.d.ts +48 -0
- package/dist/device-install/index.cjs +1 -0
- package/dist/device-install/index.d.ts +3 -0
- package/dist/device-install/index.js +78 -0
- package/dist/device-install/react.cjs +1 -0
- package/dist/device-install/react.d.ts +1 -0
- package/dist/device-install/react.js +4 -0
- package/dist/device-install-dialog-CjH25hnN.js +2 -0
- package/dist/device-install-dialog-W5Xv9kWL.mjs +443 -0
- package/dist/device-install-dialog.css +1 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/use-device-install.d.ts +73 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +942 -900
- package/dist/use-device-install-Y1u6vIBB.js +31 -0
- package/dist/use-device-install-sDVvby1V.mjs +13627 -0
- package/package.json +15 -2
- package/src/components/device-install/device-install-dialog.css +325 -0
- package/src/components/device-install/device-install-dialog.tsx +495 -0
- package/src/components/device-install/index.ts +2 -0
- package/src/components/remote-control.tsx +167 -58
- package/src/core/device-install/apple/client.ts +152 -0
- package/src/core/device-install/apple/crypto.ts +202 -0
- package/src/core/device-install/apple/gsa-srp.ts +127 -0
- package/src/core/device-install/apple/index.ts +5 -0
- package/src/core/device-install/apple/provisioning.ts +298 -0
- package/src/core/device-install/apple/relay.ts +221 -0
- package/src/core/device-install/index.ts +4 -0
- package/src/core/device-install/operations/index.ts +6 -0
- package/src/core/device-install/operations/limbuild-client.ts +104 -0
- package/src/core/device-install/operations/operations.ts +217 -0
- package/src/core/device-install/operations/relay-client.ts +255 -0
- package/src/core/device-install/operations/relay-protocol.ts +71 -0
- package/src/core/device-install/operations/usbmux.ts +270 -0
- package/src/core/device-install/operations/webusb-dom.d.ts +54 -0
- package/src/core/device-install/operations/webusb.ts +105 -0
- package/src/core/device-install/storage/browser-storage.ts +263 -0
- package/src/core/device-install/storage/index.ts +1 -0
- package/src/core/device-install/types.ts +65 -0
- package/src/device-install/index.ts +3 -0
- package/src/device-install/react.ts +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-device-install.ts +1221 -0
- package/src/index.ts +3 -0
- package/vite.config.ts +6 -2
|
@@ -142,33 +142,25 @@ interface RemoteControlProps {
|
|
|
142
142
|
axMaxBackoffMs?: number;
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
|
-
*
|
|
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
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
-
*
|
|
155
|
-
* camera
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
//
|
|
1685
|
-
//
|
|
1686
|
-
//
|
|
1687
|
-
//
|
|
1688
|
-
//
|
|
1689
|
-
//
|
|
1690
|
-
//
|
|
1691
|
-
//
|
|
1692
|
-
const
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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
|
-
//
|
|
2142
|
-
//
|
|
2143
|
-
//
|
|
2144
|
-
//
|
|
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
|
|
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
|
+
}
|