@expo/serve-sim 0.11.0 → 0.12.0
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 +24 -0
- package/dist/middleware.js +123 -123
- package/dist/serve-sim.js +143 -143
- package/package.json +2 -1
- package/src/middleware.ts +167 -59
- package/src/single-device.ts +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/serve-sim",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"homepage": "https://github.com/expo/expo-device-hub/tree/main/packages/serve-sim/packages/serve-sim",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/expo/expo-device-hub/issues"
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"src/camera-helper.ts",
|
|
44
44
|
"src/connect-to-fetch.ts",
|
|
45
45
|
"src/middleware.ts",
|
|
46
|
+
"src/single-device.ts",
|
|
46
47
|
"src/native.ts",
|
|
47
48
|
"src/state.ts",
|
|
48
49
|
"src/stream-settings.ts",
|
package/src/middleware.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SingleDevicePolicy } from "./single-device";
|
|
1
2
|
import { getScreenshotDisplayIdsAsync } from "./screenshot-display";
|
|
2
3
|
import {
|
|
3
4
|
captureScreenshotFileAsync,
|
|
@@ -196,8 +197,7 @@ type SimctlAllList = {
|
|
|
196
197
|
devices: Record<string, Array<Omit<SimctlDevice, "runtime">>>;
|
|
197
198
|
};
|
|
198
199
|
|
|
199
|
-
type
|
|
200
|
-
type StartRequestBody = { udid?: string };
|
|
200
|
+
type DeviceLifecycleRequestBody = { udid?: unknown };
|
|
201
201
|
type ReleaseRequestBody = { targetId?: string };
|
|
202
202
|
type HighlightRequestBody = { targetId?: string; on?: boolean };
|
|
203
203
|
/** Re-exported alias for the canonical device-state record in `./state`. */
|
|
@@ -292,7 +292,7 @@ function classifyStaleState(
|
|
|
292
292
|
booted: Set<string> | null,
|
|
293
293
|
selfPid: number,
|
|
294
294
|
): StaleStateAction {
|
|
295
|
-
if (booted && isSimulatorUdid(state.device) && !booted.has(state.device)) {
|
|
295
|
+
if (booted && isSimulatorUdid(state.device) && !booted.has(state.device.toUpperCase())) {
|
|
296
296
|
return state.pid === selfPid ? "recycle-self" : "recycle-helper";
|
|
297
297
|
}
|
|
298
298
|
return "keep";
|
|
@@ -450,7 +450,7 @@ function getPreferredDeviceUdid(): string | null {
|
|
|
450
450
|
return udid;
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
-
export async function readServeSimStates(): Promise<ServeSimState[]> {
|
|
453
|
+
export async function readServeSimStates(device?: string): Promise<ServeSimState[]> {
|
|
454
454
|
let files: string[];
|
|
455
455
|
try {
|
|
456
456
|
files = readdirSync(stateDir()).filter((f) => f.startsWith("server-") && f.endsWith(".json"));
|
|
@@ -459,12 +459,13 @@ export async function readServeSimStates(): Promise<ServeSimState[]> {
|
|
|
459
459
|
}
|
|
460
460
|
const booted = await getBootedUdids();
|
|
461
461
|
const bootedAt = bootedSnapshot.at;
|
|
462
|
-
void retryPendingCaptureCleanup();
|
|
462
|
+
void retryPendingCaptureCleanup({}, device);
|
|
463
463
|
const states: ServeSimState[] = [];
|
|
464
464
|
for (const f of files) {
|
|
465
465
|
const path = join(stateDir(), f);
|
|
466
466
|
try {
|
|
467
467
|
const state: ServeSimState = JSON.parse(readFileSync(path, "utf-8"));
|
|
468
|
+
if (device && state.device.toUpperCase() !== device.toUpperCase()) continue;
|
|
468
469
|
try {
|
|
469
470
|
process.kill(state.pid, 0);
|
|
470
471
|
} catch {
|
|
@@ -521,7 +522,7 @@ export function selectServeSimState(
|
|
|
521
522
|
device?: string | null,
|
|
522
523
|
): ServeSimState | null {
|
|
523
524
|
if (device) {
|
|
524
|
-
return states.find((state) => state.device === device) ?? null;
|
|
525
|
+
return states.find((state) => state.device.toUpperCase() === device.toUpperCase()) ?? null;
|
|
525
526
|
}
|
|
526
527
|
return states[0] ?? null;
|
|
527
528
|
}
|
|
@@ -994,10 +995,14 @@ export async function disableNetworkCaptureForStoppedDevice(
|
|
|
994
995
|
}
|
|
995
996
|
|
|
996
997
|
/** Retry failed cleanups until the session that failed is gone; a newer session is left alone. */
|
|
997
|
-
export async function retryPendingCaptureCleanup(
|
|
998
|
+
export async function retryPendingCaptureCleanup(
|
|
999
|
+
deps: CaptureCleanupDeps = {},
|
|
1000
|
+
device?: string,
|
|
1001
|
+
): Promise<void> {
|
|
998
1002
|
const sessionOf = deps.session ?? ((id: string) => captureRuntime.storeFor(id));
|
|
999
1003
|
await Promise.all(
|
|
1000
1004
|
[...pendingCaptureCleanup].map(async ([udid, session]) => {
|
|
1005
|
+
if (device && udid.toUpperCase() !== device.toUpperCase()) return;
|
|
1001
1006
|
if (sessionOf(udid) !== session) {
|
|
1002
1007
|
pendingCaptureCleanup.delete(udid);
|
|
1003
1008
|
return;
|
|
@@ -1058,6 +1063,7 @@ export function previewConfigForState(
|
|
|
1058
1063
|
requirePreviewToken = false,
|
|
1059
1064
|
shareUrl?: string,
|
|
1060
1065
|
disableShare = false,
|
|
1066
|
+
singleDevice?: string,
|
|
1061
1067
|
): ServeSimState & {
|
|
1062
1068
|
basePath: string;
|
|
1063
1069
|
logsEndpoint: string;
|
|
@@ -1090,6 +1096,7 @@ export function previewConfigForState(
|
|
|
1090
1096
|
requireToken?: boolean;
|
|
1091
1097
|
shareUrl?: string;
|
|
1092
1098
|
disableShare?: boolean;
|
|
1099
|
+
singleDevice?: string;
|
|
1093
1100
|
} {
|
|
1094
1101
|
const gridApiBase = (base === "" ? "" : base) + "/grid/api";
|
|
1095
1102
|
const legacyCodec = typeof streamSettingsOrCodec === "string" ? streamSettingsOrCodec : undefined;
|
|
@@ -1102,6 +1109,7 @@ export function previewConfigForState(
|
|
|
1102
1109
|
const { token: _sessionToken, streamSettings: _foreignStreamSettings, ...publicState } = state;
|
|
1103
1110
|
return {
|
|
1104
1111
|
...publicState,
|
|
1112
|
+
...(singleDevice ? { singleDevice } : {}),
|
|
1105
1113
|
basePath: base,
|
|
1106
1114
|
logsEndpoint: endpoint(base, "/logs", state.device),
|
|
1107
1115
|
crashesEndpoint: endpoint(base, "/crashes", state.device),
|
|
@@ -1360,7 +1368,7 @@ function sortGridSimulators(
|
|
|
1360
1368
|
return 5;
|
|
1361
1369
|
};
|
|
1362
1370
|
const stateRank = (device: SimctlDevice): number => {
|
|
1363
|
-
if (helperByUdid.has(device.udid)) return 0;
|
|
1371
|
+
if (helperByUdid.has(device.udid.toUpperCase())) return 0;
|
|
1364
1372
|
if (selectedDevice && device.udid === selectedDevice) return 1;
|
|
1365
1373
|
if (device.state === "Booted") return 2;
|
|
1366
1374
|
if (device.udid === preferredUdid) return 3;
|
|
@@ -1410,12 +1418,18 @@ function listAllSimulators(): Promise<SimctlDevice[]> {
|
|
|
1410
1418
|
});
|
|
1411
1419
|
}
|
|
1412
1420
|
|
|
1413
|
-
async function readGridSnapshot(
|
|
1421
|
+
async function readGridSnapshot(
|
|
1422
|
+
selectedDevice: string | null,
|
|
1423
|
+
deviceScope?: string,
|
|
1424
|
+
): Promise<{
|
|
1414
1425
|
simulators: SimctlDevice[];
|
|
1415
1426
|
helperByUdid: Map<string, ServeSimState>;
|
|
1416
1427
|
}> {
|
|
1417
|
-
const [states, simulators] = await Promise.all([
|
|
1418
|
-
|
|
1428
|
+
const [states, simulators] = await Promise.all([
|
|
1429
|
+
readServeSimStates(deviceScope),
|
|
1430
|
+
listAllSimulators(),
|
|
1431
|
+
]);
|
|
1432
|
+
const helperByUdid = new Map(states.map((state) => [state.device.toUpperCase(), state] as const));
|
|
1419
1433
|
return {
|
|
1420
1434
|
simulators: sortGridSimulators(simulators, helperByUdid, selectedDevice),
|
|
1421
1435
|
helperByUdid,
|
|
@@ -1430,7 +1444,7 @@ function gridStatusesForRequest(
|
|
|
1430
1444
|
proxyHelpers: boolean,
|
|
1431
1445
|
): GridDeviceStatus[] {
|
|
1432
1446
|
return simulators.map((simulator) => {
|
|
1433
|
-
const helper = helperByUdid.get(simulator.udid);
|
|
1447
|
+
const helper = helperByUdid.get(simulator.udid.toUpperCase());
|
|
1434
1448
|
const remoteHelper = helper
|
|
1435
1449
|
? rewriteStateForRequestHost(
|
|
1436
1450
|
helper,
|
|
@@ -1567,10 +1581,14 @@ function buildMemoryReport(): MemoryReport {
|
|
|
1567
1581
|
export interface SimMiddlewareOptions {
|
|
1568
1582
|
/** Standalone process handoff; omitted by embedded servers. */
|
|
1569
1583
|
configureResources?: (configuration: SessionConfiguration) => Promise<void>;
|
|
1584
|
+
/** Keep an orphan restricted to its startup device until its private ownership claim. */
|
|
1585
|
+
prepareForAdoption?: boolean;
|
|
1570
1586
|
/** Base path to serve the preview at. Default: "/.sim" */
|
|
1571
1587
|
basePath?: string;
|
|
1572
1588
|
/** Pin this preview server to a specific simulator UDID. */
|
|
1573
1589
|
device?: string;
|
|
1590
|
+
/** Restrict every route and control action to this simulator UDID. */
|
|
1591
|
+
singleDevice?: string;
|
|
1574
1592
|
/**
|
|
1575
1593
|
* Per-session bearer token gating `/exec` and network-capture HTTP routes.
|
|
1576
1594
|
* Auto-generated if omitted. The token is injected into the preview HTML
|
|
@@ -1729,12 +1747,17 @@ export function handleLogsRequest(
|
|
|
1729
1747
|
);
|
|
1730
1748
|
}
|
|
1731
1749
|
|
|
1732
|
-
async function selectDeviceAndReap(
|
|
1733
|
-
|
|
1750
|
+
async function selectDeviceAndReap(
|
|
1751
|
+
selectedDevice: string | null,
|
|
1752
|
+
deviceScope?: string,
|
|
1753
|
+
): Promise<ServeSimState | null> {
|
|
1754
|
+
const states = await readServeSimStates(deviceScope);
|
|
1734
1755
|
const state = selectServeSimState(states, selectedDevice);
|
|
1735
1756
|
const live = states.map((s) => s.device);
|
|
1736
|
-
|
|
1737
|
-
|
|
1757
|
+
if (!deviceScope) {
|
|
1758
|
+
crashRuntime.prune(live);
|
|
1759
|
+
logBufferCache.prune(live);
|
|
1760
|
+
}
|
|
1738
1761
|
return state;
|
|
1739
1762
|
}
|
|
1740
1763
|
|
|
@@ -1993,6 +2016,38 @@ export async function handleCaptureEntriesRequest(
|
|
|
1993
2016
|
}
|
|
1994
2017
|
|
|
1995
2018
|
export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
2019
|
+
const initialPolicy =
|
|
2020
|
+
options?.singleDevice === undefined ? null : new SingleDevicePolicy(options.singleDevice);
|
|
2021
|
+
let policy = initialPolicy;
|
|
2022
|
+
if (options?.prepareForAdoption) {
|
|
2023
|
+
if (!options.device) throw new Error("Prepared adoption requires one device");
|
|
2024
|
+
policy ??= new SingleDevicePolicy(options.device);
|
|
2025
|
+
}
|
|
2026
|
+
if (policy && options?.device) policy.resolveTarget(options.device);
|
|
2027
|
+
const readSessionGridSnapshot = async (selectedDevice: string | null) => {
|
|
2028
|
+
const sessionDevice = policy?.device;
|
|
2029
|
+
const snapshot = await readGridSnapshot(selectedDevice, sessionDevice);
|
|
2030
|
+
if (sessionDevice)
|
|
2031
|
+
snapshot.simulators = snapshot.simulators.filter(
|
|
2032
|
+
(d) => d.udid.toUpperCase() === sessionDevice,
|
|
2033
|
+
);
|
|
2034
|
+
return snapshot;
|
|
2035
|
+
};
|
|
2036
|
+
const requestDeviceAllowed = (rawUrl: string): boolean => {
|
|
2037
|
+
if (!policy) return true;
|
|
2038
|
+
try {
|
|
2039
|
+
const parsed = new URL(rawUrl, "http://localhost");
|
|
2040
|
+
for (const device of parsed.searchParams.getAll("device")) policy.resolveTarget(device);
|
|
2041
|
+
const helper = helperProxyTarget(rawUrl, helperPrefix);
|
|
2042
|
+
if (helper?.device != null) policy.resolveTarget(helper.device);
|
|
2043
|
+
// inspect-webkit cannot reconcile its targets with a simulator UDID.
|
|
2044
|
+
return (
|
|
2045
|
+
parsed.pathname !== devtoolsPrefix && !parsed.pathname.startsWith(`${devtoolsPrefix}/`)
|
|
2046
|
+
);
|
|
2047
|
+
} catch {
|
|
2048
|
+
return false;
|
|
2049
|
+
}
|
|
2050
|
+
};
|
|
1996
2051
|
const reportReadiness = createReadinessReporter();
|
|
1997
2052
|
validatedAdditionalDylibs();
|
|
1998
2053
|
const streamSettings =
|
|
@@ -2000,7 +2055,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2000
2055
|
const base = (options?.basePath ?? "/.sim").replace(/\/+$/, "");
|
|
2001
2056
|
const helperPrefix = helperProxyPrefix(base);
|
|
2002
2057
|
const devtoolsPrefix = devtoolsProxyPrefix(base);
|
|
2003
|
-
const proxyHelpers = options?.proxyHelpers ?? false;
|
|
2058
|
+
const proxyHelpers = policy ? true : (options?.proxyHelpers ?? false);
|
|
2004
2059
|
const getInspectWebKitBridge = options?.inspectWebKitBridge ?? ensureInspectWebKitBridge;
|
|
2005
2060
|
// Per-process random token. Anyone who can read the preview HTML same-origin
|
|
2006
2061
|
// can call /exec and network-capture; cross-origin pages and LAN clients cannot,
|
|
@@ -2074,8 +2129,32 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2074
2129
|
}
|
|
2075
2130
|
};
|
|
2076
2131
|
|
|
2132
|
+
const resolveLifecycleDevice = (body: string, res: SimRes): string | null => {
|
|
2133
|
+
const refuse = (status: number, error: string): null => {
|
|
2134
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
2135
|
+
res.end(JSON.stringify({ ok: false, error }));
|
|
2136
|
+
return null;
|
|
2137
|
+
};
|
|
2138
|
+
let udid: unknown;
|
|
2139
|
+
try {
|
|
2140
|
+
udid = (JSON.parse(body) as DeviceLifecycleRequestBody | null)?.udid;
|
|
2141
|
+
} catch {}
|
|
2142
|
+
if (
|
|
2143
|
+
typeof udid !== "string" ||
|
|
2144
|
+
!/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(udid)
|
|
2145
|
+
) {
|
|
2146
|
+
return refuse(400, "Invalid or missing udid");
|
|
2147
|
+
}
|
|
2148
|
+
try {
|
|
2149
|
+
return policy?.resolveTarget(udid) ?? udid;
|
|
2150
|
+
} catch {
|
|
2151
|
+
return refuse(403, "Device is outside this single-device session.");
|
|
2152
|
+
}
|
|
2153
|
+
};
|
|
2154
|
+
|
|
2077
2155
|
const handleUiRequest: UiRequestHandler = async (payload) => {
|
|
2078
|
-
const p = (payload
|
|
2156
|
+
const p = { ...(payload as object) } as { device?: string; option?: string; value?: string };
|
|
2157
|
+
if (policy) p.device = policy.resolveTarget(p.device);
|
|
2079
2158
|
// Must start alphanumeric and stay bounded: a leading "-" is parsed as a flag by simctl.
|
|
2080
2159
|
if (typeof p.device !== "string" || !/^[0-9A-Za-z][0-9A-Za-z-]{0,255}$/.test(p.device)) {
|
|
2081
2160
|
throw new Error("missing or invalid device udid");
|
|
@@ -2140,7 +2219,16 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2140
2219
|
res.end();
|
|
2141
2220
|
return;
|
|
2142
2221
|
}
|
|
2143
|
-
|
|
2222
|
+
if (ownPath && !requestDeviceAllowed(rawUrl)) {
|
|
2223
|
+
res.writeHead(403, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
2224
|
+
res.end(
|
|
2225
|
+
JSON.stringify({
|
|
2226
|
+
error: "This single-device session cannot access another device or WebKit targets.",
|
|
2227
|
+
}),
|
|
2228
|
+
);
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
const requestedDevice = policy?.device ?? queryDevice(rawUrl);
|
|
2144
2232
|
const selectedDevice = requestedDevice ?? options?.device ?? null;
|
|
2145
2233
|
const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
|
|
2146
2234
|
|
|
@@ -2230,7 +2318,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2230
2318
|
|
|
2231
2319
|
// Serve the preview page
|
|
2232
2320
|
if (url === base || url === base + "/") {
|
|
2233
|
-
const states = await readServeSimStates();
|
|
2321
|
+
const states = await readServeSimStates(policy?.device);
|
|
2234
2322
|
const state = selectServeSimState(states, selectedDevice);
|
|
2235
2323
|
if (state) void initializeClipboard(state.device);
|
|
2236
2324
|
let html = loadHtml();
|
|
@@ -2242,6 +2330,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2242
2330
|
const minimal = JSON.stringify({
|
|
2243
2331
|
basePath: base,
|
|
2244
2332
|
execToken,
|
|
2333
|
+
...(policy ? { singleDevice: policy.device } : {}),
|
|
2245
2334
|
...(shareUrl ? { shareUrl } : {}),
|
|
2246
2335
|
...(disableShare ? { disableShare: true } : {}),
|
|
2247
2336
|
});
|
|
@@ -2269,6 +2358,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2269
2358
|
requirePreviewToken,
|
|
2270
2359
|
shareUrl,
|
|
2271
2360
|
disableShare,
|
|
2361
|
+
policy?.device,
|
|
2272
2362
|
),
|
|
2273
2363
|
);
|
|
2274
2364
|
const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
|
|
@@ -2312,7 +2402,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2312
2402
|
// Static simulator metadata. The browser keeps this catalog in memory and
|
|
2313
2403
|
// receives state/helper changes through the compact status feed below.
|
|
2314
2404
|
if (url === base + "/grid/api/catalog") {
|
|
2315
|
-
const { simulators } = await
|
|
2405
|
+
const { simulators } = await readSessionGridSnapshot(selectedDevice);
|
|
2316
2406
|
const total = simulators.length;
|
|
2317
2407
|
const { limit, offset } = parseGridPaging(rawUrl);
|
|
2318
2408
|
const page = limit == null ? simulators : simulators.slice(offset, offset + limit);
|
|
@@ -2341,7 +2431,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2341
2431
|
}
|
|
2342
2432
|
|
|
2343
2433
|
const computeGridStatuses = async (): Promise<string> => {
|
|
2344
|
-
const { simulators, helperByUdid } = await
|
|
2434
|
+
const { simulators, helperByUdid } = await readSessionGridSnapshot(selectedDevice);
|
|
2345
2435
|
return JSON.stringify({
|
|
2346
2436
|
statuses: gridStatusesForRequest(simulators, helperByUdid, req, base, proxyHelpers),
|
|
2347
2437
|
});
|
|
@@ -2439,7 +2529,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2439
2529
|
|
|
2440
2530
|
// Grid JSON: every supported simulator, annotated with running helper info if any.
|
|
2441
2531
|
if (url === base + "/grid/api") {
|
|
2442
|
-
const { simulators, helperByUdid } = await
|
|
2532
|
+
const { simulators, helperByUdid } = await readSessionGridSnapshot(selectedDevice);
|
|
2443
2533
|
const total = simulators.length;
|
|
2444
2534
|
const { limit, offset } = parseGridPaging(rawUrl);
|
|
2445
2535
|
const page = limit == null ? simulators : simulators.slice(offset, offset + limit);
|
|
@@ -2475,15 +2565,8 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2475
2565
|
body += typeof chunk === "string" ? chunk : chunk.toString();
|
|
2476
2566
|
});
|
|
2477
2567
|
req.on("end", async () => {
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
udid = (JSON.parse(body) as ShutdownRequestBody).udid ?? "";
|
|
2481
|
-
} catch {}
|
|
2482
|
-
if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(udid)) {
|
|
2483
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2484
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid or missing udid" }));
|
|
2485
|
-
return;
|
|
2486
|
-
}
|
|
2568
|
+
const udid = resolveLifecycleDevice(body, res);
|
|
2569
|
+
if (udid === null) return;
|
|
2487
2570
|
// Stop our own in-process capture for this device first (no-op if it
|
|
2488
2571
|
// isn't streamed here). This frees the native session immediately
|
|
2489
2572
|
// rather than waiting for the next poll's reaper to notice.
|
|
@@ -2523,15 +2606,8 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2523
2606
|
body += typeof chunk === "string" ? chunk : chunk.toString();
|
|
2524
2607
|
});
|
|
2525
2608
|
req.on("end", () => {
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
udid = (JSON.parse(body) as StartRequestBody).udid ?? "";
|
|
2529
|
-
} catch {}
|
|
2530
|
-
if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(udid)) {
|
|
2531
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2532
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid or missing udid" }));
|
|
2533
|
-
return;
|
|
2534
|
-
}
|
|
2609
|
+
const udid = resolveLifecycleDevice(body, res);
|
|
2610
|
+
if (udid === null) return;
|
|
2535
2611
|
const port = req.socket.localPort ?? 0;
|
|
2536
2612
|
void startDeviceInProcess(
|
|
2537
2613
|
udid,
|
|
@@ -2573,7 +2649,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2573
2649
|
// /devtools/page/:id on localhost; the preview adds iframe-safe frontend
|
|
2574
2650
|
// URLs so the browser UI can embed Chrome DevTools.
|
|
2575
2651
|
if (url === base + "/devtools") {
|
|
2576
|
-
const states = await readServeSimStates();
|
|
2652
|
+
const states = await readServeSimStates(policy?.device);
|
|
2577
2653
|
const state = selectServeSimState(states, selectedDevice);
|
|
2578
2654
|
if (!state) {
|
|
2579
2655
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
@@ -2695,7 +2771,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2695
2771
|
}
|
|
2696
2772
|
|
|
2697
2773
|
if (url === base + "/readyz") {
|
|
2698
|
-
const states = await readServeSimStates();
|
|
2774
|
+
const states = await readServeSimStates(policy?.device);
|
|
2699
2775
|
const state = selectServeSimState(states, selectedDevice);
|
|
2700
2776
|
if (!state) {
|
|
2701
2777
|
reportReadiness("unavailable", "session-unavailable", selectedDevice ?? undefined);
|
|
@@ -2733,7 +2809,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2733
2809
|
|
|
2734
2810
|
// JSON API: serve-sim state
|
|
2735
2811
|
if (url === base + "/api") {
|
|
2736
|
-
const states = await readServeSimStates();
|
|
2812
|
+
const states = await readServeSimStates(policy?.device);
|
|
2737
2813
|
const state = selectServeSimState(states, selectedDevice);
|
|
2738
2814
|
if (state) void initializeClipboard(state.device);
|
|
2739
2815
|
// The web UI polls /api every ~2s, so logging every hit floods the
|
|
@@ -2776,6 +2852,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2776
2852
|
requirePreviewToken,
|
|
2777
2853
|
shareUrl,
|
|
2778
2854
|
disableShare,
|
|
2855
|
+
policy?.device,
|
|
2779
2856
|
)
|
|
2780
2857
|
: null,
|
|
2781
2858
|
),
|
|
@@ -2896,7 +2973,9 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2896
2973
|
req,
|
|
2897
2974
|
res,
|
|
2898
2975
|
rawUrl,
|
|
2899
|
-
async () =>
|
|
2976
|
+
async () =>
|
|
2977
|
+
selectServeSimState(await readServeSimStates(policy?.device), selectedDevice)?.device ??
|
|
2978
|
+
null,
|
|
2900
2979
|
);
|
|
2901
2980
|
return;
|
|
2902
2981
|
}
|
|
@@ -3069,7 +3148,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3069
3148
|
// on change instead of re-sending identical JSON on a fixed interval.
|
|
3070
3149
|
if (url === base + "/api/events") {
|
|
3071
3150
|
const computeConfig = async (): Promise<string> => {
|
|
3072
|
-
const states = await readServeSimStates();
|
|
3151
|
+
const states = await readServeSimStates(policy?.device);
|
|
3073
3152
|
const state = selectServeSimState(states, selectedDevice);
|
|
3074
3153
|
if (state) void initializeClipboard(state.device);
|
|
3075
3154
|
const remoteState = state
|
|
@@ -3092,6 +3171,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3092
3171
|
requirePreviewToken,
|
|
3093
3172
|
shareUrl,
|
|
3094
3173
|
disableShare,
|
|
3174
|
+
policy?.device,
|
|
3095
3175
|
)
|
|
3096
3176
|
: null,
|
|
3097
3177
|
);
|
|
@@ -3174,7 +3254,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3174
3254
|
}
|
|
3175
3255
|
|
|
3176
3256
|
if (url === base + "/crashes" || url === base + "/crashes/") {
|
|
3177
|
-
const state = await selectDeviceAndReap(selectedDevice);
|
|
3257
|
+
const state = await selectDeviceAndReap(selectedDevice, policy?.device);
|
|
3178
3258
|
await handleCrashesRequestAfter(
|
|
3179
3259
|
() => crashRuntime.start({ deferToRetry: true }).catch(() => {}),
|
|
3180
3260
|
req,
|
|
@@ -3201,7 +3281,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3201
3281
|
);
|
|
3202
3282
|
return;
|
|
3203
3283
|
}
|
|
3204
|
-
const state = await selectDeviceAndReap(selectedDevice);
|
|
3284
|
+
const state = await selectDeviceAndReap(selectedDevice, policy?.device);
|
|
3205
3285
|
const params = new URL(rawUrl, "http://127.0.0.1").searchParams;
|
|
3206
3286
|
await handleCrashReportRequest(
|
|
3207
3287
|
req,
|
|
@@ -3217,13 +3297,18 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3217
3297
|
}
|
|
3218
3298
|
|
|
3219
3299
|
if (url === base + "/logs") {
|
|
3220
|
-
handleLogsRequest(
|
|
3300
|
+
handleLogsRequest(
|
|
3301
|
+
req,
|
|
3302
|
+
res,
|
|
3303
|
+
await selectDeviceAndReap(selectedDevice, policy?.device),
|
|
3304
|
+
rawUrl,
|
|
3305
|
+
);
|
|
3221
3306
|
return;
|
|
3222
3307
|
}
|
|
3223
3308
|
|
|
3224
3309
|
// SSE: normalized accessibility snapshot stream
|
|
3225
3310
|
if (url === base + "/ax") {
|
|
3226
|
-
const states = await readServeSimStates();
|
|
3311
|
+
const states = await readServeSimStates(policy?.device);
|
|
3227
3312
|
// The client may have left during that await; its close event has already fired.
|
|
3228
3313
|
if (req.destroyed) return;
|
|
3229
3314
|
const state = selectServeSimState(states, selectedDevice);
|
|
@@ -3239,7 +3324,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3239
3324
|
"X-Accel-Buffering": "no",
|
|
3240
3325
|
});
|
|
3241
3326
|
res.write(":\n\n");
|
|
3242
|
-
axStreamerCache.prune(states.map((s) => s.device));
|
|
3327
|
+
if (!policy) axStreamerCache.prune(states.map((s) => s.device));
|
|
3243
3328
|
const ax = axStreamerCache.get(state.device);
|
|
3244
3329
|
const removeClient = ax.addClient(res);
|
|
3245
3330
|
req.on("close", removeClient);
|
|
@@ -3249,7 +3334,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3249
3334
|
// SSE of the user app's live CPU/memory: an `event: meta` frame (schema,
|
|
3250
3335
|
// udid, hostCores, cadence), then one `data:` line per sample.
|
|
3251
3336
|
if (url === base + "/metrics") {
|
|
3252
|
-
const states = await readServeSimStates();
|
|
3337
|
+
const states = await readServeSimStates(policy?.device);
|
|
3253
3338
|
// The client may have left during that await; its close event has already fired.
|
|
3254
3339
|
if (req.destroyed) return;
|
|
3255
3340
|
const state = selectServeSimState(states, selectedDevice);
|
|
@@ -3258,7 +3343,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3258
3343
|
}
|
|
3259
3344
|
|
|
3260
3345
|
if (url === base + "/network-capture") {
|
|
3261
|
-
const states = await readServeSimStates();
|
|
3346
|
+
const states = await readServeSimStates(policy?.device);
|
|
3262
3347
|
// The client may have left during that await; its close event has already fired.
|
|
3263
3348
|
if (req.destroyed) return;
|
|
3264
3349
|
const state = selectServeSimState(states, selectedDevice);
|
|
@@ -3268,14 +3353,14 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3268
3353
|
|
|
3269
3354
|
// Not under "/network-capture/", so these can never be read as a request id.
|
|
3270
3355
|
if (url === base + "/network-capture.ndjson") {
|
|
3271
|
-
const states = await readServeSimStates();
|
|
3356
|
+
const states = await readServeSimStates(policy?.device);
|
|
3272
3357
|
const state = selectServeSimState(states, selectedDevice);
|
|
3273
3358
|
await handleCaptureEntriesRequest(req, res, state, captureRuntime);
|
|
3274
3359
|
return;
|
|
3275
3360
|
}
|
|
3276
3361
|
|
|
3277
3362
|
if (url === base + "/network-capture.har") {
|
|
3278
|
-
const states = await readServeSimStates();
|
|
3363
|
+
const states = await readServeSimStates(policy?.device);
|
|
3279
3364
|
const state = selectServeSimState(states, selectedDevice);
|
|
3280
3365
|
await handleCaptureHarRequest(req, res, state, captureRuntime);
|
|
3281
3366
|
return;
|
|
@@ -3297,7 +3382,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3297
3382
|
);
|
|
3298
3383
|
return;
|
|
3299
3384
|
}
|
|
3300
|
-
const states = await readServeSimStates();
|
|
3385
|
+
const states = await readServeSimStates(policy?.device);
|
|
3301
3386
|
const state = selectServeSimState(states, selectedDevice);
|
|
3302
3387
|
handleCaptureBodyRequest(req, res, state, id, captureRuntime);
|
|
3303
3388
|
return;
|
|
@@ -3308,7 +3393,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3308
3393
|
// log line. Filtering is done here (not in the browser) so the SSE stream
|
|
3309
3394
|
// stays narrow and the client can listen without rate-limit concerns.
|
|
3310
3395
|
if (url === base + "/appstate") {
|
|
3311
|
-
const states = await readServeSimStates();
|
|
3396
|
+
const states = await readServeSimStates(policy?.device);
|
|
3312
3397
|
// The client may have left during that await; its close event has already fired.
|
|
3313
3398
|
if (req.destroyed) return;
|
|
3314
3399
|
const state = selectServeSimState(states, selectedDevice);
|
|
@@ -3372,7 +3457,11 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3372
3457
|
return;
|
|
3373
3458
|
}
|
|
3374
3459
|
const rawUrl = req.url ?? "";
|
|
3375
|
-
|
|
3460
|
+
if (!requestDeviceAllowed(rawUrl)) {
|
|
3461
|
+
socket.destroy();
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
const selectedDevice = policy?.device ?? queryDevice(rawUrl) ?? options?.device ?? null;
|
|
3376
3465
|
const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
|
|
3377
3466
|
const devtoolsTarget = devtoolsProxyTarget(rawUrl, devtoolsPrefix);
|
|
3378
3467
|
if (devtoolsTarget) {
|
|
@@ -3422,6 +3511,9 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3422
3511
|
ssePrefixes: sseStreamPaths(base),
|
|
3423
3512
|
onUiRequest: handleUiRequest,
|
|
3424
3513
|
serveSimBinPath: serveSimBinPath(),
|
|
3514
|
+
get singleDevicePolicy() {
|
|
3515
|
+
return policy ?? undefined;
|
|
3516
|
+
},
|
|
3425
3517
|
deviceName: bootedDeviceName,
|
|
3426
3518
|
onActionResult: (action, params, result) => recordActionEvent(action, params, result),
|
|
3427
3519
|
onSseRequest(path, websocketRequest) {
|
|
@@ -3461,11 +3553,15 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3461
3553
|
websocket.close();
|
|
3462
3554
|
return true;
|
|
3463
3555
|
}
|
|
3556
|
+
if (!requestDeviceAllowed(request.url)) {
|
|
3557
|
+
websocket.close();
|
|
3558
|
+
return true;
|
|
3559
|
+
}
|
|
3464
3560
|
if (execWebSocketHandler(request, websocket)) return true;
|
|
3465
3561
|
if (
|
|
3466
3562
|
claimHelperHidSocket(request, websocket, {
|
|
3467
3563
|
helperProxyTarget: (rawUrl) => helperProxyTarget(rawUrl, helperPrefix),
|
|
3468
|
-
fallbackDevice: options?.device ?? null,
|
|
3564
|
+
fallbackDevice: policy?.device ?? options?.device ?? null,
|
|
3469
3565
|
resolveSession: (device) => getDeviceSession(device, streamSettings),
|
|
3470
3566
|
})
|
|
3471
3567
|
)
|
|
@@ -3483,6 +3579,18 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
3483
3579
|
if (sessionConfigured || !options?.configureResources || streamSettings?.transport !== "webrtc")
|
|
3484
3580
|
throw new Error("Session cannot be configured");
|
|
3485
3581
|
sessionConfigured = true;
|
|
3582
|
+
const adoptedPolicy =
|
|
3583
|
+
configuration.singleDevice === undefined
|
|
3584
|
+
? initialPolicy
|
|
3585
|
+
: new SingleDevicePolicy(configuration.singleDevice);
|
|
3586
|
+
if (adoptedPolicy) {
|
|
3587
|
+
if (!options.device) throw new Error("Single-device adoption requires one startup device");
|
|
3588
|
+
adoptedPolicy.resolveTarget(options.device);
|
|
3589
|
+
initialPolicy?.resolveTarget(adoptedPolicy.device);
|
|
3590
|
+
}
|
|
3591
|
+
// Preparation admitted only the startup device. Apply the owner's final
|
|
3592
|
+
// restriction before awaiting resources; existing exec sockets read it per message.
|
|
3593
|
+
policy = adoptedPolicy;
|
|
3486
3594
|
await options.configureResources(configuration);
|
|
3487
3595
|
if (options.device)
|
|
3488
3596
|
await peekDeviceSession(options.device)?.updateStreamSettings(configuration.stream);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @ref LLP 0003#single-device-sessions — session target and layout contract
|
|
2
|
+
/** Immutable server policy. Device selection preferences must never override it. */
|
|
3
|
+
export class SingleDevicePolicy {
|
|
4
|
+
readonly device: string;
|
|
5
|
+
|
|
6
|
+
constructor(device: string) {
|
|
7
|
+
if (!/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(device)) {
|
|
8
|
+
throw new Error("single-device requires a simulator UDID");
|
|
9
|
+
}
|
|
10
|
+
this.device = device.toUpperCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
resolveTarget(requested?: string | null): string {
|
|
14
|
+
if (requested != null && requested.toUpperCase() !== this.device) {
|
|
15
|
+
throw new Error("This single-device session cannot access another device.");
|
|
16
|
+
}
|
|
17
|
+
return this.device;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
scopeAction(action: string, params: Record<string, unknown> = {}): Record<string, unknown> {
|
|
21
|
+
// These act on host-wide state or create a separate server outside this policy.
|
|
22
|
+
if (["server.detach", "server.kill", "home.watch"].includes(action)) {
|
|
23
|
+
throw new Error(`Action ${action} is unavailable in single-device mode.`);
|
|
24
|
+
}
|
|
25
|
+
if (params.udid !== undefined && typeof params.udid !== "string") {
|
|
26
|
+
throw new Error("Invalid single-device target.");
|
|
27
|
+
}
|
|
28
|
+
// Action schemas discard this field for file-only operations. Every device
|
|
29
|
+
// action gets a concrete target, including when the client omits one.
|
|
30
|
+
return { ...params, udid: this.resolveTarget(params.udid as string | undefined) };
|
|
31
|
+
}
|
|
32
|
+
}
|