simulator-use 0.10.1 → 0.11.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/README.md CHANGED
@@ -126,7 +126,7 @@ DemoTests/rejectsAnEmailWithoutAt(): Tests/SignInTests.swift:18: Expectation fai
126
126
 
127
127
  ## Several agents, one Mac
128
128
 
129
- - Each agent runs its own MCP server and holds its own Simulator through `device_use`.
129
+ - Each agent runs its own MCP server and holds its own Simulator through `device_use`, and its own cloud phone: one agent's `cloud start` never moves another's tools, its `cloud stop` never ends another's phone, and the phone stops when the agent's server ends. A `cloud start` typed in a terminal is the command line's own.
130
130
  - Boots never overlap, across every simulator-use process on the Mac: two Simulators booting at once on an 8 GB Mac pushed it into swap for minutes.
131
131
  - `SIMULATOR_USE_MAX_BOOTED` caps how many run at once; asking for another shuts down the others first.
132
132
  - Steps on one Simulator run one at a time, across every simulator-use process on the Mac, so two agents' or two commands' taps never interleave.
@@ -184,13 +184,14 @@ From a shell: `npx simulator-use look`.
184
184
  Where this machine can't run the Simulator (Linux, Windows, a cloud agent), or for CI, the same tools run on a cloud iPhone Simulator on [NoMac](https://nomac.app/?utm_source=simulator-use), a Mac with Xcode, $0.80 an hour by the minute:
185
185
 
186
186
  ```
187
- simulator-use cloud start # signs in through a link the first time (or NOMAC_API_KEY), starts a phone
187
+ simulator-use cloud start # signs in through a link the first time (or NOMAC_API_KEY), starts a phone (--device ipad for an iPad)
188
188
  simulator-use run # every tool now runs there; run and test send the project first
189
+ simulator-use cloud extend 30 # 30 minutes more (an hour by default), up to 8 hours in all
189
190
  simulator-use cloud stop # ends it; the tools run on this machine again
190
191
  simulator-use cloud run . --tests # CI in one line: start, build, run, test, a screenshot and a recording, stop
191
192
  ```
192
193
 
193
- Only the files the phone lacks are sent (a one-file change sends one file), into the account's own encrypted workspace, kept between sessions so builds stay incremental; build output and dependencies stay behind. The `cloud` tool does the same over MCP.
194
+ Only the files the phone lacks are sent (a one-file change sends one file), into the account's own encrypted workspace, kept between sessions so builds stay incremental; build output and dependencies stay behind. Only files changed since the last send are read again (an index in simulator-use's state folder; `SIMULATOR_USE_SYNC_INDEX=0` reads every file). A CocoaPods project builds there too: `pod install` runs on the phone's host before the build when the pods are not the ones `Podfile` and `Podfile.lock` name, keeping the lock's versions, and the next build skips it. The `cloud` tool does the same over MCP.
194
195
 
195
196
  `cloud start` answers a link for a person to watch the phone in a browser and one to use it. `cloud stop` answers the session's replay: the screen as a video beside every call (typed text left out), kept for 7 days.
196
197
 
