simulator-use 0.10.1 → 0.12.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.
@@ -3,51 +3,190 @@
3
3
  * workspace lacks. The manifest (each file's path, size, mtime and SHA-256) goes in pages; the host answers the
4
4
  * paths it lacks; those go as one gzip tar, in 256 KiB chunks up to 8 MiB, else to an upload link; place writes
5
5
  * them and every mtime (new mtimes alone make Xcode compile again), and removes what the folder no longer has.
6
- * Build output and dependencies are left out, and left alone on the host.
6
+ * Build output and dependencies are left out, and left alone on the host. A file's hash is kept in an index on
7
+ * this machine, so only files that changed since the last sync are read again.
7
8
  */
8
9
  import { spawn } from "node:child_process";
9
10
  import { createHash, randomBytes } from "node:crypto";
10
- import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
11
- import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
11
+ import { createReadStream, lstatSync, readdirSync, realpathSync } from "node:fs";
12
+ import { mkdir, mkdtemp, readFile, readlink, rename, rm, writeFile } from "node:fs/promises";
12
13
  import { tmpdir } from "node:os";
13
14
  import { join, relative, sep } from "node:path";
14
15
  import { run } from "../engine/exec.js";
16
+ import { stateDir } from "../engine/state.js";
17
+ import { setting } from "../settings.js";
15
18
  /** Folders a build makes or a package manager fills: never sent, never removed on the host. */
16
19
  export const SKIP_DIRS = new Set([".git", ".hg", ".svn", "DerivedData", "build", ".build", "node_modules", "Pods", "xcuserdata", ".swiftpm", ".expo", ".gradle"]);
17
- const SKIP_FILES = (name) => name === ".DS_Store" || name.endsWith(".xcuserstate");
20
+ const SKIP_FILES = (name) => name === ".DS_Store" || name.endsWith(".xcuserstate") || secretFile(name);
21
+ /**
22
+ * Key and credential files, never sent even when git tracks them: environment files (but their examples), signing
23
+ * keys and certificates, provisioning profiles, keystores, SSH keys and .netrc. A Simulator build needs none of them.
24
+ */
25
+ export const secretFile = (name) => (/^\.env(\..+)?$/.test(name) && !/\.(example|sample|template)$/.test(name)) ||
26
+ /\.(p8|p12|pfx|pem|key|mobileprovision|provisionprofile|keystore|jks)$/i.test(name) ||
27
+ /^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/.test(name) ||
28
+ name === ".netrc";
18
29
  // The protocol's limits (NoMac's mac-protocol, simManifestEntrySchema and the sim route's body).
19
30
  const PAGE_ENTRIES = 1000;
20
31
  const PAGE_BYTES = 192 * 1024;
21
32
  const CHUNK = 256 * 1024;
22
33
  const INLINE_MAX = 8 * 1024 * 1024;
23
34
  const ARCHIVE_MAX = 2 * 1024 ** 3;
24
- /** The project's files as the host is to have them: build output, dependencies and editor state left out. */
25
- export function listProject(folder) {
26
- const out = [];
27
- const walk = (dir) => {
35
+ /**
36
+ * The project's files as the host is to have them: in a git repository, the files git tracks or would track
37
+ * (.gitignore's rules kept), else the folder's; build output, dependencies, editor state and key or credential files
38
+ * left out either way. With only (a .app beside others in its folder), that one alone and all of it: a bundle's own
39
+ * folders (an app's node_modules or build) are part of the app. Files are hashed a few at a time, never blocking the process (a
40
+ * cloud tunnel runs in it), and a file whose size, mtime, ctime and inode match the index is not read again.
41
+ */
42
+ export async function listProject(folder, only) {
43
+ const found = [];
44
+ const walk = (dir, whole) => {
28
45
  for (const name of readdirSync(dir).sort()) {
29
46
  if (SKIP_FILES(name))
30
47
  continue;
31
48
  const path = join(dir, name);
32
49
  const st = lstatSync(path);
33
50
  if (st.isDirectory()) {
34
- if (!SKIP_DIRS.has(name))
35
- walk(path);
51
+ if (whole || !SKIP_DIRS.has(name))
52
+ walk(path, whole);
36
53
  continue;
37
54
  }
38
- const rel = relative(folder, path).split(sep).join("/");
39
- if (st.isSymbolicLink()) {
40
- const target = readlinkSync(path);
41
- out.push({ path: rel, size: Buffer.byteLength(target), mtime_ms: Math.floor(st.mtimeMs), sha256: sha256(target), kind: "symlink" });
42
- }
43
- else if (st.isFile()) {
44
- out.push({ path: rel, size: st.size, mtime_ms: Math.floor(st.mtimeMs), sha256: sha256(readFileSync(path)), kind: st.mode & 0o111 ? "executable" : "file" });
45
- }
55
+ if (st.isSymbolicLink() || st.isFile())
56
+ found.push({ rel: relative(folder, path).split(sep).join("/"), path, st });
46
57
  }
47
58
  };
48
- walk(folder);
59
+ if (only) {
60
+ const top = join(folder, only);
61
+ if (lstatSync(top, { throwIfNoEntry: false })?.isDirectory())
62
+ walk(top, true);
63
+ }
64
+ else {
65
+ const tracked = await filesInGit(folder);
66
+ if (!tracked)
67
+ walk(folder, false);
68
+ else {
69
+ for (const rel of tracked.sort()) {
70
+ const parts = rel.split("/");
71
+ if (SKIP_FILES(parts.at(-1)) || parts.slice(0, -1).some((part) => SKIP_DIRS.has(part)))
72
+ continue;
73
+ const path = join(folder, ...parts);
74
+ const st = lstatSync(path, { throwIfNoEntry: false });
75
+ if (st && (st.isSymbolicLink() || st.isFile()))
76
+ found.push({ rel, path, st });
77
+ }
78
+ }
79
+ }
80
+ // Windows keeps no executable bit: git's index has it for the files it tracks (a build phase's script).
81
+ const gitExecutable = process.platform === "win32" ? await executablesInGit(folder) : undefined;
82
+ const indexed = setting("SYNC_INDEX") !== "0";
83
+ const file = indexFile(folder, only);
84
+ const last = indexed ? await readIndex(file) : undefined;
85
+ const scanned = Date.now();
86
+ const next = {};
87
+ const out = new Array(found.length);
88
+ await eachLimit(found, HASHING, async ({ rel, path, st }, i) => {
89
+ if (st.isSymbolicLink()) {
90
+ const target = await readlink(path);
91
+ out[i] = { path: rel, size: Buffer.byteLength(target), mtime_ms: Math.floor(st.mtimeMs), sha256: sha256(target), kind: "symlink" };
92
+ return;
93
+ }
94
+ const known = last?.files[rel];
95
+ // Git's rule for a racy entry: a file changed within RACY_MS of the last scan may have changed again since,
96
+ // with the same mtime, so it is read again.
97
+ const same = known && known.size === st.size && known.mtime_ms === st.mtimeMs && known.ctime_ms === st.ctimeMs && known.ino === st.ino && st.mtimeMs < last.scanned_ms - RACY_MS;
98
+ const hash = same ? known.sha256 : await hashFile(path, st.size);
99
+ next[rel] = { size: st.size, mtime_ms: st.mtimeMs, ctime_ms: st.ctimeMs, ino: st.ino, sha256: hash };
100
+ const executable = gitExecutable ? gitExecutable.has(rel) : Boolean(st.mode & 0o111);
101
+ out[i] = { path: rel, size: st.size, mtime_ms: Math.floor(st.mtimeMs), sha256: hash, kind: executable ? "executable" : "file" };
102
+ });
103
+ if (indexed)
104
+ await writeIndex(file, { scanned_ms: scanned, files: next }).catch(() => undefined);
49
105
  return out;
50
106
  }
107
+ /** The files git tracks as executable (mode 100755) under folder, by their path from it; none outside a repository. */
108
+ /**
109
+ * The files git tracks in folder, or would (untracked but not ignored), from the folder, a submodule's too; undefined
110
+ * when folder is not a repository's top (or git is not there).
111
+ */
112
+ export async function filesInGit(folder) {
113
+ try {
114
+ const top = (await run("git", ["-C", folder, "rev-parse", "--show-toplevel"], { timeoutMs: 5_000 })).stdout.trim();
115
+ if (realpathSync(top) !== realpathSync(folder))
116
+ return undefined;
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ const list = async (dir, prefix, depth) => {
122
+ const listed = (await run("git", ["-C", dir, "ls-files", "-co", "--exclude-standard", "-z"], { timeoutMs: 60_000 })).stdout.split("\0").filter(Boolean);
123
+ const out = [];
124
+ for (const rel of listed.map((path) => path.replace(/\/+$/, ""))) {
125
+ // A submodule (or a repository inside) is listed as its folder: its own files, by its own rules.
126
+ if (depth < 4 && lstatSync(join(dir, rel), { throwIfNoEntry: false })?.isDirectory())
127
+ out.push(...(await list(join(dir, rel), `${prefix}${rel}/`, depth + 1).catch(() => [])));
128
+ else
129
+ out.push(`${prefix}${rel}`);
130
+ }
131
+ return out;
132
+ };
133
+ try {
134
+ return await list(folder, "", 0);
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }
140
+ export async function executablesInGit(folder) {
141
+ try {
142
+ const listed = (await run("git", ["-C", folder, "ls-files", "-s", "-z"], { timeoutMs: 30_000 })).stdout;
143
+ // Each entry: <mode> <object> <stage>\t<path>, NUL-separated; the paths are from the folder.
144
+ return new Set(listed.split("\0").filter((e) => e.startsWith("100755 ")).map((e) => e.slice(e.indexOf("\t") + 1)));
145
+ }
146
+ catch {
147
+ return new Set();
148
+ }
149
+ }
150
+ /** Files hashed at once. */
151
+ const HASHING = 8;
152
+ /** A file changed within this long of a scan is read again at the next one (coarse mtimes on some file systems). */
153
+ const RACY_MS = 2_000;
154
+ const indexFile = (folder, only) => join(stateDir(), "sync-index", `${sha256(`${realpathSync(folder)}\0${only ?? ""}`).slice(0, 32)}.json`);
155
+ async function readIndex(file) {
156
+ try {
157
+ const index = JSON.parse(await readFile(file, "utf8"));
158
+ return typeof index.scanned_ms === "number" && index.files && typeof index.files === "object" ? index : undefined;
159
+ }
160
+ catch {
161
+ return undefined;
162
+ }
163
+ }
164
+ async function writeIndex(file, index) {
165
+ await mkdir(join(stateDir(), "sync-index"), { recursive: true });
166
+ const tmp = `${file}.${process.pid}.tmp`;
167
+ await writeFile(tmp, JSON.stringify(index), { mode: 0o600 });
168
+ await rename(tmp, file);
169
+ }
170
+ /** A file's SHA-256: small files read at once, larger ones streamed. */
171
+ async function hashFile(path, size) {
172
+ if (size < 1024 * 1024)
173
+ return sha256(await readFile(path));
174
+ const hash = createHash("sha256");
175
+ for await (const chunk of createReadStream(path))
176
+ hash.update(chunk);
177
+ return hash.digest("hex");
178
+ }
179
+ /** Runs work on each item, at most limit at a time. */
180
+ async function eachLimit(items, limit, work) {
181
+ let next = 0;
182
+ const worker = async () => {
183
+ while (next < items.length) {
184
+ const i = next++;
185
+ await work(items[i], i);
186
+ }
187
+ };
188
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
189
+ }
51
190
  const sha256 = (data) => createHash("sha256").update(data).digest("hex");
52
191
  /** Pages of at most 1000 entries and about 192 KiB each, in the manifest's order. */
53
192
  export function pages(entries) {
@@ -110,7 +249,7 @@ async function pack(folder, paths, file) {
110
249
  export async function syncFolder(api, session, folder, options = {}) {
111
250
  const { only, onStep } = options;
112
251
  const started = Date.now();
113
- const entries = listProject(folder).filter((e) => !only || e.path === only || e.path.startsWith(`${only}/`));
252
+ const entries = await listProject(folder, only);
114
253
  if (!entries.length)
115
254
  throw new Error(`${folder} has no files to send.`);
116
255
  const project = options.project ?? (await projectId(folder));
@@ -119,11 +258,15 @@ export async function syncFolder(api, session, folder, options = {}) {
119
258
  if (book.length > 256)
120
259
  throw new Error(`${folder} has ${entries.length} files: more than NoMac's sync takes (256 pages).`);
121
260
  onStep?.(`listing ${entries.length} files`);
122
- const missing = [];
123
- for (let page = 0; page < book.length; page++) {
124
- const answer = await api.json("POST", `/api/v1/simulator/sessions/${session}/sync`, { op: "manifest", sync, project, page, pages: book.length, entries: book[page] });
125
- missing.push(...(answer.missing ?? []));
126
- }
261
+ // Pages go 4 at a time: the host keeps each by its number, and places once it has them all.
262
+ const answered = new Array(book.length);
263
+ await eachLimit(book, 4, async (entriesOfPage, page) => {
264
+ const answer = await api.json("POST", `/api/v1/simulator/sessions/${session}/sync`, { op: "manifest", sync, project, page, pages: book.length, entries: entriesOfPage });
265
+ // Only paths this page listed are packed: an answer naming any other file of the folder sends nothing more.
266
+ const listed = new Set(entriesOfPage.map((e) => e.path));
267
+ answered[page] = (answer.missing ?? []).filter((path) => typeof path === "string" && listed.has(path));
268
+ });
269
+ const missing = answered.flat();
127
270
  let archive;
128
271
  let bytes = 0;
129
272
  if (missing.length) {
@@ -13,26 +13,32 @@ const RESPONSE = /^HTTP\/1\.[01] (\d{3})/;
13
13
  // The WebSocket may hold this much unsent before a local connection waits for it.
14
14
  const SEND_BUFFER = 4 * 1024 * 1024;
15
15
  export class Tunnel {
16
+ /** How long the phone's relay has to answer once the connection is up; tests shorten it. */
17
+ static helloWaitMs = 30_000;
16
18
  session;
17
19
  localPort;
18
20
  localHost;
19
21
  phonePort;
20
22
  state = "connecting";
21
23
  error;
24
+ /** Said once with the next answer: the phone's port changed when the tunnel came back. */
25
+ notice;
22
26
  requests = [];
23
27
  connections = 0;
24
28
  api;
25
29
  onRequest;
30
+ onNotice;
26
31
  ws;
27
32
  conns = new Map();
28
33
  stopped = false;
29
34
  drops = 0;
30
- constructor(api, session, localPort, localHost = "127.0.0.1", onRequest) {
35
+ constructor(api, session, localPort, localHost = "127.0.0.1", onRequest, onNotice) {
31
36
  this.api = api;
32
37
  this.session = session;
33
38
  this.localPort = localPort;
34
39
  this.localHost = localHost;
35
40
  this.onRequest = onRequest;
41
+ this.onNotice = onNotice;
36
42
  }
37
43
  /** Opens the tunnel; answers the port the phone's apps reach once the phone's relay listens. */
38
44
  async start() {
@@ -58,6 +64,15 @@ export class Tunnel {
58
64
  const reader = new FrameReader();
59
65
  let opened = 0;
60
66
  let answered = false;
67
+ // A relay that never says which port it took would leave cloud tunnel waiting for ever.
68
+ const silent = setTimeout(() => {
69
+ if (answered)
70
+ return;
71
+ answered = true;
72
+ this.stop();
73
+ this.error = "the phone's relay did not answer";
74
+ reject(new ToolError(`The phone's tunnel did not open within ${Math.max(1, Math.round(Tunnel.helloWaitMs / 1000))} s.`, "The phone must be ready; call cloud status, then cloud tunnel again."));
75
+ }, Tunnel.helloWaitMs);
61
76
  ws.onmessage = (event) => {
62
77
  let frames;
63
78
  try {
@@ -69,9 +84,16 @@ export class Tunnel {
69
84
  }
70
85
  for (const f of frames) {
71
86
  if (f.type === HELLO) {
72
- this.phonePort = Number(JSON.parse(f.payload.toString("utf8")).port);
87
+ const port = Number(JSON.parse(f.payload.toString("utf8")).port);
88
+ // The host keeps the port when it can; when it could not, an app pointed at the old one fails.
89
+ if (this.phonePort !== undefined && port !== this.phonePort) {
90
+ this.notice = `The tunnel came back on another port of the phone, ${port} (it was ${this.phonePort}): app_run again with env "API_URL=http://127.0.0.1:${port}" for an app that used the old one.`;
91
+ this.onNotice?.(this.notice);
92
+ }
93
+ this.phonePort = port;
73
94
  this.state = "open";
74
95
  opened = Date.now();
96
+ clearTimeout(silent);
75
97
  if (!answered) {
76
98
  answered = true;
77
99
  resolve(this.phonePort);
@@ -86,6 +108,7 @@ export class Tunnel {
86
108
  }
87
109
  };
88
110
  ws.onclose = () => {
111
+ clearTimeout(silent);
89
112
  for (const conn of this.conns.values())
90
113
  conn.socket.destroy();
91
114
  this.conns.clear();
@@ -12,3 +12,7 @@ export function cloudHint() {
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(" ");
15
+ /** The next step on a machine that can't run the iOS Simulator: a cloud phone first, when the hint is on. */
16
+ export function cloudFirst(otherwise) {
17
+ return setting("NO_CLOUD_HINT") ? otherwise : `cloud start runs every tool on a NoMac cloud iPhone Simulator, $0.80 an hour by the minute (${NOMAC_URL}); then call this again. Or: ${otherwise.replace(/^./, (c) => c.toLowerCase())}`;
18
+ }
@@ -4,6 +4,7 @@ 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 { laterVersion } from "./xcode.js";
7
8
  import { deviceDir, deviceEnv, deviceTimeZone } from "./state.js";
8
9
  const simctl = (args, timeoutMs = 60_000) => run("xcrun", ["simctl", ...args], { timeoutMs });
9
10
  /** The value of a key in `simctl appinfo`'s old-style plist output. */
@@ -36,11 +37,17 @@ export async function bundleIdOf(appPath) {
36
37
  const { stdout } = await run("plutil", ["-extract", "CFBundleIdentifier", "raw", plist], { timeoutMs: 10_000 });
37
38
  return stdout.trim();
38
39
  }
39
- export async function install(udid, appPath) {
40
+ /** Installs a .app; runtime ("iOS 27.0", the Simulator's) lets a refusal say when the app needs a later iOS. */
41
+ export async function install(udid, appPath, runtime) {
40
42
  try {
41
43
  await simctl(["install", udid, appPath], 120_000);
42
44
  }
43
45
  catch (error) {
46
+ const phone = runtime && /^iOS (\d+(?:\.\d+)*)$/.exec(runtime)?.[1];
47
+ const needs = phone ? (await run("plutil", ["-extract", "MinimumOSVersion", "raw", join(appPath, "Info.plist")], { timeoutMs: 10_000 }).catch(() => undefined))?.stdout.trim() : undefined;
48
+ if (phone && needs && laterVersion(needs, phone)) {
49
+ throw new ToolError(`Could not install ${appPath}: it needs iOS ${needs} (its deployment target) and this Simulator runs ${runtime}.`, `Build it with a deployment target of iOS ${phone} or earlier (IPHONEOS_DEPLOYMENT_TARGET), then install it again.`);
50
+ }
44
51
  throw new ToolError(`Could not install ${appPath}: ${error.message}`, "Check it is built for the Simulator (iphonesimulator), not a device.");
45
52
  }
46
53
  }
@@ -62,11 +69,6 @@ export function freshFile(path) {
62
69
  }
63
70
  return openSync(path, "w");
64
71
  }
65
- /**
66
- * Launches fresh (any running copy is stopped) and keeps the app's print() output for app_logs.
67
- * simctl's --stdout wrote nothing on iOS 26.5, so a detached `simctl launch --console-pty` holds the app's
68
- * output instead, writing to a file until the app ends; NSUnbufferedIO is what Xcode sets so lines arrive at once.
69
- */
70
72
  export async function launch(udid, bundleId) {
71
73
  const file = await consoleFile(udid, bundleId);
72
74
  const timeZone = await deviceTimeZone(udid);
@@ -79,6 +81,8 @@ export async function launch(udid, bundleId) {
79
81
  env: { ...process.env, ...env, SIMCTL_CHILD_NSUnbufferedIO: "YES", ...(timeZone ? { SIMCTL_CHILD_TZ: timeZone } : {}) },
80
82
  });
81
83
  closeSync(fd);
84
+ let ended = false;
85
+ child.once("exit", () => (ended = true));
82
86
  // A launch that fails ends the launcher within moments; a running app keeps it alive.
83
87
  const code = await new Promise((resolve) => {
84
88
  const timer = setTimeout(() => resolve(undefined), 2_000);
@@ -93,6 +97,7 @@ export async function launch(udid, bundleId) {
93
97
  }
94
98
  throw new ToolError(`Could not launch ${bundleId}: ${output.split("\n").slice(-3).join(" ")}`);
95
99
  }
100
+ return { ended: () => ended };
96
101
  }
97
102
  /** The app's own system log lines, streamed to a file while simulator-use watches it. */
98
103
  export async function streamFile(udid, bundleId) {
@@ -158,26 +163,32 @@ export async function pushPayload(udid, bundleId, payload) {
158
163
  child.stdin.end(payload);
159
164
  });
160
165
  }
166
+ /** Runs xcrun with the text on its input: what is typed never goes in an argument, which every user of a Mac can read. */
167
+ export function withInput(args, input, timeoutMs) {
168
+ return new Promise((resolve, reject) => {
169
+ const child = spawn("xcrun", args, { stdio: ["pipe", "ignore", "pipe"] });
170
+ let stderr = "";
171
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
172
+ child.stderr.on("data", (d) => (stderr += d));
173
+ child.on("error", (error) => (clearTimeout(timer), reject(error)));
174
+ child.on("close", (code, signal) => (clearTimeout(timer), code === 0 ? resolve() : reject(new ToolError(stderr.trim() || (signal ? `ended by ${signal}` : `exit ${code}`)))));
175
+ child.stdin.on("error", () => { });
176
+ child.stdin.end(input);
177
+ });
178
+ }
161
179
  /** Puts text on the Simulator's clipboard, for pasting what the keyboard can't type (é, 日本, emoji). */
162
180
  export async function setPasteboard(udid, text) {
163
181
  try {
164
- await new Promise((resolve, reject) => {
165
- const child = spawn("xcrun", ["simctl", "pbcopy", udid], { stdio: ["pipe", "ignore", "pipe"] });
166
- let stderr = "";
167
- child.stderr.on("data", (d) => (stderr += d));
168
- child.on("error", reject);
169
- child.on("close", (code, signal) => (code === 0 ? resolve() : reject(new ToolError(`Could not set the Simulator's clipboard: ${stderr.trim() || signal || `exit ${code}`}`))));
170
- child.stdin.end(text);
171
- });
182
+ await withInput(["simctl", "pbcopy", udid], text, 15_000);
172
183
  }
173
184
  catch (error) {
174
185
  // A macOS user with no screen session (a shared Simulator host's users) has no pasteboard server, and simctl
175
- // pbcopy crashes: the helper sets the phone's own pasteboard from inside the phone instead.
186
+ // pbcopy crashes: the helper sets the phone's own pasteboard from inside the phone instead, from its input.
176
187
  const helper = pasteHelper();
177
188
  if (!helper)
178
- throw error;
189
+ throw new ToolError(`Could not set the Simulator's clipboard: ${error.message}`);
179
190
  try {
180
- await simctl(["spawn", udid, helper, text], 15_000);
191
+ await withInput(["simctl", "spawn", udid, helper, "-"], text, 15_000);
181
192
  }
182
193
  catch (fallback) {
183
194
  throw new ToolError(`Could not set the Simulator's clipboard: ${error.message}; from inside the phone: ${fallback.message}`);
@@ -79,6 +79,20 @@ export async function screenshot(udid, points) {
79
79
  await rm(dir, { recursive: true, force: true });
80
80
  }
81
81
  }
82
+ /** The screen at the device's full resolution, as a PNG in file: for a person, a report or a store, not the agent's eyes. */
83
+ export async function screenshotFile(udid, file) {
84
+ const answer = await engineCall(udid, { op: "screenshot", png_path: file });
85
+ if (answer?.ok && existsSync(file))
86
+ return;
87
+ if (answer?.busy)
88
+ throw new ToolError("The screenshot took more than 30 s.", "Take it again: the phone is still working on an earlier request.");
89
+ try {
90
+ await run(axePath(), ["screenshot", "--output", file, "--udid", udid], { timeoutMs: 30_000 });
91
+ }
92
+ catch (error) {
93
+ throw new ToolError(`Could not take a screenshot: ${error.message}`);
94
+ }
95
+ }
82
96
  /**
83
97
  * Any AXe command may open and close an automation session of its own (a slider is found by its id), so each one is
84
98
  * followed by the time the next engine waits from (noteSessionClosed).
@@ -3,11 +3,13 @@ import { setting } from "../settings.js";
3
3
  /** Runs a program without a shell. Rejects with its stderr when it fails or runs past the timeout. */
4
4
  export function run(file, args, options = {}) {
5
5
  const started = Date.now();
6
+ // A program under the build's clamp (taskpolicy -c <clamp> <program> ...) is named for itself, not for taskpolicy.
7
+ const [program, shown] = file === "/usr/sbin/taskpolicy" && args[0] === "-c" && args[2] ? [args[2], args.slice(3)] : [file, args];
6
8
  return new Promise((resolve, reject) => {
7
- execFile(file, args, { timeout: options.timeoutMs ?? 30_000, maxBuffer: options.maxBytes ?? 64 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
9
+ execFile(file, args, { timeout: options.timeoutMs ?? 30_000, maxBuffer: options.maxBytes ?? 64 * 1024 * 1024, encoding: "utf8", ...(options.env ? { env: options.env } : {}) }, (error, stdout, stderr) => {
8
10
  // SIMULATOR_USE_DEBUG=1 prints every program run and how long it took, to find where time goes.
9
11
  if (setting("DEBUG"))
10
- process.stderr.write(`simulator-use: ${file.split("/").pop()} ${args.slice(0, 2).join(" ")} ${Date.now() - started} ms${error ? " (failed)" : ""}\n`);
12
+ process.stderr.write(`simulator-use: ${program.split("/").pop()} ${shown.slice(0, 2).join(" ")} ${Date.now() - started} ms${error ? " (failed)" : ""}\n`);
11
13
  if (!error)
12
14
  return resolve({ stdout, stderr });
13
15
  const code = error.code;
@@ -16,7 +18,7 @@ export function run(file, args, options = {}) {
16
18
  : error.killed
17
19
  ? `timed out after ${Math.round((options.timeoutMs ?? 30_000) / 1000)} s`
18
20
  : (stderr || error.message).trim();
19
- reject(Object.assign(new Error(`${file.split("/").pop()} ${args[0] ?? ""}: ${reason}`), { stderr, code: error.code }));
21
+ reject(Object.assign(new Error(`${program.split("/").pop()} ${shown[0] ?? ""}: ${reason}`), { stderr, code: error.code }));
20
22
  });
21
23
  });
22
24
  }
@@ -10,12 +10,24 @@ export function cancelAllJobs() {
10
10
  if (!job.result)
11
11
  job.cancel?.();
12
12
  }
13
- // Node runs no exit handler when a signal ends the process, so the signals stop the jobs themselves.
13
+ /** Work that must end before a signal ends the process (a cloud phone to stop, a tunnel to close). */
14
+ const beforeExit = new Set();
15
+ /** Runs work when a signal ends the process, up to 15 s in all; answers how to take it back. */
16
+ export function onSignalExit(work) {
17
+ beforeExit.add(work);
18
+ return () => beforeExit.delete(work);
19
+ }
20
+ // Node runs no exit handler when a signal ends the process, so the signals stop the jobs themselves. A second
21
+ // signal while that work runs ends the process at once (the handler is gone by then).
14
22
  process.on("exit", cancelAllJobs);
15
23
  for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
16
24
  process.once(signal, () => {
17
25
  cancelAllJobs();
18
- process.exit(128 + { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 }[signal]);
26
+ const code = 128 + { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 }[signal];
27
+ if (!beforeExit.size)
28
+ process.exit(code);
29
+ const work = [...beforeExit].map((w) => w().catch(() => undefined));
30
+ void Promise.race([Promise.all(work), delay(15_000, undefined, { ref: false })]).finally(() => process.exit(code));
19
31
  });
20
32
  }
21
33
  export function startJob(title, work) {
@@ -60,10 +60,12 @@ export function cleanConsole(text) {
60
60
  }
61
61
  /**
62
62
  * Crash reports of a Simulator app land on the Mac itself, in ~/Library/Logs/DiagnosticReports, named after
63
- * the app's executable. An .ips file is one JSON header line, then the report as JSON.
63
+ * the app's executable. An .ips file is one JSON header line, then the report as JSON. Another app with the same
64
+ * executable name, or the same app on another Simulator, writes reports there too: a report is this app's when its
65
+ * header's bundleID is the app's, and this device's when its process path (or coalition) names the device.
64
66
  */
65
- export async function crashReports(executable, since, limit = 3) {
66
- const dir = join(homedir(), "Library", "Logs", "DiagnosticReports");
67
+ export async function crashReports(executable, since, of = {}, limit = 3) {
68
+ const dir = of.dir ?? join(homedir(), "Library", "Logs", "DiagnosticReports");
67
69
  let names;
68
70
  try {
69
71
  names = (await readdir(dir)).filter((n) => n.startsWith(`${executable}-`) && n.endsWith(".ips"));
@@ -72,13 +74,38 @@ export async function crashReports(executable, since, limit = 3) {
72
74
  return [];
73
75
  }
74
76
  const files = await Promise.all(names.map(async (n) => ({ file: join(dir, n), at: (await stat(join(dir, n))).mtimeMs })));
75
- const recent = files.filter((f) => f.at >= since).sort((a, b) => b.at - a.at).slice(0, limit);
76
- return Promise.all(recent.map(async (f) => {
77
+ const recent = files.filter((f) => f.at >= since).sort((a, b) => b.at - a.at);
78
+ const out = [];
79
+ for (const f of recent) {
80
+ if (out.length >= limit)
81
+ break;
77
82
  const text = await readFile(f.file, "utf8");
83
+ if (!reportOf(text, of.bundleId, of.udid))
84
+ continue;
78
85
  // When it crashed, not when macOS finished writing the report (up to a minute later).
79
86
  const time = /"captureTime"\s*:\s*"([^"]+)"/.exec(text)?.[1] ?? new Date(f.at).toISOString();
80
- return { file: f.file, time, summary: summarizeIps(text, executable) };
81
- }));
87
+ out.push({ file: f.file, time, summary: summarizeIps(text, executable) });
88
+ }
89
+ return out;
90
+ }
91
+ /** Whether a report is this app's on this device; a report without the field to tell is kept. */
92
+ function reportOf(text, bundleId, udid) {
93
+ const newline = text.indexOf("\n");
94
+ let header = {};
95
+ let body = {};
96
+ try {
97
+ header = JSON.parse(text.slice(0, newline < 0 ? undefined : newline));
98
+ body = JSON.parse(text.slice(newline + 1));
99
+ }
100
+ catch {
101
+ // An unreadable part tells nothing.
102
+ }
103
+ if (bundleId && typeof header.bundleID === "string" && header.bundleID !== bundleId)
104
+ return false;
105
+ const where = [body.procPath, body.coalitionName].filter((v) => typeof v === "string");
106
+ if (udid && where.length && !where.some((w) => w.includes(`/Devices/${udid}/`) || w.endsWith(`.${udid}`)))
107
+ return false;
108
+ return true;
82
109
  }
83
110
  /**
84
111
  * What went wrong and where: the exception, the reason, and the crashing thread from the top down to just past
@@ -1,4 +1,6 @@
1
- import { withCloudHint } from "../cloud-hint.js";
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { cloudFirst, withCloudHint } from "../cloud-hint.js";
2
4
  import { ToolError } from "../errors.js";
3
5
  import { run } from "./exec.js";
4
6
  import { withLock } from "./lock.js";
@@ -36,9 +38,30 @@ export function parseDevices(json) {
36
38
  /** The MCP server lives for a whole session: one list serves the calls of the next few seconds (0.35 s saved each). */
37
39
  const CACHE_MS = 5_000;
38
40
  let cached;
39
- export async function listDevices(fresh = false) {
41
+ /** The developer folder Xcode was found in (DEVELOPER_DIR, or none for xcode-select's): it does not go away. */
42
+ let xcodeFound;
43
+ /**
44
+ * Why this machine can't run the iOS Simulator at all, or nothing: not a Mac, or a Mac without Xcode (none, or only
45
+ * the Command Line Tools, which have no simctl). Checked before any tool that needs the Simulator, so the agent
46
+ * reads that and its way on, never a build tool's error. xcode-select -p never asks a person to install anything.
47
+ */
48
+ export async function simulatorMissing() {
40
49
  if (process.platform !== "darwin")
41
- throw new ToolError(NO_SIMULATOR, withCloudHint("Run simulator-use on a Mac with Xcode."));
50
+ return new ToolError("This machine can't run the iOS Simulator: it needs a Mac with Xcode.", cloudFirst("Run simulator-use on a Mac with Xcode."));
51
+ const asked = process.env.DEVELOPER_DIR ?? "";
52
+ if (xcodeFound === asked)
53
+ return undefined;
54
+ const developer = asked || (await run("xcode-select", ["-p"], { timeoutMs: 10_000 }).then((r) => r.stdout.trim(), () => ""));
55
+ if (developer && existsSync(join(developer, "usr", "bin", "simctl"))) {
56
+ xcodeFound = asked;
57
+ return undefined;
58
+ }
59
+ return new ToolError("This Mac has no Xcode, so it can't run the iOS Simulator (the Command Line Tools alone have none).", cloudFirst("Install Xcode from the App Store and open it once."));
60
+ }
61
+ export async function listDevices(fresh = false) {
62
+ const missing = process.platform !== "darwin" ? await simulatorMissing() : undefined;
63
+ if (missing)
64
+ throw missing;
42
65
  if (!fresh && cached && Date.now() - cached.at < CACHE_MS)
43
66
  return cached.devices;
44
67
  try {
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
@@ -12,8 +12,30 @@ import { setting } from "../settings.js";
12
12
  */
13
13
  let held;
14
14
  let persist = false;
15
- /** Where simulator-use keeps its files: SIMULATOR_USE_STATE_DIR, else simulator-use in the user's temporary folder. */
16
- export const stateDir = () => setting("STATE_DIR") || join(tmpdir(), "simulator-use");
15
+ /**
16
+ * Where simulator-use keeps its files: SIMULATOR_USE_STATE_DIR, else a folder of this user's in the temporary folder,
17
+ * readable by this user alone (ownStateDir).
18
+ */
19
+ let own;
20
+ export const stateDir = () => setting("STATE_DIR") || (own ??= ownStateDir(tmpdir()));
21
+ /**
22
+ * simulator-use's folder in base, this user's alone: macOS gives each user a temporary folder of their own, but
23
+ * Linux's /tmp is shared, so there the folder is named for the user. Made 0700; one that is a link, or another
24
+ * user's, is refused, and one others can read is closed.
25
+ */
26
+ export function ownStateDir(base, platform = process.platform) {
27
+ const uid = process.getuid?.();
28
+ if (uid === undefined || platform === "win32")
29
+ return join(base, "simulator-use");
30
+ const dir = join(base, platform === "darwin" ? "simulator-use" : `simulator-use-${uid}`);
31
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
32
+ const st = lstatSync(dir);
33
+ if (!st.isDirectory() || st.uid !== uid)
34
+ throw new Error(`${dir} is not this user's own folder (a link, or another user's). Set SIMULATOR_USE_STATE_DIR to a folder of yours.`);
35
+ if (st.mode & 0o077)
36
+ chmodSync(dir, 0o700);
37
+ return dir;
38
+ }
17
39
  const heldFile = () => join(stateDir(), "held-device.json");
18
40
  /** The CLI keeps the held device between its calls. */
19
41
  export function persistHeld() {