@expo/serve-sim 0.1.53 → 0.2.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/dist/state.js CHANGED
@@ -1 +1 @@
1
- import{tmpdir as O}from"os";import{join as G}from"path";import{readdirSync as P,mkdirSync as Q,writeFileSync as U,renameSync as V}from"fs";var B=G(O(),"serve-sim"),$=G(B,"server.json");function W(q){return G(B,`server-${q}.json`)}function k(q,z,C="/",H="127.0.0.1",K){let J=H==="0.0.0.0"||H==="::"?"127.0.0.1":H,M=C.replace(/^\/+/,"").replace(/\/+$/,""),N=M===""?"":`/${M}`;return{pid:process.pid,port:z,device:q,url:`http://${J}:${z}`,streamUrl:`http://${J}:${z}${N}/helper/${q}/stream.mjpeg`,wsUrl:`ws://${J}:${z}${N}/helper/${q}/ws`,...K?{streamSettings:K}:{}}}function w(q){Q(B,{recursive:!0});let z=W(q.device),C=`${z}.${process.pid}.tmp`;U(C,JSON.stringify(q,null,2),{mode:384}),V(C,z)}function L(){try{return P(B).filter((q)=>q.startsWith("server-")&&q.endsWith(".json")).map((q)=>G(B,q))}catch{return[]}}export{w as writeServeSimState,W as stateFileForDevice,L as listStateFiles,k as inProcessServeSimState,$ as STATE_FILE,B as STATE_DIR};
1
+ import{tmpdir as M}from"os";import{join as G}from"path";import{readdirSync as N,mkdirSync as O,writeFileSync as Q,renameSync as R,readFileSync as T,unlinkSync as U}from"fs";function H(){return process.env.SERVE_SIM_STATE_DIR||G(M(),"serve-sim")}function K(q){return G(H(),`server-${q}.json`)}function Y(q,z){let A=(C)=>({url:C.url,streamUrl:C.streamUrl,wsUrl:C.wsUrl,port:C.port,device:C.device}),B=q.length===1?A(q[0]):{devices:q.map(A)};return z?{...B,token:z}:B}function Z(q,z,A="/",B="127.0.0.1",C){let E=B==="0.0.0.0"||B==="::"?"127.0.0.1":B,I=A.replace(/^\/+/,"").replace(/\/+$/,""),J=I===""?"":`/${I}`;return{pid:process.pid,port:z,device:q,url:`http://${E}:${z}`,streamUrl:`http://${E}:${z}${J}/helper/${q}/stream.mjpeg`,wsUrl:`ws://${E}:${z}${J}/helper/${q}/ws`,...C?{streamSettings:C}:{}}}function _(q){O(H(),{recursive:!0});let z=K(q.device),A=`${z}.${process.pid}.tmp`;Q(A,JSON.stringify(q,null,2),{mode:384}),R(A,z)}function $(q,z){let A=K(q),B;try{B=JSON.parse(T(A,"utf-8"))}catch{return}if(B.pid!==z)return;try{U(A)}catch{}}function L(){try{let q=H();return N(q).filter((z)=>z.startsWith("server-")&&z.endsWith(".json")).map((z)=>G(q,z))}catch{return[]}}export{_ as writeServeSimState,K as stateFileForDevice,H as stateDir,Y as previewStartupPayload,L as listStateFiles,Z as inProcessServeSimState,$ as clearServeSimState};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/serve-sim",
3
- "version": "0.1.53",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -73,7 +73,10 @@
73
73
  },
74
74
  "scripts": {
75
75
  "build": "bun run build.ts",
76
- "dev": "bun run dev.ts"
76
+ "dev": "bun run dev.ts",
77
+ "tart": "bun scripts/tart/cli.ts",
78
+ "tart-test": "bun scripts/tart/cli.ts test",
79
+ "tart-dev": "bun scripts/tart/cli.ts dev"
77
80
  },
78
81
  "devDependencies": {
79
82
  "@types/bun": "latest",
@@ -91,7 +94,8 @@
91
94
  "react-dom": "^19.0.0",
92
95
  "tailwindcss": "^4.1.7",
93
96
  "typescript": "^5.7.0",
94
- "werift": "^0.24.4"
97
+ "werift": "^0.24.4",
98
+ "zod": "^4.5.4"
95
99
  },