@@ -200,7 +201,7 @@ An app that talks to a backend on your machine reaches it through a tunnel (Node
200
201
  simulator-use cloud tunnel 8080 # until Ctrl-C: the phone reaches localhost:8080 here, each request printed
201
202
  ```
202
203
 
203
- The answer says the address the phone's apps use, such as `http://127.0.0.1:20199`; point the app there, for example with `run --env "API_URL=http://127.0.0.1:20199"`. Each request that passes is listed with its status and time (`cloud tunnel` without a port shows them over MCP). It lasts while the phone runs and comes back by itself after a dropped connection; `cloud tunnel` with port 0 closes it.
204
+ The answer says the address the phone's apps use, such as `http://127.0.0.1:20199`; point the app there, for example with `run --env "API_URL=http://127.0.0.1:20199"`. Each request that passes is listed with its status and time (`cloud tunnel` without a port shows them over MCP). It lasts while the phone runs and comes back by itself after a dropped connection; `cloud tunnel` with port 0 closes it. While it is open, anything on the phone reaches that port: its apps, pages in its Safari and your project's build; nothing outside the phone does.
204
205
 
205
206
  On GitHub, from any runner, with the result posted on each pull request and the screenshot and recording kept as artifacts:
206
207
 
@@ -209,14 +210,16 @@ permissions:
209
210
  pull-requests: write
210
211
  steps:
211
212
  - uses: actions/checkout@v5
212
- - uses: NoMac-app/simulator-use@v0.10
213
+ - uses: NoMac-app/simulator-use@v0.11
213
214
  with:
214
215
  api-key: ${{ secrets.NOMAC_API_KEY }}
215
216
  ```
216
217
 
218
+ It runs the simulator-use of its own tag (`@v0.11` runs what that tag holds; `version` picks another). `path` is the project's folder, `scheme` the scheme when the project has several, and `tests: false` skips the tests (a project without a test target fails otherwise: zero tests is not a pass). A cancelled run stops its phone, and a pull request from a fork, which gets no secrets, fails with that reason. A job's log and its pull request comment can be public, so in CI (`CI` or `GITHUB_ACTIONS` set) `cloud run` prints no link that opens without signing in: files it links are kept in the run's artifacts, and the replay's link is read with the account's key (`GET https://nomac.app/api/v1/simulator/sessions/{id}`, `links.replay`).
219
+
217
220
  ## Privacy
218
221
 
219
- simulator-use sends nothing unless cloud mode is on. There is no telemetry and no network access at runtime: it runs `xcrun simctl`, the bundled AXe binary and `sips`, all on your Mac. With cloud on, it sends the project being run or tested, and the tools' calls, to NoMac, which keeps the session's replay for 7 days.
222
+ simulator-use sends nothing unless cloud mode is on. There is no telemetry and no network access at runtime: it runs `xcrun simctl`, the bundled AXe binary and `sips`, all on your Mac. With cloud on, it sends the project being run or tested, and the tools' calls, to NoMac, which keeps the session's replay for 7 days. The project is the git repository it is in, as the files git tracks or would track (what `.gitignore` leaves out stays on your machine), or its folder when it is in none; either way never build output, dependencies, editor state or key and credential files (`.env` files but their examples, `.p8`, `.p12`, `.pfx`, `.pem`, `.key`, provisioning profiles, keystores, SSH keys, `.netrc`), even when git tracks them.
220
223
 
221
224
  ## How it works
222
225
 
package/dist/act/steps.js CHANGED
@@ -186,3 +186,12 @@ export function describeTarget(t) {
186
186
  return `@${t.x},${t.y}`;
187
187
  return `${t.kind ? `${t.kind} ` : ""}${JSON.stringify(t.label)}`;
188
188
  }
189
+ /** A step as an answer repeats it: a fill's or type's text left out, since the answer can reach logs and a replay. */
190
+ export function maskedStep(line) {
191
+ const trimmed = line.trim();
192
+ const verb = (/^\S+/.exec(trimmed)?.[0] ?? "").toLowerCase();
193
+ if (verb !== "fill" && verb !== "type")
194
+ return trimmed;
195
+ const step = parseStep(trimmed);
196
+ return typeof step !== "string" && step.op === "fill" ? `fill ${describeTarget(step.target)} …` : `${verb} …`;
197
+ }
package/dist/cli.js CHANGED
@@ -5,9 +5,13 @@
5
5
  import { writeFile } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+ import { persistCloud } from "./cloud/account.js";
10
+ import { onSignalExit } from "./engine/jobs.js";
8
11
  import { persistHeld } from "./engine/state.js";
9
12
  import { serve } from "./mcp.js";
10
13
  import { callTool, toContent, TOOLS, VERSION } from "./registry.js";
14
+ import { stopServerPhone } from "./tools/cloud.js";
11
15
  import { relay, relayPorts } from "./tunnel-relay.js";
12
16
  const flag = (name) => `--${name.replace(/_/g, "-")}`;
13
17
  /** Parses `--name value` and `--switch` into arguments the tool's schema accepts. */
@@ -151,12 +155,17 @@ export async function main(argv) {
151
155
  process.exit(0);
152
156
  }
153
157
  if (command === "mcp") {
158
+ // The server's cloud phone is its agent's own: stopped when the server ends, by a signal or below.
159
+ onSignalExit(stopServerPhone);
154
160
  await serve();
155
- // The client closed stdin: answer what was in flight, then go, even if a child process still holds a pipe.
161
+ // The client closed stdin: answer what was in flight, stop the server's phone, then go, even if a child process
162
+ // still holds a pipe.
163
+ await Promise.race([stopServerPhone().catch(() => undefined), delay(15_000, undefined, { ref: false })]);
156
164
  process.exit(0);
157
165
  }
158
- // From here on each command is its own process: the Simulator chosen with `use` is kept in a file.
166
+ // From here on each command is its own process: the Simulator chosen with `use` and cloud mode are kept in files.
159
167
  persistHeld();
168
+ persistCloud();
160
169
  if (command === "call")
161
170
  return call(rest);
162
171
  const tool = TOOLS.find((t) => t.command === command || t.name === command);
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Cloud mode's account and phone: the NoMac account it signs in to (NOMAC_API_KEY, else the NoMac CLI's
3
3
  * ~/.nomac/credentials.json, else NoMac's device sign-in, whose key is saved there for both tools), and the phone
4
- * it uses, kept in simulator-use's state folder so every CLI call reaches the same one.
4
+ * it uses: an MCP server's own, in its memory; the CLI's in simulator-use's state folder, so every CLI call reaches
5
+ * the same one.
5
6
  */
6
7
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
7
8
  import { homedir, hostname } from "node:os";
@@ -34,7 +35,21 @@ async function saveKey(key, base) {
34
35
  await chmod(credentialsFile(), 0o600);
35
36
  }
36
37
  const stateFile = () => join(stateDir(), "cloud.json");
38
+ /**
39
+ * An MCP server keeps cloud mode in its memory, as it keeps its held Simulator: each agent runs its own server, so
40
+ * one agent's cloud start never sends another agent's tools to its phone, and one's cloud stop never ends another's.
41
+ * A CLI call is its own process, so the CLI keeps it in a file (persistCloud), and cloud start carries over to the
42
+ * next command.
43
+ */
44
+ let memory = { on: false };
45
+ let persistent = false;
46
+ /** The CLI keeps cloud mode between its calls. */
47
+ export function persistCloud() {
48
+ persistent = true;
49
+ }
37
50
  export async function cloudState() {
51
+ if (!persistent)
52
+ return { ...memory };
38
53
  try {
39
54
  return JSON.parse(await readFile(stateFile(), "utf8"));
40
55
  }
@@ -43,6 +58,10 @@ export async function cloudState() {
43
58
  }
44
59
  }
45
60
  export async function setCloudState(state) {
61
+ if (!persistent) {
62
+ memory = { ...state };
63
+ return;
64
+ }
46
65
  await mkdir(stateDir(), { recursive: true });
47
66
  await writeFile(stateFile(), JSON.stringify(state), { mode: 0o600 });
48
67
  }
@@ -57,6 +76,9 @@ export async function signIn() {
57
76
  const ready = await account();
58
77
  if (ready)
59
78
  return { api: ready };
79
+ // In CI nobody can open a sign-in link: no key is a missing secret (a fork's pull request gets none).
80
+ if (process.env.CI)
81
+ throw new Error("No NOMAC_API_KEY: in CI, set it from a secret (a pull request from a fork gets no secrets).");
60
82
  const state = await cloudState();
61
83
  const base = (process.env.NOMAC_API_URL || DEFAULT_API).replace(/\/+$/, "");
62
84
  let pending = state.signIn && state.signIn.expires_at > Date.now() && state.signIn.base === base ? state.signIn : undefined;
package/dist/cloud/api.js CHANGED
@@ -6,10 +6,13 @@ import { VERSION } from "../registry.js";
6
6
  export class CloudError extends Error {
7
7
  code;
8
8
  status;
9
- constructor(code, message, status) {
9
+ /** The rest of NoMac's error: starting (a phone not ready yet), retry_after_seconds, next_action ... */
10
+ details;
11
+ constructor(code, message, status, details = {}) {
10
12
  super(message);
11
13
  this.code = code;
12
14
  this.status = status;
15
+ this.details = details;
13
16
  }
14
17
  }
15
18
  const PASSING = new Set([429, 502, 503, 504]);
@@ -55,7 +58,8 @@ export class CloudApi {
55
58
  await pause(attempt, Number(response.headers.get("retry-after")));
56
59
  continue;
57
60
  }
58
- throw new CloudError(error.code ?? `http_${response.status}`, error.message ?? `NoMac answered ${response.status}.`, response.status);
61
+ const { code: _code, message: _message, ...details } = error;
62
+ throw new CloudError(error.code ?? `http_${response.status}`, error.message ?? `NoMac answered ${response.status}.`, response.status, details);
59
63
  }
60
64
  }
61
65
  }
