simulator-use 0.10.0 → 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 +9 -6
- package/dist/act/steps.js +9 -0
- package/dist/cli.js +11 -2
- package/dist/cloud/account.js +23 -1
- package/dist/cloud/api.js +6 -2
- package/dist/cloud/forward.js +46 -7
- package/dist/cloud/sync.js +168 -25
- package/dist/cloud/tunnel.js +25 -2
- package/dist/cloud-hint.js +4 -0
- package/dist/engine/apps.js +28 -17
- package/dist/engine/exec.js +1 -1
- package/dist/engine/jobs.js +14 -2
- package/dist/engine/logs.js +34 -7
- package/dist/engine/simctl.js +26 -3
- package/dist/engine/state.js +25 -3
- package/dist/engine/xcode.js +165 -14
- package/dist/hosted.js +250 -44
- package/dist/registry.js +15 -3
- package/dist/state-archive.js +196 -0
- package/dist/tools/app-control.js +17 -3
- package/dist/tools/app-logs.js +2 -2
- package/dist/tools/app-run.js +10 -3
- package/dist/tools/cloud.js +212 -41
- package/dist/tools/devices.js +1 -1
- package/dist/tools/launch-app.js +8 -2
- package/dist/tools/tests-run.js +12 -2
- package/dist/tools/ui-act.js +4 -4
- package/dist/tools.json +20 -7
- package/package.json +1 -1
- package/vendor/axe/simulator-use-engine +0 -0
- package/vendor/axe/simulator-use-pbset +0 -0
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.
|
|
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
|
|
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`
|
|
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);
|
package/dist/cloud/account.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/cloud/forward.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
};
|
package/dist/cloud/sync.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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) {
|
package/dist/cloud/tunnel.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/cloud-hint.js
CHANGED
|
@@ -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
|
+
}
|