96
100
  "dependencies": {
97
101
  "inspect-webkit": "^0.0.5",
package/src/ax.ts CHANGED
@@ -79,6 +79,18 @@ function normalizeAxTree(roots: RawAxeNode[]): AxSnapshot {
79
79
  };
80
80
  }
81
81
 
82
+ const SOFTWARE_KEYBOARD_KEY_IDS = ["delete", "shift", "space", "more", "dictation"];
83
+
84
+ export async function isSoftwareKeyboardVisible(udid: string): Promise<boolean> {
85
+ const snapshot = await snapshotFromNative(udid);
86
+ if (snapshot.errors?.length) return false;
87
+ const keys = new Set<string>();
88
+ for (const el of snapshot.elements) {
89
+ if (SOFTWARE_KEYBOARD_KEY_IDS.includes(el.id)) keys.add(el.id);
90
+ }
91
+ return keys.size >= 2;
92
+ }
93
+
82
94
  async function snapshotFromNative(udid: string): Promise<AxSnapshot> {
83
95
  let raw: RawAxeNode[];
84
96
  try {
@@ -1,9 +1,11 @@
1
1
  import { createHash } from "crypto";
2
2
  import { existsSync, readFileSync } from "fs";
3
3
  import { join } from "path";
4
- import { STATE_DIR } from "./state";
4
+ import { stateDir } from "./state";
5
5
 
6
- export const CAMERA_STATE_DIR = join(STATE_DIR, "simcam");
6
+ export function cameraStateDir(): string {
7
+ return join(stateDir(), "simcam");
8
+ }
7
9
  const HELPER_TIMEOUT_MS = 3000;
8
10
 
9
11
  interface InjectedBundlesState {
@@ -28,11 +30,11 @@ export interface CameraStatusReply extends CameraHelperReply {
28
30
  }
29
31
 
30
32
  export function cameraHelperPidFile(udid: string): string {
31
- return join(CAMERA_STATE_DIR, `${udid}.pid`);
33
+ return join(cameraStateDir(), `${udid}.pid`);
32
34
  }
33
35
 
34
36
  export function cameraHelperBundlesFile(udid: string): string {
35
- return join(CAMERA_STATE_DIR, `${udid}.bundles.json`);
37
+ return join(cameraStateDir(), `${udid}.bundles.json`);
36
38
  }
37
39
 
38
40
  export function cameraHelperSocketFile(udid: string): string {
package/src/middleware.ts CHANGED
@@ -1,10 +1,10 @@
1
+ import { execFile, execSync, spawn, type ChildProcess } from "child_process";
1
2
  import { readdirSync, readFileSync, existsSync, unlinkSync, watch, type FSWatcher } from "fs";
2
3
  import { readFile, unlink } from "fs/promises";
3
- import { execSync, spawn, exec, execFile, type ChildProcess, type ExecException } from "child_process";
4
4
  import { tmpdir } from "os";
5
5
  import { join } from "path";
6
6
  import { createServer as createNetServer } from "net";
7
- import { createHash, randomBytes, timingSafeEqual } from "crypto";
7
+ import { createHash, randomBytes } from "crypto";
8
8
  import type { IncomingMessage, ServerResponse } from "http";
9
9
  import type { Socket } from "net";
10
10
  // `ws` (kept external in the build) supplies a WebSocket *client* for the
@@ -24,13 +24,14 @@ import {
24
24
  sendCorsPreflight,
25
25
  type HidSocket,
26
26
  } from "./device-session";
27
+ import { assertPreviewAccess, assertUpgradeAccess } from "./session-auth";
27
28
  import {
28
- eventLogEventForCommand,
29
+ eventLogEventForAction,
29
30
  readEventLog,
30
31
  recordEventLogEvent,
31
32
  subscribeEventLog,
32
33
  } from "./event-log";
33
- import { inProcessServeSimState, writeServeSimState, type ServeSimDeviceState, type StreamSettings } from "./state";
34
+ import { inProcessServeSimState, stateDir, writeServeSimState, type ServeSimDeviceState, type StreamSettings } from "./state";
34
35
  import { debugMw } from "./debug";
35
36
  import {
36
37
  resolveDevicePlaceholderAsset,
@@ -54,7 +55,6 @@ export type SimMiddleware = WebMiddleware & {
54
55
 
55
56
  // Injected at build time as a base64-encoded string via `define`
56
57
  declare const __PREVIEW_HTML_B64__: string;
57
- const STATE_DIR = join(tmpdir(), "serve-sim");
58
58
  // Last logged result of a GET /api selection, used to suppress the
59
59
  // once-every-poll duplicate debugMw lines (the UI polls /api every ~2s).
60
60
  let lastApiLogKey: string | undefined;
@@ -117,8 +117,6 @@ type ShutdownRequestBody = { udid?: string };
117
117
  type StartRequestBody = { udid?: string };
118
118
  type ReleaseRequestBody = { targetId?: string };
119
119
  type HighlightRequestBody = { targetId?: string; on?: boolean };
120
- type ExecRequestBody = { command?: string };
121
-
122
120
  /** Re-exported alias for the canonical device-state record in `./state`. */
123
121
  export type ServeSimState = ServeSimDeviceState;
124
122
 
@@ -149,12 +147,16 @@ function eventLogSinceId(rawUrl: string): number | undefined {
149
147
  return Number.isFinite(since) ? since : undefined;
150
148
  }
151
149
 
152
- function recordCommandEvent(command: string, result: { exitCode?: number }): void {
150
+ function recordActionEvent(
151
+ action: string,
152
+ params: Record<string, unknown> | undefined,
153
+ result: { exitCode?: number },
154
+ ): void {
153
155
  try {
154
- const event = eventLogEventForCommand(command, result);
156
+ const event = eventLogEventForAction(action, params, result);
155
157
  if (event) recordEventLogEvent(event);
156
158
  } catch {
157
- // Event-log recording is diagnostic; it must never break the exec path.
159
+ // Event-log recording is diagnostic; it must never break the action path.
158
160
  }
159
161
  }
160
162
 
@@ -359,7 +361,7 @@ function getPreferredDeviceUdid(): string | null {
359
361
  export async function readServeSimStates(): Promise<ServeSimState[]> {
360
362
  let files: string[];
361
363
  try {
362
- files = readdirSync(STATE_DIR).filter(
364
+ files = readdirSync(stateDir()).filter(
363
365
  (f) => f.startsWith("server-") && f.endsWith(".json"),
364
366
  );
365
367
  } catch {
@@ -368,7 +370,7 @@ export async function readServeSimStates(): Promise<ServeSimState[]> {
368
370
  const booted = await getBootedUdids();
369
371
  const states: ServeSimState[] = [];
370
372
  for (const f of files) {
371
- const path = join(STATE_DIR, f);
373
+ const path = join(stateDir(), f);
372
374
  try {
373
375
  const state: ServeSimState = JSON.parse(readFileSync(path, "utf-8"));
374
376
  try {
@@ -847,11 +849,25 @@ function serveHelperInProcess(
847
849
  * preview server itself serves the device's /helper routes in-process. Resolves
848
850
  * to an error string on boot failure, or null on success.
849
851
  */
852
+ /** Carries the session token like the primary device's does, or its readers start failing. */
853
+ export function gridDeviceState(
854
+ udid: string,
855
+ port: number,
856
+ base: string,
857
+ streamSettings?: StreamSettings,
858
+ sessionToken?: string,
859
+ ): ServeSimDeviceState {
860
+ const state = inProcessServeSimState(udid, port, base, "127.0.0.1", streamSettings);
861
+ return sessionToken ? { ...state, token: sessionToken } : state;
862
+ }
863
+
850
864
  export async function startDeviceInProcess(
851
865
  udid: string,
852
866
  port: number,
853
867
  base: string,
854
868
  streamSettings?: StreamSettings,
869
+ /** Session token, when the server runs gated. */
870
+ sessionToken?: string,
855
871
  ): Promise<string | null> {
856
872
  // `simctl boot` errors when already booted — ignore and let bootstatus confirm.
857
873
  await new Promise<void>((resolve) => execFile("xcrun", ["simctl", "boot", udid], () => resolve()));
@@ -874,7 +890,7 @@ export async function startDeviceInProcess(
874
890
  });
875
891
  if (!booted) return `Device ${udid} failed to reach booted state`;
876
892
  }
877
- writeServeSimState(inProcessServeSimState(udid, port, base, "127.0.0.1", streamSettings));
893
+ writeServeSimState(gridDeviceState(udid, port, base, streamSettings, sessionToken));
878
894
  return null;
879
895
  }
880
896
 
@@ -958,7 +974,6 @@ function attachHidInProcess(
958
974
  export function previewConfigForState(
959
975
  state: ServeSimState,
960
976
  base: string,
961
- serveSimBin: string,
962
977
  execToken: string,
963
978
  streamSettingsOrCodec?: StreamSettings | string,
964
979
  proxyHelpers = false,
@@ -973,7 +988,6 @@ export function previewConfigForState(
973
988
  cameraStatusEndpoint: string;
974
989
  devtoolsEndpoint: string;
975
990
  streamSettingsEndpoint: string;
976
- serveSimBin: string;
977
991
  gridApiEndpoint: string;
978
992
  gridCatalogEndpoint: string;
979
993
  gridStatusEndpoint: string;
@@ -995,8 +1009,11 @@ export function previewConfigForState(
995
1009
  const streamSettings = typeof streamSettingsOrCodec === "object"
996
1010
  ? streamSettingsOrCodec
997
1011
  : httpStreamSettingsFromLegacyCodec(legacyCodec);
1012
+ // Every serve-sim's state file is readable here and ?device= is caller-supplied, so neither the
1013
+ // token nor the TURN credentials may ride along: that hands one instance's secrets to another.
1014
+ const { token: _sessionToken, streamSettings: _foreignStreamSettings, ...publicState } = state;
998
1015
  return {
999
- ...state,
1016
+ ...publicState,
1000
1017
  basePath: base,
1001
1018
  logsEndpoint: endpoint(base, "/logs", state.device),
1002
1019
  appStateEndpoint: endpoint(base, "/appstate", state.device),
@@ -1007,7 +1024,6 @@ export function previewConfigForState(
1007
1024
  cameraStatusEndpoint: `${base === "/" ? "" : base}/helper/${encodeURIComponent(state.device)}/camera/status`,
1008
1025
  devtoolsEndpoint: endpoint(base, "/devtools", state.device),
1009
1026
  streamSettingsEndpoint: streamSettingsEndpointFrom(state.streamUrl),
1010
- serveSimBin,
1011
1027
  gridApiEndpoint: gridApiBase,
1012
1028
  gridCatalogEndpoint: gridApiBase + "/catalog",
1013
1029
  gridStatusEndpoint: gridApiBase + "/status",
@@ -1475,6 +1491,8 @@ export interface SimMiddlewareOptions {
1475
1491
  * cross-origin pages cannot read it.
1476
1492
  */
1477
1493
  execToken?: string;
1494
+ /** Off by default: a loopback-only server is already reachable to whoever is on the machine. */
1495
+ requirePreviewToken?: boolean;
1478
1496
  /** Stream transport and codec settings for the preview. */
1479
1497
  streamSettings?: StreamSettings;
1480
1498
  /**
@@ -1507,20 +1525,6 @@ function httpStreamSettingsFromLegacyCodec(codec: string | undefined): StreamSet
1507
1525
  return undefined;
1508
1526
  }
1509
1527
 
1510
- function safeEqualString(a: string, b: string): boolean {
1511
- const ab = Buffer.from(a);
1512
- const bb = Buffer.from(b);
1513
- if (ab.length !== bb.length) return false;
1514
- return timingSafeEqual(ab, bb);
1515
- }
1516
-
1517
- function isJsonContentType(value: string | undefined): boolean {
1518
- if (!value) return false;
1519
- // `application/json; charset=utf-8` etc. — only the media type matters.
1520
- const mediaType = value.split(";", 1)[0]!.trim().toLowerCase();
1521
- return mediaType === "application/json";
1522
- }
1523
-
1524
1528
  /**
1525
1529
  * Connect-style middleware that serves the simulator preview UI.
1526
1530
  *
@@ -1580,23 +1584,35 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1580
1584
  // can call /exec; cross-origin pages and LAN clients cannot, because they
1581
1585
  // can't read this value (it's only injected into the preview page's config).
1582
1586
  const execToken = options?.execToken ?? randomBytes(32).toString("base64url");
1587
+ const requirePreviewToken = options?.requirePreviewToken ?? false;
1583
1588
  const metricsCorsOrigins = options?.metricsCorsOrigins ?? [];
1584
1589
 
1585
1590
  // Simulator-settings requests run in-process (just the underlying simctl /
1586
1591
  // ax-tool spawn) instead of round-tripping a full `node <cli>` exec per
1587
1592
  // sidebar interaction.
1593
+ /** Validation messages are the caller's own input. A child's failure carries host paths. */
1594
+ const sanitizeUiFailure = async <T,>(work: Promise<T>): Promise<T> => {
1595
+ try {
1596
+ return await work;
1597
+ } catch (err) {
1598
+ console.error("serve-sim ui request failed:", err);
1599
+ throw new Error("the simulator rejected this UI request");
1600
+ }
1601
+ };
1602
+
1588
1603
  const handleUiRequest: UiRequestHandler = async (payload) => {
1589
1604
  const p = (payload ?? {}) as { device?: string; option?: string; value?: string };
1590
- if (typeof p.device !== "string" || !/^[0-9A-Za-z-]+$/.test(p.device)) {
1605
+ // Must start alphanumeric and stay bounded: a leading "-" is parsed as a flag by simctl.
1606
+ if (typeof p.device !== "string" || !/^[0-9A-Za-z][0-9A-Za-z-]{0,255}$/.test(p.device)) {
1591
1607
  throw new Error("missing or invalid device udid");
1592
1608
  }
1593
1609
  if (p.option === undefined) {
1594
- return { status: await getUiStatus(p.device) };
1610
+ return { status: await sanitizeUiFailure(getUiStatus(p.device)) };
1595
1611
  }
1596
- if (!UI_OPTIONS[p.option]) throw new Error(`unknown option: ${p.option}`);
1612
+ if (!Object.hasOwn(UI_OPTIONS, p.option)) throw new Error(`unknown option: ${p.option}`);
1597
1613
  const value = typeof p.value === "string" ? normalizeUiValue(p.option, p.value) : null;
1598
1614
  if (value === null) throw new Error(`invalid value for ${p.option}: ${p.value}`);
1599
- await setUiOption(p.device, p.option, value);
1615
+ await sanitizeUiFailure(setUiOption(p.device, p.option, value));
1600
1616
  try {
1601
1617
  recordEventLogEvent({
1602
1618
  device: p.device,
@@ -1612,6 +1628,8 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1612
1628
  }
1613
1629
  return { ok: true };
1614
1630
  };
1631
+ /** Reachable without the session token: liveness probes cannot carry one. */
1632
+ const UNGATED_PATHS = ["/healthz", "/readyz"];
1615
1633
 
1616
1634
  const connectMiddleware = (async (req: SimReq, res: SimRes, next?: SimNext) => {
1617
1635
  const rawUrl: string = req.url ?? "";
@@ -1621,6 +1639,14 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1621
1639
  const selectedDevice = requestedDevice ?? options?.device ?? null;
1622
1640
  const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
1623
1641
 
1642
+ // Gated as a whole rather than per route, so a new route is protected by default.
1643
+ if (
1644
+ !UNGATED_PATHS.some((path) => url === base + path)
1645
+ && !assertPreviewAccess(req, res, execToken, { required: requirePreviewToken, basePath: base })
1646
+ ) {
1647
+ return;
1648
+ }
1649
+
1624
1650
  const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
1625
1651
  if (helperTarget) {
1626
1652
  const device = helperTarget.device ?? selectedDevice;
@@ -1647,8 +1673,12 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1647
1673
  return;
1648
1674
  }
1649
1675
  try {
1676
+ // The gate accepts `?token=` here, so forward the rest of the query but never that.
1677
+ const upstreamQuery = new URLSearchParams(qIndex === -1 ? "" : rawUrl.slice(qIndex + 1));
1678
+ upstreamQuery.delete("token");
1679
+ const upstreamSuffix = upstreamQuery.size === 0 ? "" : `?${upstreamQuery.toString()}`;
1650
1680
  const upstream = await fetch(
1651
- `https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${qIndex === -1 ? "" : rawUrl.slice(qIndex)}`,
1681
+ `https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${upstreamSuffix}`,
1652
1682
  );
1653
1683
  const headers: Record<string, string> = {
1654
1684
  "Cache-Control": "public, max-age=604800",
@@ -1683,7 +1713,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1683
1713
 
1684
1714
  if (state) {
1685
1715
  const remoteState = rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers);
1686
- const config = JSON.stringify(previewConfigForState(remoteState, base, serveSimBinPath(), execToken, streamSettings, proxyHelpers));
1716
+ const config = JSON.stringify(previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers));
1687
1717
  const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
1688
1718
  html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
1689
1719
  }
@@ -1828,7 +1858,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1828
1858
  watcherRetry = null;
1829
1859
  if (closed || res.writableEnded || watcher) return;
1830
1860
  try {
1831
- watcher = watch(STATE_DIR, onFsEvent);
1861
+ watcher = watch(stateDir(), onFsEvent);
1832
1862
  watcher.on("error", () => {
1833
1863
  watcher?.close();
1834
1864
  watcher = null;
@@ -1933,7 +1963,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1933
1963
  return;
1934
1964
  }
1935
1965
  const port = req.socket.localPort ?? 0;
1936
- void startDeviceInProcess(udid, port, base, streamSettings).then((error) => {
1966
+ void startDeviceInProcess(
1967
+ udid,
1968
+ port,
1969
+ base,
1970
+ streamSettings,
1971
+ requirePreviewToken ? execToken : undefined,
1972
+ ).then((error) => {
1937
1973
  if (res.writableEnded) return;
1938
1974
  if (error) {
1939
1975
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -2112,7 +2148,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2112
2148
  "Cache-Control": "no-store",
2113
2149
  });
2114
2150
  const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
2115
- res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken, streamSettings, proxyHelpers) : null));
2151
+ res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers) : null));
2116
2152
  return;
2117
2153
  }
2118
2154
 
@@ -2248,7 +2284,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2248
2284
  const state = selectServeSimState(states, selectedDevice);
2249
2285
  const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
2250
2286
  return JSON.stringify(
2251
- remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken, streamSettings, proxyHelpers) : null,
2287
+ remoteState ? previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers) : null,
2252
2288
  );
2253
2289
  };
2254
2290
 
@@ -2292,7 +2328,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2292
2328
  watcherRetry = null;
2293
2329
  if (closed || res.writableEnded || watcher) return;
2294
2330
  try {
2295
- watcher = watch(STATE_DIR, onFsEvent);
2331
+ watcher = watch(stateDir(), onFsEvent);
2296
2332
  watcher.on("error", () => {
2297
2333
  watcher?.close();
2298
2334
  watcher = null;
@@ -2403,84 +2439,6 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2403
2439
  return;
2404
2440
  }
2405
2441
 
2406
- // POST /exec — run a shell command on the host. Gated by a per-process
2407
- // bearer token injected only into the same-origin preview HTML, with
2408
- // Content-Type + Origin checks to block CORS-simple CSRF (a malicious
2409
- // page POSTing `text/plain` JSON to a dev server bound to a public iface)
2410
- // and LAN attackers who can reach the port but can't read the token.
2411
- if ((url === base + "/exec" || url === base + "/exec/") && req.method === "POST") {
2412
- // 1. Reject anything that isn't a JSON request, killing the
2413
- // `enctype="text/plain"` CORS-simple form-POST path.
2414
- if (!isJsonContentType(req.headers["content-type"])) {
2415
- res.writeHead(415, { "Content-Type": "application/json" });
2416
- res.end(JSON.stringify({ stdout: "", stderr: "Unsupported Media Type", exitCode: 1 }));
2417
- return;
2418
- }
2419
- // 2. If the browser supplied an Origin, require it match this server.
2420
- // Same-origin XHR from the preview page sets Origin to our own URL;
2421
- // a cross-origin page's Origin won't match.
2422
- const origin = req.headers.origin;
2423
- if (origin) {
2424
- try {
2425
- const originHost = new URL(origin).host;
2426
- if (originHost !== req.headers.host) {
2427
- res.writeHead(403, { "Content-Type": "application/json" });
2428
- res.end(JSON.stringify({ stdout: "", stderr: "Cross-origin request blocked", exitCode: 1 }));
2429
- return;
2430
- }
2431
- } catch {
2432
- res.writeHead(403, { "Content-Type": "application/json" });
2433
- res.end(JSON.stringify({ stdout: "", stderr: "Invalid Origin", exitCode: 1 }));
2434
- return;
2435
- }
2436
- }
2437
- // 3. Require the per-session bearer token. Cross-origin pages cannot
2438
- // read it from window.__SIM_PREVIEW__; non-browser callers must
2439
- // have copied it from the CLI output.
2440
- const authHeader = req.headers.authorization ?? "";
2441
- const match = /^Bearer\s+(.+)$/i.exec(authHeader);
2442
- if (!match || !safeEqualString(match[1]!.trim(), execToken)) {
2443
- res.writeHead(401, { "Content-Type": "application/json" });
2444
- res.end(JSON.stringify({ stdout: "", stderr: "Unauthorized", exitCode: 1 }));
2445
- return;
2446
- }
2447
- let body = "";
2448
- let aborted = false;
2449
- req.on("data", (chunk: Buffer | string) => {
2450
- body += typeof chunk === "string" ? chunk : chunk.toString();
2451
- // Cheap belt-and-braces cap so a runaway POST can't OOM the dev server.
2452
- if (body.length > 4 * 1024 * 1024) {
2453
- aborted = true;
2454
- res.writeHead(413, { "Content-Type": "application/json" });
2455
- res.end(JSON.stringify({ stdout: "", stderr: "Payload Too Large", exitCode: 1 }));
2456
- req.destroy();
2457
- }
2458
- });
2459
- req.on("end", () => {
2460
- if (aborted) return;
2461
- let command = "";
2462
- try {
2463
- command = (JSON.parse(body) as ExecRequestBody).command ?? "";
2464
- } catch {}
2465
- if (!command) {
2466
- res.writeHead(400, { "Content-Type": "application/json" });
2467
- res.end(JSON.stringify({ stdout: "", stderr: "Missing command", exitCode: 1 }));
2468
- return;
2469
- }
2470
- exec(command, { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
2471
- const exitCode = err ? (err as ExecException).code ?? 1 : 0;
2472
- recordCommandEvent(command, { exitCode });
2473
- res.writeHead(200, { "Content-Type": "application/json" });
2474
- res.end(JSON.stringify({
2475
- stdout: stdout.toString(),
2476
- stderr: stderr.toString(),
2477
- exitCode,
2478
- }));
2479
- });
2480
- });
2481
- return;
2482
- }
2483
-
2484
2442
  // SSE: foreground-app change stream. Emits `{bundleId, pid}` events
2485
2443
  // parsed from SpringBoard's "Setting process visibility to: Foreground"
2486
2444
  // log line. Filtering is done here (not in the browser) so the SSE stream
@@ -2531,6 +2489,24 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2531
2489
  if (next) return next();
2532
2490
  }) as ConnectMiddleware;
2533
2491
  connectMiddleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {
2492
+ // Upgrades skip the HTTP request path, and the HID and devtools sockets carry no token of
2493
+ // their own, so gate them here too.
2494
+ if (
2495
+ !assertUpgradeAccess(
2496
+ {
2497
+ authorization: req.headers.authorization,
2498
+ cookie: req.headers.cookie,
2499
+ origin: req.headers.origin,
2500
+ host: req.headers.host,
2501
+ "sec-fetch-site": req.headers["sec-fetch-site"],
2502
+ },
2503
+ execToken,
2504
+ { required: requirePreviewToken },
2505
+ )
2506
+ ) {
2507
+ socket.destroy();
2508
+ return;
2509
+ }
2534
2510
  const rawUrl = req.url ?? "";
2535
2511
  const selectedDevice = queryDevice(rawUrl) ?? options?.device ?? null;
2536
2512
  const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
@@ -2560,12 +2536,8 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2560
2536
  }
2561
2537
  socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
2562
2538
  };
2563
- // WebSocket exec channel same auth/origin policy as POST /exec, but off
2564
- // the browser's per-origin HTTP connection pool so multiple preview tabs
2565
- // (each holding MJPEG + SSE streams) can't starve exec actions. Servers
2566
- // mounting this middleware should forward `upgrade` events here (the
2567
- // built-in preview server does); the client falls back to POST /exec when
2568
- // the upgrade never completes.
2539
+ // Off the browser's per-origin connection pool, so preview tabs holding MJPEG and SSE streams
2540
+ // can't starve actions. Hosts mounting this middleware forward `upgrade` events here.
2569
2541
  const fetchMiddleware = (async (request: Request) => {
2570
2542
  return connectToFetch(connectMiddleware, request);
2571
2543
  }) as SimMiddleware;
@@ -2583,16 +2555,36 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2583
2555
  `${base}/ax`,
2584
2556
  ],
2585
2557
  onUiRequest: handleUiRequest,
2586
- onCommandResult: (command, result) => recordCommandEvent(command, result),
2558
+ serveSimBinPath: serveSimBinPath(),
2559
+ onActionResult: (action, params, result) => recordActionEvent(action, params, result),
2587
2560
  onSseRequest(path, websocketRequest) {
2588
2561
  const url = new URL(path, websocketRequest.url);
2562
+ // The exec channel already authenticated, so its fan-out carries the token past the gate.
2589
2563
  return fetchMiddleware(new Request(url, {
2590
- headers: { accept: "text/event-stream" },
2564
+ headers: { accept: "text/event-stream", authorization: `Bearer ${execToken}` },
2591
2565
  }));
2592
2566
  },
2593
2567
  });
2594
2568
 
2595
2569
  fetchMiddleware.handleWebSocket = (request: Request, websocket: UpgradeHandlerWebSocket): boolean => {
2570
+ // Embedded hosts forward accepted sockets and bypass the request gate. The exec channel
2571
+ // re-checks the token in its first frame; the helper HID socket does not.
2572
+ if (
2573
+ !assertUpgradeAccess(
2574
+ {
2575
+ authorization: request.headers.get("authorization") ?? undefined,
2576
+ cookie: request.headers.get("cookie") ?? undefined,
2577
+ origin: request.headers.get("origin") ?? undefined,
2578
+ host: request.headers.get("host") ?? undefined,
2579
+ "sec-fetch-site": request.headers.get("sec-fetch-site") ?? undefined,
2580
+ },
2581
+ execToken,
2582
+ { required: requirePreviewToken },
2583
+ )
2584
+ ) {
2585
+ websocket.close();
2586
+ return true;
2587
+ }
2596
2588
  if (execWebSocketHandler(request, websocket)) return true;
2597
2589
  if (claimHelperHidSocket(request, websocket, {
2598
2590
  helperProxyTarget: (rawUrl) => helperProxyTarget(rawUrl, helperPrefix),
package/src/native.ts CHANGED
@@ -66,6 +66,7 @@ interface NativeAddon {
66
66
  ) => SimCaptureHandle;
67
67
  axDescribe(udid: string): Promise<string>;
68
68
  axFrontmost(udid: string): Promise<string>;
69
+ setHardwareKeyboard(udid: string, enabled: boolean): Promise<boolean>;
69
70
  }
70
71
 
71
72
  // (codec, data, width, height, flags) — codec 0=MJPEG 1=AVCC; flags bit0=desc bit1=keyframe.
@@ -303,3 +304,12 @@ export function axDescribeAsync(udid: string): Promise<string> {
303
304
  export function axFrontmostAsync(udid: string): Promise<string> {
304
305
  return load().axFrontmost(udid);
305
306
  }
307
+
308
+ /**
309
+ * Connect/disconnect the device's hardware keyboard (⌘⇧K). Disconnecting makes
310
+ * the guest show its on-screen keyboard. Per-device; resolves true when the
311
+ * CoreSimulator call succeeds (not a read-back of the resulting state).
312
+ */
313
+ export function setHardwareKeyboard(udid: string, enabled: boolean): Promise<boolean> {
314
+ return load().setHardwareKeyboard(udid, enabled);
315
+ }