@mgcrea/mcp-ios-simulator 0.2.1 → 0.3.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/README.md +93 -25
- package/dist/cli.js +1 -1
- package/dist/{config-D-Zn0z5X.js → config-D0JgmT_U.js} +509 -43
- package/dist/config-D0JgmT_U.js.map +1 -0
- package/dist/index.d.ts +124 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/dist/config-D-Zn0z5X.js.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { mkdir, mkdtemp, open, readFile, rm, writeFile } from "node:fs/promises";
|
|
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";
|
|
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";
|
|
@@ -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: "b6a6636",
|
|
27
|
+
gitCommitDate: "2026-10-08T23:42:11+02:00"
|
|
28
28
|
};
|
|
29
29
|
//#endregion
|
|
30
30
|
//#region src/client/errors.ts
|
|
@@ -206,6 +206,52 @@ const toDisplayInfo = (profile, capture) => {
|
|
|
206
206
|
};
|
|
207
207
|
};
|
|
208
208
|
//#endregion
|
|
209
|
+
//#region src/client/logs.ts
|
|
210
|
+
const defaultProcessAlive = (pid) => {
|
|
211
|
+
try {
|
|
212
|
+
process.kill(pid, 0);
|
|
213
|
+
return true;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return err.code === "EPERM";
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
var LaunchCaptures = class {
|
|
219
|
+
alive;
|
|
220
|
+
captures = /* @__PURE__ */ new Map();
|
|
221
|
+
latest;
|
|
222
|
+
constructor(alive = defaultProcessAlive) {
|
|
223
|
+
this.alive = alive;
|
|
224
|
+
}
|
|
225
|
+
key(udid, bundleId) {
|
|
226
|
+
return `${udid}\u0000${bundleId}`;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* One capture per simulator and app. A relaunch replaces the record rather
|
|
230
|
+
* than adding a second, so a read never has to choose between two files of
|
|
231
|
+
* the same app — the older file stays on disk, it is just no longer "the" log.
|
|
232
|
+
*/
|
|
233
|
+
record(capture) {
|
|
234
|
+
this.captures.set(this.key(capture.udid, capture.bundleId), capture);
|
|
235
|
+
this.latest = capture;
|
|
236
|
+
}
|
|
237
|
+
find(udid, bundleId) {
|
|
238
|
+
if (udid && bundleId) return this.captures.get(this.key(udid, bundleId));
|
|
239
|
+
const all = [...this.captures.values()].filter((c) => (!udid || c.udid === udid) && (!bundleId || c.bundleId === bundleId));
|
|
240
|
+
if (all.length === 0) return void 0;
|
|
241
|
+
return this.latest && all.includes(this.latest) ? this.latest : all.toSorted((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Whether the app is still writing. A pid can be reused once the app exits,
|
|
245
|
+
* and that is accepted: the cost of a false "running" is only that a final
|
|
246
|
+
* half-written line waits for the next read.
|
|
247
|
+
*/
|
|
248
|
+
running(capture) {
|
|
249
|
+
return capture.pid !== void 0 && this.alive(capture.pid);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
/** True when `path` exists on this Mac. */
|
|
253
|
+
const exists = async (path) => stat(path).then(() => true, () => false);
|
|
254
|
+
//#endregion
|
|
209
255
|
//#region src/client/shape.ts
|
|
210
256
|
/**
|
|
211
257
|
* Flatten the runtime-keyed map `list devices` returns.
|
|
@@ -228,7 +274,8 @@ const summarizeDevices = (devices, runtimes, deviceTypes) => {
|
|
|
228
274
|
deviceTypeIdentifier: sim.deviceTypeIdentifier,
|
|
229
275
|
available: sim.isAvailable !== false,
|
|
230
276
|
...sim.availabilityError ? { unavailableReason: sim.availabilityError } : {},
|
|
231
|
-
...sim.lastBootedAt ? { lastBootedAt: sim.lastBootedAt } : {}
|
|
277
|
+
...sim.lastBootedAt ? { lastBootedAt: sim.lastBootedAt } : {},
|
|
278
|
+
...sim.dataPath ? { dataPath: sim.dataPath } : {}
|
|
232
279
|
})));
|
|
233
280
|
};
|
|
234
281
|
/** `file:///Users/…/Foo.app/` → `/Users/…/Foo.app`, which is what a person can use. */
|
|
@@ -291,6 +338,7 @@ const remedyFor = (stderr) => {
|
|
|
291
338
|
if (text.includes("unable to lookup in current state: shutdown")) return "The simulator is shut down. Boot it with ios_simulator_power {\"state\":\"booted\"}.";
|
|
292
339
|
if (text.includes("current state: booted")) return "The simulator is already booted, which for most operations means there is nothing to do.";
|
|
293
340
|
if (text.includes("no such file or directory")) return "Check the path exists and is a `.app` **bundle directory** built for the simulator — an `.ipa` or a device build will not install, and reports exactly this.";
|
|
341
|
+
if (text.includes("the request to open")) return "Check the app is installed with ios_simulator_list_apps: a bundle id that is not there fails exactly like this, with nothing in the message saying so.";
|
|
294
342
|
if (text.includes("failed to initialize io ports")) return "The simulator's runtime is not installed, so CoreSimulator cannot service it. Install the runtime, or clear the orphans with `xcrun simctl delete unavailable`.";
|
|
295
343
|
};
|
|
296
344
|
/**
|
|
@@ -399,17 +447,24 @@ var Simctl = class {
|
|
|
399
447
|
/**
|
|
400
448
|
* Put a window on a booted simulator. A `boot` alone is headless.
|
|
401
449
|
*
|
|
402
|
-
* Xcode 27 ships no Simulator.app — the window belongs to DeviceHub
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
450
|
+
* Xcode 27 ships no Simulator.app — the window belongs to DeviceHub — while
|
|
451
|
+
* Xcode 26 and earlier only have Simulator.app. Try the old app first, since
|
|
452
|
+
* on a Mac with both the one the user already has open is the one they
|
|
453
|
+
* expect; fall back to DeviceHub.
|
|
454
|
+
*
|
|
455
|
+
* DeviceHub is opened by bundle id, never by URL. Measured on Xcode 27.0: no
|
|
456
|
+
* `devices://` form selects a device — `device/<UDID>`, a bare UDID and
|
|
457
|
+
* `simulator/<UDID>` all leave the window on whatever it showed — and a query
|
|
458
|
+
* form it did not recognise made DeviceHub quit, which shuts down every
|
|
459
|
+
* booted simulator on the Mac. So it cannot be told which device to show;
|
|
460
|
+
* the caller is told that instead.
|
|
406
461
|
*/
|
|
407
|
-
async openApp(openPath
|
|
462
|
+
async openApp(openPath) {
|
|
408
463
|
try {
|
|
409
464
|
await this.exec(openPath, ["-a", "Simulator"], this.opts.timeoutMs);
|
|
410
465
|
return "Simulator";
|
|
411
466
|
} catch {
|
|
412
|
-
await this.exec(openPath, [
|
|
467
|
+
await this.exec(openPath, ["-b", "com.apple.dt.Devices"], this.opts.timeoutMs);
|
|
413
468
|
return "DeviceHub";
|
|
414
469
|
}
|
|
415
470
|
}
|
|
@@ -447,23 +502,55 @@ var Simctl = class {
|
|
|
447
502
|
* Launch, optionally replacing a running copy.
|
|
448
503
|
*
|
|
449
504
|
* Environment variables do not go on the command line: simctl reads them from
|
|
450
|
-
* its **own** environment under a `SIMCTL_CHILD_` prefix. `ExecImpl`
|
|
451
|
-
* minimal environment on purpose, so
|
|
452
|
-
*
|
|
505
|
+
* its **own** environment under a `SIMCTL_CHILD_` prefix. `ExecImpl` takes
|
|
506
|
+
* argv and pins a minimal environment on purpose, so the variables reach
|
|
507
|
+
* simctl through `/usr/bin/env` as `SIMCTL_CHILD_NAME=value` argv entries in
|
|
508
|
+
* front of xcrun — still no shell, and still one argv entry per value. Names
|
|
509
|
+
* are checked here because `env` reads everything up to the first entry with
|
|
510
|
+
* no `=` as an assignment, so a name holding one would shift the command.
|
|
511
|
+
*
|
|
512
|
+
* `stdout` and `stderr` may be the same path, and that is how a log capture
|
|
513
|
+
* uses them: measured on Xcode 27.0 with an iOS 27.0 runtime, a probe app
|
|
514
|
+
* writing to `print`, to stderr and to `Logger` in turn left one file with all
|
|
515
|
+
* three interleaved in order, nothing overwritten. The redirect *appends* — a
|
|
516
|
+
* second launch into the same file kept the first one's lines — and where it
|
|
517
|
+
* lands is not always the path given; see `SimulatorClient.locateRedirect`.
|
|
453
518
|
*/
|
|
454
519
|
async launch(udid, bundleId, opts = {}) {
|
|
455
520
|
assertNotBulkTarget(udid);
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
...opts.stdout ? [`--stdout=${opts.stdout}`] : [],
|
|
459
|
-
...opts.stderr ? [`--stderr=${opts.stderr}`] : []
|
|
460
|
-
];
|
|
461
|
-
return this.run([
|
|
521
|
+
const argv = [
|
|
522
|
+
"simctl",
|
|
462
523
|
"launch",
|
|
463
|
-
...
|
|
524
|
+
...[
|
|
525
|
+
...opts.terminateExisting ? ["--terminate-running-process"] : [],
|
|
526
|
+
...opts.stdout ? [`--stdout=${opts.stdout}`] : [],
|
|
527
|
+
...opts.stderr ? [`--stderr=${opts.stderr}`] : []
|
|
528
|
+
],
|
|
464
529
|
udid,
|
|
465
530
|
bundleId,
|
|
466
531
|
...opts.args ?? []
|
|
532
|
+
];
|
|
533
|
+
const env = Object.entries(opts.env ?? {});
|
|
534
|
+
if (env.length === 0) return this.run(argv.slice(1));
|
|
535
|
+
for (const [name] of env) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new IosError$1(`Refusing environment variable name "${name}".`, { remedy: "Use letters, digits and underscores only, e.g. OS_ACTIVITY_DT_MODE." });
|
|
536
|
+
this.opts.logger?.debug?.("simctl", ...argv.slice(1), `(env: ${env.map(([k]) => k).join(" ")})`);
|
|
537
|
+
const { stdout } = await this.exec(this.opts.envPath, [
|
|
538
|
+
...env.map(([name, value]) => `SIMCTL_CHILD_${name}=${value}`),
|
|
539
|
+
this.opts.xcrunPath,
|
|
540
|
+
...argv
|
|
541
|
+
], this.opts.timeoutMs);
|
|
542
|
+
return stdout;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Remove an app and its data container. Irreversible, which is why the tool
|
|
546
|
+
* in front of this takes `confirm`.
|
|
547
|
+
*/
|
|
548
|
+
async uninstall(udid, bundleId) {
|
|
549
|
+
assertNotBulkTarget(udid);
|
|
550
|
+
await this.run([
|
|
551
|
+
"uninstall",
|
|
552
|
+
udid,
|
|
553
|
+
bundleId
|
|
467
554
|
]);
|
|
468
555
|
}
|
|
469
556
|
async terminate(udid, bundleId) {
|
|
@@ -638,6 +725,7 @@ var SimulatorClient = class {
|
|
|
638
725
|
plutilPath;
|
|
639
726
|
openPath;
|
|
640
727
|
execTimeoutMs;
|
|
728
|
+
captures;
|
|
641
729
|
opts;
|
|
642
730
|
wdaClients = /* @__PURE__ */ new Map();
|
|
643
731
|
/** `simctl list` is ~80ms and most tools want it twice; 2s collapses that. */
|
|
@@ -653,9 +741,11 @@ var SimulatorClient = class {
|
|
|
653
741
|
this.plutilPath = opts.plutilPath;
|
|
654
742
|
this.openPath = opts.openPath;
|
|
655
743
|
this.execTimeoutMs = opts.execTimeoutMs;
|
|
744
|
+
this.captures = new LaunchCaptures(opts.processAlive);
|
|
656
745
|
this.simctl = new Simctl({
|
|
657
746
|
xcrunPath: opts.xcrunPath,
|
|
658
747
|
plutilPath: opts.plutilPath,
|
|
748
|
+
envPath: opts.envPath ?? "/usr/bin/env",
|
|
659
749
|
timeoutMs: opts.execTimeoutMs,
|
|
660
750
|
...opts.exec ? { exec: opts.exec } : {},
|
|
661
751
|
...opts.logger ? { logger: opts.logger } : {}
|
|
@@ -750,6 +840,41 @@ var SimulatorClient = class {
|
|
|
750
840
|
const capture = capturePng ? pngDimensions(Buffer.from(capturePng, "base64")) : void 0;
|
|
751
841
|
return toDisplayInfo(profile, capture);
|
|
752
842
|
}
|
|
843
|
+
/**
|
|
844
|
+
* Where a `simctl launch --stdout/--stderr` file actually landed.
|
|
845
|
+
*
|
|
846
|
+
* Not necessarily where it was asked to. The redirect is opened by the
|
|
847
|
+
* simulator's own launchd, which sees the simulator's filesystem, and that
|
|
848
|
+
* has its own `/tmp` and `/var/folders`. Measured on an iOS 27.0 runtime:
|
|
849
|
+
* `--stdout=/tmp/x/a.out` created `<dataPath>/tmp/x/a.out`, and a path under
|
|
850
|
+
* `/var/folders/…/T` — which is where `os.tmpdir()`, and so this server's
|
|
851
|
+
* default output directory, points — landed under `<dataPath>/var/folders/…`,
|
|
852
|
+
* the `/private` prefix of either making no difference. A path under `/Users`
|
|
853
|
+
* was written exactly where it said, missing directories created.
|
|
854
|
+
*
|
|
855
|
+
* Rather than encode which prefixes are remapped, which is a property of a
|
|
856
|
+
* runtime and can change with the next one, this looks: the path as given
|
|
857
|
+
* first, then the same path under the simulator's data root. Neither existing
|
|
858
|
+
* means the redirect went somewhere unforeseen, and the path as given is
|
|
859
|
+
* returned with `found: false` so the caller can say so.
|
|
860
|
+
*/
|
|
861
|
+
async locateRedirect(target, path) {
|
|
862
|
+
if (await exists(path)) return {
|
|
863
|
+
path,
|
|
864
|
+
found: true
|
|
865
|
+
};
|
|
866
|
+
if (target.dataPath) {
|
|
867
|
+
const inside = join(target.dataPath, path.replace(/^\/private(?=\/)/, ""));
|
|
868
|
+
if (await exists(inside)) return {
|
|
869
|
+
path: inside,
|
|
870
|
+
found: true
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
path,
|
|
875
|
+
found: false
|
|
876
|
+
};
|
|
877
|
+
}
|
|
753
878
|
/** The capture lane that needs no runner — the whole reason this server is cheap to start. */
|
|
754
879
|
async screenshotPng(target) {
|
|
755
880
|
this.requireBooted(target);
|
|
@@ -821,6 +946,25 @@ const { bundleIdArg, confirmArg, detailArg, screenshotArg, settleArg, xArg, yArg
|
|
|
821
946
|
const deviceArg = args.targetArg;
|
|
822
947
|
//#endregion
|
|
823
948
|
//#region src/tools/apps.ts
|
|
949
|
+
/** `io.mgcrea.Canopy: 12345` is the whole of what `simctl launch` prints. */
|
|
950
|
+
const parsePid = (output) => {
|
|
951
|
+
const pid = /:\s*(\d+)\s*$/.exec(output.trim())?.[1];
|
|
952
|
+
return pid ? Number(pid) : void 0;
|
|
953
|
+
};
|
|
954
|
+
const NOT_FOUND_WARNING = "The log file is neither where it was asked for nor under the simulator's data directory. Set IOS_SIMULATOR_OUTPUT_DIR to a directory under your home folder, which the simulator writes to as-is.";
|
|
955
|
+
/**
|
|
956
|
+
* Find the redirect, giving the simulator a moment to create it. `simctl
|
|
957
|
+
* launch` returns once the app is spawned, and the file is opened as part of
|
|
958
|
+
* that spawn, so the first look nearly always finds it; the retries only cover
|
|
959
|
+
* a slow spawn rather than a known race.
|
|
960
|
+
*/
|
|
961
|
+
const locate = async (client, target, path) => {
|
|
962
|
+
for (let attempt = 0;; attempt += 1) {
|
|
963
|
+
const found = await client.locateRedirect(target, path);
|
|
964
|
+
if (found.found || attempt >= 4) return found;
|
|
965
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
966
|
+
}
|
|
967
|
+
};
|
|
824
968
|
const registerListAppsTool = (server, client) => {
|
|
825
969
|
server.registerTool("ios_simulator_list_apps", {
|
|
826
970
|
title: "iOS Simulator: List Apps",
|
|
@@ -860,39 +1004,95 @@ const registerAppTools = (server, client, launchArgs, outputDir) => {
|
|
|
860
1004
|
}));
|
|
861
1005
|
server.registerTool("ios_simulator_launch", {
|
|
862
1006
|
title: "iOS Simulator: Launch",
|
|
863
|
-
description: "Launch an installed app. Pass `arguments` to put it into a fixture or demo mode — that is what IOS_SIMULATOR_LAUNCH_ARGS sets as the default for every launch that does not override it. Standard output and error are captured to files under the output directory, so an app that dies on launch leaves something readable behind.",
|
|
1007
|
+
description: "Launch an installed app. Pass `arguments` to put it into a fixture or demo mode — that is what IOS_SIMULATOR_LAUNCH_ARGS sets as the default for every launch that does not override it. Standard output and error are captured to files under the output directory, so an app that dies on launch leaves something readable behind. Pass `capture_logs: true` to also get its Logger/os_log output, in one file read with ios_simulator_read_logs.",
|
|
864
1008
|
inputSchema: z.object({
|
|
865
1009
|
device: deviceArg,
|
|
866
1010
|
bundle_id: bundleIdArg,
|
|
867
1011
|
arguments: z.array(z.string()).optional().describe("Launch arguments, e.g. [\"-DemoMode\", \"YES\"]. Overrides IOS_SIMULATOR_LAUNCH_ARGS rather than adding to it."),
|
|
868
|
-
terminate_first: z.boolean().default(true).describe("Replace a running copy rather than attaching to it. On by default so a launch means a fresh process and a predictable first screen.")
|
|
1012
|
+
terminate_first: z.boolean().default(true).describe("Replace a running copy rather than attaching to it. On by default so a launch means a fresh process and a predictable first screen."),
|
|
1013
|
+
capture_logs: z.boolean().default(false).describe("Write stdout, stderr and every Logger/os_log message to one fresh file, for ios_simulator_read_logs. Sets OS_ACTIVITY_DT_MODE=YES in the app's environment, which is what Xcode sets to mirror unified logging onto stderr; without it an app's Logger output reaches no file at all.")
|
|
869
1014
|
}),
|
|
870
1015
|
annotations: {
|
|
871
1016
|
readOnlyHint: false,
|
|
872
1017
|
destructiveHint: false,
|
|
873
1018
|
idempotentHint: false
|
|
874
1019
|
}
|
|
875
|
-
}, async ({ device, bundle_id, arguments: args, terminate_first }) => wrap(async () => {
|
|
1020
|
+
}, async ({ device, bundle_id, arguments: args, terminate_first, capture_logs }) => wrap(async () => {
|
|
876
1021
|
const target = client.requireBooted(await client.resolveTarget(device));
|
|
877
1022
|
const effective = args ?? launchArgs;
|
|
878
|
-
const
|
|
879
|
-
|
|
1023
|
+
const said = {
|
|
1024
|
+
...effective.length > 0 ? { arguments: effective } : {},
|
|
1025
|
+
...args === void 0 && launchArgs.length > 0 ? { argumentsFrom: "IOS_SIMULATOR_LAUNCH_ARGS" } : {}
|
|
1026
|
+
};
|
|
1027
|
+
if (capture_logs) {
|
|
1028
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-");
|
|
1029
|
+
const requested = join(outputDir, "logs", `${bundle_id}-${stamp}.log`);
|
|
1030
|
+
const output = await client.simctl.launch(target.id, bundle_id, {
|
|
1031
|
+
args: effective,
|
|
1032
|
+
terminateExisting: terminate_first,
|
|
1033
|
+
stdout: requested,
|
|
1034
|
+
stderr: requested,
|
|
1035
|
+
env: { OS_ACTIVITY_DT_MODE: "YES" }
|
|
1036
|
+
});
|
|
1037
|
+
const pid = parsePid(output);
|
|
1038
|
+
const log = await locate(client, target, requested);
|
|
1039
|
+
client.captures.record({
|
|
1040
|
+
udid: target.id,
|
|
1041
|
+
simulatorName: target.name,
|
|
1042
|
+
bundleId: bundle_id,
|
|
1043
|
+
path: log.path,
|
|
1044
|
+
pid,
|
|
1045
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1046
|
+
});
|
|
1047
|
+
return {
|
|
1048
|
+
launched: bundle_id,
|
|
1049
|
+
...pid !== void 0 ? { pid } : {},
|
|
1050
|
+
...said,
|
|
1051
|
+
capturingLogs: true,
|
|
1052
|
+
log: log.path,
|
|
1053
|
+
...log.found ? {} : { warning: NOT_FOUND_WARNING }
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
const stdout = join(outputDir, `${bundle_id}.out.log`);
|
|
1057
|
+
const stderr = join(outputDir, `${bundle_id}.err.log`);
|
|
880
1058
|
const output = await client.simctl.launch(target.id, bundle_id, {
|
|
881
1059
|
args: effective,
|
|
882
1060
|
terminateExisting: terminate_first,
|
|
883
1061
|
stdout,
|
|
884
1062
|
stderr
|
|
885
1063
|
});
|
|
886
|
-
const pid =
|
|
1064
|
+
const pid = parsePid(output);
|
|
1065
|
+
const [out, err] = await Promise.all([locate(client, target, stdout), locate(client, target, stderr)]);
|
|
887
1066
|
return {
|
|
888
1067
|
launched: bundle_id,
|
|
889
|
-
...pid ? { pid
|
|
890
|
-
...
|
|
891
|
-
...args === void 0 && launchArgs.length > 0 ? { argumentsFrom: "IOS_SIMULATOR_LAUNCH_ARGS" } : {},
|
|
1068
|
+
...pid !== void 0 ? { pid } : {},
|
|
1069
|
+
...said,
|
|
892
1070
|
logs: {
|
|
893
|
-
stdout,
|
|
894
|
-
stderr
|
|
895
|
-
}
|
|
1071
|
+
stdout: out.path,
|
|
1072
|
+
stderr: err.path
|
|
1073
|
+
},
|
|
1074
|
+
...out.found && err.found ? {} : { warning: NOT_FOUND_WARNING }
|
|
1075
|
+
};
|
|
1076
|
+
}));
|
|
1077
|
+
server.registerTool("ios_simulator_uninstall", {
|
|
1078
|
+
title: "iOS Simulator: Uninstall",
|
|
1079
|
+
description: "Remove an app from the simulator, **with its data container** — documents, databases and preferences all go, and nothing brings them back. To get back to a cold start while keeping the data, use ios_simulator_terminate. To test a genuine first launch of one app, this is narrower than ios_simulator_erase, which wipes every app on the simulator. Uninstalling an app that is not installed also succeeds, so success does not prove it was there.",
|
|
1080
|
+
inputSchema: z.object({
|
|
1081
|
+
device: deviceArg,
|
|
1082
|
+
bundle_id: bundleIdArg,
|
|
1083
|
+
confirm: z.literal(true).describe("Must be true. Explicit acknowledgement that the app's data is deleted.")
|
|
1084
|
+
}),
|
|
1085
|
+
annotations: {
|
|
1086
|
+
readOnlyHint: false,
|
|
1087
|
+
destructiveHint: true,
|
|
1088
|
+
idempotentHint: true
|
|
1089
|
+
}
|
|
1090
|
+
}, async ({ device, bundle_id }) => wrap(async () => {
|
|
1091
|
+
const target = client.requireBooted(await client.resolveTarget(device));
|
|
1092
|
+
await client.simctl.uninstall(target.id, bundle_id);
|
|
1093
|
+
return {
|
|
1094
|
+
uninstalled: bundle_id,
|
|
1095
|
+
udid: target.id
|
|
896
1096
|
};
|
|
897
1097
|
}));
|
|
898
1098
|
server.registerTool("ios_simulator_terminate", {
|
|
@@ -1340,7 +1540,10 @@ const registerLifecycleTools = (server, client) => {
|
|
|
1340
1540
|
const already = target.state === "Booted";
|
|
1341
1541
|
if (!already) await client.simctl.boot(target.id);
|
|
1342
1542
|
let windowWarning;
|
|
1343
|
-
|
|
1543
|
+
let windowNote;
|
|
1544
|
+
if (open_window) await client.simctl.openApp(client.openPath).then((app) => {
|
|
1545
|
+
if (app === "DeviceHub") windowNote = `Opened DeviceHub. It may still be showing another device: select ${target.name} in its sidebar to watch this one.`;
|
|
1546
|
+
}).catch((err) => {
|
|
1344
1547
|
windowWarning = `Booted, but no window could be opened: ${err instanceof Error ? err.message : String(err)}`;
|
|
1345
1548
|
});
|
|
1346
1549
|
const deadline = Date.now() + wait_ms;
|
|
@@ -1355,12 +1558,13 @@ const registerLifecycleTools = (server, client) => {
|
|
|
1355
1558
|
name: target.name,
|
|
1356
1559
|
state: observed,
|
|
1357
1560
|
...already ? { alreadyThere: true } : {},
|
|
1561
|
+
...windowNote ? { note: windowNote } : {},
|
|
1358
1562
|
...observed === "Booted" ? windowWarning ? { warning: windowWarning } : {} : { warning: `Still ${observed} after ${wait_ms}ms. Raise wait_ms, or check Xcode.` }
|
|
1359
1563
|
};
|
|
1360
1564
|
}));
|
|
1361
1565
|
server.registerTool("ios_simulator_erase", {
|
|
1362
1566
|
title: "iOS Simulator: Erase",
|
|
1363
|
-
description: "Wipe a simulator back to a factory state: installed apps, their data, the keychain and every granted permission.
|
|
1567
|
+
description: "Wipe a simulator back to a factory state: installed apps, their data, the keychain and every granted permission. Irreversible, hence `confirm`; ios_simulator_uninstall is the narrower way to a first launch of one app. A booted simulator is shut down first and booted again afterwards, because simctl refuses to erase a running one.",
|
|
1364
1568
|
inputSchema: z.object({
|
|
1365
1569
|
device: deviceArg,
|
|
1366
1570
|
confirm: confirmArg
|
|
@@ -1385,6 +1589,247 @@ const registerLifecycleTools = (server, client) => {
|
|
|
1385
1589
|
}));
|
|
1386
1590
|
};
|
|
1387
1591
|
//#endregion
|
|
1592
|
+
//#region src/client/crash.ts
|
|
1593
|
+
/**
|
|
1594
|
+
* Simulator crash reports, out of this Mac's own DiagnosticReports.
|
|
1595
|
+
*
|
|
1596
|
+
* A simulator app is a Mac process, so when it crashes the Mac's ReportCrash
|
|
1597
|
+
* writes the `.ips` — into `~/Library/Logs/DiagnosticReports`, older ones under
|
|
1598
|
+
* `Retired/`, mixed in with every Mac app's and daemon's. On the machine this
|
|
1599
|
+
* was written against that was 593 `.ips` files, 464 of them from simulators,
|
|
1600
|
+
* and nearly all of those were the simulators' own daemons rather than an app
|
|
1601
|
+
* anyone was debugging.
|
|
1602
|
+
*
|
|
1603
|
+
* Telling the two apart, as measured on Xcode 27.0 with an iOS 27.0 runtime:
|
|
1604
|
+
*
|
|
1605
|
+
* - The header line's `platform` is the Mach-O platform of the crashed binary.
|
|
1606
|
+
* Every simulator report had 7 (`PLATFORM_IOSSIMULATOR` in
|
|
1607
|
+
* `<mach-o/loader.h>`) and every Mac report had 1, with no exceptions either
|
|
1608
|
+
* way. That is one line of a few hundred bytes, so it is the filter.
|
|
1609
|
+
* - The header's `is_simulated` is **not** the marker, despite the name: it
|
|
1610
|
+
* was set on 13 reports, every one a Mac process with platform 1 — Safari,
|
|
1611
|
+
* Mail and two of their helpers.
|
|
1612
|
+
* - `procPath` does not say either: it is anonymised to `/Volumes/VOLUME/…` or
|
|
1613
|
+
* `/Users/USER/…` with the middle starred out, so no device path survives.
|
|
1614
|
+
* - Which simulator is in the report body, as `coalitionName`:
|
|
1615
|
+
* `com.apple.CoreSimulator.SimDevice.<UDID>`. Its key ended by byte 1,731 in
|
|
1616
|
+
* every one of the 464, so reading the first `HEAD_BYTES` finds it without
|
|
1617
|
+
* parsing reports that run to 80 KB.
|
|
1618
|
+
*/
|
|
1619
|
+
/** `PLATFORM_*SIMULATOR` in `<mach-o/loader.h>`: iOS, tvOS, watchOS, visionOS. Only 7 was seen. */
|
|
1620
|
+
const SIMULATOR_PLATFORMS = /* @__PURE__ */ new Set([
|
|
1621
|
+
7,
|
|
1622
|
+
8,
|
|
1623
|
+
9,
|
|
1624
|
+
12
|
|
1625
|
+
]);
|
|
1626
|
+
/** Enough for the header and the body's `coalitionName`, with room to spare. */
|
|
1627
|
+
const HEAD_BYTES = 8192;
|
|
1628
|
+
const COALITION = /"coalitionName"\s*:\s*"com\.apple\.CoreSimulator\.SimDevice\.([0-9A-Fa-f-]{36})"/;
|
|
1629
|
+
/** Opened at once. The directory holds hundreds of files; this keeps clear of EMFILE. */
|
|
1630
|
+
const CONCURRENCY = 32;
|
|
1631
|
+
const readHead = async (path) => {
|
|
1632
|
+
const handle = await open(path, "r");
|
|
1633
|
+
try {
|
|
1634
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
1635
|
+
const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
|
|
1636
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
1637
|
+
} finally {
|
|
1638
|
+
await handle.close();
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
const parseHeader = (text) => {
|
|
1642
|
+
const newline = text.indexOf("\n");
|
|
1643
|
+
try {
|
|
1644
|
+
return JSON.parse(newline === -1 ? text : text.slice(0, newline));
|
|
1645
|
+
} catch {
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
};
|
|
1649
|
+
/** `2026-10-08 23:28:26.00 +0200` → epoch ms, for ordering. File mtimes are not reliable here. */
|
|
1650
|
+
const timestampMs = (value) => {
|
|
1651
|
+
const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?) ([+-]\d{2})(\d{2})$/.exec(value ?? "");
|
|
1652
|
+
return m ? Date.parse(`${m[1]}T${m[2]}${m[3]}:${m[4]}`) : 0;
|
|
1653
|
+
};
|
|
1654
|
+
const listIps = async (dir) => {
|
|
1655
|
+
const top = await readdir(dir).catch((err) => {
|
|
1656
|
+
if (err.code === "ENOENT") return [];
|
|
1657
|
+
throw err;
|
|
1658
|
+
});
|
|
1659
|
+
const retired = await readdir(join(dir, "Retired")).catch(() => []);
|
|
1660
|
+
return [...top.filter((name) => name.endsWith(".ips")), ...retired.filter((name) => name.endsWith(".ips")).map((name) => `Retired/${name}`)];
|
|
1661
|
+
};
|
|
1662
|
+
/**
|
|
1663
|
+
* Every simulator report in `dir`, newest first. Reads the first 8 KB of each
|
|
1664
|
+
* `.ips`, never the whole file, except to find the UDID in the rare report
|
|
1665
|
+
* whose `coalitionName` did not fit in that.
|
|
1666
|
+
*/
|
|
1667
|
+
const listSimulatorCrashReports = async (dir) => {
|
|
1668
|
+
const names = await listIps(dir);
|
|
1669
|
+
const rows = [];
|
|
1670
|
+
for (let i = 0; i < names.length; i += CONCURRENCY) {
|
|
1671
|
+
const batch = await Promise.all(names.slice(i, i + CONCURRENCY).map(async (name) => {
|
|
1672
|
+
const path = join(dir, name);
|
|
1673
|
+
const head = await readHead(path).catch(() => "");
|
|
1674
|
+
const header = parseHeader(head);
|
|
1675
|
+
if (!header || !SIMULATOR_PLATFORMS.has(Number(header.platform))) return void 0;
|
|
1676
|
+
let udid = COALITION.exec(head)?.[1];
|
|
1677
|
+
if (!udid) udid = COALITION.exec(await readFile(path, "utf8").catch(() => ""))?.[1];
|
|
1678
|
+
return {
|
|
1679
|
+
name,
|
|
1680
|
+
process: header.app_name || header.name || crashProcessName(name),
|
|
1681
|
+
bundleId: header.bundleID,
|
|
1682
|
+
kind: crashKind(header.bug_type),
|
|
1683
|
+
timestamp: header.timestamp,
|
|
1684
|
+
udid: udid?.toUpperCase()
|
|
1685
|
+
};
|
|
1686
|
+
}));
|
|
1687
|
+
for (const row of batch) if (row) rows.push(row);
|
|
1688
|
+
}
|
|
1689
|
+
return rows.toSorted((a, b) => timestampMs(b.timestamp) - timestampMs(a.timestamp));
|
|
1690
|
+
};
|
|
1691
|
+
/**
|
|
1692
|
+
* The absolute path of a report named by `ios_simulator_list_crash_logs`, or a
|
|
1693
|
+
* refusal. The schema already rejects `..` and absolute names; this is the
|
|
1694
|
+
* check that cannot be argued with, made on the resolved path.
|
|
1695
|
+
*/
|
|
1696
|
+
const crashReportPath = (dir, name) => {
|
|
1697
|
+
const root = resolve(dir);
|
|
1698
|
+
const path = resolve(root, name);
|
|
1699
|
+
const rel = relative(root, path);
|
|
1700
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new IosError$1(`"${name}" is not inside the crash reports directory.`, { remedy: "Pass a `name` exactly as ios_simulator_list_crash_logs returned it." });
|
|
1701
|
+
return path;
|
|
1702
|
+
};
|
|
1703
|
+
/** Whether a report's header says it came from a simulator. */
|
|
1704
|
+
const isSimulatorReport = (text) => {
|
|
1705
|
+
const header = parseHeader(text);
|
|
1706
|
+
return header !== void 0 && SIMULATOR_PLATFORMS.has(Number(header.platform));
|
|
1707
|
+
};
|
|
1708
|
+
/** The simulator a full report came from, when its body says. */
|
|
1709
|
+
const reportUdid = (text) => COALITION.exec(text)?.[1]?.toUpperCase();
|
|
1710
|
+
//#endregion
|
|
1711
|
+
//#region src/tools/logs.ts
|
|
1712
|
+
/** UDID → name, for labelling reports. Best effort: a Mac with no Xcode still has reports. */
|
|
1713
|
+
const simulatorNames = async (client) => {
|
|
1714
|
+
try {
|
|
1715
|
+
return new Map((await client.listSimulators()).map((sim) => [sim.id.toUpperCase(), sim.name]));
|
|
1716
|
+
} catch {
|
|
1717
|
+
return /* @__PURE__ */ new Map();
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
/**
|
|
1721
|
+
* Crash reports: the half of "logs" that needs nothing launched. Observing, so
|
|
1722
|
+
* registered whatever the write gate says — reading a report changes nothing
|
|
1723
|
+
* on the simulator, the same way a screenshot does not.
|
|
1724
|
+
*/
|
|
1725
|
+
const registerCrashLogTools = (server, client, ctx) => {
|
|
1726
|
+
const dir = ctx.config.crashReportsDir;
|
|
1727
|
+
server.registerTool("ios_simulator_list_crash_logs", {
|
|
1728
|
+
title: "iOS Simulator: List Crash Logs",
|
|
1729
|
+
description: "List crash reports from simulator processes, newest first. A simulator app's crash is written by this Mac into ~/Library/Logs/DiagnosticReports alongside the Mac's own crashes; this keeps only the simulators' and says which simulator each came from. Most of them are the simulators' own system daemons, so pass `process` to find your app's. Read one with ios_simulator_get_crash_log. Nothing needs to be booted.",
|
|
1730
|
+
inputSchema: z.object({
|
|
1731
|
+
device: deviceArg.describe("Only reports from this simulator, by UDID or name as shown by ios_simulator_list. Omit for every simulator's — unlike the other tools, this does not default to the booted one, since a crash often outlives the boot it happened in."),
|
|
1732
|
+
process: z.string().optional().describe("Case-insensitive substring of the process name or bundle id, e.g. \"Canopy\" or \"io.mgcrea\". The process name is the app's executable name, usually but not always its display name."),
|
|
1733
|
+
kinds: z.array(z.string()).optional().describe("Report kinds to include, e.g. [\"crash\"]. Defaults to [\"crash\", \"jetsam\", \"user_fault\"]; kinds without a name come back as \"bug_type_<n>\". Pass [\"all\"] for everything."),
|
|
1734
|
+
limit: z.number().int().min(1).max(200).default(20).describe("Maximum reports to return, newest first (1-200). Defaults to 20.")
|
|
1735
|
+
}),
|
|
1736
|
+
annotations: { readOnlyHint: true }
|
|
1737
|
+
}, async ({ device, process, kinds, limit }) => wrap(async () => {
|
|
1738
|
+
const target = device ? await client.resolveTarget(device) : void 0;
|
|
1739
|
+
const wanted = kinds ?? DEFAULT_CRASH_KINDS;
|
|
1740
|
+
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);
|
|
1743
|
+
return {
|
|
1744
|
+
...target ? {
|
|
1745
|
+
simulator: target.name,
|
|
1746
|
+
udid: target.id
|
|
1747
|
+
} : {},
|
|
1748
|
+
reports: matching.slice(0, limit).map(({ udid, bundleId, ...row }) => ({
|
|
1749
|
+
...row,
|
|
1750
|
+
...bundleId ? { bundleId } : {},
|
|
1751
|
+
...names && udid ? {
|
|
1752
|
+
udid,
|
|
1753
|
+
...names.get(udid) ? { simulator: names.get(udid) } : {}
|
|
1754
|
+
} : {}
|
|
1755
|
+
})),
|
|
1756
|
+
total: matching.length
|
|
1757
|
+
};
|
|
1758
|
+
}));
|
|
1759
|
+
server.registerTool("ios_simulator_get_crash_log", {
|
|
1760
|
+
title: "iOS Simulator: Get Crash Log",
|
|
1761
|
+
description: "Read one simulator crash report and return what matters in it: the exception, why the process was terminated, any application-specific message, and the faulting thread's top frames with library names resolved. The full report's path is returned too; it is often tens of kilobytes, so read it only when the summary is not enough. A Swift fatalError's message may not be in the report at all — on a simulator it goes to the app's stderr.",
|
|
1762
|
+
inputSchema: z.object({ name: z.string().regex(/^[^/][^\0]*\.ips$/, "Pass a `name` exactly as ios_simulator_list_crash_logs returned it.").refine((value) => !value.split("/").includes(".."), "`name` cannot contain `..`.").describe("The report's `name` from ios_simulator_list_crash_logs, e.g. \"Canopy-2026-10-08-101500.ips\" or \"Retired/Canopy-2026-10-02-213409.ips\". Older reports move under Retired/, so a name that worked yesterday may need listing again.") }),
|
|
1763
|
+
annotations: { readOnlyHint: true }
|
|
1764
|
+
}, async ({ name }) => wrap(async () => {
|
|
1765
|
+
const path = crashReportPath(dir, name);
|
|
1766
|
+
let text;
|
|
1767
|
+
try {
|
|
1768
|
+
text = await readFile(path, "utf8");
|
|
1769
|
+
} catch {
|
|
1770
|
+
throw new IosError$1(`No crash report named ${name}.`, { remedy: "Run ios_simulator_list_crash_logs again: reports move to Retired/ as they age, and the system deletes old ones." });
|
|
1771
|
+
}
|
|
1772
|
+
if (!isSimulatorReport(text)) throw new IosError$1(`${name} is a crash report from this Mac, not from a simulator.`, { remedy: "Pick a report from ios_simulator_list_crash_logs, which lists only simulators'." });
|
|
1773
|
+
const summary = summarizeCrashReport(text);
|
|
1774
|
+
const udid = reportUdid(text);
|
|
1775
|
+
const simulator = udid ? (await simulatorNames(client)).get(udid) : void 0;
|
|
1776
|
+
const asi = summary.crash?.["applicationSpecificInfo"];
|
|
1777
|
+
return {
|
|
1778
|
+
name,
|
|
1779
|
+
path,
|
|
1780
|
+
...udid ? { udid } : {},
|
|
1781
|
+
...simulator ? { simulator } : {},
|
|
1782
|
+
...summary,
|
|
1783
|
+
...summary.crash ? asi ? {} : { note: "The report carries no application-specific message. A Swift fatalError's text goes to the app's stderr on a simulator" + (ctx.allowWrites ? ", so relaunch with ios_simulator_launch `capture_logs: true` and read it with ios_simulator_read_logs." : ".") } : { note: "Not a crash report with a faulting thread; read the file at `path`." }
|
|
1784
|
+
};
|
|
1785
|
+
}));
|
|
1786
|
+
};
|
|
1787
|
+
/**
|
|
1788
|
+
* Reading an app's captured log. Registered with the launch tool it depends on,
|
|
1789
|
+
* behind the write gate: without `ios_simulator_launch` there is never anything
|
|
1790
|
+
* to read.
|
|
1791
|
+
*/
|
|
1792
|
+
const registerLogReadTools = (server, client) => {
|
|
1793
|
+
server.registerTool("ios_simulator_read_logs", {
|
|
1794
|
+
title: "iOS Simulator: Read Logs",
|
|
1795
|
+
description: "Read an app's log output: stdout, stderr and every Logger/os_log message the process emits, interleaved in one file. Only available for an app started with ios_simulator_launch `capture_logs: true`. Returns the newest `limit` lines plus a `next` cursor; pass it back to get only what arrived since, which is how to see what one tap logged. `capturing` turns false once the app's process has exited, and what it wrote stays readable. A crash's backtrace is not in here — see ios_simulator_list_crash_logs — but its fatalError message is.",
|
|
1796
|
+
inputSchema: z.object({
|
|
1797
|
+
device: deviceArg,
|
|
1798
|
+
bundle_id: z.string().optional().describe("Which app's capture, e.g. \"io.mgcrea.Canopy\". Defaults to the most recent capture."),
|
|
1799
|
+
cursor: z.number().int().min(0).optional().describe("The `next` value from the previous read, to get only newer lines. Omit to read from the start of the capture."),
|
|
1800
|
+
filter: z.string().optional().describe("Case-insensitive regular expression a line must match, e.g. \"error|fail\" or \"\\[Sync\\]\". Applied before `limit`, so it finds matches across the whole range."),
|
|
1801
|
+
limit: z.number().int().min(1).max(2e3).default(200).describe("Most lines to return; the newest are kept and the rest counted in `omitted`. Defaults to 200 — filter rather than raising this.")
|
|
1802
|
+
}),
|
|
1803
|
+
annotations: { readOnlyHint: true }
|
|
1804
|
+
}, async ({ device, bundle_id, cursor, filter, limit }) => wrap(async () => {
|
|
1805
|
+
const target = device ? await client.resolveTarget(device) : void 0;
|
|
1806
|
+
const capture = client.captures.find(target?.id, bundle_id);
|
|
1807
|
+
if (!capture) throw new IosError$1(bundle_id ? `No log capture for ${bundle_id}.` : "No log capture has been started.", { remedy: "Launch the app with ios_simulator_launch {\"bundle_id\":\"…\",\"capture_logs\":true}. A capture belongs to this server process, so one started before it restarted is not known here, though its file is still under the output directory." });
|
|
1808
|
+
const running = client.captures.running(capture);
|
|
1809
|
+
let result;
|
|
1810
|
+
try {
|
|
1811
|
+
result = await readLogFile(capture.path, {
|
|
1812
|
+
cursor,
|
|
1813
|
+
filter,
|
|
1814
|
+
limit,
|
|
1815
|
+
complete: !running
|
|
1816
|
+
});
|
|
1817
|
+
} catch (err) {
|
|
1818
|
+
if (err.code !== "ENOENT") throw err;
|
|
1819
|
+
throw new IosError$1(`The log file for ${capture.bundleId} is gone: ${capture.path}`, { remedy: "Erasing the simulator deletes it when it was written under the simulator's data directory. Launch again with `capture_logs: true`." });
|
|
1820
|
+
}
|
|
1821
|
+
return {
|
|
1822
|
+
bundleId: capture.bundleId,
|
|
1823
|
+
simulator: capture.simulatorName,
|
|
1824
|
+
capturing: running,
|
|
1825
|
+
...capture.pid !== void 0 ? { pid: capture.pid } : {},
|
|
1826
|
+
...result,
|
|
1827
|
+
path: capture.path,
|
|
1828
|
+
...result.skippedBytes > 0 ? { note: `${result.skippedBytes} bytes before this window were not read. Pass the returned \`next\` sooner, or read the file at \`path\`.` } : {}
|
|
1829
|
+
};
|
|
1830
|
+
}));
|
|
1831
|
+
};
|
|
1832
|
+
//#endregion
|
|
1388
1833
|
//#region src/tools/runner.ts
|
|
1389
1834
|
const defaultSpawnRunner = async (command, args, { env, logPath }) => {
|
|
1390
1835
|
await mkdir(dirname(logPath), { recursive: true });
|
|
@@ -1462,6 +1907,12 @@ const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpa
|
|
|
1462
1907
|
* refused, because a refusal still lets a model try, retry and reason about a
|
|
1463
1908
|
* way around it.
|
|
1464
1909
|
*
|
|
1910
|
+
* Reading logs splits across the line. Crash reports are files this Mac
|
|
1911
|
+
* already wrote, so listing and reading them is observing and survives the
|
|
1912
|
+
* gate. `ios_simulator_read_logs` is read-only too, but it only ever reads what
|
|
1913
|
+
* `ios_simulator_launch` started capturing, so it goes where launch goes — a
|
|
1914
|
+
* tool that can only answer "no capture" is noise in the list.
|
|
1915
|
+
*
|
|
1465
1916
|
* The observe half is unusually capable here: `screenshot` goes through simctl,
|
|
1466
1917
|
* so seeing the screen needs no WebDriverAgent and no setup of any kind. Only
|
|
1467
1918
|
* `ui_tree` does, and that is a runtime fact reported by
|
|
@@ -1483,10 +1934,12 @@ const registerTools = (server, client, ctx, spawnRunner) => {
|
|
|
1483
1934
|
registerListTool(server, client);
|
|
1484
1935
|
registerListAppsTool(server, client);
|
|
1485
1936
|
registerScreenTools(server, shared);
|
|
1937
|
+
registerCrashLogTools(server, client, ctx);
|
|
1486
1938
|
if (!ctx.allowWrites) return;
|
|
1487
1939
|
registerInputTools(server, shared);
|
|
1488
1940
|
registerLifecycleTools(server, client);
|
|
1489
1941
|
registerAppTools(server, client, ctx.config.launchArgs, ctx.config.outputDir);
|
|
1942
|
+
registerLogReadTools(server, client);
|
|
1490
1943
|
registerEnvironmentTools(server, client);
|
|
1491
1944
|
registerRunnerTools(server, client, ctx.config.outputDir, spawnRunner);
|
|
1492
1945
|
};
|
|
@@ -1495,9 +1948,10 @@ const registerTools = (server, client, ctx, spawnRunner) => {
|
|
|
1495
1948
|
const SERVER_NAME = BUILD_INFO.name;
|
|
1496
1949
|
const SERVER_VERSION = BUILD_INFO.version;
|
|
1497
1950
|
/**
|
|
1498
|
-
* A pure factory. The injectable seams — `exec`, `fetch
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
1951
|
+
* A pure factory. The injectable seams — `exec`, `fetch`, `spawnRunner` and
|
|
1952
|
+
* `processAlive`, plus `crashReportsDir` in the config — are the whole reason
|
|
1953
|
+
* the test suite can drive real tools through the real SDK with no simulator,
|
|
1954
|
+
* no Xcode, no WebDriverAgent and no process left running.
|
|
1501
1955
|
* Nothing below `config.ts` reads `process.env`.
|
|
1502
1956
|
*/
|
|
1503
1957
|
const createServer = (opts) => {
|
|
@@ -1519,6 +1973,7 @@ const createServer = (opts) => {
|
|
|
1519
1973
|
...config.simulatorId ? { defaultSimulatorId: config.simulatorId } : {},
|
|
1520
1974
|
...opts.exec ? { exec: opts.exec } : {},
|
|
1521
1975
|
...opts.fetch ? { fetch: opts.fetch } : {},
|
|
1976
|
+
...opts.processAlive ? { processAlive: opts.processAlive } : {},
|
|
1522
1977
|
...opts.logger ? { logger: opts.logger } : {}
|
|
1523
1978
|
});
|
|
1524
1979
|
registerTools(server, client, {
|
|
@@ -1564,14 +2019,24 @@ const ConfigSchema = z.object({
|
|
|
1564
2019
|
* **On by default**, which is the one place this server deliberately breaks
|
|
1565
2020
|
* the fleet convention. What makes the device server read-only is that a
|
|
1566
2021
|
* phone belongs to a real person; a simulator is disposable and holds
|
|
1567
|
-
* nobody's data. `ios_simulator_erase`
|
|
1568
|
-
* because
|
|
2022
|
+
* nobody's data. `ios_simulator_erase` and `ios_simulator_uninstall` still
|
|
2023
|
+
* require an explicit `confirm`, because they are the irreversible ones.
|
|
1569
2024
|
*/
|
|
1570
2025
|
allowWrites: z.boolean().default(true),
|
|
1571
2026
|
/** Applied by `launch` when the call passes no `arguments` of its own. */
|
|
1572
2027
|
launchArgs: z.array(z.string()).default([]),
|
|
1573
|
-
/**
|
|
2028
|
+
/**
|
|
2029
|
+
* Saved screenshots, launch logs and the runner log. A launch log is opened
|
|
2030
|
+
* by the simulator rather than by this server, and a simulator has its own
|
|
2031
|
+
* `/tmp` and `/var/folders` — under this default it lands in the
|
|
2032
|
+
* simulator's data directory, and `launch` reports where it really is.
|
|
2033
|
+
*/
|
|
1574
2034
|
outputDir: z.string().min(1).default(join(tmpdir(), "mcp-ios-simulator")),
|
|
2035
|
+
/**
|
|
2036
|
+
* Where this Mac's ReportCrash writes `.ips` files, simulator apps' included.
|
|
2037
|
+
* Configurable for tests and for a report folder copied off another Mac.
|
|
2038
|
+
*/
|
|
2039
|
+
crashReportsDir: z.string().min(1).default(join(homedir(), "Library", "Logs", "DiagnosticReports")),
|
|
1575
2040
|
maxTreeBytes: z.number().int().min(1e3).max(5e5).default(24e3),
|
|
1576
2041
|
xcrunPath: z.string().min(1).default("/usr/bin/xcrun"),
|
|
1577
2042
|
sipsPath: z.string().min(1).default("/usr/bin/sips"),
|
|
@@ -1639,6 +2104,7 @@ const loadConfig = (env = process.env, configPath = defaultConfigPath(env)) => {
|
|
|
1639
2104
|
allowWrites: pick(parseBool(env.IOS_SIMULATOR_ALLOW_WRITES), "allowWrites"),
|
|
1640
2105
|
launchArgs: pick(parseArgs(env.IOS_SIMULATOR_LAUNCH_ARGS), "launchArgs"),
|
|
1641
2106
|
outputDir: pick(trimmed(env.IOS_SIMULATOR_OUTPUT_DIR), "outputDir"),
|
|
2107
|
+
crashReportsDir: pick(trimmed(env.IOS_SIMULATOR_CRASH_REPORTS_DIR), "crashReportsDir"),
|
|
1642
2108
|
maxTreeBytes: pick(parseIntOpt(env.IOS_SIMULATOR_MAX_TREE_BYTES), "maxTreeBytes"),
|
|
1643
2109
|
xcrunPath: pick(trimmed(env.IOS_SIMULATOR_XCRUN_PATH), "xcrunPath"),
|
|
1644
2110
|
sipsPath: pick(trimmed(env.IOS_SIMULATOR_SIPS_PATH), "sipsPath"),
|
|
@@ -1649,4 +2115,4 @@ const loadConfig = (env = process.env, configPath = defaultConfigPath(env)) => {
|
|
|
1649
2115
|
//#endregion
|
|
1650
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 };
|
|
1651
2117
|
|
|
1652
|
-
//# sourceMappingURL=config-
|
|
2118
|
+
//# sourceMappingURL=config-D0JgmT_U.js.map
|