@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/serve-sim",
3
- "version": "0.11.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 ShutdownRequestBody = { udid?: string };
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(deps: CaptureCleanupDeps = {}): Promise<void> {
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(selectedDevice: string | null): Promise<{
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([readServeSimStates(), listAllSimulators()]);
1418
- const helperByUdid = new Map(states.map((state) => [state.device, state] as const));
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(selectedDevice: string | null): Promise<ServeSimState | null> {
1733
- const states = await readServeSimStates();
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
- crashRuntime.prune(live);
1737
- logBufferCache.prune(live);
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 ?? {}) as { device?: string; option?: string; value?: string };
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
- const requestedDevice = queryDevice(rawUrl);
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 readGridSnapshot(selectedDevice);
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 readGridSnapshot(selectedDevice);
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 readGridSnapshot(selectedDevice);
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
- let udid = "";
2479
- try {
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
- let udid = "";
2527
- try {
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 () => selectServeSimState(await readServeSimStates(), selectedDevice)?.device ?? null,
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(req, res, await selectDeviceAndReap(selectedDevice), rawUrl);
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
- const selectedDevice = queryDevice(rawUrl) ?? options?.device ?? null;
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
+ }