simulator-use 0.9.0 → 0.10.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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * A NoMac phone's tunnel to a backend on this machine (NoMac Simulator plan 10.7 E6): the phone's apps reach
3
+ * http://127.0.0.1:<port> on the phone, and each connection they make is carried here and dialed to the local port.
4
+ * The requests that pass are kept (method, path, status, time, size), as a capture of what the app asked the backend.
5
+ * The tunnel comes back by itself after a dropped connection while the phone runs.
6
+ */
7
+ import { connect } from "node:net";
8
+ import { ToolError } from "../errors.js";
9
+ import { CLOSE, DATA, dataFrames, frame, FrameReader, HELLO, MAX_CONNECTIONS, OPEN } from "../tunnel-frames.js";
10
+ const KEEP = 200;
11
+ const REQUEST = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) (\S{1,2048}) HTTP\/1\.[01]\r\n/;
12
+ const RESPONSE = /^HTTP\/1\.[01] (\d{3})/;
13
+ // The WebSocket may hold this much unsent before a local connection waits for it.
14
+ const SEND_BUFFER = 4 * 1024 * 1024;
15
+ export class Tunnel {
16
+ session;
17
+ localPort;
18
+ localHost;
19
+ phonePort;
20
+ state = "connecting";
21
+ error;
22
+ requests = [];
23
+ connections = 0;
24
+ api;
25
+ onRequest;
26
+ ws;
27
+ conns = new Map();
28
+ stopped = false;
29
+ drops = 0;
30
+ constructor(api, session, localPort, localHost = "127.0.0.1", onRequest) {
31
+ this.api = api;
32
+ this.session = session;
33
+ this.localPort = localPort;
34
+ this.localHost = localHost;
35
+ this.onRequest = onRequest;
36
+ }
37
+ /** Opens the tunnel; answers the port the phone's apps reach once the phone's relay listens. */
38
+ async start() {
39
+ if (typeof globalThis.WebSocket !== "function")
40
+ throw new ToolError("cloud tunnel needs Node 22 or later (for its WebSocket).", "Update Node, then call cloud tunnel again.");
41
+ return this.open();
42
+ }
43
+ stop() {
44
+ this.stopped = true;
45
+ this.state = "closed";
46
+ this.ws?.close();
47
+ for (const conn of this.conns.values())
48
+ conn.socket.destroy();
49
+ this.conns.clear();
50
+ }
51
+ async open() {
52
+ const access = await this.api.json("POST", `/api/v1/simulator/sessions/${this.session}/tunnel`, {});
53
+ const url = String(access.url), token = String(access.token);
54
+ return new Promise((resolve, reject) => {
55
+ const ws = new WebSocket(url, [String(access.protocol ?? "nomac.tunnel"), `nomac.token.${token}`]);
56
+ ws.binaryType = "arraybuffer";
57
+ this.ws = ws;
58
+ const reader = new FrameReader();
59
+ let opened = 0;
60
+ let answered = false;
61
+ ws.onmessage = (event) => {
62
+ let frames;
63
+ try {
64
+ frames = reader.push(Buffer.from(event.data));
65
+ }
66
+ catch {
67
+ ws.close();
68
+ return;
69
+ }
70
+ for (const f of frames) {
71
+ if (f.type === HELLO) {
72
+ this.phonePort = Number(JSON.parse(f.payload.toString("utf8")).port);
73
+ this.state = "open";
74
+ opened = Date.now();
75
+ if (!answered) {
76
+ answered = true;
77
+ resolve(this.phonePort);
78
+ }
79
+ }
80
+ else if (f.type === OPEN)
81
+ this.dial(f.conn);
82
+ else if (f.type === DATA)
83
+ this.toLocal(f.conn, f.payload);
84
+ else if (f.type === CLOSE)
85
+ this.drop(f.conn);
86
+ }
87
+ };
88
+ ws.onclose = () => {
89
+ for (const conn of this.conns.values())
90
+ conn.socket.destroy();
91
+ this.conns.clear();
92
+ if (this.ws !== ws || this.stopped)
93
+ return;
94
+ if (!answered) {
95
+ answered = true;
96
+ this.state = "closed";
97
+ reject(new ToolError("The phone's tunnel did not open.", "The phone must be ready; call cloud status, then cloud tunnel again."));
98
+ return;
99
+ }
100
+ // Back after a dropped connection (a NoMac deploy, the network), while the phone runs: a connection that
101
+ // lasted a minute starts the count again.
102
+ this.drops = Date.now() - opened > 60_000 ? 1 : this.drops + 1;
103
+ this.state = "connecting";
104
+ if (this.drops > 8) {
105
+ this.state = "closed";
106
+ this.error = "the tunnel kept dropping";
107
+ return;
108
+ }
109
+ setTimeout(() => {
110
+ if (!this.stopped)
111
+ this.open().catch((error) => {
112
+ this.state = "closed";
113
+ this.error = error.message;
114
+ });
115
+ }, Math.min(30_000, 1000 * 2 ** (this.drops - 1)));
116
+ };
117
+ });
118
+ }
119
+ send(bytes, socket) {
120
+ const ws = this.ws;
121
+ if (!ws || ws.readyState !== WebSocket.OPEN)
122
+ return;
123
+ ws.send(bytes);
124
+ if (socket && ws.bufferedAmount > SEND_BUFFER) {
125
+ socket.pause();
126
+ const wait = setInterval(() => {
127
+ if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount < SEND_BUFFER / 2) {
128
+ clearInterval(wait);
129
+ socket.resume();
130
+ }
131
+ }, 20);
132
+ }
133
+ }
134
+ dial(id) {
135
+ if (this.conns.size >= MAX_CONNECTIONS) {
136
+ this.send(frame(CLOSE, id));
137
+ return;
138
+ }
139
+ const socket = connect({ port: this.localPort, host: this.localHost });
140
+ const conn = { socket };
141
+ this.conns.set(id, conn);
142
+ this.connections++;
143
+ socket.on("data", (chunk) => {
144
+ this.noteResponse(conn, chunk);
145
+ for (const f of dataFrames(id, chunk))
146
+ this.send(f, socket);
147
+ });
148
+ socket.on("close", () => {
149
+ if (this.conns.get(id) !== conn)
150
+ return;
151
+ this.conns.delete(id);
152
+ this.finish(conn);
153
+ this.send(frame(CLOSE, id));
154
+ });
155
+ socket.on("error", (error) => {
156
+ if (error.code === "ECONNREFUSED")
157
+ this.error = `nothing listens on ${this.localHost}:${this.localPort} on this machine`;
158
+ socket.destroy();
159
+ });
160
+ }
161
+ toLocal(id, bytes) {
162
+ const conn = this.conns.get(id);
163
+ if (!conn)
164
+ return;
165
+ this.noteRequest(conn, bytes);
166
+ conn.socket.write(bytes);
167
+ }
168
+ drop(id) {
169
+ const conn = this.conns.get(id);
170
+ if (!conn)
171
+ return;
172
+ this.conns.delete(id);
173
+ this.finish(conn);
174
+ conn.socket.destroy();
175
+ }
176
+ // The capture: a request line at the start of what the app sends, the status line of what comes back.
177
+ noteRequest(conn, bytes) {
178
+ if (conn.pending) {
179
+ conn.pending.entry.bytes += bytes.length;
180
+ return;
181
+ }
182
+ const m = REQUEST.exec(bytes.subarray(0, 2100).toString("latin1"));
183
+ if (!m)
184
+ return;
185
+ conn.answered = undefined;
186
+ conn.pending = { entry: { method: m[1], path: m[2], bytes: bytes.length, at: new Date().toISOString() }, started: Date.now() };
187
+ }
188
+ noteResponse(conn, bytes) {
189
+ const pending = conn.pending;
190
+ if (!pending) {
191
+ if (conn.answered)
192
+ conn.answered.bytes += bytes.length;
193
+ return;
194
+ }
195
+ pending.entry.bytes += bytes.length;
196
+ const m = RESPONSE.exec(bytes.subarray(0, 32).toString("latin1"));
197
+ if (!m)
198
+ return;
199
+ pending.entry.status = Number(m[1]);
200
+ pending.entry.ms = Date.now() - pending.started;
201
+ this.keep(pending.entry);
202
+ // The rest of the answer counts here; the next request on this connection (keep-alive) starts a new entry.
203
+ conn.answered = pending.entry;
204
+ conn.pending = undefined;
205
+ }
206
+ finish(conn) {
207
+ if (conn.pending && conn.pending.entry.status === undefined)
208
+ this.keep({ ...conn.pending.entry, ms: Date.now() - conn.pending.started });
209
+ conn.pending = undefined;
210
+ }
211
+ keep(entry) {
212
+ this.requests.push(entry);
213
+ if (this.requests.length > KEEP)
214
+ this.requests.splice(0, this.requests.length - KEEP);
215
+ this.onRequest?.(entry);
216
+ }
217
+ }
218
+ /** A request as a line: "GET /api/items → 200 in 34 ms (1.2 KB)"; as it is answered, without its size (still coming). */
219
+ export function describeRequest(r, withSize = true) {
220
+ const size = r.bytes >= 1024 ? `${(r.bytes / 1024).toFixed(1)} KB` : `${r.bytes} B`;
221
+ return `${r.method} ${r.path} → ${r.status ?? "no answer"}${r.ms !== undefined ? ` in ${r.ms} ms` : ""}${withSize ? ` (${size})` : ""}`;
222
+ }
@@ -1,14 +1,14 @@
1
1
  import { setting } from "./settings.js";