@@ -12,6 +12,10 @@ import { account, cloudState, setCloudState } from "./account.js";
12
12
  import { CloudError } from "./api.js";
13
13
  import { randomKey, syncFolder } from "./sync.js";
14
14
  const JOB = /Next: job_status \{job_id: "([^"]+)"\}/;
15
+ // job_status's answer while the job still runs (tools/jobs.ts): the same job is read again.
16
+ const STILL_RUNNING = /\nNext: job_status again, or job_cancel\./;
17
+ /** How long a call waits for a phone that is still starting (queued for a free one, or being prepared). */
18
+ const STARTING_WAIT_MS = { cli: 5 * 60_000, mcp: 30_000 };
15
19
  /** The phone cloud mode uses, or why there is none. */
16
20
  export async function cloudPhone() {
17
21
  const state = await cloudState();
@@ -48,8 +52,20 @@ async function projectRoot(path) {
48
52
  }
49
53
  return { folder, inside: relative(folder, realpathSync(local)).split(sep).join("/") };
50
54
  }
55
+ /** Waits for a phone that is still starting, up to waitMs: its state as last read (ready, or not yet). */
56
+ async function whenReady(api, session, waitMs) {
57
+ const deadline = Date.now() + waitMs;
58
+ for (;;) {
59
+ const phone = (await api.json("GET", `/api/v1/simulator/sessions/${session}`)).session;
60
+ if (phone.state !== "queued" && phone.state !== "provisioning")
61
+ return phone;
62
+ if (Date.now() >= deadline)
63
+ return phone;
64
+ await new Promise((r) => setTimeout(r, 2_000));
65
+ }
66
+ }
51
67
  /** Runs one tool on the phone and answers as the local tool would. */
