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.
- package/README.md +22 -14
- 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/files.js +57 -0
- package/dist/cloud/forward.js +58 -8
- 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/axe.js +14 -0
- package/dist/engine/exec.js +5 -3
- 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 +171 -16
- 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 +160 -39
- package/dist/tools/device-use.js +1 -1
- 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/ui-look.js +10 -3
- package/dist/tools.json +25 -8
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/vendor/axe/simulator-use-engine +0 -0
- package/vendor/axe/simulator-use-pbset +0 -0
package/README.md
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# simulator-use
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**The fastest, most token-efficient way for an AI agent to use the iOS Simulator.** simulator-use gives Claude Code, Codex, Cursor and any other MCP client a phone: it builds and runs your Xcode project, reads the screen as a few lines of text, taps and types by label, and runs your tests case by case. Its own native engine talks to the Simulator directly, so reading the whole screen takes about 250 ms and a tap goes out in under a millisecond, where tools that start a helper for every call take 1.5 to 3 seconds. Free and open source (MIT), on any Mac with Xcode, and from Linux, Windows or a Mac without Xcode through a cloud iPhone.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- **Fast:** a whole-screen read in about 250 ms, a tap sent in under 1 ms, a screenshot in about 60 ms (measured on an M6 Mac mini, October 2026).
|
|
6
|
+
- **Token-efficient:** one line per element the agent can use, nothing else; a dozen tools with short descriptions.
|
|
7
|
+
- **Complete:** build, install, launch, XCTest and XCUITest per case, logs and crashes, video, permission prompts, iPhone and iPad.
|
|
8
|
+
- **Continuously improved:** weekly releases driven by thousands of daily sessions.
|
|
9
|
+
- **Anywhere:** `npx simulator-use cloud start` gives a cloud iPhone with Xcode to any machine.
|
|
10
|
+
|
|
11
|
+
It builds, runs and tests Xcode projects, reads and uses the screen of any app (SwiftUI, UIKit, React Native), answers iOS's permission prompts, reads the app's logs and crashes, and records video.
|
|
6
12
|
|
|
7
|
-
It builds, runs and tests Xcode projects, reads and uses the screen of any app (SwiftUI, UIKit, React Native), answers iOS's permission prompts, reads the app's logs and crashes, and records video. Tested on Xcode 26.5 and 27 with iOS 26.5 and 27, on iPhone and iPad.
|
|
8
13
|
|
|
9
14
|
## What the agent sees
|
|
10
15
|
|
|
11
16
|
```
|
|
12
|
-
Demo · Sign In · iPhone 17 Pro (iOS
|
|
17
|
+
Demo · Sign In · iPhone 17 Pro (iOS 27.0)
|
|
13
18
|
textfield "Email" #email @201,194
|
|
14
19
|
securefield "Password" #password @201,246
|
|
15
20
|
switch "Remember me" #remember off @341,298
|
|
@@ -22,7 +27,7 @@ tabs: Login (selected) @158,822, Items @244,822
|
|
|
22
27
|
- Groups, icons inside buttons, scroll bars, the status bar and the keyboard's keys are folded away. The tab bar, scrolling and the keyboard are one line each.
|
|
23
28
|
- Only what can be tapped is listed. An alert, a sheet, a menu or a full-screen view in front hides the screen behind it, and iOS's own permission prompts read as alerts over the app. Rows under the navigation bar, the tab bar or the keyboard are counted as hidden (above, below, to the left or right), not listed.
|
|
24
29
|
- A web view in UIKit (Capacitor, React Native's WebView, a sign-in page inside most apps) is read point by point, and its headings, texts, fields, checkboxes, buttons and links are listed like the rest. Web views hosted by SwiftUI, Safari's own pages, in-app browsers, maps, games and iOS's out-of-process pickers have nothing for accessibility to read. They come back as one `unreadable` line with their place, so the agent knows to take a screenshot and tap by point.
|
|
25
|
-
- A screenshot, when asked for, is sized to points, so a point in the picture is a point to tap.
|
|
30
|
+
- A screenshot, when asked for, is sized to points, so a point in the picture is a point to tap. `screenshot_file` saves the screen at full resolution as a PNG for a person, a report or the App Store; in cloud mode it is saved on your machine too.
|
|
26
31
|
- The sign-in screen above is about 70 tokens. The full accessibility tree it comes from is about 40,000.
|
|
27
32
|
|
|
28
33
|
## Using the screen
|
|
@@ -67,7 +72,7 @@ Every step is checked, so nothing succeeds silently:
|
|
|
67
72
|
| `devices` | `simulator-use devices` | Lists the Simulators on this Mac with their runtime, UDID and state, and its Xcodes |
|
|
68
73
|
| `device_use` | `simulator-use use "iPhone 17 Pro"` | Chooses the Simulator the other tools use, booting it (one boot at a time); dark mode, a clean status bar |
|
|
69
74
|
| `app_control` | `simulator-use app launch dev.example.app` | `install`, `launch` (answers with the first screen), `terminate`, `uninstall`, `open_url`; `grant`, `revoke` and `reset_permissions`; `location`; `push`; the phone's `appearance`, `locale`, `timezone`, `text_size` and a clean `status_bar` |
|
|
70
|
-
| `ui_look` | `simulator-use look` | Reads the screen; `find` keeps matching lines, `screenshot` adds a picture, `full` saves the whole tree |
|
|
75
|
+
| `ui_look` | `simulator-use look` | Reads the screen; `find` keeps matching lines, `screenshot` adds a picture, `screenshot_file` saves a full-resolution PNG, `full` saves the whole tree |
|
|
71
76
|
| `ui_act` | `simulator-use act "tap #signIn" ...` | Does steps in order and answers with what changed |
|
|
72
77
|
| `app_logs` | `simulator-use logs` | The app's print output and its own system log lines; `grep`, `wait_for`, `crashes` |
|
|
73
78
|
| `tests_run` | `simulator-use test` | Runs the unit and UI tests; each case as it finishes, counts, each failure as `file:line` and the slowest; "0 tests ran" is an error |
|
|
@@ -87,7 +92,7 @@ The app's permissions are granted when it is installed and its first launch's pr
|
|
|
87
92
|
```
|
|
88
93
|
Built Demo in 4.2 s, installed dev.simtap.demo.
|
|
89
94
|
Launched Demo in 3.9 s.
|
|
90
|
-
Demo · Sign In · iPhone 17 Pro (iOS
|
|
95
|
+
Demo · Sign In · iPhone 17 Pro (iOS 27.0)
|
|
91
96
|
textfield "Email" #email @201,194
|
|
92
97
|
...
|
|
93
98
|
```
|
|
@@ -126,7 +131,7 @@ DemoTests/rejectsAnEmailWithoutAt(): Tests/SignInTests.swift:18: Expectation fai
|
|
|
126
131
|
|
|
127
132
|
## Several agents, one Mac
|
|
128
133
|
|
|
129
|
-
- Each agent runs its own MCP server and holds its own Simulator through `device_use
|
|
134
|
+
- 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
135
|
- 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
136
|
- `SIMULATOR_USE_MAX_BOOTED` caps how many run at once; asking for another shuts down the others first.
|
|
132
137
|
- 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.
|
|
@@ -140,7 +145,7 @@ Agents that run simulator-use through a shell, a command per call, get the same
|
|
|
140
145
|
|
|
141
146
|
```
|
|
142
147
|
$ simulator-use call ui_look '{"find":"Sign In"}'
|
|
143
|
-
{"content":[{"type":"text","text":"Demo · Sign In · iPhone 17 Pro (iOS
|
|
148
|
+
{"content":[{"type":"text","text":"Demo · Sign In · iPhone 17 Pro (iOS 27.0)\nbutton \"Sign In\" #signIn @201,350"}]}
|
|
144
149
|
```
|
|
145
150
|
|
|
146
151
|
## What it can't do yet
|
|
@@ -184,13 +189,14 @@ From a shell: `npx simulator-use look`.
|
|
|
184
189
|
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
190
|
|
|
186
191
|
```
|
|
187
|
-
simulator-use cloud start # signs in through a link the first time (or NOMAC_API_KEY), starts a phone
|
|
192
|
+
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
193
|
simulator-use run # every tool now runs there; run and test send the project first
|
|
194
|
+
simulator-use cloud extend 30 # 30 minutes more (an hour by default), up to 8 hours in all
|
|
189
195
|
simulator-use cloud stop # ends it; the tools run on this machine again
|
|
190
196
|
simulator-use cloud run . --tests # CI in one line: start, build, run, test, a screenshot and a recording, stop
|
|
191
197
|
```
|
|
192
198
|
|
|
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.
|
|
199
|
+
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
200
|
|
|
195
201
|
`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
202
|
|
|
@@ -200,7 +206,7 @@ An app that talks to a backend on your machine reaches it through a tunnel (Node
|
|
|
200
206
|
simulator-use cloud tunnel 8080 # until Ctrl-C: the phone reaches localhost:8080 here, each request printed
|
|
201
207
|
```
|
|
202
208
|
|
|
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.
|
|
209
|
+
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
210
|
|
|
205
211
|
On GitHub, from any runner, with the result posted on each pull request and the screenshot and recording kept as artifacts:
|
|
206
212
|
|
|
@@ -209,14 +215,16 @@ permissions:
|
|
|
209
215
|
pull-requests: write
|
|
210
216
|
steps:
|
|
211
217
|
- uses: actions/checkout@v5
|
|
212
|
-
- uses: NoMac-app/simulator-use@v0.
|
|
218
|
+
- uses: NoMac-app/simulator-use@v0.12
|
|
213
219
|
with:
|
|
214
220
|
api-key: ${{ secrets.NOMAC_API_KEY }}
|
|
215
221
|
```
|
|
216
222
|
|
|
223
|
+
It runs the simulator-use of its own tag (`@v0.12` 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`).
|
|
224
|
+
|
|
217
225
|
## Privacy
|
|
218
226
|
|
|
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.
|
|
227
|
+
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
228
|
|
|
221
229
|
## How it works
|
|
222
230
|
|
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
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files a phone's answers link to, saved on this machine: a link ends with its phone, and a local tool's answer
|
|
3
|
+
* names a path.
|
|
4
|
+
*/
|
|
5
|
+
import { createWriteStream, existsSync } from "node:fs";
|
|
6
|
+
import { unlink } from "node:fs/promises";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
|
+
import { Readable } from "node:stream";
|
|
9
|
+
import { pipeline } from "node:stream/promises";
|
|
10
|
+
/** A file link in a phone's answer: <base>/api/v1/simulator/files/<payload>.<signature>, never the sentence's period. */
|
|
11
|
+
const FILE_LINK = /https?:\/\/[^\s"'<>]+?\/api\/v1\/simulator\/files\/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
|
|
12
|
+
const KEEP_FILES = 10;
|
|
13
|
+
const KEEP_BYTES = 200 * 1024 * 1024;
|
|
14
|
+
/** The file's name as the link carries it (its payload's n), made safe for a folder. */
|
|
15
|
+
function linkName(link) {
|
|
16
|
+
try {
|
|
17
|
+
const payload = /\/files\/([A-Za-z0-9_-]+)\./.exec(link)?.[1];
|
|
18
|
+
const name = payload ? JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).n : undefined;
|
|
19
|
+
const safe = typeof name === "string" ? basename(name).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "") : "";
|
|
20
|
+
return safe || undefined;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The files a run's answers link to (a failed build's full log), kept in out before the phone stops: a link that
|
|
28
|
+
* was never opened ends with its phone. At most 10 files and 200 MB; each link in lines becomes its file's path.
|
|
29
|
+
*/
|
|
30
|
+
export async function keepFiles(lines, out) {
|
|
31
|
+
const links = [...new Set(lines.join("\n").match(FILE_LINK) ?? [])].slice(0, KEEP_FILES);
|
|
32
|
+
let total = 0;
|
|
33
|
+
for (const [i, link] of links.entries()) {
|
|
34
|
+
const name = linkName(link) ?? `simulator-use-file-${i + 1}`;
|
|
35
|
+
const file = join(out, existsSync(join(out, name)) ? `${i + 1}-${name}` : name);
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(link, { signal: AbortSignal.timeout(120_000) });
|
|
38
|
+
if (!response.ok || !response.body || total + Number(response.headers.get("content-length") ?? 0) > KEEP_BYTES)
|
|
39
|
+
continue;
|
|
40
|
+
let size = 0;
|
|
41
|
+
const capped = Readable.fromWeb(response.body).map((chunk) => {
|
|
42
|
+
size += chunk.length;
|
|
43
|
+
if (total + size > KEEP_BYTES)
|
|
44
|
+
throw new Error("over the run's file budget");
|
|
45
|
+
return chunk;
|
|
46
|
+
});
|
|
47
|
+
await pipeline(capped, createWriteStream(file));
|
|
48
|
+
total += size;
|
|
49
|
+
for (let j = 0; j < lines.length; j++)
|
|
50
|
+
lines[j] = lines[j].split(link).join(file);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// The link stays in the text; a file cut short is not kept.
|
|
54
|
+
await unlink(file).catch(() => undefined);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/cloud/forward.js
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* With cloud on, every tool runs on the NoMac phone instead of this machine: called by name through NoMac's API
|
|
3
3
|
* and polled until it ends. app_run and tests_run send their project first, and app_control install its .app, so
|
|
4
|
-
* the paths the phone takes are its own. Answers read as a local tool's do; files they name are links
|
|
4
|
+
* the paths the phone takes are its own. Answers read as a local tool's do; files they name are links, but for the
|
|
5
|
+
* files ui_look saves, which are saved here.
|
|
5
6
|
*/
|
|
6
7
|
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
8
|
+
import { mkdir } from "node:fs/promises";
|
|
7
9
|
import { basename, dirname, extname, relative, resolve, sep } from "node:path";
|
|
8
10
|
import { createHash } from "node:crypto";
|
|
9
11
|
import { run } from "../engine/exec.js";
|
|
12
|
+
import { stateDir } from "../engine/state.js";
|
|
10
13
|
import { ToolError } from "../errors.js";
|
|
11
14
|
import { account, cloudState, setCloudState } from "./account.js";
|
|
12
15
|
import { CloudError } from "./api.js";
|
|
16
|
+
import { keepFiles } from "./files.js";
|
|
13
17
|
import { randomKey, syncFolder } from "./sync.js";
|
|
14
18
|
const JOB = /Next: job_status \{job_id: "([^"]+)"\}/;
|
|
19
|
+
// job_status's answer while the job still runs (tools/jobs.ts): the same job is read again.
|
|
20
|
+
const STILL_RUNNING = /\nNext: job_status again, or job_cancel\./;
|
|
21
|
+
/** How long a call waits for a phone that is still starting (queued for a free one, or being prepared). */
|
|
22
|
+
const STARTING_WAIT_MS = { cli: 5 * 60_000, mcp: 30_000 };
|
|
15
23
|
/** The phone cloud mode uses, or why there is none. */
|
|
16
24
|
export async function cloudPhone() {
|
|
17
25
|
const state = await cloudState();
|
|
@@ -48,8 +56,20 @@ async function projectRoot(path) {
|
|
|
48
56
|
}
|
|
49
57
|
return { folder, inside: relative(folder, realpathSync(local)).split(sep).join("/") };
|
|
50
58
|
}
|
|
59
|
+
/** Waits for a phone that is still starting, up to waitMs: its state as last read (ready, or not yet). */
|
|
60
|
+
async function whenReady(api, session, waitMs) {
|
|
61
|
+
const deadline = Date.now() + waitMs;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const phone = (await api.json("GET", `/api/v1/simulator/sessions/${session}`)).session;
|
|
64
|
+
if (phone.state !== "queued" && phone.state !== "provisioning")
|
|
65
|
+
return phone;
|
|
66
|
+
if (Date.now() >= deadline)
|
|
67
|
+
return phone;
|
|
68
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
51
71
|
/** Runs one tool on the phone and answers as the local tool would. */
|
|
52
|
-
export async function forward(name, args, context) {
|
|
72
|
+
export async function forward(name, args, context, waited = false) {
|
|
53
73
|
const { api, session } = await cloudPhone();
|
|
54
74
|
let sent = args;
|
|
55
75
|
let note = "";
|
|
@@ -70,16 +90,33 @@ export async function forward(name, args, context) {
|
|
|
70
90
|
sent = { ...args, app: `${synced.root}/${basename(app)}` };
|
|
71
91
|
}
|
|
72
92
|
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;
|
|
93
|
+
// The command line waits for a long build, as it does on this Mac: job_status until the job ends. A job named
|
|
94
|
+
// in an error (another build of the project running) is not this call's.
|
|
95
|
+
const job = context.waitForJobs && !result.isError ? JOB.exec(result.text)?.[1] : undefined;
|
|
96
|
+
for (let rounds = 0; job && rounds < 120; rounds++) {
|
|
78
97
|
result = await call(api, session, "job_status", { job_id: job });
|
|
98
|
+
if (result.isError || !STILL_RUNNING.test(result.text))
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
// A file ui_look saved (the screenshot, the whole tree) is saved here too, as the tool on this machine does.
|
|
102
|
+
if (name === "ui_look" && (args.screenshot_file || args.full) && !result.isError) {
|
|
103
|
+
const lines = [result.text];
|
|
104
|
+
await mkdir(stateDir(), { recursive: true });
|
|
105
|
+
await keepFiles(lines, stateDir());
|
|
106
|
+
result = { ...result, text: lines[0] };
|
|
79
107
|
}
|
|
80
108
|
return note ? { ...result, text: `${note}${result.text}` } : result;
|
|
81
109
|
}
|
|
82
110
|
catch (error) {
|
|
111
|
+
// A phone still starting (waiting for a free one, or being prepared) is waited for, then the call goes again.
|
|
112
|
+
if (error instanceof CloudError && error.code === "session_not_ready" && error.details.starting === true) {
|
|
113
|
+
const phone = waited ? undefined : await whenReady(api, session, context.waitForJobs ? STARTING_WAIT_MS.cli : STARTING_WAIT_MS.mcp).catch(() => undefined);
|
|
114
|
+
if (phone?.state === "ready")
|
|
115
|
+
return forward(name, args, context, true);
|
|
116
|
+
if (phone && !["queued", "provisioning"].includes(phone.state))
|
|
117
|
+
return ended(session);
|
|
118
|
+
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 };
|
|
119
|
+
}
|
|
83
120
|
if (error instanceof CloudError && (error.code === "session_not_found" || error.code === "session_not_ready" || error.code === "session_expired"))
|
|
84
121
|
return ended(session);
|
|
85
122
|
if (error instanceof CloudError)
|
|
@@ -87,6 +124,19 @@ export async function forward(name, args, context) {
|
|
|
87
124
|
throw error;
|
|
88
125
|
}
|
|
89
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* 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
|
|
129
|
+
* 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).
|
|
130
|
+
*/
|
|
131
|
+
export function stopNotice(answer) {
|
|
132
|
+
const notice = answer.stop_notice;
|
|
133
|
+
if (!notice?.message)
|
|
134
|
+
return "";
|
|
135
|
+
if (notice.reason === "credit")
|
|
136
|
+
return `\n${notice.message}`;
|
|
137
|
+
const when = (notice.minutes_left ?? 0) <= 1 ? "within a minute" : `in about ${notice.minutes_left} minutes`;
|
|
138
|
+
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.`;
|
|
139
|
+
}
|
|
90
140
|
async function call(api, session, name, args) {
|
|
91
141
|
let answer = await api.json("POST", `/api/v1/simulator/sessions/${session}/tools/${name}`, args, `cli-${name}-${randomKey()}`);
|
|
92
142
|
while (answer.state === "running" && typeof answer.id === "string") {
|
|
@@ -102,7 +152,7 @@ async function call(api, session, name, args) {
|
|
|
102
152
|
const picture = (result.content ?? []).find((c) => c.type === "image" && c.data);
|
|
103
153
|
const notices = answer.notices?.includes("phone_restarted") ? "\nThe phone restarted; its apps and data are kept." : "";
|
|
104
154
|
return {
|
|
105
|
-
text: `${text}${notices}`,
|
|
155
|
+
text: `${text}${notices}${stopNotice(answer)}`,
|
|
106
156
|
...(picture ? { image: { data: Buffer.from(picture.data, "base64"), mimeType: picture.mimeType === "image/png" ? "image/png" : "image/jpeg" } } : {}),
|
|
107
157
|
...(result.isError ? { isError: true } : {}),
|
|
108
158
|
};
|