2
2
  /**
3
- * Where simulator-use can't run (no Mac, no Xcode, no Simulator runtime), the agent is told where to get a Mac: NoMac,
4
- * which makes simulator-use, rents agents cloud Macs with Xcode by the minute. Never shown when simulator-use works;
3
+ * Where simulator-use can't run (no Mac, no Xcode, no Simulator runtime), the agent is told it can run the same
4
+ * tools on a cloud phone: cloud mode, on NoMac, which makes simulator-use. Never shown when simulator-use works;
5
5
  * SIMULATOR_USE_NO_CLOUD_HINT=1 hides it.
6
6
  */
7
7
  export const NOMAC_URL = "https://nomac.app/?utm_source=simulator-use";
8
8
  export function cloudHint() {
9
9
  if (setting("NO_CLOUD_HINT"))
10
10
  return "";
11
- return `No Mac to run it on? NoMac gives your agent a cloud Mac with Xcode and the iOS Simulator ready, billed by the minute: ${NOMAC_URL}`;
11
+ return `No Mac to run it on? The cloud tool (simulator-use cloud start) runs the same tools on a NoMac cloud iPhone Simulator, $0.80 an hour by the minute: ${NOMAC_URL}`;
12
12
  }
13
13
  /** A next step, with the hint after it when it is on. */