52
- export async function forward(name, args, context) {
68
+ export async function forward(name, args, context, waited = false) {
53
69
  const { api, session } = await cloudPhone();
54
70
  let sent = args;
55
71
  let note = "";
@@ -70,16 +86,26 @@ export async function forward(name, args, context) {
70
86
  sent = { ...args, app: `${synced.root}/${basename(app)}` };
71
87
  }
72
88
  let result = await call(api, session, name, sent);
73
- // The command line waits for a long build, as it does on this Mac: job_status until the job ends.
74
- for (let rounds = 0; context.waitForJobs && rounds < 60; rounds++) {
75
- const job = JOB.exec(result.text)?.[1];
76
- if (!job)
77
- break;
89
+ // The command line waits for a long build, as it does on this Mac: job_status until the job ends. A job named
90
+ // in an error (another build of the project running) is not this call's.
91
+ const job = context.waitForJobs && !result.isError ? JOB.exec(result.text)?.[1] : undefined;
92
+ for (let rounds = 0; job && rounds < 120; rounds++) {
78
93
  result = await call(api, session, "job_status", { job_id: job });
94
+ if (result.isError || !STILL_RUNNING.test(result.text))
95
+ break;
79
96
  }
80
97
  return note ? { ...result, text: `${note}${result.text}` } : result;
81
98
  }
82
99
  catch (error) {
100
+ // A phone still starting (waiting for a free one, or being prepared) is waited for, then the call goes again.
101
+ if (error instanceof CloudError && error.code === "session_not_ready" && error.details.starting === true) {
102
+ const phone = waited ? undefined : await whenReady(api, session, context.waitForJobs ? STARTING_WAIT_MS.cli : STARTING_WAIT_MS.mcp).catch(() => undefined);
103
+ if (phone?.state === "ready")
104
+ return forward(name, args, context, true);
105
+ if (phone && !["queued", "provisioning"].includes(phone.state))
106
+ return ended(session);
107
+ return { text: `The cloud phone ${session} is still starting${phone?.message ? `: ${phone.message}` : "."}\nNext: ${name} again in a moment (nothing ran); cloud status shows its place.`, isError: true };
108
+ }
83
109
  if (error instanceof CloudError && (error.code === "session_not_found" || error.code === "session_not_ready" || error.code === "session_expired"))
84
110
  return ended(session);
85
111
  if (error instanceof CloudError)
@@ -87,6 +113,19 @@ export async function forward(name, args, context) {
87
113
  throw error;
88
114
  }
89
115
  }
116
+ /**
117
+ * NoMac's stop notice, in the phone's last 15 minutes before a limit or the account's credit's end, as cloud mode can
118
+ * act on it: it keeps no saved states, so a new phone after this one is the way on (its build stays in the workspace).
119
+ */
120
+ export function stopNotice(answer) {
121
+ const notice = answer.stop_notice;
122
+ if (!notice?.message)
123
+ return "";
124
+ if (notice.reason === "credit")
125
+ return `\n${notice.message}`;
126
+ const when = (notice.minutes_left ?? 0) <= 1 ? "within a minute" : `in about ${notice.minutes_left} minutes`;
127
+ return `\nThe cloud phone stops ${when}, at its ${notice.reason === "spend_cap" ? "spend cap" : "time limit"}: cloud extend keeps it running an hour longer. Once it stops, its apps and data go with it; the project's build stays, so the next cloud start builds incrementally.`;
128
+ }
90
129
  async function call(api, session, name, args) {
91
130
  let answer = await api.json("POST", `/api/v1/simulator/sessions/${session}/tools/${name}`, args, `cli-${name}-${randomKey()}`);
92
131
  while (answer.state === "running" && typeof answer.id === "string") {
@@ -102,7 +141,7 @@ async function call(api, session, name, args) {
102
141
  const picture = (result.content ?? []).find((c) => c.type === "image" && c.data);
103
142
  const notices = answer.notices?.includes("phone_restarted") ? "\nThe phone restarted; its apps and data are kept." : "";
104
143
  return {
105
- text: `${text}${notices}`,
144
+ text: `${text}${notices}${stopNotice(answer)}`,
106
145
  ...(picture ? { image: { data: Buffer.from(picture.data, "base64"), mimeType: picture.mimeType === "image/png" ? "image/png" : "image/jpeg" } } : {}),
107
146
  ...(result.isError ? { isError: true } : {}),
108
147
  };
@@ -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
+ }