@mgcrea/mcp-ios-simulator 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/dist/cli.js +1 -1
- package/dist/{config-HmqayUrt.js → config-BA3aCrX9.js} +292 -81
- package/dist/config-BA3aCrX9.js.map +1 -0
- package/dist/index.d.ts +89 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +4 -4
- package/scripts/wda.sh +2 -2
- package/dist/config-HmqayUrt.js.map +0 -1
package/README.md
CHANGED
|
@@ -95,8 +95,8 @@ failure.
|
|
|
95
95
|
| `IOS_SIMULATOR_ID` | the only booted simulator | UDID, name, or `booted`. Two booted and no value is an error that names them. |
|
|
96
96
|
| `IOS_SIMULATOR_ALLOW_WRITES` | **on** | `0` drops the seventeen driving tools from `tools/list`. |
|
|
97
97
|
| `IOS_SIMULATOR_LAUNCH_ARGS` | none | Launch arguments applied when `launch` passes none of its own. |
|
|
98
|
-
| `IOS_SIMULATOR_WDA_PORT` | `8100` |
|
|
99
|
-
| `IOS_SIMULATOR_WDA_URL` |
|
|
98
|
+
| `IOS_SIMULATOR_WDA_PORT` | `8100` | Where `restart_wda` starts looking for a free port; the fallback runner port. |
|
|
99
|
+
| `IOS_SIMULATOR_WDA_URL` | none | Pins every simulator to one runner URL; rarely needed. |
|
|
100
100
|
| `IOS_SIMULATOR_MAX_TREE_BYTES` | `24000` | Byte cap on a `ui_tree` payload. |
|
|
101
101
|
| `IOS_SIMULATOR_TIMEOUT_MS` | `120000` | Budget for one `simctl` call; a boot is slow. |
|
|
102
102
|
| `IOS_SIMULATOR_WDA_TIMEOUT_MS` | `30000` | Budget for one WebDriverAgent call. |
|
|
@@ -137,6 +137,13 @@ scripts/wda.sh run # leave this running
|
|
|
137
137
|
scripts/wda.sh status # is it answering?
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
+
Several simulators can be driven at once, each with its own runner. The server
|
|
141
|
+
finds a simulator's runner by its port's owner, so no port needs configuring:
|
|
142
|
+
`ios_simulator_restart_wda {"device":"…"}` starts one on the first free port, and
|
|
143
|
+
from a shell `IOS_SIMULATOR_ID=<udid> USE_PORT=8101 scripts/wda.sh run` does the
|
|
144
|
+
same by hand. A call for a simulator whose runner is not up is refused rather
|
|
145
|
+
than sent to another simulator's.
|
|
146
|
+
|
|
140
147
|
Installed from npm, the same script is the `ios-simulator-wda` binary:
|
|
141
148
|
|
|
142
149
|
```bash
|
|
@@ -223,8 +230,9 @@ Xcode 27.0 (27A266a) with an iOS 27.0 runtime.
|
|
|
223
230
|
- **WebDriverAgent scans ports 8100-8199 when `USE_PORT` is unset.** With two
|
|
224
231
|
booted simulators, the second runner comes up healthy on 8101 and a server
|
|
225
232
|
pointed at 8100 drives the _first_ one while every call reports success.
|
|
226
|
-
`scripts/wda.sh` always pins `USE_PORT`, and
|
|
227
|
-
|
|
233
|
+
`scripts/wda.sh` always pins `USE_PORT`, and every UI call looks up the runner
|
|
234
|
+
it needs by its owner — `SIMULATOR_UDID` from `ps -Eww` on each listener
|
|
235
|
+
`lsof` finds in 8100-8199 — rather than trusting a fixed port.
|
|
228
236
|
- **`listapps` returns an old-style NeXTSTEP plist, not JSON.** `--json` is a flag
|
|
229
237
|
on `list` and nothing else.
|
|
230
238
|
- **A status bar override survives a reboot.** Clear it or it quietly pollutes
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { P as BUILD_INFO, a as createServer, n as loadConfig, t as defaultConfigPath } from "./config-BA3aCrX9.js";
|
|
3
3
|
import { ZodError } from "zod";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
5
5
|
// src/cli.ts
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
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";
|
|
@@ -23,8 +23,8 @@ const pkg = readPackageJson();
|
|
|
23
23
|
const BUILD_INFO = {
|
|
24
24
|
name: pkg.name,
|
|
25
25
|
version: pkg.version,
|
|
26
|
-
gitCommit: "
|
|
27
|
-
gitCommitDate: "2026-
|
|
26
|
+
gitCommit: "07a8b77",
|
|
27
|
+
gitCommitDate: "2026-11-05T00:19:14+01:00"
|
|
28
28
|
};
|
|
29
29
|
// src/client/errors.ts
|
|
30
30
|
/** Raising the budget, or noticing the simulator died mid-call. */
|
|
@@ -55,6 +55,22 @@ const START_RUNNER_TOOL_REMEDY = "Start it with ios_simulator_restart_wda, which
|
|
|
55
55
|
const startRunnerRemedy = (allowWrites) => allowWrites ? START_RUNNER_TOOL_REMEDY : START_RUNNER_SCRIPT_REMEDY;
|
|
56
56
|
/** What to do when WebDriverAgent does not answer at all. */
|
|
57
57
|
const wdaUnavailableRemedy = (allowWrites) => `${startRunnerRemedy(allowWrites)} Everything except ios_simulator_ui_tree and the input tools works without it — screenshots go through simctl. If something else already forwards the port, set IOS_SIMULATOR_WDA_URL.`;
|
|
58
|
+
/**
|
|
59
|
+
* The target has no runner of its own, and the port this server would fall
|
|
60
|
+
* back to is another simulator's.
|
|
61
|
+
*
|
|
62
|
+
* Refused rather than forwarded, because the failure it prevents does not look
|
|
63
|
+
* like one: the other runner answers healthily, a tap lands on someone else's
|
|
64
|
+
* screen, and the UI tree that comes back describes an app the caller never
|
|
65
|
+
* launched.
|
|
66
|
+
*/
|
|
67
|
+
var RunnerBelongsElsewhereError = class extends IosError {
|
|
68
|
+
name = "RunnerBelongsElsewhereError";
|
|
69
|
+
constructor(opts) {
|
|
70
|
+
const { target, owner, port, freePort, allowWrites } = opts;
|
|
71
|
+
super(`No WebDriverAgent runner belongs to ${target.name}. Port ${port} is ${owner}'s runner, so a tap or UI tree read sent there would go to that simulator's screen instead.`, { remedy: allowWrites ? `Start one for this simulator with ios_simulator_restart_wda {"device":"${target.id}"} — it picks a free port (${freePort} right now) and this server finds it by itself. Give it about fifteen seconds.` : `Start one for this simulator on a free port: IOS_SIMULATOR_ID=${target.id} USE_PORT=${freePort} scripts/wda.sh run. This server finds it by itself once it answers.` });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
58
74
|
/** No simulator matched, or several did and none was named. */
|
|
59
75
|
var SimulatorNotFoundError = class extends IosError {
|
|
60
76
|
name = "SimulatorNotFoundError";
|
|
@@ -249,6 +265,105 @@ var LaunchCaptures = class {
|
|
|
249
265
|
};
|
|
250
266
|
/** True when `path` exists on this Mac. */
|
|
251
267
|
const exists = async (path) => stat(path).then(() => true, () => false);
|
|
268
|
+
// src/client/runners.ts
|
|
269
|
+
/** WebDriverAgent's own scan range when `USE_PORT` is unset (FBConfiguration.m). */
|
|
270
|
+
const WDA_DEFAULT_PORT = 8100;
|
|
271
|
+
/**
|
|
272
|
+
* The ranges worth scanning: WebDriverAgent's default one, and the one this
|
|
273
|
+
* server allocates from when IOS_SIMULATOR_WDA_PORT moves it elsewhere.
|
|
274
|
+
*/
|
|
275
|
+
const runnerRanges = (basePort) => {
|
|
276
|
+
const ranges = [[WDA_DEFAULT_PORT, 8199]];
|
|
277
|
+
if (basePort < 8100 || basePort >= 8200) ranges.push([basePort, Math.min(65535, basePort + 100 - 1)]);
|
|
278
|
+
return ranges;
|
|
279
|
+
};
|
|
280
|
+
/**
|
|
281
|
+
* `lsof -F pn` output: a `p<pid>` line opens each process, then one `n<addr>`
|
|
282
|
+
* line per socket. A runner listening on both stacks shows up twice.
|
|
283
|
+
*/
|
|
284
|
+
const parseLsof = (stdout) => {
|
|
285
|
+
const seen = /* @__PURE__ */ new Set();
|
|
286
|
+
const out = [];
|
|
287
|
+
let pid;
|
|
288
|
+
for (const line of stdout.split("\n")) if (line.startsWith("p")) {
|
|
289
|
+
const value = Number(line.slice(1));
|
|
290
|
+
pid = Number.isInteger(value) ? value : void 0;
|
|
291
|
+
} else if (line.startsWith("n") && pid !== void 0) {
|
|
292
|
+
const port = Number(/:(\d+)$/.exec(line)?.[1]);
|
|
293
|
+
if (!Number.isInteger(port)) continue;
|
|
294
|
+
const key = `${pid}:${port}`;
|
|
295
|
+
if (seen.has(key)) continue;
|
|
296
|
+
seen.add(key);
|
|
297
|
+
out.push({
|
|
298
|
+
pid,
|
|
299
|
+
port
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
};
|
|
304
|
+
/**
|
|
305
|
+
* `ps -Eww -o pid=,command=` output, one process per line with its environment
|
|
306
|
+
* appended. Every process inside a simulator carries `SIMULATOR_UDID`.
|
|
307
|
+
*/
|
|
308
|
+
const parsePsUdids = (stdout) => {
|
|
309
|
+
const out = /* @__PURE__ */ new Map();
|
|
310
|
+
for (const line of stdout.split("\n")) {
|
|
311
|
+
const pid = Number(/^\s*(\d+)\s/.exec(line)?.[1]);
|
|
312
|
+
const udid = /SIMULATOR_UDID=([0-9A-Fa-f-]{36})/.exec(line)?.[1];
|
|
313
|
+
if (Number.isInteger(pid) && udid) out.set(pid, udid);
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
};
|
|
317
|
+
/**
|
|
318
|
+
* Every listener in the runner ranges, each attributed to its simulator.
|
|
319
|
+
*
|
|
320
|
+
* A simulator shares the host's network stack, so a runner's port is a host
|
|
321
|
+
* port and nothing in WebDriverAgent's `/status` names the simulator behind it
|
|
322
|
+
* — the process environment is the only reliable owner. Failure is an empty
|
|
323
|
+
* list rather than an error: lsof exits 1 when nothing matches, and not knowing
|
|
324
|
+
* who owns a port must degrade to the old fixed-port behaviour, not break
|
|
325
|
+
* every UI call.
|
|
326
|
+
*/
|
|
327
|
+
const scanRunners = async (exec, basePort, timeoutMs = 5e3) => {
|
|
328
|
+
let sockets;
|
|
329
|
+
try {
|
|
330
|
+
const args = [
|
|
331
|
+
"-nP",
|
|
332
|
+
"-sTCP:LISTEN",
|
|
333
|
+
"-F",
|
|
334
|
+
"pn"
|
|
335
|
+
];
|
|
336
|
+
for (const [from, to] of runnerRanges(basePort)) args.push(`-iTCP:${from}-${to}`);
|
|
337
|
+
sockets = parseLsof((await exec("/usr/sbin/lsof", args, timeoutMs)).stdout);
|
|
338
|
+
} catch {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
if (sockets.length === 0) return [];
|
|
342
|
+
let udids = /* @__PURE__ */ new Map();
|
|
343
|
+
try {
|
|
344
|
+
const pids = [...new Set(sockets.map((s) => s.pid))].join(",");
|
|
345
|
+
udids = parsePsUdids((await exec("/bin/ps", [
|
|
346
|
+
"-Eww",
|
|
347
|
+
"-o",
|
|
348
|
+
"pid=,command=",
|
|
349
|
+
"-p",
|
|
350
|
+
pids
|
|
351
|
+
], timeoutMs)).stdout);
|
|
352
|
+
} catch {}
|
|
353
|
+
return sockets.map((s) => {
|
|
354
|
+
const udid = udids.get(s.pid);
|
|
355
|
+
return udid ? {
|
|
356
|
+
...s,
|
|
357
|
+
udid
|
|
358
|
+
} : s;
|
|
359
|
+
}).toSorted((a, b) => a.port - b.port);
|
|
360
|
+
};
|
|
361
|
+
/** The first port from `basePort` up that nothing listens on. */
|
|
362
|
+
const freePort = (listeners, basePort) => {
|
|
363
|
+
const taken = new Set(listeners.map((l) => l.port));
|
|
364
|
+
for (let port = basePort; port < basePort + 100 && port <= 65535; port++) if (!taken.has(port)) return port;
|
|
365
|
+
return basePort;
|
|
366
|
+
};
|
|
252
367
|
// src/client/shape.ts
|
|
253
368
|
/**
|
|
254
369
|
* Flatten the runtime-keyed map `list devices` returns.
|
|
@@ -349,6 +464,12 @@ const remedyFor = (stderr) => {
|
|
|
349
464
|
const assertNotBulkTarget = (udid) => {
|
|
350
465
|
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." });
|
|
351
466
|
};
|
|
467
|
+
/**
|
|
468
|
+
* `io … screenshot` refusing to pick between a multi-display device's screens.
|
|
469
|
+
* Matched on simctl's own words rather than exit 22 alone, which is just
|
|
470
|
+
* EINVAL and covers any malformed argument.
|
|
471
|
+
*/
|
|
472
|
+
const isAmbiguousDisplay = (err) => err instanceof CommandError && /Cannot choose between \d+ displays/.test(`${String(err.details ?? "")}\n${err.message}`);
|
|
352
473
|
var Simctl = class {
|
|
353
474
|
opts;
|
|
354
475
|
exec;
|
|
@@ -593,19 +714,42 @@ var Simctl = class {
|
|
|
593
714
|
*
|
|
594
715
|
* A successful capture also writes a `Note: No display specified…` line to
|
|
595
716
|
* stderr, so non-empty stderr is not a failure here.
|
|
717
|
+
*
|
|
718
|
+
* A device with two displays — the iPhone Duo, iOS 27.1 runtime — makes the
|
|
719
|
+
* bare call fail outright: exit 22, "Cannot choose between 2 displays.
|
|
720
|
+
* Specify one with --display". Only then is it retried on `--display=primary`,
|
|
721
|
+
* so a single-display simulator and Xcode 26 never see the flag. Primary
|
|
722
|
+
* rather than whichever is lit, because it is the display WebDriverAgent
|
|
723
|
+
* addresses when no `currentDisplayId` is set: a capture of the other one
|
|
724
|
+
* would put every tap read off it in the wrong coordinate space. On an open
|
|
725
|
+
* Duo that display is black (appium/appium-xcuitest-driver#3012); driving
|
|
726
|
+
* the inner one means selecting it in WDA too, which nothing here does yet.
|
|
727
|
+
* The `internal` alias the help text offers fails on the Duo with exit 22.
|
|
596
728
|
*/
|
|
597
729
|
async screenshotPng(udid) {
|
|
598
730
|
assertNotBulkTarget(udid);
|
|
599
731
|
const dir = await mkdtemp(join(tmpdir(), "simshot-"));
|
|
600
732
|
const path = join(dir, "shot.png");
|
|
601
733
|
try {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
734
|
+
try {
|
|
735
|
+
await this.run([
|
|
736
|
+
"io",
|
|
737
|
+
udid,
|
|
738
|
+
"screenshot",
|
|
739
|
+
"--type=png",
|
|
740
|
+
path
|
|
741
|
+
]);
|
|
742
|
+
} catch (err) {
|
|
743
|
+
if (!isAmbiguousDisplay(err)) throw err;
|
|
744
|
+
await this.run([
|
|
745
|
+
"io",
|
|
746
|
+
udid,
|
|
747
|
+
"screenshot",
|
|
748
|
+
"--type=png",
|
|
749
|
+
"--display=primary",
|
|
750
|
+
path
|
|
751
|
+
]);
|
|
752
|
+
}
|
|
609
753
|
return (await readFile(path)).toString("base64");
|
|
610
754
|
} finally {
|
|
611
755
|
await rm(dir, {
|
|
@@ -742,6 +886,7 @@ var SimulatorClient = class {
|
|
|
742
886
|
wdaClients = /* @__PURE__ */ new Map();
|
|
743
887
|
/** `simctl list` is ~80ms and most tools want it twice; 2s collapses that. */
|
|
744
888
|
cache;
|
|
889
|
+
runnerCache;
|
|
745
890
|
profiles = /* @__PURE__ */ new Map();
|
|
746
891
|
constructor(opts) {
|
|
747
892
|
this.opts = opts;
|
|
@@ -900,15 +1045,10 @@ var SimulatorClient = class {
|
|
|
900
1045
|
return this.simctl.screenshotPng(target.id);
|
|
901
1046
|
}
|
|
902
1047
|
/**
|
|
903
|
-
* Where WebDriverAgent is
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
* port. That is also the trap — several booted simulators contend for the
|
|
908
|
-
* same one, and WebDriverAgent scans 8100-8199 when `USE_PORT` is unset, so a
|
|
909
|
-
* second runner comes up healthy on 8101 and a server pointed at 8100 drives
|
|
910
|
-
* the first simulator while reporting success. `ios_simulator_diagnostics`
|
|
911
|
-
* reports which simulator actually owns the port.
|
|
1048
|
+
* Where WebDriverAgent is *configured* to be: IOS_SIMULATOR_WDA_URL, or the
|
|
1049
|
+
* base port on loopback. Which runner a call actually reaches is decided per
|
|
1050
|
+
* simulator by `runnerUrl`; this is only the fallback when nothing better is
|
|
1051
|
+
* known.
|
|
912
1052
|
*/
|
|
913
1053
|
wdaUrl() {
|
|
914
1054
|
return this.opts.wdaUrl ?? `http://127.0.0.1:${this.opts.wdaPort}`;
|
|
@@ -917,24 +1057,110 @@ var SimulatorClient = class {
|
|
|
917
1057
|
get defaultSimulatorId() {
|
|
918
1058
|
return this.opts.defaultSimulatorId;
|
|
919
1059
|
}
|
|
1060
|
+
/** Where runner ports are allocated from, and the fallback when no owner is known. */
|
|
920
1061
|
get wdaPort() {
|
|
921
1062
|
return this.opts.wdaPort;
|
|
922
1063
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1064
|
+
/**
|
|
1065
|
+
* Every listener in the runner ranges, attributed to its simulator. Cached
|
|
1066
|
+
* for 2s: every UI call asks, and lsof plus ps costs tens of milliseconds.
|
|
1067
|
+
*/
|
|
1068
|
+
async listRunners(opts = {}) {
|
|
1069
|
+
const cached = this.runnerCache;
|
|
1070
|
+
if (!opts.fresh && cached && Date.now() - cached.at < 2e3) return cached.listeners;
|
|
1071
|
+
const listeners = await scanRunners(this.exec, this.opts.wdaPort);
|
|
1072
|
+
this.runnerCache = {
|
|
1073
|
+
at: Date.now(),
|
|
1074
|
+
listeners
|
|
1075
|
+
};
|
|
1076
|
+
return listeners;
|
|
1077
|
+
}
|
|
1078
|
+
/** Called once a runner is started, so the next call looks again. */
|
|
1079
|
+
forgetRunners() {
|
|
1080
|
+
this.runnerCache = void 0;
|
|
1081
|
+
}
|
|
1082
|
+
/** The first port nothing listens on, from the base port up. */
|
|
1083
|
+
async freeRunnerPort() {
|
|
1084
|
+
return freePort(await this.listRunners({ fresh: true }), this.opts.wdaPort);
|
|
1085
|
+
}
|
|
1086
|
+
/** The port of this simulator's own runner, when it has one. */
|
|
1087
|
+
async ownRunnerPort(target) {
|
|
1088
|
+
const id = target.id.toLowerCase();
|
|
1089
|
+
return (await this.listRunners({ fresh: true })).find((l) => l.udid?.toLowerCase() === id)?.port;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Which runner a call for this simulator goes to.
|
|
1093
|
+
*
|
|
1094
|
+
* A simulator shares the host's network stack, so every runner is a host
|
|
1095
|
+
* loopback port, and one server process — Bastion runs one for every session
|
|
1096
|
+
* on the Mac — sees all of them. A fixed port therefore meant one simulator
|
|
1097
|
+
* per process, and worse, a second simulator's calls went to the first one's
|
|
1098
|
+
* runner while every tool reported success.
|
|
1099
|
+
*
|
|
1100
|
+
* So the owner is looked up rather than assumed: the simulator's own runner
|
|
1101
|
+
* wherever it listens; failing that the configured URL, *unless* that is
|
|
1102
|
+
* known to be another simulator's, which is refused. Not knowing the owner
|
|
1103
|
+
* (lsof saw nothing) keeps the configured URL, which is the behaviour from
|
|
1104
|
+
* before ownership was checked.
|
|
1105
|
+
*/
|
|
1106
|
+
async runnerUrl(target) {
|
|
1107
|
+
const listeners = await this.listRunners();
|
|
1108
|
+
const id = target.id.toLowerCase();
|
|
1109
|
+
const own = listeners.find((l) => l.udid?.toLowerCase() === id);
|
|
1110
|
+
if (!this.opts.wdaUrl && own) return `http://127.0.0.1:${own.port}`;
|
|
1111
|
+
const configured = this.wdaUrl();
|
|
1112
|
+
const port = loopbackPort(configured);
|
|
1113
|
+
const owner = port === void 0 ? void 0 : listeners.find((l) => l.port === port && l.udid)?.udid;
|
|
1114
|
+
if (owner && owner.toLowerCase() !== id) throw new RunnerBelongsElsewhereError({
|
|
1115
|
+
target,
|
|
1116
|
+
owner: (await this.listSimulators()).find((sim) => sim.id.toLowerCase() === owner.toLowerCase())?.name ?? owner,
|
|
1117
|
+
port,
|
|
1118
|
+
freePort: freePort(listeners, this.opts.wdaPort),
|
|
1119
|
+
allowWrites: this.opts.allowWrites !== false
|
|
1120
|
+
});
|
|
1121
|
+
return configured;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* The WebDriverAgent client for one simulator, one per UDID so each keeps its
|
|
1125
|
+
* own session. The URL is resolved on every request, so a runner restarted on
|
|
1126
|
+
* another port is followed without a server restart.
|
|
1127
|
+
*/
|
|
1128
|
+
wda(target) {
|
|
1129
|
+
return this.wdaClient(target.id, () => this.runnerUrl(target));
|
|
1130
|
+
}
|
|
1131
|
+
/** The configured URL, unchecked — for diagnostics with no single target. */
|
|
1132
|
+
wdaConfigured() {
|
|
1133
|
+
return this.wdaClient("", async () => this.wdaUrl());
|
|
1134
|
+
}
|
|
1135
|
+
wdaClient(key, baseUrl) {
|
|
1136
|
+
const existing = this.wdaClients.get(key);
|
|
926
1137
|
if (existing) return existing;
|
|
927
1138
|
const client = new WdaClient({
|
|
928
|
-
baseUrl
|
|
1139
|
+
baseUrl,
|
|
929
1140
|
timeoutMs: this.opts.wdaTimeoutMs,
|
|
930
1141
|
remedies: simulatorWdaRemedies(this.opts.allowWrites !== false),
|
|
931
1142
|
...this.opts.fetch ? { fetch: this.opts.fetch } : {},
|
|
932
1143
|
...this.opts.logger ? { logger: this.opts.logger } : {}
|
|
933
1144
|
});
|
|
934
|
-
this.wdaClients.set(
|
|
1145
|
+
this.wdaClients.set(key, client);
|
|
935
1146
|
return client;
|
|
936
1147
|
}
|
|
937
1148
|
};
|
|
1149
|
+
/** The port of a loopback URL, or undefined for anything else. */
|
|
1150
|
+
const loopbackPort = (url) => {
|
|
1151
|
+
try {
|
|
1152
|
+
const parsed = new URL(url);
|
|
1153
|
+
if (![
|
|
1154
|
+
"127.0.0.1",
|
|
1155
|
+
"localhost",
|
|
1156
|
+
"[::1]"
|
|
1157
|
+
].includes(parsed.hostname)) return void 0;
|
|
1158
|
+
const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
|
|
1159
|
+
return Number.isInteger(port) ? port : void 0;
|
|
1160
|
+
} catch {
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
938
1164
|
// src/tools/util.ts
|
|
939
1165
|
/**
|
|
940
1166
|
* How the shared tools name themselves and each other in this server.
|
|
@@ -1153,40 +1379,6 @@ const registerAppTools = (server, client, launchArgs, outputDir) => {
|
|
|
1153
1379
|
};
|
|
1154
1380
|
// src/tools/diagnostics.ts
|
|
1155
1381
|
/**
|
|
1156
|
-
* Which simulator owns the WebDriverAgent port.
|
|
1157
|
-
*
|
|
1158
|
-
* This check has no device-server equivalent and it is the most valuable one
|
|
1159
|
-
* here. A simulator shares the host's network stack, so a runner's port is a
|
|
1160
|
-
* *host* port, and WebDriverAgent scans 8100-8199 when `USE_PORT` is unset. Two
|
|
1161
|
-
* booted simulators therefore give you a second runner answering healthily on
|
|
1162
|
-
* 8101 while a server pointed at 8100 drives the first one and reports success
|
|
1163
|
-
* on every call.
|
|
1164
|
-
*
|
|
1165
|
-
* Every process inside a simulator carries `SIMULATOR_UDID` in its environment,
|
|
1166
|
-
* and `ps -Eww` prints it, so the port's real owner is knowable rather than
|
|
1167
|
-
* assumed.
|
|
1168
|
-
*/
|
|
1169
|
-
const portOwner = async (client, port) => {
|
|
1170
|
-
try {
|
|
1171
|
-
const { stdout: pids } = await client.exec("/usr/sbin/lsof", [
|
|
1172
|
-
"-nP",
|
|
1173
|
-
`-iTCP:${port}`,
|
|
1174
|
-
"-sTCP:LISTEN",
|
|
1175
|
-
"-t"
|
|
1176
|
-
], 5e3);
|
|
1177
|
-
const pid = pids.trim().split("\n")[0];
|
|
1178
|
-
if (!pid) return void 0;
|
|
1179
|
-
const { stdout: env } = await client.exec("/bin/ps", [
|
|
1180
|
-
"-Eww",
|
|
1181
|
-
"-p",
|
|
1182
|
-
pid
|
|
1183
|
-
], 5e3);
|
|
1184
|
-
return /SIMULATOR_UDID=([0-9A-Fa-f-]{36})/.exec(env)?.[1];
|
|
1185
|
-
} catch {
|
|
1186
|
-
return;
|
|
1187
|
-
}
|
|
1188
|
-
};
|
|
1189
|
-
/**
|
|
1190
1382
|
* The whole point of this tool is that **it never throws**. Every other tool
|
|
1191
1383
|
* fails when nothing is booted, the runtime is missing or the runner is not up,
|
|
1192
1384
|
* and each of those looks much like the others from the outside. This one
|
|
@@ -1249,31 +1441,41 @@ const diagnose = async (client, ctx, deviceHint) => {
|
|
|
1249
1441
|
if (target) try {
|
|
1250
1442
|
display = await client.display(target);
|
|
1251
1443
|
} catch {}
|
|
1444
|
+
const listeners = await client.listRunners({ fresh: true });
|
|
1445
|
+
const names = new Map(simulators.map((sim) => [sim.id.toLowerCase(), sim.name]));
|
|
1446
|
+
const runners = listeners.flatMap((l) => l.udid ? [{
|
|
1447
|
+
udid: l.udid,
|
|
1448
|
+
name: names.get(l.udid.toLowerCase()) ?? l.udid,
|
|
1449
|
+
port: l.port
|
|
1450
|
+
}] : []);
|
|
1451
|
+
let url = client.wdaUrl();
|
|
1452
|
+
let refused = false;
|
|
1453
|
+
if (target) try {
|
|
1454
|
+
url = await client.runnerUrl(target);
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
refused = true;
|
|
1457
|
+
problems.push(err instanceof Error ? err.message : String(err));
|
|
1458
|
+
const remedy = err.remedy;
|
|
1459
|
+
if (remedy) nextSteps.push(remedy);
|
|
1460
|
+
}
|
|
1461
|
+
const port = portOf(url) ?? client.wdaPort;
|
|
1252
1462
|
const wda = {
|
|
1253
|
-
url
|
|
1254
|
-
port
|
|
1255
|
-
reachable: false
|
|
1463
|
+
url,
|
|
1464
|
+
port,
|
|
1465
|
+
reachable: false,
|
|
1466
|
+
runners
|
|
1256
1467
|
};
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1468
|
+
const owner = listeners.find((l) => l.port === port && l.udid)?.udid;
|
|
1469
|
+
if (owner) wda.boundTo = owner;
|
|
1470
|
+
if (refused) wda.error = "Not probed: the runner on this port belongs to another simulator.";
|
|
1471
|
+
else try {
|
|
1472
|
+
wda.state = await (target ? client.wda(target) : client.wdaConfigured()).status();
|
|
1262
1473
|
wda.reachable = true;
|
|
1263
1474
|
} catch (err) {
|
|
1264
1475
|
wda.error = err instanceof Error ? err.message : String(err);
|
|
1265
1476
|
problems.push("WebDriverAgent is not answering, so the UI tree and the input tools will fail.");
|
|
1266
1477
|
nextSteps.push((nothingBooted ? "Then, once it is booted: " : "") + startRunnerRemedy(ctx.allowWrites) + " Everything else — including screenshots — works without it.");
|
|
1267
1478
|
}
|
|
1268
|
-
if (wda.reachable) {
|
|
1269
|
-
const owner = await portOwner(client, client.wdaPort);
|
|
1270
|
-
if (owner) wda.boundTo = owner;
|
|
1271
|
-
if (owner && target && owner.toLowerCase() !== target.id.toLowerCase()) {
|
|
1272
|
-
const other = simulators.find((sim) => sim.id.toLowerCase() === owner.toLowerCase());
|
|
1273
|
-
problems.push(`Port ${client.wdaPort} is owned by ${other?.name ?? owner}, not ${target.name}. Every tap and every UI tree read would go to the wrong screen.`);
|
|
1274
|
-
nextSteps.push("WebDriverAgent scans 8100-8199 when USE_PORT is unset, so a second simulator's runner takes the next free port and nothing looks wrong. Give this one its own port with IOS_SIMULATOR_WDA_PORT and start its runner with a matching USE_PORT.");
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
1479
|
if (unavailable.length > 0) nextSteps.push(`${unavailable.length} simulators have no installed runtime and cannot be used at all. \`xcrun simctl delete unavailable\` clears them.`);
|
|
1278
1480
|
return {
|
|
1279
1481
|
ok: problems.length === 0,
|
|
@@ -1292,10 +1494,18 @@ const diagnose = async (client, ctx, deviceHint) => {
|
|
|
1292
1494
|
nextSteps
|
|
1293
1495
|
};
|
|
1294
1496
|
};
|
|
1497
|
+
const portOf = (url) => {
|
|
1498
|
+
try {
|
|
1499
|
+
const port = Number(new URL(url).port);
|
|
1500
|
+
return port > 0 ? port : void 0;
|
|
1501
|
+
} catch {
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1295
1505
|
const registerDiagnosticsTools = (server, client, ctx) => {
|
|
1296
1506
|
server.registerTool("ios_simulator_diagnostics", {
|
|
1297
1507
|
title: "iOS Simulator: Diagnostics",
|
|
1298
|
-
description: "Report both lanes separately and never fail: which simulators exist and which are usable, which one this server would drive, its screen geometry, and whether WebDriverAgent is answering — including
|
|
1508
|
+
description: "Report both lanes separately and never fail: which simulators exist and which are usable, which one this server would drive, its screen geometry, and whether WebDriverAgent is answering — including every simulator's runner and its port, and which one the target's taps would reach. Start here when anything else misbehaves.",
|
|
1299
1509
|
inputSchema: z.object({ device: deviceArg }),
|
|
1300
1510
|
annotations: { readOnlyHint: true }
|
|
1301
1511
|
}, async ({ device }) => wrap(async () => diagnose(client, ctx, device)));
|
|
@@ -1910,10 +2120,10 @@ const runnerBuilt = async () => {
|
|
|
1910
2120
|
const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpawnRunner) => {
|
|
1911
2121
|
server.registerTool("ios_simulator_restart_wda", {
|
|
1912
2122
|
title: "iOS Simulator: Restart WebDriverAgent",
|
|
1913
|
-
description: "Start the WebDriverAgent runner, or restart it after it has died. Only the UI tree and the input tools need it — screenshots and everything simctl does keep working without it. The runner is started detached so it outlives this conversation, and its output goes to a log file rather than to you. It does not wait: give it fifteen seconds or so, then check ios_simulator_diagnostics.",
|
|
2123
|
+
description: "Start the WebDriverAgent runner, or restart it after it has died. Only the UI tree and the input tools need it — screenshots and everything simctl does keep working without it. Each simulator gets its own runner on its own port, so several can be driven at once. The runner is started detached so it outlives this conversation, and its output goes to a log file rather than to you. It does not wait: give it fifteen seconds or so, then check ios_simulator_diagnostics.",
|
|
1914
2124
|
inputSchema: z.object({
|
|
1915
2125
|
device: deviceArg,
|
|
1916
|
-
port: z.number().int().min(1).max(65535).optional().describe("Port to bind, passed to the runner as USE_PORT.
|
|
2126
|
+
port: z.number().int().min(1).max(65535).optional().describe("Port to bind, passed to the runner as USE_PORT. Leave it out: the simulator's current runner port is reused, or else the first free one from IOS_SIMULATOR_WDA_PORT up, and the other tools find the runner wherever it listens.")
|
|
1917
2127
|
}),
|
|
1918
2128
|
annotations: {
|
|
1919
2129
|
readOnlyHint: false,
|
|
@@ -1922,7 +2132,7 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
|
|
|
1922
2132
|
}
|
|
1923
2133
|
}, async ({ device, port }) => wrap(async () => {
|
|
1924
2134
|
const target = client.requireBooted(await client.resolveTarget(device));
|
|
1925
|
-
const usePort = port ?? client.
|
|
2135
|
+
const usePort = port ?? await client.ownRunnerPort(target) ?? await client.freeRunnerPort();
|
|
1926
2136
|
const { built, products } = await runnerBuilt();
|
|
1927
2137
|
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." });
|
|
1928
2138
|
const logPath = join(outputDir, "wda.log");
|
|
@@ -1936,6 +2146,7 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
|
|
|
1936
2146
|
},
|
|
1937
2147
|
logPath
|
|
1938
2148
|
});
|
|
2149
|
+
client.forgetRunners();
|
|
1939
2150
|
if (pid === void 0) throw new IosError$1("Could not start the runner — the process had no pid.", { remedy: START_RUNNER_SCRIPT_REMEDY });
|
|
1940
2151
|
return {
|
|
1941
2152
|
started: true,
|
|
@@ -2165,6 +2376,6 @@ const loadConfig = (env = process.env, configPath = defaultConfigPath(env)) => {
|
|
|
2165
2376
|
openPath: pick(trimmed(env.IOS_SIMULATOR_OPEN_PATH), "openPath")
|
|
2166
2377
|
});
|
|
2167
2378
|
};
|
|
2168
|
-
export {
|
|
2379
|
+
export { WdaUnavailableError as A, START_RUNNER_TOOL_REMEDY as C, TOOLCHAIN_REMEDY as D, TIMEOUT_REMEDY as E, startRunnerRemedy as M, wdaUnavailableRemedy as N, ToolchainError as O, BUILD_INFO as P, START_RUNNER_SCRIPT_REMEDY as S, SimulatorNotFoundError as T, CommandTimeoutError as _, createServer as a, RunnerBelongsElsewhereError as b, assertNotBulkTarget as c, summarizeDevices as d, toListRow as f, CommandError$1 as g, toDisplayInfo as h, SERVER_VERSION as i, WritesDisabledError as j, WdaError 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, SimulatorNotBootedError as w, RuntimeUnavailableError as x, IosError$1 as y };
|
|
2169
2380
|
|
|
2170
|
-
//# sourceMappingURL=config-
|
|
2381
|
+
//# sourceMappingURL=config-BA3aCrX9.js.map
|