serve-sim 0.1.49-beta.141.1 → 0.1.49

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": "serve-sim",
3
- "version": "0.1.49-beta.141.1",
3
+ "version": "0.1.49",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { simctlArguments } from "./simctl";
3
4
 
4
5
  const exec = promisify(execFile);
5
6
  type Run = (args: string[]) => Promise<string>;
@@ -13,7 +14,7 @@ export function isDeviceHubInputShadowed(output: string): boolean {
13
14
  /** Warns when shadowed. Resolves the guest flag, or undefined when the runtime does not publish it. */
14
15
  export async function warnDeviceHubInput(udid: string, invoke: Run = run): Promise<boolean | undefined> {
15
16
  try {
16
- const shadowed = isDeviceHubInputShadowed(await invoke(["simctl", "spawn", udid, "notifyutil", "-g", key]));
17
+ const shadowed = isDeviceHubInputShadowed(await invoke(simctlArguments(["spawn", udid, "notifyutil", "-g", key])));
17
18
  if (shadowed) {
18
19
  console.warn(`[hid] Device Hub has disconnected legacy input. Run serve-sim repair-input -d ${udid} before using touch or keyboard. Repair restarts SpringBoard and closes running apps.`);
19
20
  }
@@ -24,7 +25,7 @@ export async function warnDeviceHubInput(udid: string, invoke: Run = run): Promi
24
25
  /** Raw guest flag for diagnostics: true/false, or null when unreadable. */
25
26
  export async function readDeviceHubInputShadowed(udid: string, invoke: Run = run): Promise<boolean | null> {
26
27
  try {
27
- return isDeviceHubInputShadowed(await invoke(["simctl", "spawn", udid, "notifyutil", "-g", key]));
28
+ return isDeviceHubInputShadowed(await invoke(simctlArguments(["spawn", udid, "notifyutil", "-g", key])));
28
29
  } catch {
29
30
  return null;
30
31
  }
@@ -32,7 +33,7 @@ export async function readDeviceHubInputShadowed(udid: string, invoke: Run = run
32
33
 
33
34
  /** Explicit recovery only: restarting backboardd also terminates running apps. */
34
35
  export async function repairDeviceHubInput(udid: string, invoke: Run = run): Promise<boolean> {
35
- const args = ["simctl", "spawn", udid];
36
+ const args = simctlArguments(["spawn", udid]);
36
37
  if (!isDeviceHubInputShadowed(await invoke([...args, "notifyutil", "-g", key]))) return false;
37
38
  // Order is essential: the replacement backboardd must start with state zero.
38
39
  await invoke([...args, "notifyutil", "-s", key, "0"]);
package/src/device.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { execSync } from "child_process";
1
+ import { execFileSync } from "child_process";
2
+ import { simctlArguments } from "./simctl";
2
3
 
3
4
  /**
4
5
  * UDID of a booted simulator, or null if none is booted. Prefers an iOS device
@@ -7,7 +8,7 @@ import { execSync } from "child_process";
7
8
  */
8
9
  export function findBootedDevice(): string | null {
9
10
  try {
10
- const output = execSync("xcrun simctl list devices booted -j", { encoding: "utf-8" });
11
+ const output = execFileSync("xcrun", simctlArguments(["list", "devices", "booted", "-j"]), { encoding: "utf-8" });
11
12
  const data = JSON.parse(output) as {
12
13
  devices: Record<string, Array<{ udid: string; name: string; state: string }>>;
13
14
  };
@@ -24,6 +25,17 @@ export function findBootedDevice(): string | null {
24
25
  return null;
25
26
  }
26
27
 
28
+ /** Whether the selected device set lists `udid`, in any case, as simctl reads it. */
29
+ export function isInDeviceSet(udid: string): boolean {
30
+ try {
31
+ const output = execFileSync("xcrun", simctlArguments(["list", "devices", "-j"]), { encoding: "utf-8" });
32
+ const data = JSON.parse(output) as { devices: Record<string, Array<{ udid: string }>> };
33
+ return Object.values(data.devices).some((devices) => devices.some((device) => device.udid.toUpperCase() === udid.toUpperCase()));
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
27
39
  /**
28
40
  * Resolve a device name or UDID to a UDID. A UDID is returned as-is; a name is
29
41
  * matched case-insensitively against `simctl list devices`. Exits the process
@@ -34,7 +46,7 @@ export function resolveDevice(nameOrUDID: string): string {
34
46
  return nameOrUDID;
35
47
  }
36
48
  try {
37
- const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" });
49
+ const output = execFileSync("xcrun", simctlArguments(["list", "devices", "-j"]), { encoding: "utf-8" });
38
50
  const data = JSON.parse(output) as {
39
51
  devices: Record<string, Array<{ udid: string; name: string; state: string }>>;
40
52
  };
package/src/doctor.ts CHANGED
@@ -10,6 +10,7 @@ import { homedir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { duoModelPath, resolveDuoModel, resolveDuoRendererExecutable } from "./duo-renderer";
13
+ import { simctlArguments } from "./simctl";
13
14
  import { readDeviceHubInputShadowed } from "./device-hub-input";
14
15
  import { redactHome } from "./diagnostics-redact";
15
16
 
@@ -144,7 +145,7 @@ export async function collectHostDiagnostics(options: HostDiagnosticsOptions = {
144
145
 
145
146
  /** Simulator facts for one UDID from `simctl list -j`, plus the guest Device Hub input flag. */
146
147
  export async function collectDeviceDiagnostics(udid: string, probe: Probe = defaultProbe): Promise<Record<string, unknown>> {
147
- const listing = await probe("/usr/bin/xcrun", ["simctl", "list", "devices", "-j"]);
148
+ const listing = await probe("/usr/bin/xcrun", simctlArguments(["list", "devices", "-j"]));
148
149
  let device: Record<string, unknown> | null = null;
149
150
  try {
150
151
  const parsed = JSON.parse(listing ?? "{}") as { devices?: Record<string, Array<Record<string, unknown>>> };
package/src/duo-state.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile, spawn, type ChildProcess } from "node:child_process";
2
2
  import type { SimulatorOrientation } from "./client/types";
3
+ import { simctlArguments } from "./simctl";
3
4
 
4
5
  export function parseHingeSample(line: string): number | null {
5
6
  if (!/AngleValid:Y\b/.test(line)) return null;
@@ -60,7 +61,7 @@ export class DuoStateMonitor {
60
61
  refreshOrientation(): void {
61
62
  if (this.stopped || this.enumeration) return;
62
63
  if (this.poll) clearTimeout(this.poll);
63
- this.enumeration = execFile("/usr/bin/xcrun", ["simctl", "io", this.udid, "enumerate"],
64
+ this.enumeration = execFile("/usr/bin/xcrun", simctlArguments(["io", this.udid, "enumerate"]),
64
65
  { timeout: 3000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
65
66
  this.enumeration = undefined;
66
67
  if (this.stopped) return;
@@ -88,7 +89,7 @@ export class DuoStateMonitor {
88
89
  this.onState(this.state);
89
90
  this.refreshOrientation();
90
91
  };
91
- const child = spawn("/usr/bin/xcrun", ["simctl", "spawn", this.udid, "log", "stream", "--style", "compact", "--predicate", predicate], { stdio: ["ignore", "pipe", "ignore"] });
92
+ const child = spawn("/usr/bin/xcrun", simctlArguments(["spawn", this.udid, "log", "stream", "--style", "compact", "--predicate", predicate]), { stdio: ["ignore", "pipe", "ignore"] });
92
93
  this.panelMonitor = child;
93
94
  let pending = "";
94
95
  child.stdout.setEncoding("utf8");
@@ -102,7 +103,7 @@ export class DuoStateMonitor {
102
103
  }
103
104
  });
104
105
  // Seed from this boot's most recent transition, then let live events win.
105
- this.panelSeed = execFile("/usr/bin/xcrun", ["simctl", "spawn", this.udid, "log", "show", "--last", "boot", "--style", "compact", "--predicate", predicate],
106
+ this.panelSeed = execFile("/usr/bin/xcrun", simctlArguments(["spawn", this.udid, "log", "show", "--last", "boot", "--style", "compact", "--predicate", predicate]),
106
107
  { timeout: 10000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
107
108
  this.panelSeed = undefined;
108
109
  if (!error && !liveSample) {
package/src/event-log.ts CHANGED
@@ -627,9 +627,11 @@ function isUploadPlumbing(tokens: string[]): boolean {
627
627
  function simctlCommand(tokens: string[]): { verb: string; args: string[] } | null {
628
628
  const i = tokens.findIndex((token) => token === "simctl");
629
629
  if (i < 0 || tokens[i - 1] !== "xcrun") return null;
630
- const verb = tokens[i + 1];
630
+ // `simctl --set <path> <verb>` (a `--device-set`) names its verb after the set.
631
+ const v = tokens[i + 1] === "--set" ? i + 3 : i + 1;
632
+ const verb = tokens[v];
631
633
  if (!verb) return null;
632
- return { verb, args: tokens.slice(i + 2) };
634
+ return { verb, args: tokens.slice(v + 1) };
633
635
  }
634
636
 
635
637
  function serveSimCommand(tokens: string[]): { verb: string; args: string[] } | null {
@@ -16,6 +16,7 @@ import { readFileSync, unlinkSync } from "fs";
16
16
  import { sleepSync } from "./runtime";
17
17
  import { getPortHolders } from "./ports";
18
18
  import { debugHelper, debugState } from "./debug";
19
+ import { sameDeviceSet } from "./simctl";
19
20
  import type { ServeSimDeviceState } from "./state";
20
21
  import { writeServeSimState } from "./state";
21
22
 
@@ -205,10 +206,14 @@ export type StaleStateAction = "keep" | "recycle-self" | "recycle-helper";
205
206
  * `recycle-*` as a reason to drop the pidfile — the process is still alive.
206
207
  */
207
208
  export function classifyStaleState(
208
- state: { pid: number; device: string },
209
+ state: { pid: number; device: string; deviceSet?: string },
209
210
  booted: Set<string> | null,
210
211
  selfPid: number,
212
+ deviceSet: string | undefined,
211
213
  ): StaleStateAction {
214
+ // `booted` lists only this process's device set; another set's simulator
215
+ // is not in it whether or not it is booted.
216
+ if (!sameDeviceSet(state.deviceSet, deviceSet)) return "keep";
212
217
  if (booted && isSimulatorUdid(state.device) && !booted.has(state.device)) {
213
218
  return state.pid === selfPid ? "recycle-self" : "recycle-helper";
214
219
  }
package/src/host-rpc.ts CHANGED
@@ -14,6 +14,7 @@ import { homedir } from "os";
14
14
  import { basename, dirname, join } from "path";
15
15
  import { PERMISSION_SERVICES } from "./client/utils/permissions";
16
16
  import { shellJoin } from "./shell-quote";
17
+ import { simctlArguments } from "./simctl";
17
18
  import { getUiOption, setUiOption } from "./ui-settings";
18
19
 
19
20
  // Narrow host actions for the preview page, carried as `{id, rpc}` frames on
@@ -193,7 +194,7 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
193
194
  };
194
195
 
195
196
  const appContainer = async (udid: string, bundleId: string): Promise<string> => {
196
- const r = await run("xcrun", ["simctl", "get_app_container", udid, bundleId, "app"]);
197
+ const r = await run("xcrun", simctlArguments(["get_app_container", udid, bundleId, "app"]));
197
198
  if (r.exitCode !== 0) fail(r.stderr.trim() || "App not found on simulator");
198
199
  const appPath = r.stdout.trim();
199
200
  if (!appPath) fail("Empty app path");
@@ -216,7 +217,7 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
216
217
  const watch = p.watch === true;
217
218
  await requireDevice(udid);
218
219
  if (watch) return { ...(await run("osascript", WATCH_HOME_SCRIPT.flatMap((line) => ["-e", line]))) };
219
- return { ...(await run("xcrun", ["simctl", "launch", udid, "com.apple.springboard"])) };
220
+ return { ...(await run("xcrun", simctlArguments(["launch", udid, "com.apple.springboard"]))) };
220
221
  },
221
222
 
222
223
  async screenshot(p) {
@@ -224,11 +225,11 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
224
225
  const display = p.display === undefined ? undefined : enumParam(p, "display", DISPLAYS);
225
226
  await requireDevice(udid);
226
227
  const path = join(desktop(), `serve-sim-screenshot-${timestampSlug()}.png`);
227
- const r = await run("xcrun", [
228
- "simctl", "io", udid, "screenshot",
228
+ const r = await run("xcrun", simctlArguments([
229
+ "io", udid, "screenshot",
229
230
  ...(display ? [`--display=${display}`] : []),
230
231
  path,
231
- ]);
232
+ ]));
232
233
  return { ...r, path: r.exitCode === 0 ? path : "" };
233
234
  },
234
235
 
@@ -421,7 +422,7 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
421
422
  path = screenshotPathParam(p);
422
423
  }
423
424
  await requireDevice(udid);
424
- return { ...(await run("xcrun", ["simctl", "addmedia", udid, path])) };
425
+ return { ...(await run("xcrun", simctlArguments(["addmedia", udid, path]))) };
425
426
  },
426
427
 
427
428
  async installApp(p) {
@@ -430,7 +431,7 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
430
431
  const path = uploadPathParam(p, "path", ["install"]);
431
432
  if (!path.endsWith(".ipa")) fail("invalid path");
432
433
  await requireDevice(udid);
433
- return { ...(await run("xcrun", ["simctl", "install", udid, path])) };
434
+ return { ...(await run("xcrun", simctlArguments(["install", udid, path]))) };
434
435
  },
435
436
 
436
437
  // Set (lat/lng) or clear the simulated location.
@@ -439,7 +440,7 @@ export function createHostRpcHandler(opts: HostRpcOptions): HostRpcHandler {
439
440
  const clear = p.lat === undefined && p.lng === undefined;
440
441
  const point = clear ? null : `${coordinate(p, "lat", 90).toFixed(7)},${coordinate(p, "lng", 180).toFixed(7)}`;
441
442
  await requireDevice(udid);
442
- return { ...(await run("xcrun", ["simctl", "location", udid, ...(point ? ["set", point] : ["clear"])])) };
443
+ return { ...(await run("xcrun", simctlArguments(["location", udid, ...(point ? ["set", point] : ["clear"])]))) };
443
444
  },
444
445
  };
445
446
 
package/src/middleware.ts CHANGED
@@ -51,7 +51,7 @@ import { createExecUpgradeHandler, EXEC_DISABLED_MESSAGE, type UiRequestHandler
51
51
  import { createHostRpcHandler } from "./host-rpc";
52
52
  import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-settings";
53
53
  import type { PreviewInitialState } from "./preview-initial-state";
54
- import { simctlBootStatusArguments } from "./simctl";
54
+ import { resolveDeviceSet, sameDeviceSet, simctlArguments, simctlBootStatusArguments } from "./simctl";
55
55
  import { authBootstrapPage } from "./auth-bootstrap";
56
56
  import {
57
57
  AUTH_COOKIE,
@@ -222,7 +222,7 @@ export function parseForegroundAppLogMessage(message: string): { bundleId: strin
222
222
  function detectReactNative(udid: string, bundleId: string): Promise<boolean> {
223
223
  if (RN_BUNDLE_IDS.has(bundleId)) return Promise.resolve(true);
224
224
  return new Promise((resolve) => {
225
- execFile("xcrun", ["simctl", "get_app_container", udid, bundleId, "app"],
225
+ execFile("xcrun", simctlArguments(["get_app_container", udid, bundleId, "app"]),
226
226
  { timeout: 2000 },
227
227
  (err, stdout) => {
228
228
  if (err) return resolve(false);
@@ -280,7 +280,7 @@ async function getBootedUdids(): Promise<Set<string> | null> {
280
280
  const stdout = await new Promise<string>((resolve, reject) => {
281
281
  execFile(
282
282
  "xcrun",
283
- ["simctl", "list", "devices", "booted", "-j"],
283
+ simctlArguments(["list", "devices", "booted", "-j"]),
284
284
  { encoding: "utf-8", timeout: 3_000 },
285
285
  (err, stdout) => {
286
286
  if (err) {
@@ -338,6 +338,7 @@ export async function readServeSimStates(): Promise<ServeSimState[]> {
338
338
  return [];
339
339
  }
340
340
  const booted = await getBootedUdids();
341
+ const deviceSet = resolveDeviceSet();
341
342
  const states: ServeSimState[] = [];
342
343
  for (const f of files) {
343
344
  const path = join(STATE_DIR, f);
@@ -350,11 +351,14 @@ export async function readServeSimStates(): Promise<ServeSimState[]> {
350
351
  continue;
351
352
  }
352
353
  if (state.pid !== recorded.pid) persistRecoveredState(state);
354
+ // Previews in every device set share this folder; another set's
355
+ // simulator is not one this preview can stream.
356
+ if (!sameDeviceSet(state.deviceSet, deviceSet)) continue;
353
357
  // Helper alive but its simulator was shut down — the MJPEG stream
354
358
  // would accept connections yet never produce frames, leaving the
355
359
  // preview stuck on "Connecting...". Recycle the stale state so the
356
360
  // caller can spawn a fresh helper bound to whatever is booted.
357
- const action = classifyStaleState(state, booted, process.pid);
361
+ const action = classifyStaleState(state, booted, process.pid, deviceSet);
358
362
  if (action !== "keep") {
359
363
  if (action === "recycle-self") {
360
364
  // This device is streamed in-process by *us* (the close button just
@@ -439,11 +443,12 @@ function endpoint(base: string, path: string, device: string): string {
439
443
  /**
440
444
  * A state record as it may be served over HTTP: drops the server-private
441
445
  * fields (the auth-token fingerprint and the exec opt-in recorded for
442
- * `--detach` reuse checks), which no client needs.
446
+ * `--detach` reuse checks, and the host path of the device set), which no
447
+ * client needs.
443
448
  */
444
449
  export function publicState(state: ServeSimState): ServeSimState {
445
- if (!("authTokenSha256" in state) && !("allowExec" in state)) return state;
446
- const { authTokenSha256: _fingerprint, allowExec: _allowExec, ...rest } = state;
450
+ if (!("authTokenSha256" in state) && !("allowExec" in state) && !("deviceSet" in state)) return state;
451
+ const { authTokenSha256: _fingerprint, allowExec: _allowExec, deviceSet: _deviceSet, ...rest } = state;
447
452
  return rest;
448
453
  }
449
454
 
@@ -887,7 +892,7 @@ export async function startDeviceInProcess(
887
892
  record: Pick<ServeSimDeviceState, "authTokenSha256" | "allowExec"> = {},
888
893
  ): Promise<string | null> {
889
894
  // `simctl boot` errors when already booted — ignore and let bootstatus confirm.
890
- await new Promise<void>((resolve) => execFile("xcrun", ["simctl", "boot", udid], () => resolve()));
895
+ await new Promise<void>((resolve) => execFile("xcrun", simctlArguments(["boot", udid]), () => resolve()));
891
896
  const ready = await new Promise<boolean>((resolve) => {
892
897
  // Boot was already requested above. Passing `-b` redundantly can remain
893
898
  // blocked on Xcode 27 even after `simctl list` reports Booted, so only
@@ -898,7 +903,7 @@ export async function startDeviceInProcess(
898
903
  // bootstatus can exit non-zero even when the device is actually ready;
899
904
  // confirm against the real state before reporting failure.
900
905
  const booted = await new Promise<boolean>((resolve) => {
901
- execFile("xcrun", ["simctl", "list", "devices", "-j"], (err, stdout) => {
906
+ execFile("xcrun", simctlArguments(["list", "devices", "-j"]), (err, stdout) => {
902
907
  if (err) return resolve(false);
903
908
  try {
904
909
  const data = JSON.parse(stdout) as { devices: Record<string, Array<{ udid: string; state: string }>> };
@@ -1244,7 +1249,7 @@ function listAllSimulators(): Promise<SimctlDevice[]> {
1244
1249
  return new Promise((resolve) => {
1245
1250
  execFile(
1246
1251
  "xcrun",
1247
- ["simctl", "list", "devices", "-j"],
1252
+ simctlArguments(["list", "devices", "-j"]),
1248
1253
  { encoding: "utf-8", timeout: 3_000 },
1249
1254
  (err, stdout) => {
1250
1255
  if (err) return resolve([]);
@@ -1502,6 +1507,9 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1502
1507
  const authToken = resolveAuthToken(options?.authToken);
1503
1508
  const serveDiagnostics = createDiagnosticsHandler(options?.diagnostics);
1504
1509
  const serveSimBin = options?.serveSimBin ?? resolveServeSimBin();
1510
+ // A dev server that embeds this middleware never runs the CLI: settle the
1511
+ // device set (and refuse a bad SERVE_SIM_DEVICE_SET) here, not mid-request.
1512
+ resolveDeviceSet();
1505
1513
  // Every path this middleware may answer. Standalone serve-sim mounts at "/"
1506
1514
  // (base ""), so it owns the whole origin; embedded mounts own only `base/…`.
1507
1515
  const ownsPath = (pathname: string) =>
@@ -1904,7 +1912,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1904
1912
  // Drop the snapshot so the next /grid/api call re-queries simctl
1905
1913
  // and prunes any helper bound to this now-shutdown device.
1906
1914
  bootedSnapshot = { at: 0, booted: null };
1907
- execFile("xcrun", ["simctl", "shutdown", udid], { timeout: 30_000 }, (err, _stdout, stderr) => {
1915
+ execFile("xcrun", simctlArguments(["shutdown", udid]), { timeout: 30_000 }, (err, _stdout, stderr) => {
1908
1916
  if (err) {
1909
1917
  res.writeHead(500, { "Content-Type": "application/json" });
1910
1918
  res.end(JSON.stringify({
@@ -2450,13 +2458,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2450
2458
  // AX bridge may be warming up — the log tail fills in once anything moves.
2451
2459
  }
2452
2460
 
2453
- const child: ChildProcess = spawn("xcrun", [
2454
- "simctl", "spawn", udid, "log", "stream",
2461
+ const child: ChildProcess = spawn("xcrun", simctlArguments([
2462
+ "spawn", udid, "log", "stream",
2455
2463
  "--style", "ndjson",
2456
2464
  "--level", "info",
2457
2465
  "--predicate",
2458
2466
  'process == "SpringBoard" AND eventMessage CONTAINS "Setting process visibility to: Foreground"',
2459
- ], { stdio: ["ignore", "pipe", "ignore"] });
2467
+ ]), { stdio: ["ignore", "pipe", "ignore"] });
2460
2468
 
2461
2469
  let closed = false;
2462
2470
  const emitApp = async (bundleId: string, pid?: number) => {
package/src/native.ts CHANGED
@@ -13,6 +13,7 @@ import { existsSync } from "fs";
13
13
  import { fileURLToPath } from "url";
14
14
  import { KeySequencer, type KeyType } from "./key-sequencer";
15
15
  import { maxBitrateFromEnv } from "./max-bitrate";
16
+ import { resolveDeviceSet } from "./simctl";
16
17
 
17
18
  export type { KeyType } from "./key-sequencer";
18
19
 
@@ -59,6 +60,7 @@ interface NativeAddon {
59
60
  SimCapture: new (udid: string, maxBitrate: number | null) => SimCaptureHandle;
60
61
  axDescribe(udid: string): Promise<string>;
61
62
  axFrontmost(udid: string): Promise<string>;
63
+ setDeviceSet(path: string | null): void;
62
64
  }
63
65
 
64
66
  // (data, width, height, flags) — codec 0=MJPEG 1=AVCC 2=size-only; flags bit0=desc bit1=keyframe.
@@ -164,7 +166,12 @@ function resolveAddon(): string {
164
166
 
165
167
  let addon: NativeAddon | undefined;
166
168
  function load(): NativeAddon {
167
- if (!addon) addon = require(resolveAddon()) as NativeAddon;
169
+ if (!addon) {
170
+ addon = require(resolveAddon()) as NativeAddon;
171
+ // The addon cannot read the set from the environment: under Bun a
172
+ // `process.env` write never reaches it.
173
+ addon.setDeviceSet(resolveDeviceSet() ?? null);
174
+ }
168
175
  return addon;
169
176
  }
170
177
 
package/src/simctl.ts CHANGED
@@ -1,3 +1,84 @@
1
+ import { realpathSync, statSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join, resolve } from "path";
4
+
5
+ // The CoreSimulator device set to use instead of Xcode's default, as
6
+ // `simctl --set` takes it. Tools that keep simulators in a set of their own
7
+ // (device pools, CI runners, Xcode Previews) put them where `simctl list` and
8
+ // Simulator.app never look. The variable is how the set reaches what the CLI
9
+ // starts (a --detach child, host-action subprocesses) and a dev server that
10
+ // embeds the middleware without the CLI.
11
+ const DEVICE_SET_ENV = "SERVE_SIM_DEVICE_SET";
12
+ const DEFAULT_DEVICE_SET = join(homedir(), "Library/Developer/CoreSimulator/Devices");
13
+
14
+ let resolved: { path: string | undefined } | undefined;
15
+
16
+ /**
17
+ * The device set this process targets: an absolute directory, or undefined
18
+ * for Xcode's default set. The first call settles it from `flag` (the CLI's
19
+ * `--device-set`), or else from SERVE_SIM_DEVICE_SET, and writes the result
20
+ * back to that variable for the processes serve-sim starts. Every later call
21
+ * returns the same value, so simctl, the native addon and the state file
22
+ * never disagree. Throws when the value is not an existing directory, or
23
+ * when a flag arrives after the set was settled.
24
+ */
25
+ export function resolveDeviceSet(flag?: string): string | undefined {
26
+ if (resolved !== undefined) {
27
+ if (flag !== undefined) throw new Error("--device-set arrived after the device set was settled");
28
+ return resolved.path;
29
+ }
30
+ const fromEnv = process.env[DEVICE_SET_ENV]?.trim();
31
+ let path: string | undefined;
32
+ if (flag !== undefined) path = existingDirectory(flag, "--device-set");
33
+ else if (fromEnv) path = existingDirectory(fromEnv, DEVICE_SET_ENV);
34
+ resolved = { path };
35
+ if (path === undefined) delete process.env[DEVICE_SET_ENV];
36
+ else process.env[DEVICE_SET_ENV] = path;
37
+ return path;
38
+ }
39
+
40
+ function existingDirectory(value: string, source: string): string {
41
+ const trimmed = value.trim();
42
+ const expanded = trimmed === "~" || trimmed.startsWith("~/") ? join(homedir(), trimmed.slice(1)) : trimmed;
43
+ const path = resolve(expanded);
44
+ let isDirectory = false;
45
+ try {
46
+ isDirectory = statSync(path).isDirectory();
47
+ } catch {}
48
+ if (trimmed === "" || !isDirectory) {
49
+ throw new Error(`${source}: invalid device set '${value}': expected an existing directory (a simctl --set path).`);
50
+ }
51
+ return path;
52
+ }
53
+
54
+ /**
55
+ * Whether two device-set paths (undefined: Xcode's default set) name one
56
+ * set, symlinks resolved, so `/tmp/set` and `/private/tmp/set` match, and
57
+ * so does Xcode's default folder named explicitly.
58
+ */
59
+ export function sameDeviceSet(a: string | undefined, b: string | undefined): boolean {
60
+ if (a === b) return true;
61
+ const real = (path: string) => {
62
+ try {
63
+ return realpathSync(path);
64
+ } catch {
65
+ return resolve(path);
66
+ }
67
+ };
68
+ return real(a ?? DEFAULT_DEVICE_SET) === real(b ?? DEFAULT_DEVICE_SET);
69
+ }
70
+
71
+ /** `xcrun` arguments that run `simctl <args>` against the device set. */
72
+ export function simctlArguments(args: readonly string[]): string[] {
73
+ const set = resolveDeviceSet();
74
+ return set === undefined ? ["simctl", ...args] : ["simctl", "--set", set, ...args];
75
+ }
76
+
77
+ /** The directory holding one folder per simulator UDID in the device set. */
78
+ export function simulatorDevicesDir(): string {
79
+ return resolveDeviceSet() ?? DEFAULT_DEVICE_SET;
80
+ }
81
+
1
82
  /**
2
83
  * Arguments for monitoring a boot that serve-sim has already requested.
3
84
  *
@@ -6,6 +87,6 @@
6
87
  * even though the device is already Booted. This form only monitors the boot
7
88
  * transition that serve-sim requested.
8
89
  */
9
- export function simctlBootStatusArguments(udid: string): [string, string, string] {
10
- return ["simctl", "bootstatus", udid];
90
+ export function simctlBootStatusArguments(udid: string): string[] {
91
+ return simctlArguments(["bootstatus", udid]);
11
92
  }
package/src/state.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { tmpdir } from "os";
2
2
  import { join } from "path";
3
+ import { resolveDeviceSet } from "./simctl";
3
4
  import {
4
5
  chmodSync,
5
6
  closeSync,
@@ -46,6 +47,12 @@ export interface ServeSimDeviceState {
46
47
  * opt-in differs from the one requested. Absent means off.
47
48
  */
48
49
  allowExec?: boolean;
50
+ /**
51
+ * The `--device-set` the server's simulator lives in. Absent means Xcode's
52
+ * default set. Previews in different sets share this state folder, and
53
+ * each can tell only whether its own set's simulators are booted.
54
+ */
55
+ deviceSet?: string;
49
56
  }
50
57
 
51
58
  /**
@@ -65,7 +72,9 @@ export function inProcessServeSimState(
65
72
  // leading slash (e.g. "foo") still yields well-formed `…:port/foo/helper/…`.
66
73
  const trimmed = base.replace(/^\/+/, "").replace(/\/+$/, "");
67
74
  const prefix = trimmed === "" ? "" : `/${trimmed}`;
75
+ const deviceSet = resolveDeviceSet();
68
76
  return {
77
+ ...(deviceSet !== undefined ? { deviceSet } : {}),
69
78
  pid: process.pid,
70
79
  port,
71
80
  device: udid,
@@ -3,6 +3,7 @@ import { existsSync } from "fs";
3
3
  import { join, resolve } from "path";
4
4
  import { findBootedDevice, resolveDevice } from "./device";
5
5
  import { dirnameOf } from "./runtime";
6
+ import { simctlArguments } from "./simctl";
6
7
 
7
8
  // Bun's bundler inlines a bare `__dirname` as the build machine's source
8
9
  // directory; shadow it with the runtime location so the published bundle
@@ -199,14 +200,14 @@ function run(file: string, args: string[]): Promise<string> {
199
200
  }
200
201
 
201
202
  function simctlUi(udid: string, subcommand: string, value?: string): Promise<string> {
202
- const args = ["simctl", "ui", udid, subcommand];
203
+ const args = simctlArguments(["ui", udid, subcommand]);
203
204
  if (value !== undefined) args.push(value);
204
205
  return run("xcrun", args);
205
206
  }
206
207
 
207
208
  async function axRun(udid: string, ...args: string[]): Promise<string> {
208
209
  const tool = await axSettingsTool();
209
- return run("xcrun", ["simctl", "spawn", udid, tool, ...args]);
210
+ return run("xcrun", simctlArguments(["spawn", udid, tool, ...args]));
210
211
  }
211
212
 
212
213
  function toToggle(simctlValue: string): string {