simulator-use 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -5
- package/dist/act/resolve.js +29 -2
- package/dist/cli.js +12 -0
- package/dist/cloud/account.js +110 -0
- package/dist/cloud/api.js +62 -0
- package/dist/cloud/forward.js +109 -0
- package/dist/cloud/sync.js +173 -0
- package/dist/cloud/tunnel.js +222 -0
- package/dist/cloud-hint.js +3 -3
- package/dist/engine/apps.js +21 -4
- package/dist/engine/prompts.js +71 -0
- package/dist/engine/state.js +32 -0
- package/dist/engine/testcases.js +45 -0
- package/dist/engine/xcode.js +30 -11
- package/dist/engine/xcodes.js +65 -0
- package/dist/hosted.js +928 -0
- package/dist/look/read.js +7 -1
- package/dist/mcp.js +12 -1
- package/dist/registry.js +8 -1
- package/dist/tools/app-control.js +94 -9
- package/dist/tools/app-run.js +58 -12
- package/dist/tools/cloud.js +247 -0
- package/dist/tools/devices.js +6 -2
- package/dist/tools/launch-app.js +30 -2
- package/dist/tools/tests-run.js +38 -7
- package/dist/tools/ui-act.js +6 -2
- package/dist/tools.json +445 -0
- package/dist/tunnel-frames.js +49 -0
- package/dist/tunnel-relay.js +114 -0
- package/package.json +6 -2
- package/skill/SKILL.md +2 -2
- package/vendor/axe/simulator-use-engine +0 -0
package/README.md
CHANGED
|
@@ -63,17 +63,18 @@ Every step is checked, so nothing succeeds silently:
|
|
|
63
63
|
|
|
64
64
|
| Tool | CLI | Does |
|
|
65
65
|
|---|---|---|
|
|
66
|
-
| `app_run` | `simulator-use run` | Builds the Xcode project here, installs and launches it, answers with the first screen; errors as `file:line` |
|
|
67
|
-
| `devices` | `simulator-use devices` | Lists the Simulators on this Mac with their runtime, UDID and state |
|
|
66
|
+
| `app_run` | `simulator-use run` | Builds the Xcode project here, installs and launches it, answers with the first screen; errors as `file:line`; `--xcode 26.6` builds with another installed Xcode |
|
|
67
|
+
| `devices` | `simulator-use devices` | Lists the Simulators on this Mac with their runtime, UDID and state, and its Xcodes |
|
|
68
68
|
| `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
|
-
| `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` |
|
|
69
|
+
| `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
70
|
| `ui_look` | `simulator-use look` | Reads the screen; `find` keeps matching lines, `screenshot` adds a picture, `full` saves the whole tree |
|
|
71
71
|
| `ui_act` | `simulator-use act "tap #signIn" ...` | Does steps in order and answers with what changed |
|
|
72
72
|
| `app_logs` | `simulator-use logs` | The app's print output and its own system log lines; `grep`, `wait_for`, `crashes` |
|
|
73
|
-
| `tests_run` | `simulator-use test` | Runs the unit and UI tests; counts,
|
|
73
|
+
| `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 |
|
|
74
74
|
| `screen_record` | `simulator-use record start` | Starts or stops a video of the screen; stop answers with the MP4 |
|
|
75
75
|
| `job_status` | `simulator-use job` | Waits up to 40 s for a long build and answers with its result, or its progress |
|
|
76
76
|
| `job_cancel` | `simulator-use cancel` | Stops a job |
|
|
77
|
+
| `cloud` | `simulator-use cloud start` | `start`, `status`, `stop` a NoMac cloud phone that every tool then runs on; `run` builds, tests and stops in one call (see Cloud) |
|
|
77
78
|
|
|
78
79
|
The MCP server and the CLI are built from one list, so they always match. The tool list and instructions stay under 4,000 tokens; a test enforces it.
|
|
79
80
|
|
|
@@ -81,6 +82,8 @@ The MCP server and the CLI are built from one list, so they always match. The to
|
|
|
81
82
|
|
|
82
83
|
`app_run` does the whole loop in one call: it finds the project (a workspace first, then a project, an XcodeGen spec, or `ios/`), picks the scheme, builds for the Simulator, installs, launches, and answers with the first screen.
|
|
83
84
|
|
|
85
|
+
The app's permissions are granted when it is installed and its first launch's prompts (notifications too) are answered, so no prompt stops the agent; `permissions: "ask"` keeps iOS's prompts, to test them. `open_url` answers iOS's "Open in …?" for a link that opens another app.
|
|
86
|
+
|
|
84
87
|
```
|
|
85
88
|
Built Demo in 4.2 s, installed dev.simtap.demo.
|
|
86
89
|
Launched Demo in 3.9 s.
|
|
@@ -93,6 +96,7 @@ textfield "Email" #email @201,194
|
|
|
93
96
|
- Each folder (each git worktree) builds into its own DerivedData, so rebuilds are incremental and worktrees never share products. `SIMULATOR_USE_CACHE_DIR` puts it on a disk you keep, so the next machine's build is incremental too.
|
|
94
97
|
- An XcodeGen project is regenerated from `project.yml` before each build (skipped when nothing changed), so new files are always in it.
|
|
95
98
|
- The next call in the same folder needs no arguments.
|
|
99
|
+
- `env: "API_URL=http://127.0.0.1:8080 MODE=test"` gives the app those variables (it reads them from `ProcessInfo`), as an Xcode scheme does; they stay for its later launches, and `env: ""` clears them.
|
|
96
100
|
- An MCP call answers within 40 s; a longer build answers with a job, and `job_status` waits for it. From the CLI, `simulator-use run` simply waits.
|
|
97
101
|
- Two builds of one project never run at once, also from two processes: the second says which process is building it.
|
|
98
102
|
- Expo, React Native, Flutter and Capacitor builds are not done yet; build them with their own tools, then `app_control install`.
|
|
@@ -175,9 +179,44 @@ Agents that work from a shell can use the CLI instead; `skill/SKILL.md` in the p
|
|
|
175
179
|
|
|
176
180
|
From a shell: `npx simulator-use look`.
|
|
177
181
|
|
|
182
|
+
## Cloud
|
|
183
|
+
|
|
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
|
+
|
|
186
|
+
```
|
|
187
|
+
simulator-use cloud start # signs in through a link the first time (or NOMAC_API_KEY), starts a phone
|
|
188
|
+
simulator-use run # every tool now runs there; run and test send the project first
|
|
189
|
+
simulator-use cloud stop # ends it; the tools run on this machine again
|
|
190
|
+
simulator-use cloud run . --tests # CI in one line: start, build, run, test, a screenshot and a recording, stop
|
|
191
|
+
```
|
|
192
|
+
|
|
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
|
+
|
|
195
|
+
`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
|
+
An app that talks to a backend on your machine reaches it through a tunnel (Node 22 or later):
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
simulator-use cloud tunnel 8080 # until Ctrl-C: the phone reaches localhost:8080 here, each request printed
|
|
201
|
+
```
|
|
202
|
+
|
|
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
|
+
|
|
205
|
+
On GitHub, from any runner, with the result posted on each pull request and the screenshot and recording kept as artifacts:
|
|
206
|
+
|
|
207
|
+
```yaml
|
|
208
|
+
permissions:
|
|
209
|
+
pull-requests: write
|
|
210
|
+
steps:
|
|
211
|
+
- uses: actions/checkout@v5
|
|
212
|
+
- uses: NoMac-app/simulator-use@v0.10
|
|
213
|
+
with:
|
|
214
|
+
api-key: ${{ secrets.NOMAC_API_KEY }}
|
|
215
|
+
```
|
|
216
|
+
|
|
178
217
|
## Privacy
|
|
179
218
|
|
|
180
|
-
simulator-use sends nothing
|
|
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.
|
|
181
220
|
|
|
182
221
|
## How it works
|
|
183
222
|
|
package/dist/act/resolve.js
CHANGED
|
@@ -2,8 +2,26 @@ import { line } from "../look/snapshot.js";
|
|
|
2
2
|
import { describeTarget } from "./steps.js";
|
|
3
3
|
/** Kinds a person taps, preferred over plain text with the same label. */
|
|
4
4
|
const TAPPABLE = new Set(["button", "back", "link", "cell", "tab", "switch", "checkbox", "radio", "textfield", "securefield", "searchfield", "textview", "popup", "menu", "segmented", "picker", "slider", "stepper"]);
|
|
5
|
+
/** Words that turn a label into the opposite of the target that follows them: "Don’t Allow" is not "Allow". */
|
|
6
|
+
const NEGATED = /(?:^|[^\p{L}\p{N}])(?:don['’]?t|do not|not|never|no)\s+$/iu;
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
8
|
+
* Whether a label holds the target as words of its own, not negated: "Allow" is in "Allow While Using App" and
|
|
9
|
+
* "Count: 2, Increment" holds "Increment", but "Don’t Allow" and "Disallow" do not hold "Allow" (an agent's
|
|
10
|
+
* "tap Allow" on a prompt whose buttons are "Don’t Allow" and "Continue" tapped Don’t Allow, October 23).
|
|
11
|
+
*/
|
|
12
|
+
function holds(label, want) {
|
|
13
|
+
const word = /^[\p{L}\p{N}]/u.test(want);
|
|
14
|
+
for (let at = label.indexOf(want); at >= 0; at = label.indexOf(want, at + 1)) {
|
|
15
|
+
const before = label.slice(0, at);
|
|
16
|
+
if ((word && /[\p{L}\p{N}]$/u.test(before)) || NEGATED.test(before))
|
|
17
|
+
continue;
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Finds a target on the screen: by #id exactly; by label exactly (any case), then by a unique partial match (the
|
|
24
|
+
* target as words of the label, not negated);
|
|
7
25
|
* or a point as given. Ties go to tappable elements; a tie that remains is an error listing the candidates.
|
|
8
26
|
*/
|
|
9
27
|
export function resolve(snap, target) {
|
|
@@ -19,7 +37,7 @@ export function resolve(snap, target) {
|
|
|
19
37
|
const ofKind = target.kind ? all.filter((e) => e.kind === target.kind) : all;
|
|
20
38
|
found = ofKind.filter((e) => e.label?.toLowerCase() === want);
|
|
21
39
|
if (!found.length)
|
|
22
|
-
found = ofKind.filter((e) => e.label
|
|
40
|
+
found = ofKind.filter((e) => (e.label !== undefined && holds(e.label.toLowerCase(), want)) || e.value?.toLowerCase() === want);
|
|
23
41
|
}
|
|
24
42
|
if (found.length > 1) {
|
|
25
43
|
const tappable = found.filter((e) => TAPPABLE.has(e.kind));
|
|
@@ -47,3 +65,12 @@ export function resolve(snap, target) {
|
|
|
47
65
|
};
|
|
48
66
|
}
|
|
49
67
|
export const isResolved = (r) => !("error" in r);
|
|
68
|
+
const TEXT_FIELDS = new Set(["textfield", "securefield", "searchfield", "textview"]);
|
|
69
|
+
/**
|
|
70
|
+
* The text field under a point, the smallest when fields nest: what `fill @x,y` aims at, so its kind is known
|
|
71
|
+
* (a secure field reads back as dots) even when the step named a point, not the field.
|
|
72
|
+
*/
|
|
73
|
+
export function fieldAt(elements, point) {
|
|
74
|
+
const holding = elements.filter((e) => TEXT_FIELDS.has(e.kind) && point.x >= e.frame.x && point.x <= e.frame.x + e.frame.width && point.y >= e.frame.y && point.y <= e.frame.y + e.frame.height);
|
|
75
|
+
return holding.sort((a, b) => a.frame.width * a.frame.height - b.frame.width * b.frame.height)[0];
|
|
76
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { join } from "node:path";
|
|
|
8
8
|
import { persistHeld } from "./engine/state.js";
|
|
9
9
|
import { serve } from "./mcp.js";
|
|
10
10
|
import { callTool, toContent, TOOLS, VERSION } from "./registry.js";
|
|
11
|
+
import { relay, relayPorts } from "./tunnel-relay.js";
|
|
11
12
|
const flag = (name) => `--${name.replace(/_/g, "-")}`;
|
|
12
13
|
/** Parses `--name value` and `--switch` into arguments the tool's schema accepts. */
|
|
13
14
|
export function parseArgs(tool, argv) {
|
|
@@ -138,6 +139,17 @@ export async function main(argv) {
|
|
|
138
139
|
console.log(VERSION);
|
|
139
140
|
return 0;
|
|
140
141
|
}
|
|
142
|
+
// The host's tunnel relay for a NoMac phone (not for people): its stdout is the tunnel, so nothing else is printed.
|
|
143
|
+
if (command === "tunnel-relay") {
|
|
144
|
+
try {
|
|
145
|
+
await relay(process.stdin, process.stdout, relayPorts(process.env.SIMULATOR_USE_PORTS));
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
console.error(`tunnel-relay: ${error.message}`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
141
153
|
if (command === "mcp") {
|
|
142
154
|
await serve();
|
|
143
155
|
// The client closed stdin: answer what was in flight, then go, even if a child process still holds a pipe.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud mode's account and phone: the NoMac account it signs in to (NOMAC_API_KEY, else the NoMac CLI's
|
|
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.
|
|
5
|
+
*/
|
|
6
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir, hostname } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { stateDir } from "../engine/state.js";
|
|
10
|
+
import { CloudApi } from "./api.js";
|
|
11
|
+
export const DEFAULT_API = "https://nomac.app";
|
|
12
|
+
const credentialsFile = () => join(homedir(), ".nomac", "credentials.json");
|
|
13
|
+
async function readCredentials() {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(await readFile(credentialsFile(), "utf8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The signed-in account's API, or nothing when there is no key yet. */
|
|
22
|
+
export async function account() {
|
|
23
|
+
const stored = await readCredentials();
|
|
24
|
+
const key = process.env.NOMAC_API_KEY || stored.api_key;
|
|
25
|
+
if (!key)
|
|
26
|
+
return undefined;
|
|
27
|
+
return new CloudApi(process.env.NOMAC_API_URL || stored.api_url || DEFAULT_API, key);
|
|
28
|
+
}
|
|
29
|
+
/** The key from a device sign-in, kept for simulator-use and the NoMac CLI alike (mode 0600), other fields kept. */
|
|
30
|
+
async function saveKey(key, base) {
|
|
31
|
+
const stored = await readCredentials();
|
|
32
|
+
await mkdir(join(homedir(), ".nomac"), { recursive: true, mode: 0o700 });
|
|
33
|
+
await writeFile(credentialsFile(), `${JSON.stringify({ ...stored, api_key: key, ...(base !== DEFAULT_API ? { api_url: base } : {}) }, null, 2)}\n`, { mode: 0o600 });
|
|
34
|
+
await chmod(credentialsFile(), 0o600);
|
|
35
|
+
}
|
|
36
|
+
const stateFile = () => join(stateDir(), "cloud.json");
|
|
37
|
+
export async function cloudState() {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(await readFile(stateFile(), "utf8"));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { on: false };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function setCloudState(state) {
|
|
46
|
+
await mkdir(stateDir(), { recursive: true });
|
|
47
|
+
await writeFile(stateFile(), JSON.stringify(state), { mode: 0o600 });
|
|
48
|
+
}
|
|
49
|
+
// ---- Device sign-in ---------------------------------------------------------------------------------------
|
|
50
|
+
/** How long one call waits for the person to approve before answering with the link again. */
|
|
51
|
+
const SIGN_IN_WAIT_MS = 25_000;
|
|
52
|
+
/**
|
|
53
|
+
* Signs in through NoMac's device page: the first call answers the link to open (the agent shows it to its
|
|
54
|
+
* person); later calls wait up to 25 s for the approval, then save the key. Answers the account once signed in.
|
|
55
|
+
*/
|
|
56
|
+
export async function signIn() {
|
|
57
|
+
const ready = await account();
|
|
58
|
+
if (ready)
|
|
59
|
+
return { api: ready };
|
|
60
|
+
const state = await cloudState();
|
|
61
|
+
const base = (process.env.NOMAC_API_URL || DEFAULT_API).replace(/\/+$/, "");
|
|
62
|
+
let pending = state.signIn && state.signIn.expires_at > Date.now() && state.signIn.base === base ? state.signIn : undefined;
|
|
63
|
+
if (!pending) {
|
|
64
|
+
const started = await post(base, "/api/v1/device/start", { client_name: `simulator-use on ${hostname()}`.slice(0, 100) });
|
|
65
|
+
pending = {
|
|
66
|
+
device_code: String(started.device_code),
|
|
67
|
+
user_code: String(started.user_code),
|
|
68
|
+
url: withSource(String(started.verification_uri_complete ?? started.verification_uri)),
|
|
69
|
+
expires_at: Date.now() + Number(started.expires_in ?? 900) * 1000,
|
|
70
|
+
base,
|
|
71
|
+
};
|
|
72
|
+
await setCloudState({ ...state, signIn: pending });
|
|
73
|
+
return { link: pending.url, code: pending.user_code };
|
|
74
|
+
}
|
|
75
|
+
for (const deadline = Date.now() + SIGN_IN_WAIT_MS; Date.now() < deadline;) {
|
|
76
|
+
const answer = await post(base, "/api/v1/device/token", { device_code: pending.device_code });
|
|
77
|
+
if (answer.status === "approved" && typeof answer.api_key === "string") {
|
|
78
|
+
await saveKey(answer.api_key, base);
|
|
79
|
+
await setCloudState({ ...(await cloudState()), signIn: undefined });
|
|
80
|
+
return { api: new CloudApi(base, answer.api_key) };
|
|
81
|
+
}
|
|
82
|
+
if (answer.status === "denied" || answer.status === "expired" || answer.status === "claimed") {
|
|
83
|
+
await setCloudState({ ...(await cloudState()), signIn: undefined });
|
|
84
|
+
throw new Error(answer.status === "denied" ? "The sign-in was denied on NoMac's page." : "The sign-in link expired. Call cloud start again for a new one.");
|
|
85
|
+
}
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, answer.status === "slow_down" ? 10_000 : 5_000));
|
|
87
|
+
}
|
|
88
|
+
return { link: pending.url, code: pending.user_code };
|
|
89
|
+
}
|
|
90
|
+
/** The sign-in link with simulator-use as its source, so a signup through the tool says where it came from. */
|
|
91
|
+
export function withSource(link) {
|
|
92
|
+
try {
|
|
93
|
+
const url = new URL(link);
|
|
94
|
+
url.searchParams.set("utm_source", "simulator-use");
|
|
95
|
+
return url.toString();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return link;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function post(base, path, body) {
|
|
102
|
+
const response = await fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
|
|
103
|
+
const payload = (await response.json().catch(() => ({})));
|
|
104
|
+
// 429 is the token route's slow_down: a pending sign-in, polled less often.
|
|
105
|
+
if (!response.ok && response.status !== 429) {
|
|
106
|
+
const error = (payload.error ?? {});
|
|
107
|
+
throw new Error(`NoMac's sign-in answered ${response.status}${error.message ? `: ${error.message}` : ""}.`);
|
|
108
|
+
}
|
|
109
|
+
return payload;
|
|
110
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NoMac's REST API as cloud mode calls it: the account's key on every request, JSON in and out, a retry for what
|
|
3
|
+
* passes (a lost connection, NoMac or the phone busy for a moment), and NoMac's own words when it refuses.
|
|
4
|
+
*/
|
|
5
|
+
import { VERSION } from "../registry.js";
|
|
6
|
+
export class CloudError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
status;
|
|
9
|
+
constructor(code, message, status) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const PASSING = new Set([429, 502, 503, 504]);
|
|
16
|
+
export class CloudApi {
|
|
17
|
+
base;
|
|
18
|
+
key;
|
|
19
|
+
constructor(base, key) {
|
|
20
|
+
this.base = base.replace(/\/+$/, "");
|
|
21
|
+
this.key = key;
|
|
22
|
+
}
|
|
23
|
+
/** One request: an object goes as JSON, bytes as a chunk. The idempotency key makes a retry the same request. */
|
|
24
|
+
async json(method, path, body, idempotencyKey) {
|
|
25
|
+
const headers = { authorization: `Bearer ${this.key}`, "user-agent": `simulator-use/${VERSION}` };
|
|
26
|
+
if (idempotencyKey)
|
|
27
|
+
headers["idempotency-key"] = idempotencyKey;
|
|
28
|
+
const raw = body instanceof Uint8Array;
|
|
29
|
+
if (body !== undefined)
|
|
30
|
+
headers["content-type"] = raw ? "application/octet-stream" : "application/json";
|
|
31
|
+
for (let attempt = 1;; attempt++) {
|
|
32
|
+
let response;
|
|
33
|
+
try {
|
|
34
|
+
response = await fetch(`${this.base}${path}`, { method, headers, body: body === undefined ? undefined : raw ? body : JSON.stringify(body), signal: AbortSignal.timeout(90_000) });
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (attempt < 4) {
|
|
38
|
+
await pause(attempt);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
throw new CloudError("unreachable", `NoMac did not answer (${error.message}).`, 0);
|
|
42
|
+
}
|
|
43
|
+
const text = await response.text();
|
|
44
|
+
let payload = {};
|
|
45
|
+
try {
|
|
46
|
+
payload = text ? JSON.parse(text) : {};
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
payload = {};
|
|
50
|
+
}
|
|
51
|
+
if (response.ok)
|
|
52
|
+
return payload;
|
|
53
|
+
const error = (payload.error ?? {});
|
|
54
|
+
if (PASSING.has(response.status) && attempt < 4) {
|
|
55
|
+
await pause(attempt, Number(response.headers.get("retry-after")));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
throw new CloudError(error.code ?? `http_${response.status}`, error.message ?? `NoMac answered ${response.status}.`, response.status);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const pause = (attempt, retryAfter) => new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 30) * 1000 : attempt * 2000));
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* With cloud on, every tool runs on the NoMac phone instead of this machine: called by name through NoMac's API
|
|
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.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
7
|
+
import { basename, dirname, extname, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { run } from "../engine/exec.js";
|
|
10
|
+
import { ToolError } from "../errors.js";
|
|
11
|
+
import { account, cloudState, setCloudState } from "./account.js";
|
|
12
|
+
import { CloudError } from "./api.js";
|
|
13
|
+
import { randomKey, syncFolder } from "./sync.js";
|
|
14
|
+
const JOB = /Next: job_status \{job_id: "([^"]+)"\}/;
|
|
15
|
+
/** The phone cloud mode uses, or why there is none. */
|
|
16
|
+
export async function cloudPhone() {
|
|
17
|
+
const state = await cloudState();
|
|
18
|
+
const api = await account();
|
|
19
|
+
if (!api)
|
|
20
|
+
throw new ToolError("Cloud is on but this machine is not signed in to NoMac.", "Call cloud start to sign in, or cloud stop to run tools on this machine.");
|
|
21
|
+
if (!state.session)
|
|
22
|
+
throw new ToolError("Cloud is on but has no phone.", "Call cloud start for one, or cloud stop to run tools on this machine.");
|
|
23
|
+
return { api, session: state.session };
|
|
24
|
+
}
|
|
25
|
+
/** A phone that ended (its limit, an idle stop, stopped elsewhere): cloud mode forgets it and says so. */
|
|
26
|
+
async function ended(session) {
|
|
27
|
+
const state = await cloudState();
|
|
28
|
+
if (state.session === session)
|
|
29
|
+
await setCloudState({ ...state, session: undefined });
|
|
30
|
+
return { text: `The cloud phone ${session} has ended (its time limit, an idle stop, or stopped elsewhere).\nNext: cloud start for a new one, or cloud stop to run tools on this machine again.`, isError: true };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Where a project is sent from: the repository it is in (so local packages beside it come along), else its own
|
|
34
|
+
* folder; and the path inside that the phone's tool takes.
|
|
35
|
+
*/
|
|
36
|
+
async function projectRoot(path) {
|
|
37
|
+
const local = resolve(path);
|
|
38
|
+
if (!existsSync(local))
|
|
39
|
+
throw new ToolError(`No project at ${local}.`, "Pass path: the folder with the .xcodeproj, .xcworkspace or project.yml.");
|
|
40
|
+
const isProject = [".xcodeproj", ".xcworkspace"].includes(extname(local));
|
|
41
|
+
const dir = isProject || !statSync(local).isDirectory() ? dirname(local) : local;
|
|
42
|
+
let folder = realpathSync(dir);
|
|
43
|
+
try {
|
|
44
|
+
folder = realpathSync((await run("git", ["-C", dir, "rev-parse", "--show-toplevel"], { timeoutMs: 5_000 })).stdout.trim());
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Not in a repository: the folder itself.
|
|
48
|
+
}
|
|
49
|
+
return { folder, inside: relative(folder, realpathSync(local)).split(sep).join("/") };
|
|
50
|
+
}
|
|
51
|
+
/** Runs one tool on the phone and answers as the local tool would. */
|
|
52
|
+
export async function forward(name, args, context) {
|
|
53
|
+
const { api, session } = await cloudPhone();
|
|
54
|
+
let sent = args;
|
|
55
|
+
let note = "";
|
|
56
|
+
try {
|
|
57
|
+
if (name === "app_run" || name === "tests_run") {
|
|
58
|
+
const { folder, inside } = await projectRoot(typeof args.path === "string" ? args.path : ".");
|
|
59
|
+
const synced = await syncFolder(api, session, folder);
|
|
60
|
+
sent = { ...args, path: inside ? `${synced.root}/${inside}` : synced.root };
|
|
61
|
+
note = synced.sent ? `Sent ${synced.sent} of ${synced.files} files (${Math.max(1, Math.round(synced.bytes / 1024))} KB) in ${(synced.ms / 1000).toFixed(1)} s.\n` : `Sent nothing: the phone has all ${synced.files} files.\n`;
|
|
62
|
+
}
|
|
63
|
+
else if (name === "app_control" && args.action === "install" && typeof args.app === "string") {
|
|
64
|
+
const app = resolve(args.app);
|
|
65
|
+
if (extname(app) !== ".app" || !existsSync(app) || !statSync(app).isDirectory())
|
|
66
|
+
throw new ToolError(`${args.app} is not a .app built for the Simulator.`, "Build with xcodebuild -sdk iphonesimulator; the .app is under Build/Products/Debug-iphonesimulator.");
|
|
67
|
+
// Its own project on the phone, so this upload never touches the files of the project it was built from.
|
|
68
|
+
const project = `prj_${createHash("sha256").update(`app:${realpathSync(app)}`).digest("hex").slice(0, 32)}`;
|
|
69
|
+
const synced = await syncFolder(api, session, dirname(app), { only: basename(app), project });
|
|
70
|
+
sent = { ...args, app: `${synced.root}/${basename(app)}` };
|
|
71
|
+
}
|
|
72
|
+
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;
|
|
78
|
+
result = await call(api, session, "job_status", { job_id: job });
|
|
79
|
+
}
|
|
80
|
+
return note ? { ...result, text: `${note}${result.text}` } : result;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error instanceof CloudError && (error.code === "session_not_found" || error.code === "session_not_ready" || error.code === "session_expired"))
|
|
84
|
+
return ended(session);
|
|
85
|
+
if (error instanceof CloudError)
|
|
86
|
+
return { text: `NoMac: ${error.message}`, isError: true };
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function call(api, session, name, args) {
|
|
91
|
+
let answer = await api.json("POST", `/api/v1/simulator/sessions/${session}/tools/${name}`, args, `cli-${name}-${randomKey()}`);
|
|
92
|
+
while (answer.state === "running" && typeof answer.id === "string") {
|
|
93
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
94
|
+
answer = await api.json("GET", `/api/v1/simulator/sessions/${session}/calls/${answer.id}`);
|
|
95
|
+
}
|
|
96
|
+
if (answer.state === "cancelled")
|
|
97
|
+
return { text: `${name} was cancelled.`, isError: true };
|
|
98
|
+
const result = answer.result;
|
|
99
|
+
if (!result)
|
|
100
|
+
return { text: `${name} failed on the phone: ${String(answer.error ?? answer.state)}.`, isError: true };
|
|
101
|
+
const text = (result.content ?? []).filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n");
|
|
102
|
+
const picture = (result.content ?? []).find((c) => c.type === "image" && c.data);
|
|
103
|
+
const notices = answer.notices?.includes("phone_restarted") ? "\nThe phone restarted; its apps and data are kept." : "";
|
|
104
|
+
return {
|
|
105
|
+
text: `${text}${notices}`,
|
|
106
|
+
...(picture ? { image: { data: Buffer.from(picture.data, "base64"), mimeType: picture.mimeType === "image/png" ? "image/png" : "image/jpeg" } } : {}),
|
|
107
|
+
...(result.isError ? { isError: true } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NoMac Simulator's sync, from cloud mode: a project folder sent to the phone's workspace with only what the
|
|
3
|
+
* workspace lacks. The manifest (each file's path, size, mtime and SHA-256) goes in pages; the host answers the
|
|
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
|
+
* 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.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
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";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join, relative, sep } from "node:path";
|
|
14
|
+
import { run } from "../engine/exec.js";
|
|
15
|
+
/** Folders a build makes or a package manager fills: never sent, never removed on the host. */
|
|
16
|
+
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");
|
|
18
|
+
// The protocol's limits (NoMac's mac-protocol, simManifestEntrySchema and the sim route's body).
|
|
19
|
+
const PAGE_ENTRIES = 1000;
|
|
20
|
+
const PAGE_BYTES = 192 * 1024;
|
|
21
|
+
const CHUNK = 256 * 1024;
|
|
22
|
+
const INLINE_MAX = 8 * 1024 * 1024;
|
|
23
|
+
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) => {
|
|
28
|
+
for (const name of readdirSync(dir).sort()) {
|
|
29
|
+
if (SKIP_FILES(name))
|
|
30
|
+
continue;
|
|
31
|
+
const path = join(dir, name);
|
|
32
|
+
const st = lstatSync(path);
|
|
33
|
+
if (st.isDirectory()) {
|
|
34
|
+
if (!SKIP_DIRS.has(name))
|
|
35
|
+
walk(path);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
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
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
walk(folder);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
52
|
+
/** Pages of at most 1000 entries and about 192 KiB each, in the manifest's order. */
|
|
53
|
+
export function pages(entries) {
|
|
54
|
+
const out = [[]];
|
|
55
|
+
let bytes = 0;
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const size = JSON.stringify(entry).length + 1;
|
|
58
|
+
if (out.at(-1).length >= PAGE_ENTRIES || (bytes + size > PAGE_BYTES && out.at(-1).length)) {
|
|
59
|
+
out.push([]);
|
|
60
|
+
bytes = 0;
|
|
61
|
+
}
|
|
62
|
+
out.at(-1).push(entry);
|
|
63
|
+
bytes += size;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The project's id on NoMac: the hash of its git remote (and its folder inside the repository), or of the folder's
|
|
69
|
+
* path where it has none, so the same project lands in the same place in the account's workspace from any clone.
|
|
70
|
+
*/
|
|
71
|
+
export async function projectId(folder) {
|
|
72
|
+
let key = realpathSync(folder);
|
|
73
|
+
try {
|
|
74
|
+
const remote = (await run("git", ["-C", folder, "config", "--get", "remote.origin.url"], { timeoutMs: 5_000 })).stdout.trim();
|
|
75
|
+
const top = (await run("git", ["-C", folder, "rev-parse", "--show-toplevel"], { timeoutMs: 5_000 })).stdout.trim();
|
|
76
|
+
if (remote && top)
|
|
77
|
+
key = `${normalizeRemote(remote)}#${relative(realpathSync(top), key).split(sep).join("/")}`;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Not a git repository, or no remote: the folder's own path.
|
|
81
|
+
}
|
|
82
|
+
return `prj_${sha256(key).slice(0, 32)}`;
|
|
83
|
+
}
|
|
84
|
+
/** git@github.com:a/b.git, https://user:token@github.com/a/b and https://github.com/a/b/ are one remote. */
|
|
85
|
+
export function normalizeRemote(remote) {
|
|
86
|
+
const scp = /^[\w.-]+@([^:/]+):(.+)$/.exec(remote);
|
|
87
|
+
const url = scp ? `ssh://${scp[1]}/${scp[2]}` : remote;
|
|
88
|
+
try {
|
|
89
|
+
const u = new URL(url);
|
|
90
|
+
return `${u.hostname.toLowerCase()}/${u.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "")}`;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return remote.replace(/\.git$/, "");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** A gzip tar of these paths, without macOS's metadata files, paths read NUL-separated from a list. */
|
|
97
|
+
async function pack(folder, paths, file) {
|
|
98
|
+
const list = `${file}.list`;
|
|
99
|
+
await writeFile(list, `${paths.join("\0")}\0`);
|
|
100
|
+
const args = [...(process.platform === "darwin" ? ["--no-mac-metadata"] : []), "--null", "-czf", file, "-C", folder, "-T", list];
|
|
101
|
+
await new Promise((resolve, reject) => {
|
|
102
|
+
const child = spawn("tar", args, { env: { ...process.env, COPYFILE_DISABLE: "1" }, stdio: ["ignore", "ignore", "pipe"] });
|
|
103
|
+
let stderr = "";
|
|
104
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
105
|
+
child.on("error", reject);
|
|
106
|
+
child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`tar: ${stderr.trim().slice(0, 300) || `exit ${code}`}`))));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Sends a folder to the phone's workspace. Answers where it is on the host: the path app_run and tests_run take. */
|
|
110
|
+
export async function syncFolder(api, session, folder, options = {}) {
|
|
111
|
+
const { only, onStep } = options;
|
|
112
|
+
const started = Date.now();
|
|
113
|
+
const entries = listProject(folder).filter((e) => !only || e.path === only || e.path.startsWith(`${only}/`));
|
|
114
|
+
if (!entries.length)
|
|
115
|
+
throw new Error(`${folder} has no files to send.`);
|
|
116
|
+
const project = options.project ?? (await projectId(folder));
|
|
117
|
+
const sync = `cli-${randomKey()}`;
|
|
118
|
+
const book = pages(entries);
|
|
119
|
+
if (book.length > 256)
|
|
120
|
+
throw new Error(`${folder} has ${entries.length} files: more than NoMac's sync takes (256 pages).`);
|
|
121
|
+
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
|
+
}
|
|
127
|
+
let archive;
|
|
128
|
+
let bytes = 0;
|
|
129
|
+
if (missing.length) {
|
|
130
|
+
onStep?.(`sending ${missing.length} file${missing.length > 1 ? "s" : ""}`);
|
|
131
|
+
const dir = await mkdtemp(join(tmpdir(), "simulator-use-sync-"));
|
|
132
|
+
try {
|
|
133
|
+
const file = join(dir, "archive.tgz");
|
|
134
|
+
await pack(folder, missing, file);
|
|
135
|
+
const data = await readFile(file);
|
|
136
|
+
bytes = data.length;
|
|
137
|
+
if (bytes > ARCHIVE_MAX)
|
|
138
|
+
throw new Error(`The changed files pack to ${Math.round(bytes / 1024 ** 2)} MB: more than the 2 GB a sync takes.`);
|
|
139
|
+
const sum = sha256(data);
|
|
140
|
+
if (bytes <= INLINE_MAX) {
|
|
141
|
+
for (let offset = 0; offset < bytes || offset === 0; offset += CHUNK) {
|
|
142
|
+
await api.json("PUT", `/api/v1/simulator/sessions/${session}/sync/blobs/${sum}?size=${bytes}&offset=${offset}`, data.subarray(offset, Math.min(offset + CHUNK, bytes)));
|
|
143
|
+
if (bytes === 0)
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
archive = { blob: sum };
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// Larger archives go straight to storage, and the host fetches them from there.
|
|
150
|
+
const link = await api.json("POST", `/api/v1/simulator/sessions/${session}/sync/upload`, { size: bytes, sha256: sum });
|
|
151
|
+
const put = await fetch(String(link.upload_url), { method: "PUT", body: data, headers: { "content-length": String(bytes) } });
|
|
152
|
+
if (!put.ok)
|
|
153
|
+
throw new Error(`The upload to NoMac's storage failed: ${put.status}.`);
|
|
154
|
+
archive = link.archive;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
await rm(dir, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
onStep?.("placing");
|
|
162
|
+
for (const deadline = Date.now() + 10 * 60_000;;) {
|
|
163
|
+
const placed = await api.json("POST", `/api/v1/simulator/sessions/${session}/sync`, { op: "place", sync, ...(archive ? { archive } : {}) });
|
|
164
|
+
if (placed.state === "done")
|
|
165
|
+
return { root: String(placed.root), files: entries.length, sent: missing.length, bytes, ms: Date.now() - started };
|
|
166
|
+
if (placed.state !== "running")
|
|
167
|
+
throw new Error(`The sync failed on the phone: ${String(placed.error ?? placed.state)}.`);
|
|
168
|
+
if (Date.now() > deadline)
|
|
169
|
+
throw new Error("The sync was still being written after 10 minutes.");
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export const randomKey = () => randomBytes(16).toString("hex");
|