@mgcrea/mcp-ios-simulator 0.3.1 → 0.4.1

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/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { N as BUILD_INFO, a as createServer, n as loadConfig, t as defaultConfigPath } from "./config-D0JgmT_U.js";
2
+ import { N as BUILD_INFO, a as createServer, n as loadConfig, t as defaultConfigPath } from "./config-SdCcoXC3.js";
3
3
  import { ZodError } from "zod";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
5
- //#region src/cli.ts
5
+ // src/cli.ts
6
6
  const stderrLogger = {
7
7
  debug: (...args) => {
8
8
  if (process.env.IOS_SIMULATOR_DEBUG) console.error("[ios-simulator-mcp]", ...args);
@@ -41,7 +41,6 @@ main().catch((err) => {
41
41
  console.error(`[ios-simulator-mcp] fatal: ${describeFatal(err)}`);
42
42
  process.exit(1);
43
43
  });
44
- //#endregion
45
44
  export {};
46
45
 
47
46
  //# sourceMappingURL=cli.js.map
@@ -1,13 +1,13 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
- import { readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { CommandError, CommandTimeoutError, DEFAULT_CRASH_KINDS, IosError, IosError as IosError$1, ToolchainError, WdaClient, WdaError, WdaUnavailableError, assertNoShellMetachars, crashKind, crashProcessName, createArgs, createExec, readLogFile, registerInputTools, registerScreenTools, summarizeCrashReport, wrap } from "@mgcrea/mcp-ios-core";
4
+ import { CommandError, CommandError as CommandError$1, CommandTimeoutError, DEFAULT_CRASH_KINDS, IosError, IosError as IosError$1, ToolchainError, WdaClient, WdaError, WdaUnavailableError, assertNoShellMetachars, crashKind, crashProcessName, createArgs, createExec, readLogFile, registerInputTools, registerScreenTools, summarizeCrashReport, wrap } from "@mgcrea/mcp-ios-core";
5
5
  import { mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
6
6
  import { homedir, tmpdir } from "node:os";
7
7
  import { z } from "zod";
8
8
  import { spawn } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
- //#region src/build-info.ts
10
+ // src/build-info.ts
11
11
  const readPackageJson = () => {
12
12
  try {
13
13
  const pkgUrl = new URL("../package.json", import.meta.url);
@@ -23,11 +23,10 @@ const pkg = readPackageJson();
23
23
  const BUILD_INFO = {
24
24
  name: pkg.name,
25
25
  version: pkg.version,
26
- gitCommit: "b6a6636",
27
- gitCommitDate: "2026-10-08T23:42:11+02:00"
26
+ gitCommit: "6789bb6",
27
+ gitCommitDate: "2026-11-04T10:23:19+01:00"
28
28
  };
29
- //#endregion
30
- //#region src/client/errors.ts
29
+ // src/client/errors.ts
31
30
  /** Raising the budget, or noticing the simulator died mid-call. */
32
31
  const TIMEOUT_REMEDY = "Raise IOS_SIMULATOR_TIMEOUT_MS if this is a slow operation (a boot or a large `install` genuinely takes tens of seconds), or check the simulator is still booted.";
33
32
  const TOOLCHAIN_REMEDY = "Install Xcode and run `xcode-select --install`, or point IOS_SIMULATOR_XCRUN_PATH at a different xcrun.";
@@ -99,8 +98,7 @@ var WritesDisabledError = class extends IosError {
99
98
  };
100
99
  /** A shut-down simulator captures nothing, and that is the usual cause here. */
101
100
  const EMPTY_SCREENSHOT_REMEDY = "The simulator produced an empty capture. Check it is actually booted with ios_simulator_diagnostics, and that its runtime is installed.";
102
- //#endregion
103
- //#region src/client/display.ts
101
+ // src/client/display.ts
104
102
  /**
105
103
  * Screen geometry for a simulator, which has no `devicectl device info displays`.
106
104
  *
@@ -191,10 +189,11 @@ const pngDimensions = (png) => {
191
189
  * screen, so mapping it would be a lie.
192
190
  */
193
191
  const toDisplayInfo = (profile, capture) => {
194
- const scale = profile.mainScreenScale ?? 1;
192
+ const scale = profile.mainScreenScale;
195
193
  const pixelWidth = capture?.width ?? profile.mainScreenWidth;
196
194
  const pixelHeight = capture?.height ?? profile.mainScreenHeight;
197
195
  if (!pixelWidth || !pixelHeight) throw new IosError$1("Could not determine the simulator's screen geometry.", { remedy: "The device type's profile.plist could not be read and no screenshot was available. Run ios_simulator_diagnostics — an unavailable runtime is the usual cause." });
196
+ if (!scale) throw new IosError$1("Could not determine the simulator's screen scale.", { remedy: "The device type's profile.plist could not be read, so points cannot be told from pixels. Run ios_simulator_diagnostics — an unavailable runtime is the usual cause." });
198
197
  const landscape = profile.mainScreenWidth !== void 0 && profile.mainScreenHeight !== void 0 ? pixelWidth > pixelHeight && profile.mainScreenWidth < profile.mainScreenHeight : pixelWidth > pixelHeight;
199
198
  return {
200
199
  pixelWidth,
@@ -205,8 +204,7 @@ const toDisplayInfo = (profile, capture) => {
205
204
  orientation: capture ? landscape ? "landscape" : "portrait" : "unknown"
206
205
  };
207
206
  };
208
- //#endregion
209
- //#region src/client/logs.ts
207
+ // src/client/logs.ts
210
208
  const defaultProcessAlive = (pid) => {
211
209
  try {
212
210
  process.kill(pid, 0);
@@ -251,8 +249,7 @@ var LaunchCaptures = class {
251
249
  };
252
250
  /** True when `path` exists on this Mac. */
253
251
  const exists = async (path) => stat(path).then(() => true, () => false);
254
- //#endregion
255
- //#region src/client/shape.ts
252
+ // src/client/shape.ts
256
253
  /**
257
254
  * Flatten the runtime-keyed map `list devices` returns.
258
255
  *
@@ -307,8 +304,7 @@ const toListRow = (sim) => ({
307
304
  ...sim.available ? {} : { available: false },
308
305
  ...sim.unavailableReason ? { unavailableReason: sim.unavailableReason } : {}
309
306
  });
310
- //#endregion
311
- //#region src/client/simctl.ts
307
+ // src/client/simctl.ts
312
308
  /**
313
309
  * Pull the human sentence out of a CoreSimulator error envelope.
314
310
  *
@@ -353,11 +349,23 @@ const remedyFor = (stderr) => {
353
349
  const assertNotBulkTarget = (udid) => {
354
350
  if (udid === "all" || udid === "unavailable" || udid === "booted") throw new IosError$1(`Refusing to pass "${udid}" to simctl as a device.`, { remedy: "simctl reads these as *every* matching simulator, so `erase all` would wipe the machine. Resolve to one concrete UDID with ios_simulator_list first." });
355
351
  };
352
+ /**
353
+ * `io … screenshot` refusing to pick between a multi-display device's screens.
354
+ * Matched on simctl's own words rather than exit 22 alone, which is just
355
+ * EINVAL and covers any malformed argument.
356
+ */
357
+ const isAmbiguousDisplay = (err) => err instanceof CommandError && /Cannot choose between \d+ displays/.test(`${String(err.details ?? "")}\n${err.message}`);
356
358
  var Simctl = class {
357
359
  opts;
358
360
  exec;
359
361
  /** Device types change only when Xcode is updated; re-reading is pure cost. */
360
362
  deviceTypes;
363
+ /**
364
+ * Read once, like device types: both change only when Xcode or a runtime is
365
+ * installed, and every tool call resolves a simulator. The cost is that a
366
+ * runtime added or deleted while this server runs is seen after a restart.
367
+ */
368
+ runtimes;
361
369
  constructor(opts) {
362
370
  this.opts = opts;
363
371
  this.exec = opts.exec ?? createExec({
@@ -367,9 +375,9 @@ var Simctl = class {
367
375
  });
368
376
  }
369
377
  /** Raw stdout, for the subcommands that answer in prose. */
370
- async run(args) {
378
+ async run(args, timeoutMs = this.opts.timeoutMs) {
371
379
  this.opts.logger?.debug?.("simctl", ...args);
372
- const { stdout } = await this.exec(this.opts.xcrunPath, ["simctl", ...args], this.opts.timeoutMs);
380
+ const { stdout } = await this.exec(this.opts.xcrunPath, ["simctl", ...args], timeoutMs);
373
381
  return stdout;
374
382
  }
375
383
  /**
@@ -426,7 +434,8 @@ var Simctl = class {
426
434
  return (await this.runJson(["list", "devices"])).devices ?? {};
427
435
  }
428
436
  async listRuntimes() {
429
- return (await this.runJson(["list", "runtimes"])).runtimes ?? [];
437
+ this.runtimes ??= (await this.runJson(["list", "runtimes"])).runtimes ?? [];
438
+ return this.runtimes;
430
439
  }
431
440
  async listDeviceTypes() {
432
441
  this.deviceTypes ??= (await this.runJson(["list", "devicetypes"])).devicetypes ?? [];
@@ -436,6 +445,16 @@ var Simctl = class {
436
445
  assertNotBulkTarget(udid);
437
446
  await this.run(["boot", udid]);
438
447
  }
448
+ /**
449
+ * Wait until a booting simulator has finished, or `timeoutMs` passes (a
450
+ * `CommandTimeoutError`). Not the state in `list`: measured on an iOS 27.0
451
+ * runtime, that reads "Booted" 0.4s after `boot` returns, while this returns
452
+ * 17s later, once the system is up.
453
+ */
454
+ async bootstatus(udid, timeoutMs) {
455
+ assertNotBulkTarget(udid);
456
+ await this.run(["bootstatus", udid], timeoutMs);
457
+ }
439
458
  async shutdown(udid) {
440
459
  assertNotBulkTarget(udid);
441
460
  await this.run(["shutdown", udid]);
@@ -580,19 +599,42 @@ var Simctl = class {
580
599
  *
581
600
  * A successful capture also writes a `Note: No display specified…` line to
582
601
  * stderr, so non-empty stderr is not a failure here.
602
+ *
603
+ * A device with two displays — the iPhone Duo, iOS 27.1 runtime — makes the
604
+ * bare call fail outright: exit 22, "Cannot choose between 2 displays.
605
+ * Specify one with --display". Only then is it retried on `--display=primary`,
606
+ * so a single-display simulator and Xcode 26 never see the flag. Primary
607
+ * rather than whichever is lit, because it is the display WebDriverAgent
608
+ * addresses when no `currentDisplayId` is set: a capture of the other one
609
+ * would put every tap read off it in the wrong coordinate space. On an open
610
+ * Duo that display is black (appium/appium-xcuitest-driver#3012); driving
611
+ * the inner one means selecting it in WDA too, which nothing here does yet.
612
+ * The `internal` alias the help text offers fails on the Duo with exit 22.
583
613
  */
584
614
  async screenshotPng(udid) {
585
615
  assertNotBulkTarget(udid);
586
616
  const dir = await mkdtemp(join(tmpdir(), "simshot-"));
587
617
  const path = join(dir, "shot.png");
588
618
  try {
589
- await this.run([
590
- "io",
591
- udid,
592
- "screenshot",
593
- "--type=png",
594
- path
595
- ]);
619
+ try {
620
+ await this.run([
621
+ "io",
622
+ udid,
623
+ "screenshot",
624
+ "--type=png",
625
+ path
626
+ ]);
627
+ } catch (err) {
628
+ if (!isAmbiguousDisplay(err)) throw err;
629
+ await this.run([
630
+ "io",
631
+ udid,
632
+ "screenshot",
633
+ "--type=png",
634
+ "--display=primary",
635
+ path
636
+ ]);
637
+ }
596
638
  return (await readFile(path)).toString("base64");
597
639
  } finally {
598
640
  await rm(dir, {
@@ -702,8 +744,7 @@ var Simctl = class {
702
744
  }
703
745
  }
704
746
  };
705
- //#endregion
706
- //#region src/client/simulator.ts
747
+ // src/client/simulator.ts
707
748
  /**
708
749
  * What to say when WebDriverAgent fails, on a simulator.
709
750
  *
@@ -767,6 +808,13 @@ var SimulatorClient = class {
767
808
  return simulators;
768
809
  }
769
810
  /**
811
+ * Drop the cached list. Called after a tool changes a simulator's state, so
812
+ * the next call within the cache window does not act on the state before it.
813
+ */
814
+ forgetSimulators() {
815
+ this.cache = void 0;
816
+ }
817
+ /**
770
818
  * Resolve a hint to exactly one simulator, or refuse.
771
819
  *
772
820
  * The device server's rule — never guess between two — transfers, but
@@ -835,7 +883,7 @@ var SimulatorClient = class {
835
883
  exec: this.exec,
836
884
  timeoutMs: this.execTimeoutMs
837
885
  }) : {};
838
- if (id) this.profiles.set(id, profile);
886
+ if (id && profile.mainScreenScale !== void 0) this.profiles.set(id, profile);
839
887
  }
840
888
  const capture = capturePng ? pngDimensions(Buffer.from(capturePng, "base64")) : void 0;
841
889
  return toDisplayInfo(profile, capture);
@@ -894,6 +942,10 @@ var SimulatorClient = class {
894
942
  wdaUrl() {
895
943
  return this.opts.wdaUrl ?? `http://127.0.0.1:${this.opts.wdaPort}`;
896
944
  }
945
+ /** The simulator `resolveTarget` falls back to when no hint is given. */
946
+ get defaultSimulatorId() {
947
+ return this.opts.defaultSimulatorId;
948
+ }
897
949
  get wdaPort() {
898
950
  return this.opts.wdaPort;
899
951
  }
@@ -912,8 +964,7 @@ var SimulatorClient = class {
912
964
  return client;
913
965
  }
914
966
  };
915
- //#endregion
916
- //#region src/tools/util.ts
967
+ // src/tools/util.ts
917
968
  /**
918
969
  * How the shared tools name themselves and each other in this server.
919
970
  *
@@ -944,8 +995,7 @@ const { bundleIdArg, confirmArg, detailArg, screenshotArg, settleArg, xArg, yArg
944
995
  * worse than either alone.
945
996
  */
946
997
  const deviceArg = args.targetArg;
947
- //#endregion
948
- //#region src/tools/apps.ts
998
+ // src/tools/apps.ts
949
999
  /** `io.mgcrea.Canopy: 12345` is the whole of what `simctl launch` prints. */
950
1000
  const parsePid = (output) => {
951
1001
  const pid = /:\s*(\d+)\s*$/.exec(output.trim())?.[1];
@@ -1130,8 +1180,7 @@ const registerAppTools = (server, client, launchArgs, outputDir) => {
1130
1180
  return { opened: url };
1131
1181
  }));
1132
1182
  };
1133
- //#endregion
1134
- //#region src/tools/diagnostics.ts
1183
+ // src/tools/diagnostics.ts
1135
1184
  /**
1136
1185
  * Which simulator owns the WebDriverAgent port.
1137
1186
  *
@@ -1219,7 +1268,7 @@ const diagnose = async (client, ctx, deviceHint) => {
1219
1268
  try {
1220
1269
  target = await client.resolveTarget(deviceHint);
1221
1270
  } catch (err) {
1222
- if (booted.length > 0) {
1271
+ if (booted.length > 0 || (deviceHint ?? client.defaultSimulatorId) !== void 0) {
1223
1272
  problems.push(err instanceof Error ? err.message : String(err));
1224
1273
  const remedy = err.remedy;
1225
1274
  if (remedy) nextSteps.push(remedy);
@@ -1289,8 +1338,7 @@ const registerDiagnosticsTools = (server, client, ctx) => {
1289
1338
  text: JSON.stringify(await diagnose(client, ctx))
1290
1339
  }] }));
1291
1340
  };
1292
- //#endregion
1293
- //#region src/tools/environment.ts
1341
+ // src/tools/environment.ts
1294
1342
  /** simctl's own list, verbatim — a value it does not accept is not worth guessing at. */
1295
1343
  const PRIVACY_SERVICES = [
1296
1344
  "all",
@@ -1366,6 +1414,26 @@ const registerEnvironmentTools = (server, client) => {
1366
1414
  }
1367
1415
  }, async ({ device, appearance, content_size, increase_contrast, status_bar, clear_status_bar, location, clear_location, permission }) => wrap(async () => {
1368
1416
  const target = client.requireBooted(await client.resolveTarget(device));
1417
+ const statusBarFlags = status_bar ? [
1418
+ ...status_bar.time ? ["--time", status_bar.time] : [],
1419
+ ...status_bar.wifi_bars !== void 0 ? [
1420
+ "--wifiMode",
1421
+ "active",
1422
+ "--wifiBars",
1423
+ String(status_bar.wifi_bars)
1424
+ ] : [],
1425
+ ...status_bar.cellular_bars !== void 0 ? [
1426
+ "--cellularMode",
1427
+ "active",
1428
+ "--cellularBars",
1429
+ String(status_bar.cellular_bars)
1430
+ ] : [],
1431
+ ...status_bar.operator_name !== void 0 ? ["--operatorName", status_bar.operator_name] : [],
1432
+ ...status_bar.battery_level !== void 0 ? ["--batteryLevel", String(status_bar.battery_level)] : [],
1433
+ ...status_bar.battery_state ? ["--batteryState", status_bar.battery_state] : []
1434
+ ] : [];
1435
+ if (status_bar && statusBarFlags.length === 0) throw new IosError$1("`status_bar` was given with no fields to override.", { remedy: "Set at least one field, or use `clear_status_bar` to drop the overrides." });
1436
+ if (permission && permission.action !== "reset" && !permission.bundle_id) throw new IosError$1(`\`${permission.action}\` needs a bundle_id.`, { remedy: "Pass the app's bundle id, or use `reset`, which does not need one." });
1369
1437
  const applied = [];
1370
1438
  if (appearance) {
1371
1439
  await client.simctl.uiSet(target.id, "appearance", appearance);
@@ -1384,26 +1452,7 @@ const registerEnvironmentTools = (server, client) => {
1384
1452
  applied.push("status_bar cleared");
1385
1453
  }
1386
1454
  if (status_bar) {
1387
- const flags = [
1388
- ...status_bar.time ? ["--time", status_bar.time] : [],
1389
- ...status_bar.wifi_bars !== void 0 ? [
1390
- "--wifiMode",
1391
- "active",
1392
- "--wifiBars",
1393
- String(status_bar.wifi_bars)
1394
- ] : [],
1395
- ...status_bar.cellular_bars !== void 0 ? [
1396
- "--cellularMode",
1397
- "active",
1398
- "--cellularBars",
1399
- String(status_bar.cellular_bars)
1400
- ] : [],
1401
- ...status_bar.operator_name !== void 0 ? ["--operatorName", status_bar.operator_name] : [],
1402
- ...status_bar.battery_level !== void 0 ? ["--batteryLevel", String(status_bar.battery_level)] : [],
1403
- ...status_bar.battery_state ? ["--batteryState", status_bar.battery_state] : []
1404
- ];
1405
- if (flags.length === 0) throw new IosError$1("`status_bar` was given with no fields to override.", { remedy: "Set at least one field, or use `clear_status_bar` to drop the overrides." });
1406
- await client.simctl.statusBarOverride(target.id, flags);
1455
+ await client.simctl.statusBarOverride(target.id, statusBarFlags);
1407
1456
  applied.push("status_bar overridden");
1408
1457
  }
1409
1458
  if (clear_location) {
@@ -1415,17 +1464,21 @@ const registerEnvironmentTools = (server, client) => {
1415
1464
  applied.push(`location=${location.latitude},${location.longitude}`);
1416
1465
  }
1417
1466
  if (permission) {
1418
- if (permission.action !== "reset" && !permission.bundle_id) throw new IosError$1(`\`${permission.action}\` needs a bundle_id.`, { remedy: "Pass the app's bundle id, or use `reset`, which does not need one." });
1419
1467
  await client.simctl.privacy(target.id, permission.action, permission.service, permission.bundle_id);
1420
1468
  applied.push(`permission ${permission.action} ${permission.service}`);
1421
1469
  }
1422
1470
  if (applied.length === 0) throw new IosError$1("Nothing to do — no field was given.", { remedy: "Set at least one of appearance, content_size, status_bar, location, permission." });
1471
+ const [appearanceNow, contentSize, statusBar] = await Promise.all([
1472
+ client.simctl.uiGet(target.id, "appearance"),
1473
+ client.simctl.uiGet(target.id, "content_size"),
1474
+ client.simctl.statusBarList(target.id)
1475
+ ]);
1423
1476
  return {
1424
1477
  applied,
1425
1478
  state: {
1426
- appearance: await client.simctl.uiGet(target.id, "appearance"),
1427
- contentSize: await client.simctl.uiGet(target.id, "content_size"),
1428
- statusBar: await client.simctl.statusBarList(target.id)
1479
+ appearance: appearanceNow,
1480
+ contentSize,
1481
+ statusBar
1429
1482
  }
1430
1483
  };
1431
1484
  }));
@@ -1475,11 +1528,7 @@ const registerEnvironmentTools = (server, client) => {
1475
1528
  };
1476
1529
  }));
1477
1530
  };
1478
- //#endregion
1479
- //#region src/tools/lifecycle.ts
1480
- /** Booting is slow and visible; this is how long `power` waits to see it land. */
1481
- const BOOT_POLL_MS = 500;
1482
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1531
+ // src/tools/lifecycle.ts
1483
1532
  const registerListTool = (server, client) => {
1484
1533
  server.registerTool("ios_simulator_list", {
1485
1534
  title: "iOS Simulator: List",
@@ -1524,13 +1573,14 @@ const registerLifecycleTools = (server, client) => {
1524
1573
  }, async ({ device, state, open_window, wait_ms }) => wrap(async () => {
1525
1574
  const target = await client.resolveTarget(device);
1526
1575
  if (state === "shutdown") {
1527
- if (target.state !== "Booted") return {
1576
+ if (target.state === "Shutdown") return {
1528
1577
  udid: target.id,
1529
1578
  name: target.name,
1530
1579
  state: "Shutdown",
1531
1580
  alreadyThere: true
1532
1581
  };
1533
1582
  await client.simctl.shutdown(target.id);
1583
+ client.forgetSimulators();
1534
1584
  return {
1535
1585
  udid: target.id,
1536
1586
  name: target.name,
@@ -1538,7 +1588,10 @@ const registerLifecycleTools = (server, client) => {
1538
1588
  };
1539
1589
  }
1540
1590
  const already = target.state === "Booted";
1541
- if (!already) await client.simctl.boot(target.id);
1591
+ if (!already) {
1592
+ await client.simctl.boot(target.id);
1593
+ client.forgetSimulators();
1594
+ }
1542
1595
  let windowWarning;
1543
1596
  let windowNote;
1544
1597
  if (open_window) await client.simctl.openApp(client.openPath).then((app) => {
@@ -1546,20 +1599,22 @@ const registerLifecycleTools = (server, client) => {
1546
1599
  }).catch((err) => {
1547
1600
  windowWarning = `Booted, but no window could be opened: ${err instanceof Error ? err.message : String(err)}`;
1548
1601
  });
1549
- const deadline = Date.now() + wait_ms;
1550
- let observed = target.state;
1551
- while (Date.now() < deadline) {
1552
- observed = (await client.listSimulators({ fresh: true })).find((sim) => sim.id === target.id)?.state ?? observed;
1553
- if (observed === "Booted") break;
1554
- await sleep(BOOT_POLL_MS);
1602
+ let ready = already;
1603
+ let waitWarning = `Not waited for, since wait_ms is 0: it may still be starting up.`;
1604
+ if (!already && wait_ms > 0) try {
1605
+ await client.simctl.bootstatus(target.id, wait_ms);
1606
+ ready = true;
1607
+ } catch (err) {
1608
+ waitWarning = err instanceof CommandTimeoutError ? `Not finished booting after ${wait_ms}ms. Raise wait_ms, or check Xcode.` : `Booted, but could not confirm it finished: ${err instanceof Error ? err.message : String(err)}`;
1555
1609
  }
1610
+ const observed = Object.values(await client.simctl.listDevices()).flat().find((sim) => sim.udid === target.id)?.state ?? target.state;
1556
1611
  return {
1557
1612
  udid: target.id,
1558
1613
  name: target.name,
1559
1614
  state: observed,
1560
1615
  ...already ? { alreadyThere: true } : {},
1561
1616
  ...windowNote ? { note: windowNote } : {},
1562
- ...observed === "Booted" ? windowWarning ? { warning: windowWarning } : {} : { warning: `Still ${observed} after ${wait_ms}ms. Raise wait_ms, or check Xcode.` }
1617
+ ...ready ? windowWarning ? { warning: windowWarning } : {} : { warning: waitWarning }
1563
1618
  };
1564
1619
  }));
1565
1620
  server.registerTool("ios_simulator_erase", {
@@ -1580,6 +1635,7 @@ const registerLifecycleTools = (server, client) => {
1580
1635
  if (wasBooted) await client.simctl.shutdown(target.id);
1581
1636
  await client.simctl.erase(target.id);
1582
1637
  if (wasBooted) await client.simctl.boot(target.id);
1638
+ client.forgetSimulators();
1583
1639
  return {
1584
1640
  udid: target.id,
1585
1641
  name: target.name,
@@ -1588,8 +1644,7 @@ const registerLifecycleTools = (server, client) => {
1588
1644
  };
1589
1645
  }));
1590
1646
  };
1591
- //#endregion
1592
- //#region src/client/crash.ts
1647
+ // src/client/crash.ts
1593
1648
  /**
1594
1649
  * Simulator crash reports, out of this Mac's own DiagnosticReports.
1595
1650
  *
@@ -1660,6 +1715,13 @@ const listIps = async (dir) => {
1660
1715
  return [...top.filter((name) => name.endsWith(".ips")), ...retired.filter((name) => name.endsWith(".ips")).map((name) => `Retired/${name}`)];
1661
1716
  };
1662
1717
  /**
1718
+ * What each report's head said, by path; `null` for one that is not from a
1719
+ * simulator. A written `.ips` never changes and its name carries a timestamp,
1720
+ * so after the first list only new reports cost a read. A Mac with Xcode
1721
+ * holds hundreds of them, and every list used to open each one.
1722
+ */
1723
+ const scanned = /* @__PURE__ */ new Map();
1724
+ /**
1663
1725
  * Every simulator report in `dir`, newest first. Reads the first 8 KB of each
1664
1726
  * `.ips`, never the whole file, except to find the UDID in the rare report
1665
1727
  * whose `coalitionName` did not fit in that.
@@ -1670,12 +1732,18 @@ const listSimulatorCrashReports = async (dir) => {
1670
1732
  for (let i = 0; i < names.length; i += CONCURRENCY) {
1671
1733
  const batch = await Promise.all(names.slice(i, i + CONCURRENCY).map(async (name) => {
1672
1734
  const path = join(dir, name);
1735
+ const known = scanned.get(path);
1736
+ if (known !== void 0) return known ?? void 0;
1673
1737
  const head = await readHead(path).catch(() => "");
1674
1738
  const header = parseHeader(head);
1675
- if (!header || !SIMULATOR_PLATFORMS.has(Number(header.platform))) return void 0;
1739
+ if (!header) return void 0;
1740
+ if (!SIMULATOR_PLATFORMS.has(Number(header.platform))) {
1741
+ scanned.set(path, null);
1742
+ return;
1743
+ }
1676
1744
  let udid = COALITION.exec(head)?.[1];
1677
1745
  if (!udid) udid = COALITION.exec(await readFile(path, "utf8").catch(() => ""))?.[1];
1678
- return {
1746
+ const row = {
1679
1747
  name,
1680
1748
  process: header.app_name || header.name || crashProcessName(name),
1681
1749
  bundleId: header.bundleID,
@@ -1683,6 +1751,8 @@ const listSimulatorCrashReports = async (dir) => {
1683
1751
  timestamp: header.timestamp,
1684
1752
  udid: udid?.toUpperCase()
1685
1753
  };
1754
+ scanned.set(path, row);
1755
+ return row;
1686
1756
  }));
1687
1757
  for (const row of batch) if (row) rows.push(row);
1688
1758
  }
@@ -1707,8 +1777,7 @@ const isSimulatorReport = (text) => {
1707
1777
  };
1708
1778
  /** The simulator a full report came from, when its body says. */
1709
1779
  const reportUdid = (text) => COALITION.exec(text)?.[1]?.toUpperCase();
1710
- //#endregion
1711
- //#region src/tools/logs.ts
1780
+ // src/tools/logs.ts
1712
1781
  /** UDID → name, for labelling reports. Best effort: a Mac with no Xcode still has reports. */
1713
1782
  const simulatorNames = async (client) => {
1714
1783
  try {
@@ -1738,8 +1807,8 @@ const registerCrashLogTools = (server, client, ctx) => {
1738
1807
  const target = device ? await client.resolveTarget(device) : void 0;
1739
1808
  const wanted = kinds ?? DEFAULT_CRASH_KINDS;
1740
1809
  const needle = process?.toLowerCase();
1741
- const matching = (await listSimulatorCrashReports(dir)).filter((report) => (!target || report.udid === target.id.toUpperCase()) && (wanted.includes("all") || wanted.includes(report.kind)) && (!needle || report.process.toLowerCase().includes(needle) || (report.bundleId?.toLowerCase().includes(needle) ?? false)));
1742
- const names = target ? void 0 : await simulatorNames(client);
1810
+ const [all, names] = await Promise.all([listSimulatorCrashReports(dir), target ? void 0 : simulatorNames(client)]);
1811
+ const matching = all.filter((report) => (!target || report.udid === target.id.toUpperCase()) && (wanted.includes("all") || wanted.includes(report.kind)) && (!needle || report.process.toLowerCase().includes(needle) || (report.bundleId?.toLowerCase().includes(needle) ?? false)));
1743
1812
  return {
1744
1813
  ...target ? {
1745
1814
  simulator: target.name,
@@ -1829,8 +1898,7 @@ const registerLogReadTools = (server, client) => {
1829
1898
  };
1830
1899
  }));
1831
1900
  };
1832
- //#endregion
1833
- //#region src/tools/runner.ts
1901
+ // src/tools/runner.ts
1834
1902
  const defaultSpawnRunner = async (command, args, { env, logPath }) => {
1835
1903
  await mkdir(dirname(logPath), { recursive: true });
1836
1904
  const log = await open(logPath, "a");
@@ -1852,9 +1920,22 @@ const defaultSpawnRunner = async (command, args, { env, logPath }) => {
1852
1920
  };
1853
1921
  const scriptPath = () => {
1854
1922
  const here = dirname(fileURLToPath(import.meta.url));
1855
- for (const candidate of [join(here, "..", "scripts", "wda.sh"), join(here, "..", "..", "scripts", "wda.sh")]) return candidate;
1923
+ for (const candidate of [join(here, "..", "scripts", "wda.sh"), join(here, "..", "..", "scripts", "wda.sh")]) if (existsSync(candidate)) return candidate;
1856
1924
  throw new IosError$1("Could not find `scripts/wda.sh` beside this module.");
1857
1925
  };
1926
+ /**
1927
+ * Whether `wda.sh run` has anything to run: the same directory and glob it
1928
+ * looks in. Without a build it exits with "no build found" into a log nobody
1929
+ * reads, while a detached spawn has already been reported as started.
1930
+ */
1931
+ const runnerBuilt = async () => {
1932
+ const wdaDir = process.env["WDA_DIR"] || join(homedir(), ".cache", "mcp-ios-simulator", "WebDriverAgent");
1933
+ const products = join(wdaDir, ".build", "Build", "Products");
1934
+ return {
1935
+ built: (await readdir(products).catch(() => [])).some((name) => name.startsWith("WebDriverAgentRunner_iphonesimulator") && name.endsWith(".xctestrun")),
1936
+ products
1937
+ };
1938
+ };
1858
1939
  const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpawnRunner) => {
1859
1940
  server.registerTool("ios_simulator_restart_wda", {
1860
1941
  title: "iOS Simulator: Restart WebDriverAgent",
@@ -1871,12 +1952,16 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
1871
1952
  }, async ({ device, port }) => wrap(async () => {
1872
1953
  const target = client.requireBooted(await client.resolveTarget(device));
1873
1954
  const usePort = port ?? client.wdaPort;
1955
+ const { built, products } = await runnerBuilt();
1956
+ if (!built) throw new IosError$1(`No WebDriverAgent build in ${products}, so there is nothing to start.`, { remedy: "Build it once with `scripts/wda.sh setup` (or `npx -p @mgcrea/mcp-ios-simulator ios-simulator-wda setup` from an npm install), then call ios_simulator_restart_wda again." });
1874
1957
  const logPath = join(outputDir, "wda.log");
1875
1958
  const pid = await spawnRunner("/bin/bash", [scriptPath(), "run"], {
1876
1959
  env: {
1877
1960
  IOS_SIMULATOR_ID: target.id,
1878
1961
  IOS_SIMULATOR_WDA_PORT: String(usePort),
1879
- USE_PORT: String(usePort)
1962
+ USE_PORT: String(usePort),
1963
+ NODE: process.execPath,
1964
+ PATH: `${dirname(process.execPath)}:${process.env["PATH"] ?? ""}`
1880
1965
  },
1881
1966
  logPath
1882
1967
  });
@@ -1891,8 +1976,7 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
1891
1976
  };
1892
1977
  }));
1893
1978
  };
1894
- //#endregion
1895
- //#region src/tools/index.ts
1979
+ // src/tools/index.ts
1896
1980
  /**
1897
1981
  * All capability decisions in one place, so "why can't I call that" is answered
1898
1982
  * by one file.
@@ -1943,8 +2027,7 @@ const registerTools = (server, client, ctx, spawnRunner) => {
1943
2027
  registerEnvironmentTools(server, client);
1944
2028
  registerRunnerTools(server, client, ctx.config.outputDir, spawnRunner);
1945
2029
  };
1946
- //#endregion
1947
- //#region src/server.ts
2030
+ // src/server.ts
1948
2031
  const SERVER_NAME = BUILD_INFO.name;
1949
2032
  const SERVER_VERSION = BUILD_INFO.version;
1950
2033
  /**
@@ -1985,8 +2068,7 @@ const createServer = (opts) => {
1985
2068
  client
1986
2069
  };
1987
2070
  };
1988
- //#endregion
1989
- //#region src/config.ts
2071
+ // src/config.ts
1990
2072
  /**
1991
2073
  * There are no credentials here, and unlike the device server there is barely
1992
2074
  * any setup either. Two lanes reach a simulator and both are local:
@@ -2112,7 +2194,6 @@ const loadConfig = (env = process.env, configPath = defaultConfigPath(env)) => {
2112
2194
  openPath: pick(trimmed(env.IOS_SIMULATOR_OPEN_PATH), "openPath")
2113
2195
  });
2114
2196
  };
2115
- //#endregion
2116
- export { WritesDisabledError as A, SimulatorNotBootedError as C, ToolchainError as D, TOOLCHAIN_REMEDY as E, wdaUnavailableRemedy as M, BUILD_INFO as N, WdaError as O, START_RUNNER_TOOL_REMEDY as S, TIMEOUT_REMEDY as T, CommandTimeoutError as _, createServer as a, RuntimeUnavailableError as b, assertNotBulkTarget as c, summarizeDevices as d, toListRow as f, CommandError as g, toDisplayInfo as h, SERVER_VERSION as i, startRunnerRemedy as j, WdaUnavailableError as k, parseSimctlError as l, readScreenProfile as m, loadConfig as n, SimulatorClient as o, pngDimensions as p, SERVER_NAME as r, Simctl as s, defaultConfigPath as t, summarizeApps as u, EMPTY_SCREENSHOT_REMEDY as v, SimulatorNotFoundError as w, START_RUNNER_SCRIPT_REMEDY as x, IosError$1 as y };
2197
+ export { WritesDisabledError as A, SimulatorNotBootedError as C, ToolchainError as D, TOOLCHAIN_REMEDY as E, wdaUnavailableRemedy as M, BUILD_INFO as N, WdaError as O, START_RUNNER_TOOL_REMEDY as S, TIMEOUT_REMEDY as T, CommandTimeoutError as _, createServer as a, RuntimeUnavailableError as b, assertNotBulkTarget as c, summarizeDevices as d, toListRow as f, CommandError$1 as g, toDisplayInfo as h, SERVER_VERSION as i, startRunnerRemedy as j, WdaUnavailableError as k, parseSimctlError as l, readScreenProfile as m, loadConfig as n, SimulatorClient as o, pngDimensions as p, SERVER_NAME as r, Simctl as s, defaultConfigPath as t, summarizeApps as u, EMPTY_SCREENSHOT_REMEDY as v, SimulatorNotFoundError as w, START_RUNNER_SCRIPT_REMEDY as x, IosError$1 as y };
2117
2198
 
2118
- //# sourceMappingURL=config-D0JgmT_U.js.map
2199
+ //# sourceMappingURL=config-SdCcoXC3.js.map