@limrun/ui 0.9.0-rc.5 → 0.9.0-rc.6
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/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-86RDdoK9.js +2 -0
- package/dist/device-install-dialog-CnyDWf0q.mjs +462 -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 +495 -502
- package/dist/use-device-install-CbGVvwPp.js +31 -0
- package/dist/use-device-install-j1Gekpl4.mjs +13623 -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 +513 -0
- package/src/components/device-install/index.ts +2 -0
- 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 +1210 -0
- package/src/index.ts +2 -0
- package/vite.config.ts +6 -2
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { AppleSRPCompleteProof, AppleSRPInitRequest, AppleSRPInitResponse } from './gsa-srp';
|
|
2
|
+
|
|
3
|
+
export type AppleRelayResponse<T = unknown> = {
|
|
4
|
+
status: number;
|
|
5
|
+
statusText: string;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
body?: T;
|
|
8
|
+
rawBody?: string;
|
|
9
|
+
rawBodyBase64?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type AppleProvisioningRequest = {
|
|
13
|
+
method?: 'GET' | 'POST';
|
|
14
|
+
path: string;
|
|
15
|
+
payload?: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function createAppleRelaySession(limbuildApiUrl: string, token?: string) {
|
|
19
|
+
const response = await fetch(limbuildURL(limbuildApiUrl, '/apple/auth/session', token), {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: authHeaders(token),
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(`Apple relay session failed: HTTP ${response.status} ${await response.text()}`);
|
|
25
|
+
}
|
|
26
|
+
return (await response.json()) as { appleSessionId: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function deleteAppleRelaySession(limbuildApiUrl: string, appleSessionId: string, token?: string) {
|
|
30
|
+
const response = await fetch(limbuildURL(limbuildApiUrl, '/apple/auth/session/delete', token), {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: jsonHeaders(token),
|
|
33
|
+
body: JSON.stringify({ appleSessionId }),
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`Apple relay session delete failed: HTTP ${response.status} ${await response.text()}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function proxySrpInit(
|
|
41
|
+
limbuildApiUrl: string,
|
|
42
|
+
appleSessionId: string,
|
|
43
|
+
payload: AppleSRPInitRequest,
|
|
44
|
+
token?: string,
|
|
45
|
+
) {
|
|
46
|
+
return postAppleProxy<AppleSRPInitResponse>(
|
|
47
|
+
limbuildApiUrl,
|
|
48
|
+
'/apple/auth/srp/init',
|
|
49
|
+
appleSessionId,
|
|
50
|
+
payload,
|
|
51
|
+
token,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function proxySrpComplete(
|
|
56
|
+
limbuildApiUrl: string,
|
|
57
|
+
appleSessionId: string,
|
|
58
|
+
payload: AppleSRPCompleteProof & {
|
|
59
|
+
rememberMe: boolean;
|
|
60
|
+
trustTokens: string[];
|
|
61
|
+
},
|
|
62
|
+
token?: string,
|
|
63
|
+
) {
|
|
64
|
+
return postAppleProxy(limbuildApiUrl, '/apple/auth/srp/complete', appleSessionId, payload, token);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function triggerTrustedDeviceTwoFactor(
|
|
68
|
+
limbuildApiUrl: string,
|
|
69
|
+
appleSessionId: string,
|
|
70
|
+
token?: string,
|
|
71
|
+
) {
|
|
72
|
+
const response = await fetch(limbuildURL(limbuildApiUrl, '/apple/auth/2fa/trigger', token), {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: jsonHeaders(token),
|
|
75
|
+
body: JSON.stringify({ appleSessionId }),
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw new Error(`Apple 2FA trigger failed: HTTP ${response.status} ${await response.text()}`);
|
|
79
|
+
}
|
|
80
|
+
return (await response.json()) as AppleRelayResponse;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function triggerPhoneTwoFactor(
|
|
84
|
+
limbuildApiUrl: string,
|
|
85
|
+
appleSessionId: string,
|
|
86
|
+
phoneNumberId: number,
|
|
87
|
+
mode = 'sms',
|
|
88
|
+
token?: string,
|
|
89
|
+
) {
|
|
90
|
+
const response = await fetch(limbuildURL(limbuildApiUrl, '/apple/auth/2fa/phone/trigger', token), {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: jsonHeaders(token),
|
|
93
|
+
body: JSON.stringify({ appleSessionId, phoneNumberId, mode }),
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new Error(`Apple phone 2FA trigger failed: HTTP ${response.status} ${await response.text()}`);
|
|
97
|
+
}
|
|
98
|
+
return (await response.json()) as AppleRelayResponse;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function proxyTwoFactorCode(
|
|
102
|
+
limbuildApiUrl: string,
|
|
103
|
+
appleSessionId: string,
|
|
104
|
+
code: string,
|
|
105
|
+
token?: string,
|
|
106
|
+
) {
|
|
107
|
+
const response = await fetch(limbuildURL(limbuildApiUrl, '/apple/auth/2fa', token), {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: jsonHeaders(token),
|
|
110
|
+
body: JSON.stringify({ appleSessionId, code }),
|
|
111
|
+
});
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
throw new Error(`Apple 2FA proxy failed: HTTP ${response.status} ${await response.text()}`);
|
|
114
|
+
}
|
|
115
|
+
return (await response.json()) as AppleRelayResponse;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function proxyPhoneTwoFactorCode(
|
|
119
|
+
limbuildApiUrl: string,
|
|
120
|
+
appleSessionId: string,
|
|
121
|
+
phoneNumberId: number,
|
|
122
|
+
code: string,
|
|
123
|
+
mode = 'sms',
|
|
124
|
+
token?: string,
|
|
125
|
+
) {
|
|
126
|
+
const response = await fetch(limbuildURL(limbuildApiUrl, '/apple/auth/2fa/phone', token), {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: jsonHeaders(token),
|
|
129
|
+
body: JSON.stringify({ appleSessionId, phoneNumberId, mode, code }),
|
|
130
|
+
});
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
throw new Error(`Apple phone 2FA proxy failed: HTTP ${response.status} ${await response.text()}`);
|
|
133
|
+
}
|
|
134
|
+
return (await response.json()) as AppleRelayResponse;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function fetchAppleAccountSession(
|
|
138
|
+
limbuildApiUrl: string,
|
|
139
|
+
appleSessionId: string,
|
|
140
|
+
token?: string,
|
|
141
|
+
) {
|
|
142
|
+
const response = await fetch(limbuildURL(limbuildApiUrl, '/apple/auth/finalize', token), {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: jsonHeaders(token),
|
|
145
|
+
body: JSON.stringify({ appleSessionId }),
|
|
146
|
+
});
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
throw new Error(`Apple session finalization failed: HTTP ${response.status} ${await response.text()}`);
|
|
149
|
+
}
|
|
150
|
+
return (await response.json()) as AppleRelayResponse;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function proxyProvisioningRequest<T = unknown>(
|
|
154
|
+
limbuildApiUrl: string,
|
|
155
|
+
appleSessionId: string,
|
|
156
|
+
request: AppleProvisioningRequest,
|
|
157
|
+
token?: string,
|
|
158
|
+
) {
|
|
159
|
+
const response = await fetch(limbuildURL(limbuildApiUrl, '/apple/provisioning', token), {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: jsonHeaders(token),
|
|
162
|
+
body: JSON.stringify({ appleSessionId, ...request }),
|
|
163
|
+
});
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
throw new Error(`Apple provisioning proxy failed: HTTP ${response.status} ${await response.text()}`);
|
|
166
|
+
}
|
|
167
|
+
return normalizeAppleProxyResponse<T>((await response.json()) as AppleRelayResponse<T>);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function postAppleProxy<T>(
|
|
171
|
+
limbuildApiUrl: string,
|
|
172
|
+
path: string,
|
|
173
|
+
appleSessionId: string,
|
|
174
|
+
payload: unknown,
|
|
175
|
+
token?: string,
|
|
176
|
+
) {
|
|
177
|
+
const response = await fetch(limbuildURL(limbuildApiUrl, path, token), {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: jsonHeaders(token),
|
|
180
|
+
body: JSON.stringify({ appleSessionId, payload }),
|
|
181
|
+
});
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
throw new Error(`Apple proxy ${path} failed: HTTP ${response.status} ${await response.text()}`);
|
|
184
|
+
}
|
|
185
|
+
return normalizeAppleProxyResponse<T>((await response.json()) as AppleRelayResponse<T>);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizeAppleProxyResponse<T>(response: AppleRelayResponse<T>) {
|
|
189
|
+
if (response.body !== undefined || !response.rawBody) {
|
|
190
|
+
return response;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
return {
|
|
194
|
+
...response,
|
|
195
|
+
body: JSON.parse(response.rawBody) as T,
|
|
196
|
+
};
|
|
197
|
+
} catch {
|
|
198
|
+
return response;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function limbuildURL(limbuildApiUrl: string, path: string, token?: string) {
|
|
203
|
+
const base = limbuildApiUrl.replace(/\/$/, '');
|
|
204
|
+
const suffix = path.startsWith('/') ? path : `/${path}`;
|
|
205
|
+
const url = new URL(`${base}${suffix}`);
|
|
206
|
+
if (token) {
|
|
207
|
+
url.searchParams.set('token', token);
|
|
208
|
+
}
|
|
209
|
+
return url;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function jsonHeaders(token?: string): Record<string, string> {
|
|
213
|
+
return {
|
|
214
|
+
'Content-Type': 'application/json',
|
|
215
|
+
...authHeaders(token),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function authHeaders(token?: string): Record<string, string> {
|
|
220
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
221
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { BuildLogLine, DeviceInstallBuildStatus } from '../types';
|
|
2
|
+
|
|
3
|
+
export type LimbuildInfo = {
|
|
4
|
+
homeDir?: string;
|
|
5
|
+
lastBuildConfig?: {
|
|
6
|
+
bundleId?: string;
|
|
7
|
+
sdk?: string;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type StartSignedDeviceBuildOptions = {
|
|
12
|
+
limbuildApiUrl: string;
|
|
13
|
+
token?: string;
|
|
14
|
+
certificateP12Base64: string;
|
|
15
|
+
certificatePassword: string;
|
|
16
|
+
provisioningProfileBase64: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type BuildLogEventsOptions = {
|
|
20
|
+
limbuildApiUrl: string;
|
|
21
|
+
execId: string;
|
|
22
|
+
token?: string;
|
|
23
|
+
onLine: (line: BuildLogLine) => void;
|
|
24
|
+
onStatus: (status: DeviceInstallBuildStatus) => void;
|
|
25
|
+
onError?: (error: Error) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function fetchLimbuildInfo(limbuildApiUrl: string, token?: string) {
|
|
29
|
+
const url = new URL(`${limbuildApiUrl}/info`);
|
|
30
|
+
if (token) {
|
|
31
|
+
url.searchParams.set('token', token);
|
|
32
|
+
}
|
|
33
|
+
const response = await fetch(url.toString(), {
|
|
34
|
+
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
const body = await response.text();
|
|
38
|
+
throw new Error(`Info request failed: HTTP ${response.status} ${body}`);
|
|
39
|
+
}
|
|
40
|
+
return (await response.json()) as LimbuildInfo;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function startSignedDeviceBuild({
|
|
44
|
+
limbuildApiUrl,
|
|
45
|
+
token,
|
|
46
|
+
certificateP12Base64,
|
|
47
|
+
certificatePassword,
|
|
48
|
+
provisioningProfileBase64,
|
|
49
|
+
}: StartSignedDeviceBuildOptions) {
|
|
50
|
+
const url = new URL(`${limbuildApiUrl}/exec`);
|
|
51
|
+
if (token) {
|
|
52
|
+
url.searchParams.set('token', token);
|
|
53
|
+
}
|
|
54
|
+
const response = await fetch(url.toString(), {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
command: 'xcodebuild',
|
|
62
|
+
xcodebuild: { sdk: 'iphoneos' },
|
|
63
|
+
signing: {
|
|
64
|
+
certificateP12Base64,
|
|
65
|
+
certificatePassword,
|
|
66
|
+
provisioningProfileBase64,
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const body = await response.text();
|
|
72
|
+
throw new Error(`Build request failed: HTTP ${response.status} ${body}`);
|
|
73
|
+
}
|
|
74
|
+
return (await response.json()) as { execId?: string };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function watchBuildLogEvents({
|
|
78
|
+
limbuildApiUrl,
|
|
79
|
+
execId,
|
|
80
|
+
token,
|
|
81
|
+
onLine,
|
|
82
|
+
onStatus,
|
|
83
|
+
onError,
|
|
84
|
+
}: BuildLogEventsOptions) {
|
|
85
|
+
const url = new URL(`${limbuildApiUrl}/exec/${execId}/events`);
|
|
86
|
+
if (token) {
|
|
87
|
+
url.searchParams.set('token', token);
|
|
88
|
+
}
|
|
89
|
+
const events = new EventSource(url.toString());
|
|
90
|
+
onStatus('running');
|
|
91
|
+
events.addEventListener('command', (event) => onLine({ type: 'command', data: event.data }));
|
|
92
|
+
events.addEventListener('stdout', (event) => onLine({ type: 'stdout', data: event.data }));
|
|
93
|
+
events.addEventListener('stderr', (event) => onLine({ type: 'stderr', data: event.data }));
|
|
94
|
+
events.addEventListener('exitCode', (event) => {
|
|
95
|
+
const code = parseInt(event.data, 10);
|
|
96
|
+
onStatus(code === 0 ? 'succeeded' : code < 0 ? 'cancelled' : 'failed');
|
|
97
|
+
events.close();
|
|
98
|
+
});
|
|
99
|
+
events.onerror = () => {
|
|
100
|
+
events.close();
|
|
101
|
+
onError?.(new Error('Build log stream closed before completion.'));
|
|
102
|
+
};
|
|
103
|
+
return () => events.close();
|
|
104
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/// <reference path="./webusb-dom.d.ts" />
|
|
2
|
+
|
|
3
|
+
import type { DeviceHello, DeviceInstallLog, PairRecordPayload } from '../types';
|
|
4
|
+
import { RelayClient } from './relay-client';
|
|
5
|
+
import { closeUsbmuxSession, createUsbmuxSession, type UsbmuxSession } from './usbmux';
|
|
6
|
+
import { claimUsbmux, findUsbmuxCandidates, requestAppleDevice, type UsbmuxCandidate } from './webusb';
|
|
7
|
+
|
|
8
|
+
export type DeviceRelayTarget = {
|
|
9
|
+
device: USBDevice;
|
|
10
|
+
candidate: UsbmuxCandidate;
|
|
11
|
+
session?: UsbmuxSession;
|
|
12
|
+
claimedInterfaceNumber?: number;
|
|
13
|
+
hello: DeviceHello;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type RequestUSBAccessOptions = {
|
|
17
|
+
log: DeviceInstallLog;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type StartPairingRelayOptions = {
|
|
21
|
+
limbuildApiUrl: string;
|
|
22
|
+
token?: string;
|
|
23
|
+
log: DeviceInstallLog;
|
|
24
|
+
target: DeviceRelayTarget;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type StartInstallRelayOptions = StartPairingRelayOptions & {
|
|
28
|
+
pairRecord: PairRecordPayload;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export async function requestUSBAccess({ log }: RequestUSBAccessOptions) {
|
|
32
|
+
log('Selecting USB device');
|
|
33
|
+
const device = await requestAppleDevice();
|
|
34
|
+
const target = makeDeviceRelayTarget(device);
|
|
35
|
+
log(
|
|
36
|
+
'Selected USB device',
|
|
37
|
+
`${device.manufacturerName ?? ''} ${device.productName ?? ''} ${device.serialNumber ?? ''}`.trim(),
|
|
38
|
+
);
|
|
39
|
+
return target;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function startPairingRelay({ limbuildApiUrl, token, log, target }: StartPairingRelayOptions) {
|
|
43
|
+
const deviceRelayUrl = deviceRelayWebSocketUrl(limbuildApiUrl, token);
|
|
44
|
+
let relay: RelayClient | undefined;
|
|
45
|
+
try {
|
|
46
|
+
relay = await connectRelay(deviceRelayUrl, target, log);
|
|
47
|
+
const pairRecord = await relay.startPairing();
|
|
48
|
+
return { relay, pairRecord, target };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
relay?.close();
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function startInstallRelay({
|
|
56
|
+
limbuildApiUrl,
|
|
57
|
+
token,
|
|
58
|
+
log,
|
|
59
|
+
target,
|
|
60
|
+
pairRecord,
|
|
61
|
+
}: StartInstallRelayOptions) {
|
|
62
|
+
const deviceRelayUrl = deviceRelayWebSocketUrl(limbuildApiUrl, token);
|
|
63
|
+
let relay: RelayClient | undefined;
|
|
64
|
+
try {
|
|
65
|
+
relay = await connectRelay(deviceRelayUrl, target, log);
|
|
66
|
+
await relay.startInstall(pairRecord);
|
|
67
|
+
return relay;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
relay?.close();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function closeDeviceRelayTarget(target: DeviceRelayTarget | undefined, log?: DeviceInstallLog) {
|
|
75
|
+
if (!target) return;
|
|
76
|
+
if (target.session) {
|
|
77
|
+
closeUsbmuxSession(target.session);
|
|
78
|
+
target.session = undefined;
|
|
79
|
+
}
|
|
80
|
+
if (target.claimedInterfaceNumber !== undefined) {
|
|
81
|
+
try {
|
|
82
|
+
await target.device.releaseInterface(target.claimedInterfaceNumber);
|
|
83
|
+
log?.('Released usbmux interface');
|
|
84
|
+
} catch (error) {
|
|
85
|
+
log?.('USB interface release failed', errorMessage(error));
|
|
86
|
+
} finally {
|
|
87
|
+
target.claimedInterfaceNumber = undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (target.device.opened) {
|
|
91
|
+
try {
|
|
92
|
+
await target.device.close();
|
|
93
|
+
log?.('Closed USB device');
|
|
94
|
+
} catch (error) {
|
|
95
|
+
log?.('USB device close failed', errorMessage(error));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function connectRelay(deviceRelayUrl: string, target: DeviceRelayTarget, log: DeviceInstallLog) {
|
|
101
|
+
await ensureUsbmuxSession(target, log);
|
|
102
|
+
const relay = new RelayClient(deviceRelayUrl, target.session!, target.hello, log);
|
|
103
|
+
await relay.connect();
|
|
104
|
+
return relay;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function ensureUsbmuxSession(target: DeviceRelayTarget, log: DeviceInstallLog) {
|
|
108
|
+
if (target.session) return;
|
|
109
|
+
try {
|
|
110
|
+
target.candidate = await claimBestUsbmuxCandidate(target, log);
|
|
111
|
+
target.session = await createUsbmuxSession(target.device, target.candidate);
|
|
112
|
+
log('Created usbmux session');
|
|
113
|
+
} catch (error) {
|
|
114
|
+
await closeDeviceRelayTarget(target, log);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function makeDeviceRelayTarget(device: USBDevice): DeviceRelayTarget {
|
|
120
|
+
return {
|
|
121
|
+
device,
|
|
122
|
+
candidate: pickUsbmuxCandidate(device),
|
|
123
|
+
hello: {
|
|
124
|
+
serialNumber: device.serialNumber,
|
|
125
|
+
productName: device.productName,
|
|
126
|
+
manufacturerName: device.manufacturerName,
|
|
127
|
+
productId: device.productId,
|
|
128
|
+
vendorId: device.vendorId,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function claimBestUsbmuxCandidate(target: DeviceRelayTarget, log: DeviceInstallLog) {
|
|
134
|
+
const candidates = orderedUsbmuxCandidates(target.device);
|
|
135
|
+
if (candidates.length === 0) throw new Error('No Apple usbmux interface found.');
|
|
136
|
+
let lastError: unknown;
|
|
137
|
+
for (const candidate of candidates) {
|
|
138
|
+
for (const attempt of [1, 2]) {
|
|
139
|
+
try {
|
|
140
|
+
if (!target.device.opened) {
|
|
141
|
+
await target.device.open();
|
|
142
|
+
}
|
|
143
|
+
log(
|
|
144
|
+
'Claiming usbmux interface',
|
|
145
|
+
`configuration ${candidate.configurationValue}, interface ${candidate.interfaceNumber}, alternate ${candidate.alternateSetting}, attempt ${attempt}`,
|
|
146
|
+
);
|
|
147
|
+
await claimUsbmux(target.device, candidate);
|
|
148
|
+
target.claimedInterfaceNumber = candidate.interfaceNumber;
|
|
149
|
+
log(
|
|
150
|
+
'Claimed usbmux interface',
|
|
151
|
+
`configuration ${candidate.configurationValue}, interface ${candidate.interfaceNumber}`,
|
|
152
|
+
);
|
|
153
|
+
return candidate;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
lastError = error;
|
|
156
|
+
log(
|
|
157
|
+
'USB interface claim failed',
|
|
158
|
+
`configuration ${candidate.configurationValue}, interface ${candidate.interfaceNumber}: ${errorMessage(error)}`,
|
|
159
|
+
);
|
|
160
|
+
await resetUSBDevice(target);
|
|
161
|
+
await sleep(250);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
throw lastError instanceof Error ? lastError : new Error('Unable to claim any Apple usbmux interface.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function pickUsbmuxCandidate(device: USBDevice) {
|
|
169
|
+
const candidate = orderedUsbmuxCandidates(device)[0];
|
|
170
|
+
if (!candidate) throw new Error('No Apple usbmux interface found.');
|
|
171
|
+
return candidate;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function orderedUsbmuxCandidates(device: USBDevice) {
|
|
175
|
+
const candidates = findUsbmuxCandidates(device);
|
|
176
|
+
const activeConfigurationValue = device.configuration?.configurationValue;
|
|
177
|
+
return [
|
|
178
|
+
...candidates.filter((item) => item.configurationValue === activeConfigurationValue),
|
|
179
|
+
...candidates.filter((item) => item.configurationValue !== activeConfigurationValue),
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function deviceRelayWebSocketUrl(limbuildApiUrl: string, token?: string) {
|
|
184
|
+
const url = new URL(limbuildApiUrl);
|
|
185
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
186
|
+
url.pathname = `${url.pathname.replace(/\/$/, '')}/device/ws`;
|
|
187
|
+
if (token) {
|
|
188
|
+
url.searchParams.set('token', token);
|
|
189
|
+
}
|
|
190
|
+
return url.toString();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function errorMessage(error: unknown) {
|
|
194
|
+
return error instanceof Error ? error.message : String(error);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function resetUSBDevice(target: DeviceRelayTarget) {
|
|
198
|
+
if (target.claimedInterfaceNumber !== undefined) {
|
|
199
|
+
try {
|
|
200
|
+
await target.device.releaseInterface(target.claimedInterfaceNumber);
|
|
201
|
+
} catch {
|
|
202
|
+
// Best effort: claim failures often mean there is nothing we own to release.
|
|
203
|
+
}
|
|
204
|
+
target.claimedInterfaceNumber = undefined;
|
|
205
|
+
}
|
|
206
|
+
if (target.device.opened) {
|
|
207
|
+
try {
|
|
208
|
+
await target.device.close();
|
|
209
|
+
} catch {
|
|
210
|
+
// Best effort before reopening for the next candidate/attempt.
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sleep(ms: number) {
|
|
216
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
217
|
+
}
|