@mgcrea/mcp-ios-simulator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +47 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-C1TH-p-c.js +1553 -0
- package/dist/config-C1TH-p-c.js.map +1 -0
- package/dist/index.d.ts +475 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/package.json +81 -0
- package/scripts/wda.sh +145 -0
|
@@ -0,0 +1,1553 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { CommandError, CommandTimeoutError, IosError, IosError as IosError$1, ToolchainError, WdaClient, WdaError, WdaUnavailableError, assertNoShellMetachars, createArgs, createExec, registerInputTools, registerScreenTools, wrap } from "@mgcrea/mcp-ios-core";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { mkdir, mkdtemp, open, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
//#region src/build-info.ts
|
|
11
|
+
const readPackageJson = () => {
|
|
12
|
+
try {
|
|
13
|
+
const pkgUrl = new URL("../package.json", import.meta.url);
|
|
14
|
+
return JSON.parse(readFileSync(pkgUrl, "utf8"));
|
|
15
|
+
} catch {
|
|
16
|
+
return {
|
|
17
|
+
name: "@mgcrea/mcp-ios-device",
|
|
18
|
+
version: "0.0.0"
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const pkg = readPackageJson();
|
|
23
|
+
const BUILD_INFO = {
|
|
24
|
+
name: pkg.name,
|
|
25
|
+
version: pkg.version,
|
|
26
|
+
gitCommit: "140a684",
|
|
27
|
+
gitCommitDate: "2026-09-04T23:43:28+02:00"
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/client/errors.ts
|
|
31
|
+
/** Raising the budget, or noticing the simulator died mid-call. */
|
|
32
|
+
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
|
+
const TOOLCHAIN_REMEDY = "Install Xcode and run `xcode-select --install`, or point IOS_SIMULATOR_XCRUN_PATH at a different xcrun.";
|
|
34
|
+
/**
|
|
35
|
+
* How to get the runner up. Much shorter than the device server's equivalent,
|
|
36
|
+
* and that is the headline difference between the two: a simulator runner needs
|
|
37
|
+
* no Apple team, no signing, no trust prompt and no tunnel.
|
|
38
|
+
*/
|
|
39
|
+
const START_RUNNER_REMEDY = "Build and start the runner with `scripts/wda.sh setup` then `scripts/wda.sh run` (or `npx -p @mgcrea/mcp-ios-simulator ios-simulator-wda run` from an npm install) and leave it open — the HTTP server is the XCTest process, so it stops when that command does. It needs no Apple Developer team: a simulator build is not signed.";
|
|
40
|
+
/** What to do when WebDriverAgent does not answer at all. */
|
|
41
|
+
const WDA_UNAVAILABLE_REMEDY = `${START_RUNNER_REMEDY} 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.`;
|
|
42
|
+
/** No simulator matched, or several did and none was named. */
|
|
43
|
+
var SimulatorNotFoundError = class extends IosError {
|
|
44
|
+
name = "SimulatorNotFoundError";
|
|
45
|
+
constructor(message, remedy) {
|
|
46
|
+
super(message, { remedy });
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The simulator exists but is shut down.
|
|
51
|
+
*
|
|
52
|
+
* Its own error class because the fix is a single specific call and nothing
|
|
53
|
+
* else: almost every simctl subcommand fails this way, `listapps` included, so
|
|
54
|
+
* "it is shut down" would otherwise be buried in a CoreSimulator envelope that
|
|
55
|
+
* reads like a lookup bug.
|
|
56
|
+
*/
|
|
57
|
+
var SimulatorNotBootedError = class extends IosError {
|
|
58
|
+
name = "SimulatorNotBootedError";
|
|
59
|
+
constructor(name, udid) {
|
|
60
|
+
super(`${name} is shut down.`, { remedy: `Boot it with ios_simulator_power {"simulator":"${udid}","state":"booted"} — this server never boots a simulator implicitly, because a cold boot takes tens of seconds and puts a window on the user's screen.` });
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* The simulator's runtime is not installed, so CoreSimulator cannot service it.
|
|
65
|
+
*
|
|
66
|
+
* Worth its own class because the failure it prevents is not an error message
|
|
67
|
+
* at all: `simctl io` against one of these aborts with an uncaught
|
|
68
|
+
* NSInternalInconsistencyException and a thirty-line stack trace.
|
|
69
|
+
*/
|
|
70
|
+
var RuntimeUnavailableError = class extends IosError {
|
|
71
|
+
name = "RuntimeUnavailableError";
|
|
72
|
+
constructor(name, reason) {
|
|
73
|
+
super(`${name} is unavailable${reason ? `: ${reason}` : "."}`, { remedy: "The iOS runtime this simulator was created against is not installed. Install it in Xcode > Settings > Components, or clear the orphans with `xcrun simctl delete unavailable`." });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/** Thrown when a write path is reached while IOS_SIMULATOR_ALLOW_WRITES is off. */
|
|
77
|
+
var WritesDisabledError = class extends IosError {
|
|
78
|
+
name = "WritesDisabledError";
|
|
79
|
+
constructor(what) {
|
|
80
|
+
super(`${what} changes the simulator, but writes are disabled. Unset IOS_SIMULATOR_ALLOW_WRITES (it defaults to on) to enable them again.`);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/** A shut-down simulator captures nothing, and that is the usual cause here. */
|
|
84
|
+
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.";
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/client/display.ts
|
|
87
|
+
/**
|
|
88
|
+
* Screen geometry for a simulator, which has no `devicectl device info displays`.
|
|
89
|
+
*
|
|
90
|
+
* Three sources were measured, and the split between them is the interesting
|
|
91
|
+
* part:
|
|
92
|
+
*
|
|
93
|
+
* - **`profile.plist`, per device type.** `mainScreenWidth`, `mainScreenHeight`
|
|
94
|
+
* and `mainScreenScale`, agreeing exactly with both `io enumerate` and the
|
|
95
|
+
* captured PNG. A pure file read: no simctl call, nothing to boot, and no
|
|
96
|
+
* crash surface on an unavailable simulator. This is where `pointScale`
|
|
97
|
+
* comes from.
|
|
98
|
+
* - **The captured PNG's own header.** This is where *pixels* come from
|
|
99
|
+
* whenever there is a capture, because `simctl io … screenConfig geometry`
|
|
100
|
+
* can resize a booted simulator's screen at runtime and the profile would
|
|
101
|
+
* then be silently wrong — putting every tap coordinate in the wrong space
|
|
102
|
+
* with nothing looking wrong.
|
|
103
|
+
* - **`io enumerate`**, which also reports it, but is a text parse of a
|
|
104
|
+
* 150-line dump and aborts on an unavailable device. Not used.
|
|
105
|
+
*/
|
|
106
|
+
const profilePlistPath = (bundlePath) => join(bundlePath, "Contents", "Resources", "profile.plist");
|
|
107
|
+
const readScreenProfile = async (deviceType, opts) => {
|
|
108
|
+
if (!deviceType.bundlePath) return {};
|
|
109
|
+
const { stdout } = await opts.exec(opts.plutilPath, [
|
|
110
|
+
"-convert",
|
|
111
|
+
"json",
|
|
112
|
+
"-o",
|
|
113
|
+
"-",
|
|
114
|
+
profilePlistPath(deviceType.bundlePath)
|
|
115
|
+
], opts.timeoutMs);
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(stdout);
|
|
118
|
+
} catch {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Width and height straight out of a PNG's IHDR.
|
|
124
|
+
*
|
|
125
|
+
* Cheaper than shelling to `sips` for the one number that must not be stale,
|
|
126
|
+
* and the bytes are already in hand: the signature is fixed, `IHDR` is always
|
|
127
|
+
* the first chunk, and the two dimensions are big-endian at offsets 16 and 20.
|
|
128
|
+
*/
|
|
129
|
+
const pngDimensions = (png) => {
|
|
130
|
+
const SIGNATURE = Buffer.from([
|
|
131
|
+
137,
|
|
132
|
+
80,
|
|
133
|
+
78,
|
|
134
|
+
71,
|
|
135
|
+
13,
|
|
136
|
+
10,
|
|
137
|
+
26,
|
|
138
|
+
10
|
|
139
|
+
]);
|
|
140
|
+
if (png.length < 24 || !png.subarray(0, 8).equals(SIGNATURE)) return void 0;
|
|
141
|
+
if (png.subarray(12, 16).toString("ascii") !== "IHDR") return void 0;
|
|
142
|
+
return {
|
|
143
|
+
width: png.readUInt32BE(16),
|
|
144
|
+
height: png.readUInt32BE(20)
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Assemble a `DisplayInfo`.
|
|
149
|
+
*
|
|
150
|
+
* `orientation` is derived by comparing the capture's own aspect against the
|
|
151
|
+
* profile's portrait dimensions, and is `"unknown"` when there is no capture to
|
|
152
|
+
* compare — never guessed as `"portrait"`, because the whole point of the field
|
|
153
|
+
* is telling a caller whether the screen they are about to read is rotated.
|
|
154
|
+
*
|
|
155
|
+
* `backlightState` is left undefined: a simulator has no backlight, and
|
|
156
|
+
* `io enumerate`'s `Power state` is about the framebuffer port rather than the
|
|
157
|
+
* screen, so mapping it would be a lie.
|
|
158
|
+
*/
|
|
159
|
+
const toDisplayInfo = (profile, capture) => {
|
|
160
|
+
const scale = profile.mainScreenScale ?? 1;
|
|
161
|
+
const pixelWidth = capture?.width ?? profile.mainScreenWidth;
|
|
162
|
+
const pixelHeight = capture?.height ?? profile.mainScreenHeight;
|
|
163
|
+
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." });
|
|
164
|
+
const landscape = profile.mainScreenWidth !== void 0 && profile.mainScreenHeight !== void 0 ? pixelWidth > pixelHeight && profile.mainScreenWidth < profile.mainScreenHeight : pixelWidth > pixelHeight;
|
|
165
|
+
return {
|
|
166
|
+
pixelWidth,
|
|
167
|
+
pixelHeight,
|
|
168
|
+
pointWidth: Math.round(pixelWidth / scale),
|
|
169
|
+
pointHeight: Math.round(pixelHeight / scale),
|
|
170
|
+
pointScale: scale,
|
|
171
|
+
orientation: capture ? landscape ? "landscape" : "portrait" : "unknown"
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/client/shape.ts
|
|
176
|
+
/**
|
|
177
|
+
* Flatten the runtime-keyed map `list devices` returns.
|
|
178
|
+
*
|
|
179
|
+
* The key is a runtime *identifier* and identifiers are not unique — a machine
|
|
180
|
+
* carrying both iOS 26.4 and 26.4.1 has two entries under
|
|
181
|
+
* `com.apple.CoreSimulator.SimRuntime.iOS-26-4`. So the key is never used as a
|
|
182
|
+
* primary key: it is joined against `list runtimes` for a display name, 0..n
|
|
183
|
+
* matches tolerated, and carried through verbatim besides.
|
|
184
|
+
*/
|
|
185
|
+
const summarizeDevices = (devices, runtimes, deviceTypes) => {
|
|
186
|
+
const runtimeName = new Map(runtimes.map((r) => [r.identifier, r.name ?? r.version ?? r.identifier]));
|
|
187
|
+
const typeName = new Map(deviceTypes.map((t) => [t.identifier, t.name]));
|
|
188
|
+
return Object.entries(devices).flatMap(([runtimeId, sims]) => (sims ?? []).map((sim) => ({
|
|
189
|
+
id: sim.udid,
|
|
190
|
+
name: sim.name,
|
|
191
|
+
state: sim.state,
|
|
192
|
+
runtime: runtimeName.get(runtimeId) ?? runtimeId.split(".").pop() ?? runtimeId,
|
|
193
|
+
deviceType: sim.deviceTypeIdentifier ? typeName.get(sim.deviceTypeIdentifier) : void 0,
|
|
194
|
+
deviceTypeIdentifier: sim.deviceTypeIdentifier,
|
|
195
|
+
available: sim.isAvailable !== false,
|
|
196
|
+
...sim.availabilityError ? { unavailableReason: sim.availabilityError } : {},
|
|
197
|
+
...sim.lastBootedAt ? { lastBootedAt: sim.lastBootedAt } : {}
|
|
198
|
+
})));
|
|
199
|
+
};
|
|
200
|
+
/** `file:///Users/…/Foo.app/` → `/Users/…/Foo.app`, which is what a person can use. */
|
|
201
|
+
const toPath = (url) => {
|
|
202
|
+
if (!url) return void 0;
|
|
203
|
+
if (!url.startsWith("file://")) return url;
|
|
204
|
+
try {
|
|
205
|
+
return decodeURIComponent(new URL(url).pathname).replace(/\/$/, "");
|
|
206
|
+
} catch {
|
|
207
|
+
return url;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const summarizeApps = (apps, opts = {}) => Object.values(apps).filter((app) => opts.includeAll === true || app.ApplicationType === "User").map((app) => ({
|
|
211
|
+
bundleId: app.CFBundleIdentifier ?? "",
|
|
212
|
+
name: app.CFBundleDisplayName ?? app.CFBundleName,
|
|
213
|
+
version: app.CFBundleShortVersionString,
|
|
214
|
+
build: app.CFBundleVersion,
|
|
215
|
+
type: app.ApplicationType,
|
|
216
|
+
bundlePath: toPath(app.Bundle ?? app.Path),
|
|
217
|
+
dataContainer: toPath(app.DataContainer)
|
|
218
|
+
})).filter((app) => app.bundleId !== "").toSorted((a, b) => a.bundleId.localeCompare(b.bundleId));
|
|
219
|
+
/** The public shape of one row of `ios_simulator_list`. */
|
|
220
|
+
const toListRow = (sim) => ({
|
|
221
|
+
udid: sim.id,
|
|
222
|
+
name: sim.name,
|
|
223
|
+
state: sim.state,
|
|
224
|
+
runtime: sim.runtime,
|
|
225
|
+
...sim.deviceType ? { deviceType: sim.deviceType } : {},
|
|
226
|
+
...sim.available ? {} : { available: false },
|
|
227
|
+
...sim.unavailableReason ? { unavailableReason: sim.unavailableReason } : {}
|
|
228
|
+
});
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/client/simctl.ts
|
|
231
|
+
/**
|
|
232
|
+
* Pull the human sentence out of a CoreSimulator error envelope.
|
|
233
|
+
*
|
|
234
|
+
* An error was encountered processing the command (domain=…, code=405):
|
|
235
|
+
* Unable to lookup in current state: Shutdown
|
|
236
|
+
*
|
|
237
|
+
* Without this the message a caller sees is the first line, which names a
|
|
238
|
+
* numeric code and says nothing about what to do.
|
|
239
|
+
*/
|
|
240
|
+
const parseSimctlError = (stderr) => {
|
|
241
|
+
const envelope = /\(domain=([^,]+),\s*code=(\d+)\)\s*:?\s*\n?([\s\S]*)/.exec(stderr);
|
|
242
|
+
if (envelope) return {
|
|
243
|
+
domain: envelope[1]?.trim(),
|
|
244
|
+
code: Number(envelope[2]),
|
|
245
|
+
message: (envelope[3] ?? "").trim().split("\n")[0]?.trim() ?? stderr.trim()
|
|
246
|
+
};
|
|
247
|
+
return { message: stderr.trim().split("\n")[0]?.trim() ?? stderr.trim() };
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* simctl reports its interesting failures in prose, and each has a different
|
|
251
|
+
* fix. Every string here describes something only a simulator has.
|
|
252
|
+
*/
|
|
253
|
+
const remedyFor = (stderr) => {
|
|
254
|
+
const { message } = parseSimctlError(stderr);
|
|
255
|
+
const text = `${stderr} ${message}`.toLowerCase();
|
|
256
|
+
if (text.includes("invalid device")) return "That is not a UDID or a name simctl knows. Run ios_simulator_list to see what exists.";
|
|
257
|
+
if (text.includes("unable to lookup in current state: shutdown")) return "The simulator is shut down. Boot it with ios_simulator_power {\"state\":\"booted\"}.";
|
|
258
|
+
if (text.includes("current state: booted")) return "The simulator is already booted, which for most operations means there is nothing to do.";
|
|
259
|
+
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.";
|
|
260
|
+
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`.";
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* The one argument that must never reach simctl.
|
|
264
|
+
*
|
|
265
|
+
* `erase`, `delete` and `shutdown` all accept the literal string `all`, and
|
|
266
|
+
* `simctl erase all` wipes every simulator on the machine. A device hint that
|
|
267
|
+
* happens to be the word "all" is the single worst thing this server could
|
|
268
|
+
* forward, so it is refused here as well as in the resolver — the resolver is
|
|
269
|
+
* where it should be caught, and this is where it cannot be missed.
|
|
270
|
+
*/
|
|
271
|
+
const assertNotBulkTarget = (udid) => {
|
|
272
|
+
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." });
|
|
273
|
+
};
|
|
274
|
+
var Simctl = class {
|
|
275
|
+
opts;
|
|
276
|
+
exec;
|
|
277
|
+
/** Device types change only when Xcode is updated; re-reading is pure cost. */
|
|
278
|
+
deviceTypes;
|
|
279
|
+
constructor(opts) {
|
|
280
|
+
this.opts = opts;
|
|
281
|
+
this.exec = opts.exec ?? createExec({
|
|
282
|
+
timeout: "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.",
|
|
283
|
+
toolchain: "Install Xcode and run `xcode-select --install`, or point IOS_SIMULATOR_XCRUN_PATH at a different xcrun.",
|
|
284
|
+
remedyFor
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/** Raw stdout, for the subcommands that answer in prose. */
|
|
288
|
+
async run(args) {
|
|
289
|
+
this.opts.logger?.debug?.("simctl", ...args);
|
|
290
|
+
const { stdout } = await this.exec(this.opts.xcrunPath, ["simctl", ...args], this.opts.timeoutMs);
|
|
291
|
+
return stdout;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* `list` and only `list`. `-e` stops `/` being escaped as `\/`, which matters
|
|
295
|
+
* only for fixture readability but costs nothing.
|
|
296
|
+
*/
|
|
297
|
+
async runJson(args) {
|
|
298
|
+
const stdout = await this.run([
|
|
299
|
+
...args,
|
|
300
|
+
"-j",
|
|
301
|
+
"-e"
|
|
302
|
+
]);
|
|
303
|
+
try {
|
|
304
|
+
return JSON.parse(stdout);
|
|
305
|
+
} catch {
|
|
306
|
+
throw new IosError$1(`simctl ${args.join(" ")} did not return JSON.`, { details: stdout.slice(0, 400) });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* A NeXTSTEP plist, via `plutil` and a temp file.
|
|
311
|
+
*
|
|
312
|
+
* The obvious `simctl listapps … | plutil -convert json -o - -` is not
|
|
313
|
+
* expressible: `ExecImpl` takes argv and no stdin, deliberately, because that
|
|
314
|
+
* is what keeps every caller value data rather than shell syntax. So simctl's
|
|
315
|
+
* stdout is written out and handed to plutil by path. A fresh directory per
|
|
316
|
+
* call rather than a shared one, so two concurrent tools cannot read each
|
|
317
|
+
* other's answer with nothing looking wrong.
|
|
318
|
+
*/
|
|
319
|
+
async runPlist(args) {
|
|
320
|
+
const stdout = await this.run(args);
|
|
321
|
+
const dir = await mkdtemp(join(tmpdir(), "simctl-"));
|
|
322
|
+
const path = join(dir, "out.plist");
|
|
323
|
+
try {
|
|
324
|
+
await writeFile(path, stdout);
|
|
325
|
+
const { stdout: json } = await this.exec(this.opts.plutilPath, [
|
|
326
|
+
"-convert",
|
|
327
|
+
"json",
|
|
328
|
+
"-o",
|
|
329
|
+
"-",
|
|
330
|
+
path
|
|
331
|
+
], this.opts.timeoutMs);
|
|
332
|
+
return JSON.parse(json);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (err instanceof IosError$1) throw err;
|
|
335
|
+
throw new IosError$1(`Could not read simctl ${args[0]} output as a property list.`, { details: stdout.slice(0, 400) });
|
|
336
|
+
} finally {
|
|
337
|
+
await rm(dir, {
|
|
338
|
+
recursive: true,
|
|
339
|
+
force: true
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async listDevices() {
|
|
344
|
+
return (await this.runJson(["list", "devices"])).devices ?? {};
|
|
345
|
+
}
|
|
346
|
+
async listRuntimes() {
|
|
347
|
+
return (await this.runJson(["list", "runtimes"])).runtimes ?? [];
|
|
348
|
+
}
|
|
349
|
+
async listDeviceTypes() {
|
|
350
|
+
this.deviceTypes ??= (await this.runJson(["list", "devicetypes"])).devicetypes ?? [];
|
|
351
|
+
return this.deviceTypes;
|
|
352
|
+
}
|
|
353
|
+
async boot(udid) {
|
|
354
|
+
assertNotBulkTarget(udid);
|
|
355
|
+
await this.run(["boot", udid]);
|
|
356
|
+
}
|
|
357
|
+
async shutdown(udid) {
|
|
358
|
+
assertNotBulkTarget(udid);
|
|
359
|
+
await this.run(["shutdown", udid]);
|
|
360
|
+
}
|
|
361
|
+
async erase(udid) {
|
|
362
|
+
assertNotBulkTarget(udid);
|
|
363
|
+
await this.run(["erase", udid]);
|
|
364
|
+
}
|
|
365
|
+
/** Attach the Simulator UI to whatever is booted. A `boot` alone is headless. */
|
|
366
|
+
async openApp(openPath) {
|
|
367
|
+
await this.exec(openPath, ["-a", "Simulator"], this.opts.timeoutMs);
|
|
368
|
+
}
|
|
369
|
+
async listApps(udid) {
|
|
370
|
+
assertNotBulkTarget(udid);
|
|
371
|
+
return this.runPlist(["listapps", udid]);
|
|
372
|
+
}
|
|
373
|
+
async install(udid, appPath) {
|
|
374
|
+
assertNotBulkTarget(udid);
|
|
375
|
+
assertNoShellMetachars("path", appPath);
|
|
376
|
+
await this.run([
|
|
377
|
+
"install",
|
|
378
|
+
udid,
|
|
379
|
+
appPath
|
|
380
|
+
]);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Launch, optionally replacing a running copy.
|
|
384
|
+
*
|
|
385
|
+
* Environment variables do not go on the command line: simctl reads them from
|
|
386
|
+
* its **own** environment under a `SIMCTL_CHILD_` prefix. `ExecImpl` pins a
|
|
387
|
+
* minimal environment on purpose, so a caller wanting them has to say so, and
|
|
388
|
+
* this is where that would be threaded through.
|
|
389
|
+
*/
|
|
390
|
+
async launch(udid, bundleId, opts = {}) {
|
|
391
|
+
assertNotBulkTarget(udid);
|
|
392
|
+
const flags = [
|
|
393
|
+
...opts.terminateExisting ? ["--terminate-running-process"] : [],
|
|
394
|
+
...opts.stdout ? [`--stdout=${opts.stdout}`] : [],
|
|
395
|
+
...opts.stderr ? [`--stderr=${opts.stderr}`] : []
|
|
396
|
+
];
|
|
397
|
+
return this.run([
|
|
398
|
+
"launch",
|
|
399
|
+
...flags,
|
|
400
|
+
udid,
|
|
401
|
+
bundleId,
|
|
402
|
+
...opts.args ?? []
|
|
403
|
+
]);
|
|
404
|
+
}
|
|
405
|
+
async terminate(udid, bundleId) {
|
|
406
|
+
assertNotBulkTarget(udid);
|
|
407
|
+
await this.run([
|
|
408
|
+
"terminate",
|
|
409
|
+
udid,
|
|
410
|
+
bundleId
|
|
411
|
+
]);
|
|
412
|
+
}
|
|
413
|
+
async openUrl(udid, url) {
|
|
414
|
+
assertNotBulkTarget(udid);
|
|
415
|
+
await this.run([
|
|
416
|
+
"openurl",
|
|
417
|
+
udid,
|
|
418
|
+
url
|
|
419
|
+
]);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* A PNG of the screen, base64, with no WebDriverAgent involved.
|
|
423
|
+
*
|
|
424
|
+
* The documented `-` for stdout **does not work**: measured on Xcode 26.6, it
|
|
425
|
+
* writes a file literally named `-` into the current working directory and
|
|
426
|
+
* leaves stdout empty, exit code 0. So this passes a real path, like the
|
|
427
|
+
* device lane's `--json-output` does, and for the same reason uses a fresh
|
|
428
|
+
* directory per call.
|
|
429
|
+
*
|
|
430
|
+
* A successful capture also writes a `Note: No display specified…` line to
|
|
431
|
+
* stderr, so non-empty stderr is not a failure here.
|
|
432
|
+
*/
|
|
433
|
+
async screenshotPng(udid) {
|
|
434
|
+
assertNotBulkTarget(udid);
|
|
435
|
+
const dir = await mkdtemp(join(tmpdir(), "simshot-"));
|
|
436
|
+
const path = join(dir, "shot.png");
|
|
437
|
+
try {
|
|
438
|
+
await this.run([
|
|
439
|
+
"io",
|
|
440
|
+
udid,
|
|
441
|
+
"screenshot",
|
|
442
|
+
"--type=png",
|
|
443
|
+
path
|
|
444
|
+
]);
|
|
445
|
+
return (await readFile(path)).toString("base64");
|
|
446
|
+
} finally {
|
|
447
|
+
await rm(dir, {
|
|
448
|
+
recursive: true,
|
|
449
|
+
force: true
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Read a `simctl ui` option.
|
|
455
|
+
*
|
|
456
|
+
* `unknown` and `unsupported` are passed through rather than normalised: they
|
|
457
|
+
* mean genuinely different things — the runtime is too old, versus it failed
|
|
458
|
+
* to read — and collapsing them throws away a distinction a caller can act on.
|
|
459
|
+
*/
|
|
460
|
+
async uiGet(udid, option) {
|
|
461
|
+
assertNotBulkTarget(udid);
|
|
462
|
+
return (await this.run([
|
|
463
|
+
"ui",
|
|
464
|
+
udid,
|
|
465
|
+
option
|
|
466
|
+
])).trim();
|
|
467
|
+
}
|
|
468
|
+
async uiSet(udid, option, value) {
|
|
469
|
+
assertNotBulkTarget(udid);
|
|
470
|
+
await this.run([
|
|
471
|
+
"ui",
|
|
472
|
+
udid,
|
|
473
|
+
option,
|
|
474
|
+
value
|
|
475
|
+
]);
|
|
476
|
+
}
|
|
477
|
+
async statusBarOverride(udid, flags) {
|
|
478
|
+
assertNotBulkTarget(udid);
|
|
479
|
+
await this.run([
|
|
480
|
+
"status_bar",
|
|
481
|
+
udid,
|
|
482
|
+
"override",
|
|
483
|
+
...flags
|
|
484
|
+
]);
|
|
485
|
+
}
|
|
486
|
+
async statusBarClear(udid) {
|
|
487
|
+
assertNotBulkTarget(udid);
|
|
488
|
+
await this.run([
|
|
489
|
+
"status_bar",
|
|
490
|
+
udid,
|
|
491
|
+
"clear"
|
|
492
|
+
]);
|
|
493
|
+
}
|
|
494
|
+
async statusBarList(udid) {
|
|
495
|
+
assertNotBulkTarget(udid);
|
|
496
|
+
return (await this.run([
|
|
497
|
+
"status_bar",
|
|
498
|
+
udid,
|
|
499
|
+
"list"
|
|
500
|
+
])).trim();
|
|
501
|
+
}
|
|
502
|
+
async locationSet(udid, latitude, longitude) {
|
|
503
|
+
assertNotBulkTarget(udid);
|
|
504
|
+
await this.run([
|
|
505
|
+
"location",
|
|
506
|
+
udid,
|
|
507
|
+
"set",
|
|
508
|
+
`${latitude},${longitude}`
|
|
509
|
+
]);
|
|
510
|
+
}
|
|
511
|
+
async locationClear(udid) {
|
|
512
|
+
assertNotBulkTarget(udid);
|
|
513
|
+
await this.run([
|
|
514
|
+
"location",
|
|
515
|
+
udid,
|
|
516
|
+
"clear"
|
|
517
|
+
]);
|
|
518
|
+
}
|
|
519
|
+
/** `grant` and `revoke` need a bundle id; `reset` does not. */
|
|
520
|
+
async privacy(udid, action, service, bundleId) {
|
|
521
|
+
assertNotBulkTarget(udid);
|
|
522
|
+
await this.run([
|
|
523
|
+
"privacy",
|
|
524
|
+
udid,
|
|
525
|
+
action,
|
|
526
|
+
service,
|
|
527
|
+
...bundleId ? [bundleId] : []
|
|
528
|
+
]);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* A simulated remote push. The payload goes through a temp file rather than
|
|
532
|
+
* stdin, for the same argv-only reason as the plist lane.
|
|
533
|
+
*/
|
|
534
|
+
async push(udid, bundleId, payload) {
|
|
535
|
+
assertNotBulkTarget(udid);
|
|
536
|
+
const dir = await mkdtemp(join(tmpdir(), "simpush-"));
|
|
537
|
+
const path = join(dir, "payload.json");
|
|
538
|
+
try {
|
|
539
|
+
await writeFile(path, JSON.stringify(payload));
|
|
540
|
+
await this.run([
|
|
541
|
+
"push",
|
|
542
|
+
udid,
|
|
543
|
+
bundleId,
|
|
544
|
+
path
|
|
545
|
+
]);
|
|
546
|
+
} finally {
|
|
547
|
+
await rm(dir, {
|
|
548
|
+
recursive: true,
|
|
549
|
+
force: true
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
//#endregion
|
|
555
|
+
//#region src/client/simulator.ts
|
|
556
|
+
/** What to say when WebDriverAgent fails, on a simulator. */
|
|
557
|
+
const SIMULATOR_WDA_REMEDIES = {
|
|
558
|
+
unavailable: WDA_UNAVAILABLE_REMEDY,
|
|
559
|
+
notAuthorized: `WebDriverAgent is running but is refusing to drive the UI. On a simulator there is no Enable UI Automation toggle to blame, so this is nearly always a stale runner. ${START_RUNNER_REMEDY}`,
|
|
560
|
+
noForegroundApp: "WebDriverAgent can see no foreground application on a session it has just created. Launch the app with ios_simulator_launch, or restart the runner with ios_simulator_restart_wda.",
|
|
561
|
+
noSuchElement: "Call ios_simulator_ui_tree to see what is actually on screen — the element may not have appeared yet."
|
|
562
|
+
};
|
|
563
|
+
var SimulatorClient = class {
|
|
564
|
+
simctl;
|
|
565
|
+
exec;
|
|
566
|
+
sipsPath;
|
|
567
|
+
plutilPath;
|
|
568
|
+
openPath;
|
|
569
|
+
execTimeoutMs;
|
|
570
|
+
opts;
|
|
571
|
+
wdaClients = /* @__PURE__ */ new Map();
|
|
572
|
+
/** `simctl list` is ~80ms and most tools want it twice; 2s collapses that. */
|
|
573
|
+
cache;
|
|
574
|
+
profiles = /* @__PURE__ */ new Map();
|
|
575
|
+
constructor(opts) {
|
|
576
|
+
this.opts = opts;
|
|
577
|
+
this.exec = opts.exec ?? createExec({
|
|
578
|
+
timeout: "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.",
|
|
579
|
+
toolchain: "Install Xcode and run `xcode-select --install`, or point IOS_SIMULATOR_XCRUN_PATH at a different xcrun."
|
|
580
|
+
});
|
|
581
|
+
this.sipsPath = opts.sipsPath;
|
|
582
|
+
this.plutilPath = opts.plutilPath;
|
|
583
|
+
this.openPath = opts.openPath;
|
|
584
|
+
this.execTimeoutMs = opts.execTimeoutMs;
|
|
585
|
+
this.simctl = new Simctl({
|
|
586
|
+
xcrunPath: opts.xcrunPath,
|
|
587
|
+
plutilPath: opts.plutilPath,
|
|
588
|
+
timeoutMs: opts.execTimeoutMs,
|
|
589
|
+
...opts.exec ? { exec: opts.exec } : {},
|
|
590
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
async listSimulators(opts = {}) {
|
|
594
|
+
const cached = this.cache;
|
|
595
|
+
if (!opts.fresh && cached && Date.now() - cached.at < 2e3) return cached.simulators;
|
|
596
|
+
const [devices, runtimes, deviceTypes] = await Promise.all([
|
|
597
|
+
this.simctl.listDevices(),
|
|
598
|
+
this.simctl.listRuntimes(),
|
|
599
|
+
this.simctl.listDeviceTypes()
|
|
600
|
+
]);
|
|
601
|
+
const simulators = summarizeDevices(devices, runtimes, deviceTypes);
|
|
602
|
+
this.cache = {
|
|
603
|
+
at: Date.now(),
|
|
604
|
+
simulators
|
|
605
|
+
};
|
|
606
|
+
return simulators;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Resolve a hint to exactly one simulator, or refuse.
|
|
610
|
+
*
|
|
611
|
+
* The device server's rule — never guess between two — transfers, but
|
|
612
|
+
* "connected" has to become the right predicate. A typical machine carries
|
|
613
|
+
* dozens of simulators, most of them shut down and some of them unavailable,
|
|
614
|
+
* so the population is mostly non-candidates.
|
|
615
|
+
*
|
|
616
|
+
* Three things happen here that cannot happen further down:
|
|
617
|
+
*
|
|
618
|
+
* - `all` and `unavailable` are refused outright. `simctl erase all` wipes
|
|
619
|
+
* every simulator on the machine, and a hint is a string from a model.
|
|
620
|
+
* - `booted` is resolved *here* rather than passed through. simctl's own
|
|
621
|
+
* documentation says that with several booted it "will choose one of
|
|
622
|
+
* them", and a coin flip that drives the wrong screen most of the time is
|
|
623
|
+
* exactly what the refuse-to-guess rule exists to prevent.
|
|
624
|
+
* - availability is checked before the target is returned, because `simctl
|
|
625
|
+
* io` against an unavailable simulator aborts with an uncaught
|
|
626
|
+
* NSInternalInconsistencyException rather than failing.
|
|
627
|
+
*
|
|
628
|
+
* State is deliberately *not* checked: `ios_simulator_list` and
|
|
629
|
+
* `ios_simulator_diagnostics` are useful against a shut-down simulator, so
|
|
630
|
+
* requiring booted is each tool's decision, via `requireBooted`.
|
|
631
|
+
*/
|
|
632
|
+
async resolveTarget(hint) {
|
|
633
|
+
const wanted = hint ?? this.opts.defaultSimulatorId;
|
|
634
|
+
const simulators = await this.listSimulators();
|
|
635
|
+
if (wanted === "all" || wanted === "unavailable") throw new SimulatorNotFoundError(`"${wanted}" is not a simulator, it is how simctl says *every* simulator.`, "Name one by UDID or by name. `simctl erase all` would wipe the machine, so this server refuses to forward it.");
|
|
636
|
+
const usable = simulators.filter((sim) => sim.available);
|
|
637
|
+
if (wanted !== void 0 && wanted !== "booted") {
|
|
638
|
+
const byUdid = simulators.filter((sim) => sim.id.toLowerCase() === wanted.toLowerCase());
|
|
639
|
+
const named = simulators.filter((sim) => sim.name === wanted);
|
|
640
|
+
const byName = named.some((sim) => sim.available) ? named.filter((sim) => sim.available) : named;
|
|
641
|
+
const matches = byUdid.length > 0 ? byUdid : byName;
|
|
642
|
+
if (matches.length === 0) throw new SimulatorNotFoundError(`No simulator matches "${wanted}".`, simulators.length === 0 ? "simctl knows about no simulators at all. Install a runtime in Xcode > Settings > Components." : `Run ios_simulator_list to see them. ${usable.length} of ${simulators.length} are available.`);
|
|
643
|
+
if (matches.length > 1) throw new SimulatorNotFoundError(`${matches.length} simulators are named "${wanted}".`, `Pass a UDID instead: ${matches.map((sim) => `${sim.name} = ${sim.id}`).join(", ")}.`);
|
|
644
|
+
return this.assertAvailable(matches[0]);
|
|
645
|
+
}
|
|
646
|
+
const booted = usable.filter((sim) => sim.state === "Booted");
|
|
647
|
+
if (booted.length === 1) return this.assertAvailable(booted[0]);
|
|
648
|
+
if (booted.length === 0) {
|
|
649
|
+
const candidates = usable.slice(0, 6).map((sim) => `${sim.name} = ${sim.id}`).join(", ");
|
|
650
|
+
throw new SimulatorNotFoundError("No simulator is booted.", usable.length === 0 ? "No simulator on this machine has an installed runtime. Install one in Xcode > Settings > Components." : `Boot one with ios_simulator_power, e.g. ${candidates}.`);
|
|
651
|
+
}
|
|
652
|
+
throw new SimulatorNotFoundError(`${booted.length} simulators are booted, so there is no obvious default.`, `Pass \`simulator\` (or set IOS_SIMULATOR_ID) to one of: ${booted.map((sim) => `${sim.name} = ${sim.id}`).join(", ")}.`);
|
|
653
|
+
}
|
|
654
|
+
assertAvailable(sim) {
|
|
655
|
+
if (!sim.available) throw new RuntimeUnavailableError(sim.name, sim.unavailableReason);
|
|
656
|
+
return sim;
|
|
657
|
+
}
|
|
658
|
+
/** Every tool that needs a running simulator calls this; the resolver does not. */
|
|
659
|
+
requireBooted(sim) {
|
|
660
|
+
if (sim.state !== "Booted") throw new SimulatorNotBootedError(sim.name, sim.id);
|
|
661
|
+
return sim;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Geometry. Cached per device *type* rather than per simulator, since that is
|
|
665
|
+
* what the profile describes and it changes only when Xcode does.
|
|
666
|
+
*/
|
|
667
|
+
async display(target, capturePng) {
|
|
668
|
+
const id = target.deviceTypeIdentifier;
|
|
669
|
+
let profile = id ? this.profiles.get(id) : void 0;
|
|
670
|
+
if (!profile) {
|
|
671
|
+
const deviceType = (await this.simctl.listDeviceTypes()).find((t) => t.identifier === id);
|
|
672
|
+
profile = deviceType ? await readScreenProfile(deviceType, {
|
|
673
|
+
plutilPath: this.plutilPath,
|
|
674
|
+
exec: this.exec,
|
|
675
|
+
timeoutMs: this.execTimeoutMs
|
|
676
|
+
}) : {};
|
|
677
|
+
if (id) this.profiles.set(id, profile);
|
|
678
|
+
}
|
|
679
|
+
const capture = capturePng ? pngDimensions(Buffer.from(capturePng, "base64")) : void 0;
|
|
680
|
+
return toDisplayInfo(profile, capture);
|
|
681
|
+
}
|
|
682
|
+
/** The capture lane that needs no runner — the whole reason this server is cheap to start. */
|
|
683
|
+
async screenshotPng(target) {
|
|
684
|
+
this.requireBooted(target);
|
|
685
|
+
return this.simctl.screenshotPng(target.id);
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Where WebDriverAgent is.
|
|
689
|
+
*
|
|
690
|
+
* Unlike the device lane there is no tunnel address to derive: a simulator
|
|
691
|
+
* shares the host's network stack, so the runner's port *is* a host loopback
|
|
692
|
+
* port. That is also the trap — several booted simulators contend for the
|
|
693
|
+
* same one, and WebDriverAgent scans 8100-8199 when `USE_PORT` is unset, so a
|
|
694
|
+
* second runner comes up healthy on 8101 and a server pointed at 8100 drives
|
|
695
|
+
* the first simulator while reporting success. `ios_simulator_diagnostics`
|
|
696
|
+
* reports which simulator actually owns the port.
|
|
697
|
+
*/
|
|
698
|
+
wdaUrl() {
|
|
699
|
+
return this.opts.wdaUrl ?? `http://127.0.0.1:${this.opts.wdaPort}`;
|
|
700
|
+
}
|
|
701
|
+
get wdaPort() {
|
|
702
|
+
return this.opts.wdaPort;
|
|
703
|
+
}
|
|
704
|
+
wda(_target) {
|
|
705
|
+
const url = this.wdaUrl();
|
|
706
|
+
const existing = this.wdaClients.get(url);
|
|
707
|
+
if (existing) return existing;
|
|
708
|
+
const client = new WdaClient({
|
|
709
|
+
baseUrl: async () => url,
|
|
710
|
+
timeoutMs: this.opts.wdaTimeoutMs,
|
|
711
|
+
remedies: SIMULATOR_WDA_REMEDIES,
|
|
712
|
+
...this.opts.fetch ? { fetch: this.opts.fetch } : {},
|
|
713
|
+
...this.opts.logger ? { logger: this.opts.logger } : {}
|
|
714
|
+
});
|
|
715
|
+
this.wdaClients.set(url, client);
|
|
716
|
+
return client;
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
//#endregion
|
|
720
|
+
//#region src/tools/util.ts
|
|
721
|
+
/**
|
|
722
|
+
* How the shared tools name themselves and each other in this server.
|
|
723
|
+
*
|
|
724
|
+
* `copy.target` is written out rather than derived: a simulator is picked by
|
|
725
|
+
* UDID, by name, or by being the only booted one, and none of those is what the
|
|
726
|
+
* device server's version of this sentence says. `names.listTargets` is
|
|
727
|
+
* overridden for the same reason — `ios_simulator_list_devices` would be a
|
|
728
|
+
* mouthful for a tool that lists simulators.
|
|
729
|
+
*/
|
|
730
|
+
const SIMULATOR_NAMING = {
|
|
731
|
+
prefix: "ios_simulator",
|
|
732
|
+
title: "iOS Simulator",
|
|
733
|
+
noun: "simulator",
|
|
734
|
+
envPrefix: "IOS_SIMULATOR",
|
|
735
|
+
names: { listTargets: "ios_simulator_list" },
|
|
736
|
+
copy: { target: "Which simulator: its UDID or its name as shown by ios_simulator_list. Omit it when exactly one is booted — that is the normal case, and IOS_SIMULATOR_ID pins it when it is not. Unlike simctl's own `booted`, this never picks arbitrarily between two." }
|
|
737
|
+
};
|
|
738
|
+
const args = createArgs(SIMULATOR_NAMING);
|
|
739
|
+
const { bundleIdArg, confirmArg, detailArg, screenshotArg, settleArg, xArg, yArg } = args;
|
|
740
|
+
/**
|
|
741
|
+
* The schema field is `device` in every tool here, shared and local alike.
|
|
742
|
+
*
|
|
743
|
+
* It looks odd next to a server called "simulator" and it is still the right
|
|
744
|
+
* name: `simctl`'s own interface calls a simulator a device — `simctl list
|
|
745
|
+
* devices`, and `<device>` in the usage line of every subcommand — so this is
|
|
746
|
+
* Apple's word for the thing, and the `ios_simulator_` prefix already says
|
|
747
|
+
* which kind. Mixing `simulator` here with the shared tools' `device` would be
|
|
748
|
+
* worse than either alone.
|
|
749
|
+
*/
|
|
750
|
+
const deviceArg = args.targetArg;
|
|
751
|
+
//#endregion
|
|
752
|
+
//#region src/tools/apps.ts
|
|
753
|
+
const registerListAppsTool = (server, client) => {
|
|
754
|
+
server.registerTool("ios_simulator_list_apps", {
|
|
755
|
+
title: "iOS Simulator: List Apps",
|
|
756
|
+
description: "List the apps installed on a simulator, with the bundle id every other tool takes. Defaults to your own apps: a stock simulator carries about thirty, and twenty-five of them are Apple's. Each entry also carries `dataContainer` — an ordinary path on this Mac, so an app's database or logs can be read directly with no copy step.",
|
|
757
|
+
inputSchema: z.object({
|
|
758
|
+
device: deviceArg,
|
|
759
|
+
include_all: z.boolean().default(false).describe("Include Apple's built-in apps as well. Off by default; the list is long.")
|
|
760
|
+
}),
|
|
761
|
+
annotations: { readOnlyHint: true }
|
|
762
|
+
}, async ({ device, include_all }) => wrap(async () => {
|
|
763
|
+
const target = client.requireBooted(await client.resolveTarget(device));
|
|
764
|
+
const apps = await client.simctl.listApps(target.id);
|
|
765
|
+
return { apps: summarizeApps(apps, { includeAll: include_all }) };
|
|
766
|
+
}));
|
|
767
|
+
};
|
|
768
|
+
const registerAppTools = (server, client, launchArgs, outputDir) => {
|
|
769
|
+
server.registerTool("ios_simulator_install", {
|
|
770
|
+
title: "iOS Simulator: Install",
|
|
771
|
+
description: "Install a build. The path must be a `.app` **bundle directory** built for the simulator — not an `.ipa`, and not a device build, both of which fail with a bare \"No such file or directory\" that reads like a typo. Installing over an existing copy replaces it and keeps its data.",
|
|
772
|
+
inputSchema: z.object({
|
|
773
|
+
device: deviceArg,
|
|
774
|
+
path: z.string().describe("Absolute path to the .app bundle, e.g. ~/Library/Developer/Xcode/DerivedData/…/Build/Products/Debug-iphonesimulator/Foo.app")
|
|
775
|
+
}),
|
|
776
|
+
annotations: {
|
|
777
|
+
readOnlyHint: false,
|
|
778
|
+
destructiveHint: false,
|
|
779
|
+
idempotentHint: true
|
|
780
|
+
}
|
|
781
|
+
}, async ({ device, path }) => wrap(async () => {
|
|
782
|
+
if (!path.startsWith("/")) throw new IosError$1(`path must be absolute, got "${path}".`, { remedy: "Pass the full path to the .app bundle." });
|
|
783
|
+
const target = client.requireBooted(await client.resolveTarget(device));
|
|
784
|
+
await client.simctl.install(target.id, path);
|
|
785
|
+
return {
|
|
786
|
+
installed: path,
|
|
787
|
+
udid: target.id
|
|
788
|
+
};
|
|
789
|
+
}));
|
|
790
|
+
server.registerTool("ios_simulator_launch", {
|
|
791
|
+
title: "iOS Simulator: Launch",
|
|
792
|
+
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.",
|
|
793
|
+
inputSchema: z.object({
|
|
794
|
+
device: deviceArg,
|
|
795
|
+
bundle_id: bundleIdArg,
|
|
796
|
+
arguments: z.array(z.string()).optional().describe("Launch arguments, e.g. [\"-DemoMode\", \"YES\"]. Overrides IOS_SIMULATOR_LAUNCH_ARGS rather than adding to it."),
|
|
797
|
+
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.")
|
|
798
|
+
}),
|
|
799
|
+
annotations: {
|
|
800
|
+
readOnlyHint: false,
|
|
801
|
+
destructiveHint: false,
|
|
802
|
+
idempotentHint: false
|
|
803
|
+
}
|
|
804
|
+
}, async ({ device, bundle_id, arguments: args, terminate_first }) => wrap(async () => {
|
|
805
|
+
const target = client.requireBooted(await client.resolveTarget(device));
|
|
806
|
+
const effective = args ?? launchArgs;
|
|
807
|
+
const stdout = `${outputDir}/${bundle_id}.out.log`;
|
|
808
|
+
const stderr = `${outputDir}/${bundle_id}.err.log`;
|
|
809
|
+
const output = await client.simctl.launch(target.id, bundle_id, {
|
|
810
|
+
args: effective,
|
|
811
|
+
terminateExisting: terminate_first,
|
|
812
|
+
stdout,
|
|
813
|
+
stderr
|
|
814
|
+
});
|
|
815
|
+
const pid = /:\s*(\d+)\s*$/.exec(output.trim())?.[1];
|
|
816
|
+
return {
|
|
817
|
+
launched: bundle_id,
|
|
818
|
+
...pid ? { pid: Number(pid) } : {},
|
|
819
|
+
...effective.length > 0 ? { arguments: effective } : {},
|
|
820
|
+
...args === void 0 && launchArgs.length > 0 ? { argumentsFrom: "IOS_SIMULATOR_LAUNCH_ARGS" } : {},
|
|
821
|
+
logs: {
|
|
822
|
+
stdout,
|
|
823
|
+
stderr
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
}));
|
|
827
|
+
server.registerTool("ios_simulator_terminate", {
|
|
828
|
+
title: "iOS Simulator: Terminate",
|
|
829
|
+
description: "Kill a running app. The app is not uninstalled and its data is untouched — this is how you get back to a cold start without erasing anything.",
|
|
830
|
+
inputSchema: z.object({
|
|
831
|
+
device: deviceArg,
|
|
832
|
+
bundle_id: bundleIdArg
|
|
833
|
+
}),
|
|
834
|
+
annotations: {
|
|
835
|
+
readOnlyHint: false,
|
|
836
|
+
destructiveHint: false,
|
|
837
|
+
idempotentHint: true
|
|
838
|
+
}
|
|
839
|
+
}, async ({ device, bundle_id }) => wrap(async () => {
|
|
840
|
+
const target = client.requireBooted(await client.resolveTarget(device));
|
|
841
|
+
await client.simctl.terminate(target.id, bundle_id);
|
|
842
|
+
return { terminated: bundle_id };
|
|
843
|
+
}));
|
|
844
|
+
server.registerTool("ios_simulator_open_url", {
|
|
845
|
+
title: "iOS Simulator: Open URL",
|
|
846
|
+
description: "Open a URL on the simulator, which is how you exercise a deep link or a universal link without finding a way to tap one. An `https://` link opens in Safari unless the app claims it; a custom scheme goes straight to whichever app registered it.",
|
|
847
|
+
inputSchema: z.object({
|
|
848
|
+
device: deviceArg,
|
|
849
|
+
url: z.url().describe("e.g. \"myapp://garden/42\" or \"https://example.com/garden/42\".")
|
|
850
|
+
}),
|
|
851
|
+
annotations: {
|
|
852
|
+
readOnlyHint: false,
|
|
853
|
+
destructiveHint: false,
|
|
854
|
+
idempotentHint: false
|
|
855
|
+
}
|
|
856
|
+
}, async ({ device, url }) => wrap(async () => {
|
|
857
|
+
const target = client.requireBooted(await client.resolveTarget(device));
|
|
858
|
+
await client.simctl.openUrl(target.id, url);
|
|
859
|
+
return { opened: url };
|
|
860
|
+
}));
|
|
861
|
+
};
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region src/tools/diagnostics.ts
|
|
864
|
+
/**
|
|
865
|
+
* Which simulator owns the WebDriverAgent port.
|
|
866
|
+
*
|
|
867
|
+
* This check has no device-server equivalent and it is the most valuable one
|
|
868
|
+
* here. A simulator shares the host's network stack, so a runner's port is a
|
|
869
|
+
* *host* port, and WebDriverAgent scans 8100-8199 when `USE_PORT` is unset. Two
|
|
870
|
+
* booted simulators therefore give you a second runner answering healthily on
|
|
871
|
+
* 8101 while a server pointed at 8100 drives the first one and reports success
|
|
872
|
+
* on every call.
|
|
873
|
+
*
|
|
874
|
+
* Every process inside a simulator carries `SIMULATOR_UDID` in its environment,
|
|
875
|
+
* and `ps -Eww` prints it, so the port's real owner is knowable rather than
|
|
876
|
+
* assumed.
|
|
877
|
+
*/
|
|
878
|
+
const portOwner = async (client, port) => {
|
|
879
|
+
try {
|
|
880
|
+
const { stdout: pids } = await client.exec("/usr/sbin/lsof", [
|
|
881
|
+
"-nP",
|
|
882
|
+
`-iTCP:${port}`,
|
|
883
|
+
"-sTCP:LISTEN",
|
|
884
|
+
"-t"
|
|
885
|
+
], 5e3);
|
|
886
|
+
const pid = pids.trim().split("\n")[0];
|
|
887
|
+
if (!pid) return void 0;
|
|
888
|
+
const { stdout: env } = await client.exec("/bin/ps", [
|
|
889
|
+
"-Eww",
|
|
890
|
+
"-p",
|
|
891
|
+
pid
|
|
892
|
+
], 5e3);
|
|
893
|
+
return /SIMULATOR_UDID=([0-9A-Fa-f-]{36})/.exec(env)?.[1];
|
|
894
|
+
} catch {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
/**
|
|
899
|
+
* The whole point of this tool is that **it never throws**. Every other tool
|
|
900
|
+
* fails when nothing is booted, the runtime is missing or the runner is not up,
|
|
901
|
+
* and each of those looks much like the others from the outside. This one
|
|
902
|
+
* collects all of it as data and names which half is missing.
|
|
903
|
+
*/
|
|
904
|
+
const diagnose = async (client, ctx, deviceHint) => {
|
|
905
|
+
const problems = [];
|
|
906
|
+
const nextSteps = [];
|
|
907
|
+
const writes = ctx.allowWrites ? "enabled" : "disabled";
|
|
908
|
+
let simulators = [];
|
|
909
|
+
try {
|
|
910
|
+
simulators = await client.listSimulators({ fresh: true });
|
|
911
|
+
} catch (err) {
|
|
912
|
+
problems.push(`Could not list simulators: ${err instanceof Error ? err.message : String(err)}`);
|
|
913
|
+
nextSteps.push("Install Xcode and its command line tools, then run `xcrun simctl list devices`.");
|
|
914
|
+
return {
|
|
915
|
+
ok: false,
|
|
916
|
+
writes,
|
|
917
|
+
simulators: [],
|
|
918
|
+
counts: {
|
|
919
|
+
total: 0,
|
|
920
|
+
booted: 0,
|
|
921
|
+
unavailable: 0
|
|
922
|
+
},
|
|
923
|
+
wda: {
|
|
924
|
+
url: client.wdaUrl(),
|
|
925
|
+
port: client.wdaPort,
|
|
926
|
+
reachable: false
|
|
927
|
+
},
|
|
928
|
+
problems,
|
|
929
|
+
nextSteps
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
const booted = simulators.filter((sim) => sim.state === "Booted");
|
|
933
|
+
const unavailable = simulators.filter((sim) => !sim.available);
|
|
934
|
+
const counts = {
|
|
935
|
+
total: simulators.length,
|
|
936
|
+
booted: booted.length,
|
|
937
|
+
unavailable: unavailable.length
|
|
938
|
+
};
|
|
939
|
+
if (simulators.length === 0) {
|
|
940
|
+
problems.push("simctl knows about no simulators at all.");
|
|
941
|
+
nextSteps.push("Install a runtime: Xcode > Settings > Components.");
|
|
942
|
+
} else if (booted.length === 0) {
|
|
943
|
+
problems.push("No simulator is booted.");
|
|
944
|
+
nextSteps.push("Boot one with ios_simulator_power {\"state\":\"booted\"} — this server never boots implicitly.");
|
|
945
|
+
}
|
|
946
|
+
let target;
|
|
947
|
+
try {
|
|
948
|
+
target = await client.resolveTarget(deviceHint);
|
|
949
|
+
} catch (err) {
|
|
950
|
+
if (booted.length > 0) {
|
|
951
|
+
problems.push(err instanceof Error ? err.message : String(err));
|
|
952
|
+
const remedy = err.remedy;
|
|
953
|
+
if (remedy) nextSteps.push(remedy);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
let display;
|
|
957
|
+
if (target) try {
|
|
958
|
+
display = await client.display(target);
|
|
959
|
+
} catch {}
|
|
960
|
+
const wda = {
|
|
961
|
+
url: client.wdaUrl(),
|
|
962
|
+
port: client.wdaPort,
|
|
963
|
+
reachable: false
|
|
964
|
+
};
|
|
965
|
+
try {
|
|
966
|
+
wda.state = await client.wda(target ?? {
|
|
967
|
+
id: "",
|
|
968
|
+
name: ""
|
|
969
|
+
}).status();
|
|
970
|
+
wda.reachable = true;
|
|
971
|
+
} catch (err) {
|
|
972
|
+
wda.error = err instanceof Error ? err.message : String(err);
|
|
973
|
+
problems.push("WebDriverAgent is not answering, so the UI tree and the input tools will fail.");
|
|
974
|
+
nextSteps.push(`${START_RUNNER_REMEDY} Everything else — including screenshots — works without it.`);
|
|
975
|
+
}
|
|
976
|
+
if (wda.reachable) {
|
|
977
|
+
const owner = await portOwner(client, client.wdaPort);
|
|
978
|
+
if (owner) wda.boundTo = owner;
|
|
979
|
+
if (owner && target && owner.toLowerCase() !== target.id.toLowerCase()) {
|
|
980
|
+
const other = simulators.find((sim) => sim.id.toLowerCase() === owner.toLowerCase());
|
|
981
|
+
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.`);
|
|
982
|
+
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.");
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
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.`);
|
|
986
|
+
return {
|
|
987
|
+
ok: problems.length === 0,
|
|
988
|
+
writes,
|
|
989
|
+
simulators: [...booted, ...target && target.state !== "Booted" ? [target] : []].map(toListRow),
|
|
990
|
+
counts,
|
|
991
|
+
...target ? { target: {
|
|
992
|
+
udid: target.id,
|
|
993
|
+
name: target.name,
|
|
994
|
+
state: target.state,
|
|
995
|
+
runtime: target.runtime
|
|
996
|
+
} } : {},
|
|
997
|
+
...display ? { display } : {},
|
|
998
|
+
wda,
|
|
999
|
+
problems,
|
|
1000
|
+
nextSteps
|
|
1001
|
+
};
|
|
1002
|
+
};
|
|
1003
|
+
const registerDiagnosticsTools = (server, client, ctx) => {
|
|
1004
|
+
server.registerTool("ios_simulator_diagnostics", {
|
|
1005
|
+
title: "iOS Simulator: Diagnostics",
|
|
1006
|
+
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 *which* simulator owns its port, which is the one way to catch a second runner quietly taking your taps. Start here when anything else misbehaves.",
|
|
1007
|
+
inputSchema: z.object({ device: deviceArg }),
|
|
1008
|
+
annotations: { readOnlyHint: true }
|
|
1009
|
+
}, async ({ device }) => wrap(async () => diagnose(client, ctx, device)));
|
|
1010
|
+
server.registerResource("diagnostics", "ios-simulator://diagnostics", {
|
|
1011
|
+
title: "iOS Simulator diagnostics",
|
|
1012
|
+
description: "The same report as ios_simulator_diagnostics, as a resource.",
|
|
1013
|
+
mimeType: "application/json"
|
|
1014
|
+
}, async (uri) => ({ contents: [{
|
|
1015
|
+
uri: uri.href,
|
|
1016
|
+
mimeType: "application/json",
|
|
1017
|
+
text: JSON.stringify(await diagnose(client, ctx))
|
|
1018
|
+
}] }));
|
|
1019
|
+
};
|
|
1020
|
+
//#endregion
|
|
1021
|
+
//#region src/tools/environment.ts
|
|
1022
|
+
/** simctl's own list, verbatim — a value it does not accept is not worth guessing at. */
|
|
1023
|
+
const PRIVACY_SERVICES = [
|
|
1024
|
+
"all",
|
|
1025
|
+
"calendar",
|
|
1026
|
+
"contacts-limited",
|
|
1027
|
+
"contacts",
|
|
1028
|
+
"location",
|
|
1029
|
+
"location-always",
|
|
1030
|
+
"photos-add",
|
|
1031
|
+
"photos",
|
|
1032
|
+
"media-library",
|
|
1033
|
+
"microphone",
|
|
1034
|
+
"motion",
|
|
1035
|
+
"reminders",
|
|
1036
|
+
"siri"
|
|
1037
|
+
];
|
|
1038
|
+
/** APNs refuses anything larger, and it is worth saying so before the round trip. */
|
|
1039
|
+
const MAX_PUSH_BYTES = 4096;
|
|
1040
|
+
const registerEnvironmentTools = (server, client) => {
|
|
1041
|
+
server.registerTool("ios_simulator_set_environment", {
|
|
1042
|
+
title: "iOS Simulator: Set Environment",
|
|
1043
|
+
description: "Stage the simulator before a screenshot or a test: dark mode, Dynamic Type size, increased contrast, a frozen status bar, a simulated location, and app permissions. None of this is possible on a physical device, and it is most of the reason to prefer a simulator for UI work. Every field is optional; those given are applied in order and the result reports the state afterwards. A status bar override survives a reboot, so clear it when you are done or it quietly pollutes later screenshots.",
|
|
1044
|
+
inputSchema: z.object({
|
|
1045
|
+
device: deviceArg,
|
|
1046
|
+
appearance: z.enum(["light", "dark"]).optional().describe("Interface style."),
|
|
1047
|
+
content_size: z.enum([
|
|
1048
|
+
"extra-small",
|
|
1049
|
+
"small",
|
|
1050
|
+
"medium",
|
|
1051
|
+
"large",
|
|
1052
|
+
"extra-large",
|
|
1053
|
+
"extra-extra-large",
|
|
1054
|
+
"extra-extra-extra-large",
|
|
1055
|
+
"accessibility-medium",
|
|
1056
|
+
"accessibility-large",
|
|
1057
|
+
"accessibility-extra-large",
|
|
1058
|
+
"accessibility-extra-extra-large",
|
|
1059
|
+
"accessibility-extra-extra-extra-large"
|
|
1060
|
+
]).optional().describe("Dynamic Type size. The accessibility- sizes are where layouts break."),
|
|
1061
|
+
increase_contrast: z.boolean().optional().describe("Increase Contrast accessibility mode."),
|
|
1062
|
+
status_bar: z.object({
|
|
1063
|
+
time: z.string().optional().describe("Fixed clock, e.g. \"9:41\" — what App Store screenshots use."),
|
|
1064
|
+
wifi_bars: z.number().int().min(0).max(3).optional(),
|
|
1065
|
+
cellular_bars: z.number().int().min(0).max(4).optional(),
|
|
1066
|
+
operator_name: z.string().optional().describe("Carrier name; \"\" hides it."),
|
|
1067
|
+
battery_level: z.number().int().min(0).max(100).optional(),
|
|
1068
|
+
battery_state: z.enum([
|
|
1069
|
+
"charging",
|
|
1070
|
+
"charged",
|
|
1071
|
+
"discharging"
|
|
1072
|
+
]).optional()
|
|
1073
|
+
}).optional().describe("Freeze the status bar. Overrides persist until cleared."),
|
|
1074
|
+
clear_status_bar: z.boolean().default(false).describe("Drop every status bar override, restoring the real one."),
|
|
1075
|
+
location: z.object({
|
|
1076
|
+
latitude: z.number(),
|
|
1077
|
+
longitude: z.number()
|
|
1078
|
+
}).optional().describe("Teleport the device, for location-gated screens."),
|
|
1079
|
+
clear_location: z.boolean().default(false).describe("Stop simulating a location."),
|
|
1080
|
+
permission: z.object({
|
|
1081
|
+
action: z.enum([
|
|
1082
|
+
"grant",
|
|
1083
|
+
"revoke",
|
|
1084
|
+
"reset"
|
|
1085
|
+
]).describe("`grant` and `revoke` answer the prompt without showing it; `reset` makes it appear again on next use."),
|
|
1086
|
+
service: z.enum(PRIVACY_SERVICES),
|
|
1087
|
+
bundle_id: bundleIdArg.optional().describe("Required for grant and revoke; optional for reset.")
|
|
1088
|
+
}).optional().describe("Set an app permission directly. This is how you reach the denied branch of a permission check, which is otherwise a prompt nobody can tap.")
|
|
1089
|
+
}),
|
|
1090
|
+
annotations: {
|
|
1091
|
+
readOnlyHint: false,
|
|
1092
|
+
destructiveHint: false,
|
|
1093
|
+
idempotentHint: true
|
|
1094
|
+
}
|
|
1095
|
+
}, async ({ device, appearance, content_size, increase_contrast, status_bar, clear_status_bar, location, clear_location, permission }) => wrap(async () => {
|
|
1096
|
+
const target = client.requireBooted(await client.resolveTarget(device));
|
|
1097
|
+
const applied = [];
|
|
1098
|
+
if (appearance) {
|
|
1099
|
+
await client.simctl.uiSet(target.id, "appearance", appearance);
|
|
1100
|
+
applied.push(`appearance=${appearance}`);
|
|
1101
|
+
}
|
|
1102
|
+
if (content_size) {
|
|
1103
|
+
await client.simctl.uiSet(target.id, "content_size", content_size);
|
|
1104
|
+
applied.push(`content_size=${content_size}`);
|
|
1105
|
+
}
|
|
1106
|
+
if (increase_contrast !== void 0) {
|
|
1107
|
+
await client.simctl.uiSet(target.id, "increase_contrast", increase_contrast ? "enabled" : "disabled");
|
|
1108
|
+
applied.push(`increase_contrast=${increase_contrast}`);
|
|
1109
|
+
}
|
|
1110
|
+
if (clear_status_bar) {
|
|
1111
|
+
await client.simctl.statusBarClear(target.id);
|
|
1112
|
+
applied.push("status_bar cleared");
|
|
1113
|
+
}
|
|
1114
|
+
if (status_bar) {
|
|
1115
|
+
const flags = [
|
|
1116
|
+
...status_bar.time ? ["--time", status_bar.time] : [],
|
|
1117
|
+
...status_bar.wifi_bars !== void 0 ? [
|
|
1118
|
+
"--wifiMode",
|
|
1119
|
+
"active",
|
|
1120
|
+
"--wifiBars",
|
|
1121
|
+
String(status_bar.wifi_bars)
|
|
1122
|
+
] : [],
|
|
1123
|
+
...status_bar.cellular_bars !== void 0 ? [
|
|
1124
|
+
"--cellularMode",
|
|
1125
|
+
"active",
|
|
1126
|
+
"--cellularBars",
|
|
1127
|
+
String(status_bar.cellular_bars)
|
|
1128
|
+
] : [],
|
|
1129
|
+
...status_bar.operator_name !== void 0 ? ["--operatorName", status_bar.operator_name] : [],
|
|
1130
|
+
...status_bar.battery_level !== void 0 ? ["--batteryLevel", String(status_bar.battery_level)] : [],
|
|
1131
|
+
...status_bar.battery_state ? ["--batteryState", status_bar.battery_state] : []
|
|
1132
|
+
];
|
|
1133
|
+
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." });
|
|
1134
|
+
await client.simctl.statusBarOverride(target.id, flags);
|
|
1135
|
+
applied.push("status_bar overridden");
|
|
1136
|
+
}
|
|
1137
|
+
if (clear_location) {
|
|
1138
|
+
await client.simctl.locationClear(target.id);
|
|
1139
|
+
applied.push("location cleared");
|
|
1140
|
+
}
|
|
1141
|
+
if (location) {
|
|
1142
|
+
await client.simctl.locationSet(target.id, location.latitude, location.longitude);
|
|
1143
|
+
applied.push(`location=${location.latitude},${location.longitude}`);
|
|
1144
|
+
}
|
|
1145
|
+
if (permission) {
|
|
1146
|
+
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." });
|
|
1147
|
+
await client.simctl.privacy(target.id, permission.action, permission.service, permission.bundle_id);
|
|
1148
|
+
applied.push(`permission ${permission.action} ${permission.service}`);
|
|
1149
|
+
}
|
|
1150
|
+
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." });
|
|
1151
|
+
return {
|
|
1152
|
+
applied,
|
|
1153
|
+
state: {
|
|
1154
|
+
appearance: await client.simctl.uiGet(target.id, "appearance"),
|
|
1155
|
+
contentSize: await client.simctl.uiGet(target.id, "content_size"),
|
|
1156
|
+
statusBar: await client.simctl.statusBarList(target.id)
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
}));
|
|
1160
|
+
server.registerTool("ios_simulator_push", {
|
|
1161
|
+
title: "iOS Simulator: Push",
|
|
1162
|
+
description: "Deliver a push notification, with no APNs certificate and no server. The payload is a normal remote-notification body and must contain an `aps` key. Only remote pushes are simulated — not VoIP, complications or file-provider. The app must be installed; it does not have to be running.",
|
|
1163
|
+
inputSchema: z.object({
|
|
1164
|
+
device: deviceArg,
|
|
1165
|
+
bundle_id: bundleIdArg,
|
|
1166
|
+
payload: z.record(z.string(), z.unknown()).describe("The APNs payload, e.g. {\"aps\":{\"alert\":{\"title\":\"Watering due\",\"body\":\"Monstera\"},\"sound\":\"default\",\"badge\":1}}. Max 4096 bytes.")
|
|
1167
|
+
}),
|
|
1168
|
+
annotations: {
|
|
1169
|
+
readOnlyHint: false,
|
|
1170
|
+
destructiveHint: false,
|
|
1171
|
+
idempotentHint: false
|
|
1172
|
+
}
|
|
1173
|
+
}, async ({ device, bundle_id, payload }) => wrap(async () => {
|
|
1174
|
+
if (!("aps" in payload)) throw new IosError$1("A push payload must have a top-level `aps` key.", { remedy: "Wrap the notification, e.g. {\"aps\":{\"alert\":\"Hello\"}}." });
|
|
1175
|
+
const bytes = Buffer.byteLength(JSON.stringify(payload));
|
|
1176
|
+
if (bytes > MAX_PUSH_BYTES) throw new IosError$1(`The payload is ${bytes} bytes; APNs allows ${MAX_PUSH_BYTES}.`, { remedy: "Shorten it — this is refused here rather than by simctl, but the cap is real." });
|
|
1177
|
+
const target = client.requireBooted(await client.resolveTarget(device));
|
|
1178
|
+
await client.simctl.push(target.id, bundle_id, payload);
|
|
1179
|
+
return {
|
|
1180
|
+
pushed: bundle_id,
|
|
1181
|
+
bytes
|
|
1182
|
+
};
|
|
1183
|
+
}));
|
|
1184
|
+
};
|
|
1185
|
+
//#endregion
|
|
1186
|
+
//#region src/tools/lifecycle.ts
|
|
1187
|
+
/** Booting is slow and visible; this is how long `power` waits to see it land. */
|
|
1188
|
+
const BOOT_POLL_MS = 500;
|
|
1189
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1190
|
+
const registerListTool = (server, client) => {
|
|
1191
|
+
server.registerTool("ios_simulator_list", {
|
|
1192
|
+
title: "iOS Simulator: List",
|
|
1193
|
+
description: "List the simulators on this machine, with the UDID every other tool takes. Returns all of them by default — booted and shut down, available and not — because \"why can't I use that one\" has to be answerable from this list: a simulator whose runtime is missing shows `available: false` with the reason.",
|
|
1194
|
+
inputSchema: z.object({
|
|
1195
|
+
booted_only: z.boolean().default(false).describe("Only simulators that are currently running."),
|
|
1196
|
+
name_contains: z.string().optional().describe("Case-insensitive substring filter on the name, e.g. \"iPhone 17\".")
|
|
1197
|
+
}),
|
|
1198
|
+
annotations: { readOnlyHint: true }
|
|
1199
|
+
}, async ({ booted_only, name_contains }) => wrap(async () => {
|
|
1200
|
+
const all = await client.listSimulators({ fresh: true });
|
|
1201
|
+
const needle = name_contains?.toLowerCase();
|
|
1202
|
+
const matched = all.filter((sim) => (!booted_only || sim.state === "Booted") && (needle === void 0 || sim.name.toLowerCase().includes(needle)));
|
|
1203
|
+
const unavailable = all.filter((sim) => !sim.available).length;
|
|
1204
|
+
return {
|
|
1205
|
+
simulators: matched.map(toListRow),
|
|
1206
|
+
booted: all.filter((sim) => sim.state === "Booted").length,
|
|
1207
|
+
total: all.length,
|
|
1208
|
+
...unavailable > 0 ? {
|
|
1209
|
+
unavailable,
|
|
1210
|
+
note: `${unavailable} simulators have no installed runtime and cannot be used. \`xcrun simctl delete unavailable\` clears them.`
|
|
1211
|
+
} : {}
|
|
1212
|
+
};
|
|
1213
|
+
}));
|
|
1214
|
+
};
|
|
1215
|
+
const registerLifecycleTools = (server, client) => {
|
|
1216
|
+
server.registerTool("ios_simulator_power", {
|
|
1217
|
+
title: "iOS Simulator: Power",
|
|
1218
|
+
description: "Boot or shut down a simulator. Booting is the one thing this server will not do for you implicitly — a cold boot takes tens of seconds and puts a window on the user's screen, and it changes which simulator an unqualified call resolves to afterwards. Both directions are idempotent: booting a booted simulator succeeds.",
|
|
1219
|
+
inputSchema: z.object({
|
|
1220
|
+
device: deviceArg,
|
|
1221
|
+
state: z.enum(["booted", "shutdown"]).describe("The state to put it in. `booted` also waits for it to actually get there."),
|
|
1222
|
+
open_window: z.boolean().default(true).describe("Bring up the Simulator app so the screen is visible. A `boot` on its own is headless, which is fine for an agent and confusing for a person watching."),
|
|
1223
|
+
wait_ms: z.number().int().min(0).max(3e5).default(6e4).describe("How long to wait for a boot to complete before reporting what it saw.")
|
|
1224
|
+
}),
|
|
1225
|
+
annotations: {
|
|
1226
|
+
readOnlyHint: false,
|
|
1227
|
+
destructiveHint: false,
|
|
1228
|
+
idempotentHint: true
|
|
1229
|
+
}
|
|
1230
|
+
}, async ({ device, state, open_window, wait_ms }) => wrap(async () => {
|
|
1231
|
+
const target = await client.resolveTarget(device);
|
|
1232
|
+
if (state === "shutdown") {
|
|
1233
|
+
if (target.state !== "Booted") return {
|
|
1234
|
+
udid: target.id,
|
|
1235
|
+
name: target.name,
|
|
1236
|
+
state: "Shutdown",
|
|
1237
|
+
alreadyThere: true
|
|
1238
|
+
};
|
|
1239
|
+
await client.simctl.shutdown(target.id);
|
|
1240
|
+
return {
|
|
1241
|
+
udid: target.id,
|
|
1242
|
+
name: target.name,
|
|
1243
|
+
state: "Shutdown"
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
const already = target.state === "Booted";
|
|
1247
|
+
if (!already) await client.simctl.boot(target.id);
|
|
1248
|
+
if (open_window) await client.simctl.openApp(client.openPath);
|
|
1249
|
+
const deadline = Date.now() + wait_ms;
|
|
1250
|
+
let observed = target.state;
|
|
1251
|
+
while (Date.now() < deadline) {
|
|
1252
|
+
observed = (await client.listSimulators({ fresh: true })).find((sim) => sim.id === target.id)?.state ?? observed;
|
|
1253
|
+
if (observed === "Booted") break;
|
|
1254
|
+
await sleep(BOOT_POLL_MS);
|
|
1255
|
+
}
|
|
1256
|
+
return {
|
|
1257
|
+
udid: target.id,
|
|
1258
|
+
name: target.name,
|
|
1259
|
+
state: observed,
|
|
1260
|
+
...already ? { alreadyThere: true } : {},
|
|
1261
|
+
...observed === "Booted" ? {} : { warning: `Still ${observed} after ${wait_ms}ms. Raise wait_ms, or check Xcode.` }
|
|
1262
|
+
};
|
|
1263
|
+
}));
|
|
1264
|
+
server.registerTool("ios_simulator_erase", {
|
|
1265
|
+
title: "iOS Simulator: Erase",
|
|
1266
|
+
description: "Wipe a simulator back to a factory state: installed apps, their data, the keychain and every granted permission. The only irreversible tool here, and the reason it is the only one behind `confirm`. Use it to test a genuine first launch. A booted simulator is shut down first and booted again afterwards, because simctl refuses to erase a running one.",
|
|
1267
|
+
inputSchema: z.object({
|
|
1268
|
+
device: deviceArg,
|
|
1269
|
+
confirm: confirmArg
|
|
1270
|
+
}),
|
|
1271
|
+
annotations: {
|
|
1272
|
+
readOnlyHint: false,
|
|
1273
|
+
destructiveHint: true,
|
|
1274
|
+
idempotentHint: false
|
|
1275
|
+
}
|
|
1276
|
+
}, async ({ device }) => wrap(async () => {
|
|
1277
|
+
const target = await client.resolveTarget(device);
|
|
1278
|
+
const wasBooted = target.state === "Booted";
|
|
1279
|
+
if (wasBooted) await client.simctl.shutdown(target.id);
|
|
1280
|
+
await client.simctl.erase(target.id);
|
|
1281
|
+
if (wasBooted) await client.simctl.boot(target.id);
|
|
1282
|
+
return {
|
|
1283
|
+
udid: target.id,
|
|
1284
|
+
name: target.name,
|
|
1285
|
+
erased: true,
|
|
1286
|
+
rebooted: wasBooted
|
|
1287
|
+
};
|
|
1288
|
+
}));
|
|
1289
|
+
};
|
|
1290
|
+
//#endregion
|
|
1291
|
+
//#region src/tools/runner.ts
|
|
1292
|
+
const defaultSpawnRunner = async (command, args, { env, logPath }) => {
|
|
1293
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
1294
|
+
const log = await open(logPath, "a");
|
|
1295
|
+
const child = spawn(command, args, {
|
|
1296
|
+
detached: true,
|
|
1297
|
+
stdio: [
|
|
1298
|
+
"ignore",
|
|
1299
|
+
log.fd,
|
|
1300
|
+
log.fd
|
|
1301
|
+
],
|
|
1302
|
+
env: {
|
|
1303
|
+
...process.env,
|
|
1304
|
+
...env
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
child.unref();
|
|
1308
|
+
await log.close();
|
|
1309
|
+
return child.pid;
|
|
1310
|
+
};
|
|
1311
|
+
const scriptPath = () => {
|
|
1312
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1313
|
+
for (const candidate of [join(here, "..", "scripts", "wda.sh"), join(here, "..", "..", "scripts", "wda.sh")]) return candidate;
|
|
1314
|
+
throw new IosError$1("Could not find `scripts/wda.sh` beside this module.");
|
|
1315
|
+
};
|
|
1316
|
+
const registerRunnerTools = (server, client, outputDir, spawnRunner = defaultSpawnRunner) => {
|
|
1317
|
+
server.registerTool("ios_simulator_restart_wda", {
|
|
1318
|
+
title: "iOS Simulator: Restart WebDriverAgent",
|
|
1319
|
+
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.",
|
|
1320
|
+
inputSchema: z.object({
|
|
1321
|
+
device: deviceArg,
|
|
1322
|
+
port: z.number().int().min(1).max(65535).optional().describe("Port to bind, passed to the runner as USE_PORT. Defaults to IOS_SIMULATOR_WDA_PORT. Two simulators need two ports: they share this Mac's loopback, and a runner with no USE_PORT scans 8100-8199 and silently takes the next free one.")
|
|
1323
|
+
}),
|
|
1324
|
+
annotations: {
|
|
1325
|
+
readOnlyHint: false,
|
|
1326
|
+
destructiveHint: false,
|
|
1327
|
+
idempotentHint: false
|
|
1328
|
+
}
|
|
1329
|
+
}, async ({ device, port }) => wrap(async () => {
|
|
1330
|
+
const target = client.requireBooted(await client.resolveTarget(device));
|
|
1331
|
+
const usePort = port ?? client.wdaPort;
|
|
1332
|
+
const logPath = join(outputDir, "wda.log");
|
|
1333
|
+
const pid = await spawnRunner("/bin/bash", [scriptPath(), "run"], {
|
|
1334
|
+
env: {
|
|
1335
|
+
IOS_SIMULATOR_ID: target.id,
|
|
1336
|
+
IOS_SIMULATOR_WDA_PORT: String(usePort),
|
|
1337
|
+
USE_PORT: String(usePort)
|
|
1338
|
+
},
|
|
1339
|
+
logPath
|
|
1340
|
+
});
|
|
1341
|
+
if (pid === void 0) throw new IosError$1("Could not start the runner — the process had no pid.", { remedy: START_RUNNER_REMEDY });
|
|
1342
|
+
return {
|
|
1343
|
+
started: true,
|
|
1344
|
+
pid,
|
|
1345
|
+
port: usePort,
|
|
1346
|
+
udid: target.id,
|
|
1347
|
+
logPath,
|
|
1348
|
+
note: "Give it ~15s, then check ios_simulator_diagnostics for `wda.reachable`."
|
|
1349
|
+
};
|
|
1350
|
+
}));
|
|
1351
|
+
};
|
|
1352
|
+
//#endregion
|
|
1353
|
+
//#region src/tools/index.ts
|
|
1354
|
+
/**
|
|
1355
|
+
* All capability decisions in one place, so "why can't I call that" is answered
|
|
1356
|
+
* by one file.
|
|
1357
|
+
*
|
|
1358
|
+
* The split is observe versus drive, exactly as in the device server. What
|
|
1359
|
+
* differs is the **default**: writes are on. The convention that keeps the rest
|
|
1360
|
+
* of the fleet read-only exists because a mutating tool acts on someone's real
|
|
1361
|
+
* account or someone's real phone, and a simulator is neither — it holds no
|
|
1362
|
+
* person's data and `ios_simulator_erase` puts it back to factory in seconds.
|
|
1363
|
+
* Turning writes off with IOS_SIMULATOR_ALLOW_WRITES=0 restores the device
|
|
1364
|
+
* server's posture, and then the driving tools are *absent* rather than
|
|
1365
|
+
* refused, because a refusal still lets a model try, retry and reason about a
|
|
1366
|
+
* way around it.
|
|
1367
|
+
*
|
|
1368
|
+
* The observe half is unusually capable here: `screenshot` goes through simctl,
|
|
1369
|
+
* so seeing the screen needs no WebDriverAgent and no setup of any kind. Only
|
|
1370
|
+
* `ui_tree` does, and that is a runtime fact reported by
|
|
1371
|
+
* ios_simulator_diagnostics rather than a registration decision — the runner
|
|
1372
|
+
* can start and stop while this server is connected.
|
|
1373
|
+
*/
|
|
1374
|
+
const registerTools = (server, client, ctx, spawnRunner) => {
|
|
1375
|
+
const shared = {
|
|
1376
|
+
host: client,
|
|
1377
|
+
naming: SIMULATOR_NAMING,
|
|
1378
|
+
maxTreeBytes: ctx.config.maxTreeBytes,
|
|
1379
|
+
capHint: "raise IOS_SIMULATOR_MAX_TREE_BYTES",
|
|
1380
|
+
buttons: ["home"],
|
|
1381
|
+
emptyScreenshotRemedy: EMPTY_SCREENSHOT_REMEDY,
|
|
1382
|
+
includeDisplayInfo: false
|
|
1383
|
+
};
|
|
1384
|
+
registerDiagnosticsTools(server, client, ctx);
|
|
1385
|
+
registerListTool(server, client);
|
|
1386
|
+
registerListAppsTool(server, client);
|
|
1387
|
+
registerScreenTools(server, shared);
|
|
1388
|
+
if (!ctx.allowWrites) return;
|
|
1389
|
+
registerInputTools(server, shared);
|
|
1390
|
+
registerLifecycleTools(server, client);
|
|
1391
|
+
registerAppTools(server, client, ctx.config.launchArgs, ctx.config.outputDir);
|
|
1392
|
+
registerEnvironmentTools(server, client);
|
|
1393
|
+
registerRunnerTools(server, client, ctx.config.outputDir, spawnRunner);
|
|
1394
|
+
};
|
|
1395
|
+
//#endregion
|
|
1396
|
+
//#region src/server.ts
|
|
1397
|
+
const SERVER_NAME = BUILD_INFO.name;
|
|
1398
|
+
const SERVER_VERSION = BUILD_INFO.version;
|
|
1399
|
+
/**
|
|
1400
|
+
* A pure factory. The injectable seams — `exec`, `fetch` and `spawnRunner` —
|
|
1401
|
+
* are the whole reason the test suite can drive real tools through the real SDK
|
|
1402
|
+
* with no simulator, no Xcode, no WebDriverAgent and no process left running.
|
|
1403
|
+
* Nothing below `config.ts` reads `process.env`.
|
|
1404
|
+
*/
|
|
1405
|
+
const createServer = (opts) => {
|
|
1406
|
+
const { config } = opts;
|
|
1407
|
+
const server = new McpServer({
|
|
1408
|
+
name: SERVER_NAME,
|
|
1409
|
+
version: SERVER_VERSION
|
|
1410
|
+
});
|
|
1411
|
+
const client = new SimulatorClient({
|
|
1412
|
+
xcrunPath: config.xcrunPath,
|
|
1413
|
+
sipsPath: config.sipsPath,
|
|
1414
|
+
plutilPath: config.plutilPath,
|
|
1415
|
+
openPath: config.openPath,
|
|
1416
|
+
execTimeoutMs: config.execTimeoutMs,
|
|
1417
|
+
wdaTimeoutMs: config.wdaTimeoutMs,
|
|
1418
|
+
wdaPort: config.wdaPort,
|
|
1419
|
+
...config.wdaUrl ? { wdaUrl: config.wdaUrl } : {},
|
|
1420
|
+
...config.simulatorId ? { defaultSimulatorId: config.simulatorId } : {},
|
|
1421
|
+
...opts.exec ? { exec: opts.exec } : {},
|
|
1422
|
+
...opts.fetch ? { fetch: opts.fetch } : {},
|
|
1423
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
1424
|
+
});
|
|
1425
|
+
registerTools(server, client, {
|
|
1426
|
+
config,
|
|
1427
|
+
allowWrites: config.allowWrites
|
|
1428
|
+
}, opts.spawnRunner);
|
|
1429
|
+
return {
|
|
1430
|
+
server,
|
|
1431
|
+
client
|
|
1432
|
+
};
|
|
1433
|
+
};
|
|
1434
|
+
//#endregion
|
|
1435
|
+
//#region src/config.ts
|
|
1436
|
+
/**
|
|
1437
|
+
* There are no credentials here, and unlike the device server there is barely
|
|
1438
|
+
* any setup either. Two lanes reach a simulator and both are local:
|
|
1439
|
+
*
|
|
1440
|
+
* * `xcrun simctl` — Apple's own tool. It covers app lifecycle, the device's
|
|
1441
|
+
* whole staged environment, and the screen itself: `simctl io screenshot`
|
|
1442
|
+
* needs no runner at all.
|
|
1443
|
+
* * WebDriverAgent — an HTTP server inside the simulator, on the *host's*
|
|
1444
|
+
* loopback since a simulator shares the host network stack. Only the
|
|
1445
|
+
* accessibility tree and synthetic touches go through it.
|
|
1446
|
+
*/
|
|
1447
|
+
const ConfigSchema = z.object({
|
|
1448
|
+
/**
|
|
1449
|
+
* UDID, name, or the literal `booted`. Left unset, a single booted
|
|
1450
|
+
* simulator is used and two or more is an error that names them, rather
|
|
1451
|
+
* than the coin flip simctl's own `booted` performs.
|
|
1452
|
+
*/
|
|
1453
|
+
simulatorId: z.string().min(1).optional(),
|
|
1454
|
+
wdaUrl: z.url().optional(),
|
|
1455
|
+
/**
|
|
1456
|
+
* Must match the runner's `USE_PORT`. One port per simulator: they all bind
|
|
1457
|
+
* the same host loopback, so two runners on 8100 is a silent wrong-target
|
|
1458
|
+
* bug rather than a collision.
|
|
1459
|
+
*/
|
|
1460
|
+
wdaPort: z.number().int().min(1).max(65535).default(8100),
|
|
1461
|
+
wdaTimeoutMs: z.number().int().min(1e3).max(6e5).default(3e4),
|
|
1462
|
+
/** A cold boot or a large `install` genuinely takes tens of seconds. */
|
|
1463
|
+
execTimeoutMs: z.number().int().min(1e3).max(18e5).default(12e4),
|
|
1464
|
+
/**
|
|
1465
|
+
* **On by default**, which is the one place this server deliberately breaks
|
|
1466
|
+
* the fleet convention. What makes the device server read-only is that a
|
|
1467
|
+
* phone belongs to a real person; a simulator is disposable and holds
|
|
1468
|
+
* nobody's data. `ios_simulator_erase` still requires an explicit `confirm`
|
|
1469
|
+
* because it is the only irreversible tool here.
|
|
1470
|
+
*/
|
|
1471
|
+
allowWrites: z.boolean().default(true),
|
|
1472
|
+
/** Applied by `launch` when the call passes no `arguments` of its own. */
|
|
1473
|
+
launchArgs: z.array(z.string()).default([]),
|
|
1474
|
+
/** Saved screenshots, launch logs and the runner log. */
|
|
1475
|
+
outputDir: z.string().min(1).default(join(tmpdir(), "mcp-ios-simulator")),
|
|
1476
|
+
maxTreeBytes: z.number().int().min(1e3).max(5e5).default(24e3),
|
|
1477
|
+
xcrunPath: z.string().min(1).default("/usr/bin/xcrun"),
|
|
1478
|
+
sipsPath: z.string().min(1).default("/usr/bin/sips"),
|
|
1479
|
+
/** The `listapps` plist lane, and the device-type profile read. */
|
|
1480
|
+
plutilPath: z.string().min(1).default("/usr/bin/plutil"),
|
|
1481
|
+
/** `simctl boot` is headless; this is what attaches a window. */
|
|
1482
|
+
openPath: z.string().min(1).default("/usr/bin/open")
|
|
1483
|
+
}).strict().superRefine((_cfg, _ctx) => {});
|
|
1484
|
+
const trimmed = (value) => {
|
|
1485
|
+
const t = value?.trim();
|
|
1486
|
+
return t ? t : void 0;
|
|
1487
|
+
};
|
|
1488
|
+
const parseBool = (value) => {
|
|
1489
|
+
const t = trimmed(value);
|
|
1490
|
+
if (t === void 0) return void 0;
|
|
1491
|
+
return [
|
|
1492
|
+
"1",
|
|
1493
|
+
"true",
|
|
1494
|
+
"yes",
|
|
1495
|
+
"on"
|
|
1496
|
+
].includes(t.toLowerCase());
|
|
1497
|
+
};
|
|
1498
|
+
const parseIntOpt = (value) => {
|
|
1499
|
+
const t = trimmed(value);
|
|
1500
|
+
if (t === void 0) return void 0;
|
|
1501
|
+
const n = Number(t);
|
|
1502
|
+
return Number.isInteger(n) ? n : void 0;
|
|
1503
|
+
};
|
|
1504
|
+
/** Whitespace-separated, so `IOS_SIMULATOR_LAUNCH_ARGS="-DemoMode -NoCloud"` works. */
|
|
1505
|
+
const parseArgs = (value) => {
|
|
1506
|
+
const t = trimmed(value);
|
|
1507
|
+
return t === void 0 ? void 0 : t.split(/\s+/);
|
|
1508
|
+
};
|
|
1509
|
+
const defaultConfigPath = (env = process.env) => trimmed(env.IOS_SIMULATOR_CONFIG) ?? join(homedir(), ".config", "ios-simulator-mcp", "config.json");
|
|
1510
|
+
/**
|
|
1511
|
+
* `.strict()` on purpose: a typo'd `wdaPort` spelled `wda_port` must be an
|
|
1512
|
+
* error. Silently ignoring an unknown key looks exactly like "that setting had
|
|
1513
|
+
* no effect", which is the worst possible way to learn where your config came
|
|
1514
|
+
* from.
|
|
1515
|
+
*/
|
|
1516
|
+
const readConfigFile = (path) => {
|
|
1517
|
+
let raw;
|
|
1518
|
+
try {
|
|
1519
|
+
raw = readFileSync(path, "utf8");
|
|
1520
|
+
} catch {
|
|
1521
|
+
return {};
|
|
1522
|
+
}
|
|
1523
|
+
return ConfigSchema.partial().strict().parse(JSON.parse(raw));
|
|
1524
|
+
};
|
|
1525
|
+
/**
|
|
1526
|
+
* Environment first, config file second, **per field** — not whole-source.
|
|
1527
|
+
* A one-off `IOS_SIMULATOR_ALLOW_WRITES=0` has to beat a file that says `true`,
|
|
1528
|
+
* while a file that sets `simulatorId` keeps working when the environment says
|
|
1529
|
+
* nothing about it. Merging field by field is the only rule that gives both.
|
|
1530
|
+
*/
|
|
1531
|
+
const loadConfig = (env = process.env, configPath = defaultConfigPath(env)) => {
|
|
1532
|
+
const file = readConfigFile(configPath);
|
|
1533
|
+
const pick = (fromEnv, key) => fromEnv ?? file[key];
|
|
1534
|
+
return ConfigSchema.parse({
|
|
1535
|
+
simulatorId: pick(trimmed(env.IOS_SIMULATOR_ID), "simulatorId"),
|
|
1536
|
+
wdaUrl: pick(trimmed(env.IOS_SIMULATOR_WDA_URL), "wdaUrl"),
|
|
1537
|
+
wdaPort: pick(parseIntOpt(env.IOS_SIMULATOR_WDA_PORT), "wdaPort"),
|
|
1538
|
+
wdaTimeoutMs: pick(parseIntOpt(env.IOS_SIMULATOR_WDA_TIMEOUT_MS), "wdaTimeoutMs"),
|
|
1539
|
+
execTimeoutMs: pick(parseIntOpt(env.IOS_SIMULATOR_TIMEOUT_MS), "execTimeoutMs"),
|
|
1540
|
+
allowWrites: pick(parseBool(env.IOS_SIMULATOR_ALLOW_WRITES), "allowWrites"),
|
|
1541
|
+
launchArgs: pick(parseArgs(env.IOS_SIMULATOR_LAUNCH_ARGS), "launchArgs"),
|
|
1542
|
+
outputDir: pick(trimmed(env.IOS_SIMULATOR_OUTPUT_DIR), "outputDir"),
|
|
1543
|
+
maxTreeBytes: pick(parseIntOpt(env.IOS_SIMULATOR_MAX_TREE_BYTES), "maxTreeBytes"),
|
|
1544
|
+
xcrunPath: pick(trimmed(env.IOS_SIMULATOR_XCRUN_PATH), "xcrunPath"),
|
|
1545
|
+
sipsPath: pick(trimmed(env.IOS_SIMULATOR_SIPS_PATH), "sipsPath"),
|
|
1546
|
+
plutilPath: pick(trimmed(env.IOS_SIMULATOR_PLUTIL_PATH), "plutilPath"),
|
|
1547
|
+
openPath: pick(trimmed(env.IOS_SIMULATOR_OPEN_PATH), "openPath")
|
|
1548
|
+
});
|
|
1549
|
+
};
|
|
1550
|
+
//#endregion
|
|
1551
|
+
export { WritesDisabledError as A, SimulatorNotFoundError as C, WDA_UNAVAILABLE_REMEDY as D, ToolchainError as E, WdaError as O, SimulatorNotBootedError as S, TOOLCHAIN_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, BUILD_INFO 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, TIMEOUT_REMEDY as w, START_RUNNER_REMEDY as x, IosError$1 as y };
|
|
1552
|
+
|
|
1553
|
+
//# sourceMappingURL=config-C1TH-p-c.js.map
|