14
14
  export const withCloudHint = (next) => [next, cloudHint()].filter(Boolean).join(" ");
@@ -1,10 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
- import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { closeSync, existsSync, openSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { ToolError } from "../errors.js";
6
6
  import { isOurs, run } from "./exec.js";
7
- import { deviceDir } from "./state.js";
7
+ import { deviceDir, deviceEnv, deviceTimeZone } from "./state.js";
8
8
  const simctl = (args, timeoutMs = 60_000) => run("xcrun", ["simctl", ...args], { timeoutMs });
9
9
  /** The value of a key in `simctl appinfo`'s old-style plist output. */
10
10
  export function plistValue(text, key) {
@@ -48,6 +48,20 @@ export async function install(udid, appPath) {
48
48
  export async function consoleFile(udid, bundleId) {
49
49
  return join(await deviceDir(udid), `${bundleId}.console.log`);
50
50
  }
51
+ /**
52
+ * A new, empty file at path, open for writing. The previous launch's launcher can still write to the old file at
53
+ * its own offset: truncating that file left NUL bytes before the next line (October 23). So the old file goes and a
54
+ * new one is made; the old writer keeps writing into a file no one reads.
55
+ */
56
+ export function freshFile(path) {
57
+ try {
58
+ unlinkSync(path);
59
+ }
60
+ catch {
61
+ // None yet.
62
+ }
63
+ return openSync(path, "w");
64
+ }
51
65
  /**
52
66
  * Launches fresh (any running copy is stopped) and keeps the app's print() output for app_logs.
53
67
  * simctl's --stdout wrote nothing on iOS 26.5, so a detached `simctl launch --console-pty` holds the app's
@@ -55,11 +69,14 @@ export async function consoleFile(udid, bundleId) {
55
69
  */
56
70
  export async function launch(udid, bundleId) {
57
71
  const file = await consoleFile(udid, bundleId);
58
- const fd = openSync(file, "w");
72
+ const timeZone = await deviceTimeZone(udid);
73
+ const env = Object.fromEntries(Object.entries(await deviceEnv(udid)).map(([key, value]) => [`SIMCTL_CHILD_${key}`, value]));
74
+ const fd = freshFile(file);
59
75
  const child = spawn("xcrun", ["simctl", "launch", "--console-pty", "--terminate-running-process", udid, bundleId], {
60
76
  detached: true,
61
77
  stdio: ["ignore", fd, fd],
62
- env: { ...process.env, SIMCTL_CHILD_NSUnbufferedIO: "YES" },
78
+ // The time zone app_control set (iOS takes a process's TZ) and app_run's env: simctl hands SIMCTL_CHILD_ variables to the app.
79
+ env: { ...process.env, ...env, SIMCTL_CHILD_NSUnbufferedIO: "YES", ...(timeZone ? { SIMCTL_CHILD_TZ: timeZone } : {}) },
63
80
  });
64
81
  closeSync(fd);
65
82
  // A launch that fails ends the launcher within moments; a running app keeps it alive.
@@ -108,21 +108,31 @@ function parse(line) {
108
108
  }
109
109
  }
110
110
  const engines = new Map();
111
- /** Phones whose engine would not start: AXe for the rest of this process. */
112
- const unavailable = new Set();
111
+ /**
112
+ * Phones whose engine would not start, and when: AXe for a while, then a new engine is tried. A phone that was shut
113
+ * down (its engine exits, and one started meanwhile fails) gets its engine back once it is booted again, instead of
114
+ * staying on AXe for the rest of a long-lived MCP server.
115
+ */
116
+ const unavailable = new Map();
117
+ const RETRY_MS = 30_000;
113
118
  async function engineFor(udid) {
114
119
  const path = enginePath();
115
- if (!path || unavailable.has(udid))
120
+ if (!path)
121
+ return undefined;
122
+ const failedAt = unavailable.get(udid);
123
+ if (failedAt !== undefined && Date.now() - failedAt < RETRY_MS)
116
124
  return undefined;
117
125
  let engine = engines.get(udid);
118
126
  if (!engine || engine.dead) {
119
127
  engine = new Engine(path, udid);
120
128
  engines.set(udid, engine);
121
129
  }
122
- if (await engine.ready)
130
+ if (await engine.ready) {
131
+ unavailable.delete(udid);
123
132
  return engine;
133
+ }
124
134
  engines.delete(udid);
125
- unavailable.add(udid);
135
+ unavailable.set(udid, Date.now());
126
136
  return undefined;
127
137
  }
128
138
  /**
@@ -0,0 +1,71 @@
1
+ /**
2
+ * iOS's prompts that stop an agent before it reaches the app it asked for: "Open in “Expo Go”?" when a link opens
3
+ * another app, and the permission prompts an app shows on its first launch (notifications have no simctl grant).
4
+ * Answered only where the agent asked for the outcome: a link it opened itself, or an app it ran with permissions
5
+ * granted (app_run's default). With permissions: "ask" the prompts stay, for testing them.
6
+ */
7
+ import { readFile, writeFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { simctlAction } from "./apps.js";
10
+ import { deviceDir } from "./state.js";
11
+ const OPEN_IN = /^Open in “.+”\?$/;
12
+ // "Would Like to Access the Camera", "would like full access to your Photo Library" (iOS 26), "to use your location".
13
+ const PERMISSION = /would like (to )?(send you notifications|access|full access|use|find and connect)|to use your location|to track your activity/i;
14
+ /** The button that gives the app what it asks, in the order iOS words it. */
15
+ const ALLOWING = ["Allow", "Allow While Using App", "Allow Full Access", "OK"];
16
+ const alertOf = (snap) => (snap.modal === "alert" ? snap.elements.find((e) => e.kind === "alert" && e.label) : undefined);
17
+ const button = (snap, label) => snap.elements.some((e) => e.kind === "button" && e.label === label);
18
+ /** The title of the alert in front, or nothing. */
19
+ export const alertTitle = (snap) => alertOf(snap)?.label;
20
+ /** iOS's "Open in “App”?" in front, with its Open button: the title, or nothing. */
21
+ export function openInPrompt(snap) {
22
+ const alert = alertOf(snap);
23
+ return alert && OPEN_IN.test(alert.label) && button(snap, "Open") ? alert.label : undefined;
24
+ }
25
+ /** A permission prompt over the app (named as ui_look names it): its title and the button that allows, or nothing. */
26
+ export function permissionPrompt(snap, app) {
27
+ if (snap.app !== `${app} · system alert`)
28
+ return undefined;
29
+ const alert = alertOf(snap);
30
+ if (!alert || !PERMISSION.test(alert.label))
31
+ return undefined;
32
+ const allow = ALLOWING.find((label) => button(snap, label));
33
+ return allow ? { title: alert.label, allow } : undefined;
34
+ }
35
+ const modesFile = async (udid) => join(await deviceDir(udid), "permissions.json");
36
+ /** How an app was run on this Simulator: its permissions granted (prompts answered), or iOS asking. */
37
+ export async function permissionMode(udid, bundleId) {
38
+ try {
39
+ const modes = JSON.parse(await readFile(await modesFile(udid), "utf8"));
40
+ return modes[bundleId];
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ export async function setPermissionMode(udid, bundleId, mode) {
47
+ let modes = {};
48
+ try {
49
+ modes = JSON.parse(await readFile(await modesFile(udid), "utf8"));
50
+ }
51
+ catch {
52
+ // The first one.
53
+ }
54
+ modes[bundleId] = mode;
55
+ await writeFile(await modesFile(udid), JSON.stringify(modes));
56
+ }
57
+ /**
58
+ * Every permission the Simulator can grant the app (simctl privacy grant all: photos, location, contacts, camera,
59
+ * microphone and the rest), recorded so its first launch's other prompts are answered too. Answers why it could
60
+ * not, or nothing.
61
+ */
62
+ export async function grantAll(udid, bundleId) {
63
+ await setPermissionMode(udid, bundleId, "grant");
64
+ try {
65
+ await simctlAction(["privacy", udid, "grant", "all", bundleId], "grant its permissions");
66
+ return undefined;
67
+ }
68
+ catch (error) {
69
+ return error.message;
70
+ }
71
+ }
@@ -72,7 +72,12 @@ export async function xcodeVersion() {
72
72
  /** A device by name or UDID; a name on several runtimes means the booted one, else the newest. */
73
73
  export function findDevice(devices, wanted) {
74
74
  const want = wanted.trim().toLowerCase();
75
- const matches = devices.filter((d) => d.udid.toLowerCase() === want || d.name.toLowerCase() === want);
75
+ // "iPhone 17 Pro (iOS 26.5)", the way simulator-use prints a device, picks that runtime's phone; a bare name
76
+ // means the newest runtime's.
77
+ const withRuntime = /^(.+?)\s*\((.+)\)$/.exec(want);
78
+ const matches = devices.filter((d) => d.udid.toLowerCase() === want ||
79
+ d.name.toLowerCase() === want ||
80
+ (withRuntime !== null && d.name.toLowerCase() === withRuntime[1] && d.runtime.toLowerCase() === withRuntime[2]));
76
81
  return matches.find((d) => d.booted) ?? matches[0];
77
82
  }
78
83
  /**
@@ -61,3 +61,35 @@ export async function currentApp(udid) {
61
61
  export async function setCurrentApp(udid, app) {
62
62
  await writeFile(join(await deviceDir(udid), "app.json"), JSON.stringify(app));
63
63
  }
64
+ /**
65
+ * A device's time zone as simulator-use sets it (app_control timezone): iOS has no setting a tool can change, so
66
+ * every app simulator-use launches gets it as TZ.
67
+ */
68
+ export async function deviceTimeZone(udid) {
69
+ return (await deviceSettings(udid)).timeZone;
70
+ }
71
+ export async function setDeviceTimeZone(udid, timeZone) {
72
+ await setDeviceSettings(udid, { timeZone });
73
+ }
74
+ /** The environment app_run gave the device's apps (env): every later launch passes it, as Xcode's scheme would. */
75
+ export async function deviceEnv(udid) {
76
+ return (await deviceSettings(udid)).env ?? {};
77
+ }
78
+ export async function setDeviceEnv(udid, env) {
79
+ await setDeviceSettings(udid, { env: Object.keys(env).length ? env : undefined });
80
+ }
81
+ async function deviceSettings(udid) {
82
+ try {
83
+ return JSON.parse(await readFile(join(await deviceDir(udid), "settings.json"), "utf8"));
84
+ }
85
+ catch {
86
+ return {};
87
+ }
88
+ }
89
+ async function setDeviceSettings(udid, change) {
90
+ const next = { ...(await deviceSettings(udid)), ...change };
91
+ for (const key of Object.keys(next))
92
+ if (next[key] === undefined)
93
+ delete next[key];
94
+ await writeFile(join(await deviceDir(udid), "settings.json"), JSON.stringify(next));
95
+ }
@@ -0,0 +1,45 @@
1
+ // XCTest: "Test Case '-[DemoTests.SignInTests testSignIn]' passed (0.123 seconds)."
2
+ const XCTEST = /^Test Case '-\[(?:[\w]+\.)?([\w]+) ([\w]+)\]' (passed|failed|skipped) \((\d+(?:\.\d+)?) seconds\)/;
3
+ // XCTest with parallel clones (Xcode 15 on): "Test case 'SignInTests.testSignIn()' passed on 'Clone 1 of iPhone 17 Pro' (0.123 seconds)"
4
+ const XCTEST_CLONES = /^Test case '([\w]+)\.([\w]+)\(\)' (passed|failed|skipped) on '[^']*' \((\d+(?:\.\d+)?) seconds\)/;
5
+ // Swift Testing: "✔ Test signIn() passed after 0.012 seconds." and "✘ Test signIn() failed after 0.010 seconds with 1 issue.",
6
+ // but not its summary, "✔ Test run with 3 tests in 1 suite passed after 0.055 seconds."
7
+ const SWIFT_TESTING = /^[✔✘] Test (?!run with \d+ tests? in )(.+?) (passed|failed) after (\d+(?:\.\d+)?) seconds/;
8
+ const SWIFT_SKIPPED = /^[➜↷] Test (.+?) skipped/;
9
+ export function finishedCase(line) {
10
+ const text = line.trim();
11
+ let m = XCTEST.exec(text) ?? XCTEST_CLONES.exec(text);
12
+ if (m)
13
+ return { name: `${m[1]}/${m[2]}`, result: m[3], seconds: Number(m[4]) };
14
+ if ((m = SWIFT_TESTING.exec(text)))
15
+ return { name: m[1], result: m[2], seconds: Number(m[3]) };
16
+ if ((m = SWIFT_SKIPPED.exec(text)))
17
+ return { name: m[1], result: "skipped" };
18
+ return undefined;
19
+ }
20
+ /** The cases of a run so far: counts, the last one, and the slowest, for job_status while tests run and their answer. */
21
+ export class CaseTally {
22
+ passed = 0;
23
+ failed = 0;
24
+ skipped = 0;
25
+ last;
26
+ all = [];
27
+ add(c) {
28
+ this[c.result]++;
29
+ this.last = c;
30
+ this.all.push(c);
31
+ }
32
+ get count() {
33
+ return this.all.length;
34
+ }
35
+ /** "12 passed, 1 failed so far; last: SignInTests/testSignIn failed (0.4 s)". */
36
+ progress() {
37
+ const counts = `${this.passed} passed${this.failed ? `, ${this.failed} failed` : ""}${this.skipped ? `, ${this.skipped} skipped` : ""} so far`;
38
+ return this.last ? `${counts}; last: ${this.last.name} ${this.last.result}${this.last.seconds !== undefined ? ` (${this.last.seconds.toFixed(1)} s)` : ""}` : counts;
39
+ }
40
+ /** The slowest cases, when any took a second or more: where a run's time goes. */
41
+ slowest(n = 3) {
42
+ const slow = this.all.filter((c) => (c.seconds ?? 0) >= 1).sort((a, b) => (b.seconds ?? 0) - (a.seconds ?? 0)).slice(0, n);
43
+ return slow.map((c) => `${c.name} ${c.seconds.toFixed(1)} s`).join(", ");
44
+ }
45
+ }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { createWriteStream, existsSync, readdirSync, realpathSync } from "node:fs";
3
+ import { createWriteStream, existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
6
6
  import { ToolError } from "../errors.js";
@@ -37,13 +37,39 @@ export async function findProject(where) {
37
37
  }
38
38
  if (project)
39
39
  return { kind: "project", path: join(path, project), name: basename(project, ".xcodeproj"), dir: path };
40
- if (entries.includes("app.json") || entries.includes("pubspec.yaml") || entries.some((e) => e.startsWith("capacitor.config"))) {
41
- throw new ToolError("Expo, React Native, Flutter and Capacitor projects are not built by app_run yet; Xcode projects are.", "Build it with its own tool for the iOS Simulator, then app_control {action: \"install\", app: \"path/to/App.app\"}.");
40
+ const ios = join(path, "ios");
41
+ const hasIos = entries.includes("ios") && existsSync(ios);
42
+ if (entries.includes("pubspec.yaml")) {
43
+ throw new ToolError("A Flutter project: Flutter builds it, not app_run.", "Run flutter build ios --simulator, then app_control {action: \"install\", app: \"build/ios/iphonesimulator/Runner.app\"}.");
42
44
  }
43
- if (entries.includes("ios") && existsSync(join(path, "ios")))
44
- return findProject(join(path, "ios"));
45
+ if (entries.some((e) => e.startsWith("capacitor.config"))) {
46
+ throw new ToolError("A Capacitor project: its web build is copied into the iOS project first.", `Run npx cap sync ios, then app_run {path: "${join(path, "ios", "App")}"}.`);
47
+ }
48
+ if (entries.includes("app.json")) {
49
+ const expo = isExpo(join(path, "app.json"));
50
+ if (!hasIos) {
51
+ throw new ToolError(expo ? "An Expo project with no ios/ folder yet." : "A React Native project with no ios/ folder.", expo ? "Run npx expo prebuild --platform ios to make one, then app_run again; or open the project in Expo Go." : "app_run builds the Xcode project in ios/.");
52
+ }
53
+ if (!readdirSync(ios).some((e) => e.endsWith(".xcworkspace")) && existsSync(join(ios, "Podfile"))) {
54
+ throw new ToolError("ios/ has a Podfile but no workspace yet.", `Run pod install in ${ios}, then app_run again.`);
55
+ }
56
+ const found = await findProject(ios);
57
+ // A Debug build loads its JavaScript from Metro; Release puts it in the app.
58
+ return { ...found, note: [found.note, "A React Native app: a Debug build loads its JavaScript from Metro (start it first: npx react-native start, or npx expo start), and configuration Release puts it in the app."].filter(Boolean).join(" ") };
59
+ }
60
+ if (hasIos)
61
+ return findProject(ios);
45
62
  throw new ToolError(`No Xcode project in ${path}.`, "Pass path: the folder with the .xcodeproj or .xcworkspace.");
46
63
  }
64
+ /** An app.json with an "expo" key is Expo's; a React Native CLI project's has only its name. */
65
+ function isExpo(file) {
66
+ try {
67
+ return "expo" in JSON.parse(readFileSync(file, "utf8"));
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
47
73
  /**
48
74
  * XcodeGen makes the project from project.yml, and a spec edit or a new, moved or deleted source file needs a new
49
75
  * project, or the build misses it. So the project is regenerated before every build; --use-cache skips the work when
@@ -81,6 +107,27 @@ export async function pickScheme(project, wanted) {
81
107
  throw new ToolError(`${basename(project.path)} has no shared schemes.`, "Open it in Xcode once, or share a scheme (Product > Scheme > Manage Schemes).");
82
108
  throw new ToolError(`${basename(project.path)} has several schemes: ${own.join(", ")}.`, "Pass scheme.");
83
109
  }
110
+ /**
111
+ * A failed build whose cause has a known fix, said with the fix: the errors alone read like a code problem. A
112
+ * project that keeps its xcconfig out of git as a template (IceCubesApp does) fails at once from a fresh clone; a
113
+ * full disk fails deep in the linker (errno 28); packages Xcode could not fetch fail before any compile.
114
+ */
115
+ export function knownFix(errors, tail) {
116
+ const text = [...errors.map((e) => e.message), ...tail].join("\n");
117
+ const xcconfig = /Unable to open base configuration reference file '([^']+\.xcconfig)'/.exec(text)?.[1];
118
+ if (xcconfig) {
119
+ const name = basename(xcconfig);
120
+ return existsSync(`${xcconfig}.template`)
121
+ ? `the project keeps ${name} as a template: copy ${name}.template to ${name} (fill in its values if it has any), then build again.`
122
+ : `the project expects ${name}, which is not in its folder (often kept out of git): create it, then build again.`;
123
+ }
124
+ if (/No space left on device|errno=28/.test(text))
125
+ return "the disk is full: free space, or build with clean: true to clear this project's build cache.";
126
+ if (/Could not resolve package dependencies|Failed to clone repository|package resolution failed/i.test(text)) {
127
+ return "Xcode could not fetch the project's Swift packages: check the network, and that a private package's repository can be reached from this Mac.";
128
+ }
129
+ return undefined;
130
+ }
84
131
  const LOCATED = /^(\/[^:]+):(\d+):(?:(\d+):)? (error|warning|fatal error): (.+)$/;
85
132
  const BARE = /^(?:xcodebuild: |clang: |ld: |\s*)?(error|fatal error): (.+)$/;
86
133
  /** One line of xcodebuild output as an error or warning, or nothing. */
@@ -120,13 +167,31 @@ export function formatIssue(issue, base) {
120
167
  * NoMac's ~/drive, makes the next machine's build incremental), else simulator-use's own folder.
121
168
  */
122
169
  export const cacheDir = () => setting("CACHE_DIR") || stateDir();
123
- /** Each worktree builds into its own DerivedData, so builds stay incremental and never share products. */
124
- export function derivedDataFor(project) {
170
+ /**
171
+ * Each worktree builds into its own DerivedData, so builds stay incremental and never share products; an Xcode other
172
+ * than the default has its own beside it, since two Xcodes' products and module caches do not mix.
173
+ */
174
+ export function derivedDataFor(project, xcode) {
125
175
  const key = createHash("sha1").update(project.path).digest("hex").slice(0, 12);
126
- return join(cacheDir(), "DerivedData", `${project.name}-${key}`);
176
+ return join(cacheDir(), "DerivedData", `${project.name}-${key}${xcode && !xcode.default ? `-xcode${xcode.version}${xcode.beta ? "b" : ""}` : ""}`);
177
+ }
178
+ /** The environment a build with this Xcode runs in: its DEVELOPER_DIR, unless it is the default. */
179
+ export const xcodeEnv = (xcode) => (xcode && !xcode.default ? { ...process.env, DEVELOPER_DIR: xcode.developer } : process.env);
180
+ /** An Xcode older than the phone's iOS cannot name the phone as a destination: it builds for any Simulator instead. */
181
+ export const noDestination = (result) => result.tail.some((line) => /Unable to find a destination matching/i.test(line));
182
+ /**
183
+ * xcodebuild, or xcodebuild under taskpolicy's clamp when SIMULATOR_USE_BUILD_CLAMP is utility or background:
184
+ * on a shared host the phones stay quick while builds run (NoMac Simulator), and what a build starts inherits
185
+ * the clamp and cannot lift it.
186
+ */
187
+ export function buildCommand(args) {
188
+ const clamp = setting("BUILD_CLAMP");
189
+ if (clamp === "utility" || clamp === "background")
190
+ return ["/usr/sbin/taskpolicy", ["-c", clamp, "xcodebuild", ...args]];
191
+ return ["xcodebuild", args];
127
192
  }
128
193
  export async function build(project, scheme, udid, options) {
129
- const derived = derivedDataFor(project);
194
+ const derived = derivedDataFor(project, options.xcode);
130
195
  await mkdir(derived, { recursive: true });
131
196
  const log = join(derived, `${options.action ?? "build"}-${Date.now()}.log`);
132
197
  const args = [
@@ -137,7 +202,7 @@ export async function build(project, scheme, udid, options) {
137
202
  "-configuration",
138
203
  options.configuration ?? "Debug",
139
204
  "-destination",
140
- `id=${udid}`,
205
+ options.destination ?? `id=${udid}`,
141
206
  "-derivedDataPath",
142
207
  derived,
143
208
  ...(options.extra ?? []),
@@ -147,7 +212,8 @@ export async function build(project, scheme, udid, options) {
147
212
  const started = Date.now();
148
213
  const out = createWriteStream(log);
149
214
  // Its own process group, so a cancel stops the compilers it started too.
150
- const child = spawn("xcodebuild", args, { cwd: project.dir, detached: true, stdio: ["ignore", "pipe", "pipe"] });
215
+ const [command, commandArgs] = buildCommand(args);
216
+ const child = spawn(command, commandArgs, { cwd: project.dir, detached: true, stdio: ["ignore", "pipe", "pipe"], env: xcodeEnv(options.xcode) });
151
217
  options.onCancel?.(() => {
152
218
  try {
153
219
  process.kill(-child.pid, "SIGTERM");
@@ -194,8 +260,8 @@ export async function build(project, scheme, udid, options) {
194
260
  return { ok: code === 0, errors, warnings, log, ms: Date.now() - started, tail };
195
261
  }
196
262
  /** The .app a Simulator build made: the scheme's own, never a test runner. */
197
- export function builtApp(project, scheme, configuration = "Debug") {
198
- const products = join(derivedDataFor(project), "Build", "Products", `${configuration}-iphonesimulator`);
263
+ export function builtApp(project, scheme, configuration = "Debug", xcode) {
264
+ const products = join(derivedDataFor(project, xcode), "Build", "Products", `${configuration}-iphonesimulator`);
199
265
  if (!existsSync(products))
200
266
  return undefined;
201
267
  const apps = readdirSync(products).filter((e) => e.endsWith(".app") && !e.endsWith("-Runner.app"));
@@ -252,8 +318,8 @@ export async function projectFor(path, scheme) {
252
318
  }
253
319
  if (project.note)
254
320
  note += `${project.note}\n`;
255
- const rememberedScheme = memory?.project === project.path ? memory.scheme : (await remembered(project.dir))?.scheme;
256
- const chosen = await pickScheme(project, scheme ?? rememberedScheme);
321
+ const known = memory?.project === project.path ? memory : await remembered(project.dir);
322
+ const chosen = await pickScheme(project, scheme ?? known?.scheme);
257
323
  await setLastProject(project.path);
258
- return { project, scheme: chosen, dir: path ? dir : project.dir, note };
324
+ return { project, scheme: chosen, dir: path ? dir : project.dir, note, ...(known?.xcode ? { xcode: known.xcode } : {}) };
259
325
  }