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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
3
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
4
|
+
import { mkdtemp, rename, rm, stat } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Readable, Transform } from "node:stream";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { createGunzip, createGzip } from "node:zlib";
|
|
9
|
+
/**
|
|
10
|
+
* A saved phone state as NoMac keeps it in its storage (October 25, 2026), so any phone of the same model and iOS
|
|
11
|
+
* restores it, on any host: its folder as a tar, gzipped, in AES-256-GCM frames of up to 4 MiB under the state's own
|
|
12
|
+
* key (NoMac's, given for one transfer). Each frame is authenticated with its index and whether it is the last, so an
|
|
13
|
+
* archive cut short, reordered or changed is refused before anything of it is kept.
|
|
14
|
+
*/
|
|
15
|
+
const MAGIC = Buffer.from("NMS1");
|
|
16
|
+
const FRAME = 4 * 1024 * 1024;
|
|
17
|
+
const NONCE = 12;
|
|
18
|
+
const TAG = 16;
|
|
19
|
+
/** The longest a frame may claim: a corrupt length never makes the reader hold gigabytes. */
|
|
20
|
+
const MOST = FRAME + 64 * 1024;
|
|
21
|
+
const aad = (index, last) => {
|
|
22
|
+
const b = Buffer.alloc(MAGIC.length + 9);
|
|
23
|
+
MAGIC.copy(b);
|
|
24
|
+
b.writeBigUInt64BE(BigInt(index), MAGIC.length);
|
|
25
|
+
b[MAGIC.length + 8] = last ? 1 : 0;
|
|
26
|
+
return b;
|
|
27
|
+
};
|
|
28
|
+
export function stateKey(key) {
|
|
29
|
+
const raw = Buffer.from(key, "base64url");
|
|
30
|
+
if (raw.length !== 32 || raw.toString("base64url") !== key)
|
|
31
|
+
throw new Error("invalid_state_key");
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
34
|
+
/** Plain bytes in, sealed frames out. The last frame is sealed as last at the end, empty if need be. */
|
|
35
|
+
export function sealer(key) {
|
|
36
|
+
let pending = Buffer.alloc(0), index = 0, started = false;
|
|
37
|
+
const frame = (t, plain, last) => {
|
|
38
|
+
const nonce = randomBytes(NONCE);
|
|
39
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
40
|
+
cipher.setAAD(aad(index++, last));
|
|
41
|
+
const body = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
42
|
+
const length = Buffer.alloc(4);
|
|
43
|
+
length.writeUInt32BE(plain.length);
|
|
44
|
+
t.push(Buffer.concat([length, nonce, body, cipher.getAuthTag()]));
|
|
45
|
+
};
|
|
46
|
+
return new Transform({
|
|
47
|
+
transform(chunk, _enc, done) {
|
|
48
|
+
if (!started) {
|
|
49
|
+
this.push(MAGIC);
|
|
50
|
+
started = true;
|
|
51
|
+
}
|
|
52
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
53
|
+
// A full frame goes out only once more data follows it, so the one at the end is the one sealed as last.
|
|
54
|
+
while (pending.length > FRAME) {
|
|
55
|
+
frame(this, pending.subarray(0, FRAME), false);
|
|
56
|
+
pending = pending.subarray(FRAME);
|
|
57
|
+
}
|
|
58
|
+
done();
|
|
59
|
+
},
|
|
60
|
+
flush(done) {
|
|
61
|
+
if (!started)
|
|
62
|
+
this.push(MAGIC);
|
|
63
|
+
frame(this, pending, true);
|
|
64
|
+
done();
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Sealed frames in, plain bytes out: refused at the first frame that does not open, or an end with no last frame. */
|
|
69
|
+
export function opener(key) {
|
|
70
|
+
let buffered = Buffer.alloc(0), index = 0, header = false, ended = false;
|
|
71
|
+
return new Transform({
|
|
72
|
+
transform(chunk, _enc, done) {
|
|
73
|
+
buffered = buffered.length ? Buffer.concat([buffered, chunk]) : chunk;
|
|
74
|
+
try {
|
|
75
|
+
if (!header) {
|
|
76
|
+
if (buffered.length < MAGIC.length)
|
|
77
|
+
return done();
|
|
78
|
+
if (!buffered.subarray(0, MAGIC.length).equals(MAGIC))
|
|
79
|
+
throw new Error("not a NoMac phone state");
|
|
80
|
+
buffered = buffered.subarray(MAGIC.length);
|
|
81
|
+
header = true;
|
|
82
|
+
}
|
|
83
|
+
for (;;) {
|
|
84
|
+
if (buffered.length < 4)
|
|
85
|
+
break;
|
|
86
|
+
const length = buffered.readUInt32BE(0);
|
|
87
|
+
if (length > MOST)
|
|
88
|
+
throw new Error("a frame claims too much");
|
|
89
|
+
const whole = 4 + NONCE + length + TAG;
|
|
90
|
+
if (buffered.length < whole)
|
|
91
|
+
break;
|
|
92
|
+
if (ended)
|
|
93
|
+
throw new Error("data after the last frame");
|
|
94
|
+
const nonce = buffered.subarray(4, 4 + NONCE), body = buffered.subarray(4 + NONCE, 4 + NONCE + length), tag = buffered.subarray(4 + NONCE + length, whole);
|
|
95
|
+
// A frame opens as the last or as not: which, its sealing says.
|
|
96
|
+
let plain;
|
|
97
|
+
for (const last of [false, true]) {
|
|
98
|
+
try {
|
|
99
|
+
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
|
100
|
+
decipher.setAAD(aad(index, last));
|
|
101
|
+
decipher.setAuthTag(tag);
|
|
102
|
+
plain = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
103
|
+
ended = last;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
catch { /* the other way, or neither */ }
|
|
107
|
+
}
|
|
108
|
+
if (!plain)
|
|
109
|
+
throw new Error("the state does not open with its key (changed, or another state's)");
|
|
110
|
+
index++;
|
|
111
|
+
buffered = buffered.subarray(whole);
|
|
112
|
+
if (plain.length)
|
|
113
|
+
this.push(plain);
|
|
114
|
+
}
|
|
115
|
+
done();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
done(error);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
flush(done) {
|
|
122
|
+
done(!header || !ended || buffered.length ? new Error("the state is cut short") : undefined);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** tar as a stream, its errors and exit status kept: a tar that fails mid-way fails the pipeline. */
|
|
127
|
+
function tar(args, cwd) {
|
|
128
|
+
const child = spawn("/usr/bin/tar", args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
129
|
+
let stderr = "";
|
|
130
|
+
child.stderr.on("data", (d) => { stderr = (stderr + d).slice(-2000); });
|
|
131
|
+
const exited = new Promise((resolve, reject) => {
|
|
132
|
+
child.on("error", reject);
|
|
133
|
+
child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`tar exited ${code}: ${stderr.trim().split("\n").pop() ?? ""}`))));
|
|
134
|
+
});
|
|
135
|
+
return { child, exited };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The state named name, in statesDir, sealed under key and PUT to url (a link NoMac signed). It is sealed into a file
|
|
139
|
+
* in tmp first, since the storage takes a PUT of a known length. Answers the bytes sent.
|
|
140
|
+
*/
|
|
141
|
+
export async function uploadState(statesDir, name, url, key, tmp) {
|
|
142
|
+
const dir = await mkdtemp(join(tmp, "state-")), sealed = join(dir, `${name}.nms`);
|
|
143
|
+
try {
|
|
144
|
+
const packing = tar(["-cf", "-", name], statesDir);
|
|
145
|
+
packing.child.stdin.end();
|
|
146
|
+
try {
|
|
147
|
+
await pipeline(packing.child.stdout, createGzip({ level: 1 }), sealer(stateKey(key)), createWriteStream(sealed, { mode: 0o600 }));
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
packing.child.kill();
|
|
151
|
+
await packing.exited.catch(() => { });
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
await packing.exited;
|
|
155
|
+
const { size } = await stat(sealed);
|
|
156
|
+
const body = Readable.toWeb(createReadStream(sealed));
|
|
157
|
+
const response = await fetch(url, { method: "PUT", body, headers: { "content-length": String(size) }, duplex: "half" });
|
|
158
|
+
await response.body?.cancel();
|
|
159
|
+
if (!response.ok)
|
|
160
|
+
throw new Error(`the state's upload failed: storage answered ${response.status}`);
|
|
161
|
+
return size;
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The state at url (sealed under key) unpacked into statesDir as name, replacing a copy there only once all of it
|
|
169
|
+
* opened: it is unpacked beside it first, then moved into place.
|
|
170
|
+
*/
|
|
171
|
+
export async function fetchState(statesDir, name, url, key) {
|
|
172
|
+
const response = await fetch(url);
|
|
173
|
+
if (!response.ok || !response.body)
|
|
174
|
+
throw new Error(`the state's download failed: storage answered ${response.status}`);
|
|
175
|
+
const into = await mkdtemp(join(statesDir, `.${name}.fetching-`));
|
|
176
|
+
try {
|
|
177
|
+
// bsdtar keeps entries inside into: no absolute paths, no .., no writes through a symlink.
|
|
178
|
+
const unpacking = tar(["-xf", "-"], into);
|
|
179
|
+
try {
|
|
180
|
+
await pipeline(Readable.fromWeb(response.body), opener(stateKey(key)), createGunzip(), unpacking.child.stdin);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
// Stopped and gone before its folder is removed, so nothing is written into it after.
|
|
184
|
+
unpacking.child.kill();
|
|
185
|
+
await unpacking.exited.catch(() => { });
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
await unpacking.exited;
|
|
189
|
+
await stat(join(into, name, "state.json"));
|
|
190
|
+
await rm(join(statesDir, name), { recursive: true, force: true });
|
|
191
|
+
await rename(join(into, name), join(statesDir, name));
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
await rm(into, { recursive: true, force: true }).catch(() => { });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -2,12 +2,26 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { resolve as resolvePath } from "node:path";
|
|
3
3
|
import { appInfo, BUNDLE_ID, bundleIdOf, install, pushPayload, simctlAction, stopLogStream } from "../engine/apps.js";
|
|
4
4
|
import { listDevices, pickDevice } from "../engine/simctl.js";
|
|
5
|
+
import { engineCall } from "../engine/native.js";
|
|
5
6
|
import { currentApp, heldDevice, setCurrentApp, setDeviceTimeZone } from "../engine/state.js";
|
|
6
7
|
import { ToolError } from "../errors.js";
|
|
8
|
+
import { setting } from "../settings.js";
|
|
7
9
|
import { readScreen } from "../look/read.js";
|
|
8
10
|
import { render } from "../look/snapshot.js";
|
|
9
11
|
import { grantAll, openInPrompt, setPermissionMode } from "../engine/prompts.js";
|
|
10
12
|
import { launchAndLook, tapPrompt } from "./launch-app.js";
|
|
13
|
+
/**
|
|
14
|
+
* A link opens through the engine, which reads it from its input: simctl openurl takes it as an argument, which every
|
|
15
|
+
* user of a Mac can read, so a shared host (SIMULATOR_USE_HOSTED) never falls back to it.
|
|
16
|
+
*/
|
|
17
|
+
export async function openUrl(udid, url) {
|
|
18
|
+
const answer = await engineCall(udid, { op: "open_url", url }, 20_000);
|
|
19
|
+
if (answer?.ok)
|
|
20
|
+
return;
|
|
21
|
+
if (setting("HOSTED"))
|
|
22
|
+
throw new ToolError(`Could not open the link${answer?.error ? `: ${answer.error}` : ""}.`, "Check it is a full link with its scheme (https://, or the app's own), then try again.");
|
|
23
|
+
await simctlAction(["openurl", udid, url], "open the link");
|
|
24
|
+
}
|
|
11
25
|
const ACTIONS = ["launch", "install", "terminate", "uninstall", "open_url", "grant", "revoke", "reset_permissions", "location", "push", "appearance", "locale", "timezone", "text_size", "status_bar"];
|
|
12
26
|
/** simctl ui content_size's sizes. */
|
|
13
27
|
const TEXT_SIZES = ["extra-small", "small", "medium", "large", "extra-large", "extra-extra-large", "extra-extra-extra-large",
|
|
@@ -99,7 +113,7 @@ export const appControl = {
|
|
|
99
113
|
if (!path || !existsSync(path))
|
|
100
114
|
throw new ToolError("install needs app: the path to a .app built for the Simulator.", "Build with xcodebuild -sdk iphonesimulator; the .app is under Build/Products/Debug-iphonesimulator.");
|
|
101
115
|
const bundleId = await bundleIdOf(path);
|
|
102
|
-
await install(device.udid, path);
|
|
116
|
+
await install(device.udid, path, device.runtime);
|
|
103
117
|
const info = await appInfo(device.udid, bundleId);
|
|
104
118
|
await setCurrentApp(device.udid, { bundleId, executable: info?.executable });
|
|
105
119
|
const refused = await grantAll(device.udid, bundleId);
|
|
@@ -128,13 +142,13 @@ export const appControl = {
|
|
|
128
142
|
await simctlAction(["uninstall", device.udid, bundleId], `remove ${bundleId}`);
|
|
129
143
|
return { text: `Removed ${bundleId} and its data from ${label}.` };
|
|
130
144
|
case "open_url": {
|
|
131
|
-
await
|
|
145
|
+
await openUrl(device.udid, value);
|
|
132
146
|
let snap = (await readScreen(device.udid)).snap;
|
|
133
147
|
// A link that opens another app makes iOS ask "Open in “App”?": the agent asked for exactly that.
|
|
134
148
|
const asked = openInPrompt(snap);
|
|
135
149
|
if (asked)
|
|
136
150
|
snap = await tapPrompt(device, "Open");
|
|
137
|
-
return { text: `Opened
|
|
151
|
+
return { text: `Opened the link.${asked ? ` iOS asked "${asked}": answered Open.` : ""}\n${render(snap, { device: label })}` };
|
|
138
152
|
}
|
|
139
153
|
case "grant":
|
|
140
154
|
case "revoke":
|
package/dist/tools/app-logs.js
CHANGED
|
@@ -64,9 +64,9 @@ export const appLogs = {
|
|
|
64
64
|
// Swift's own message ("App.swift:64: Fatal error: ...") goes to stderr, not into the report: show both.
|
|
65
65
|
const last = await readFile(await consoleFile(device.udid, bundleId), "utf8").then((t) => cleanConsole(t).slice(-5).map(clip), () => []);
|
|
66
66
|
const output = last.length ? `\nThe app's last output:\n${last.join("\n")}` : "";
|
|
67
|
-
const crashes = await crashReports(executable, Date.now() - Math.max(seconds, 3_600) * 1000);
|
|
67
|
+
const crashes = await crashReports(executable, Date.now() - Math.max(seconds, 3_600) * 1000, { bundleId, udid: device.udid });
|
|
68
68
|
if (!crashes.length) {
|
|
69
|
-
return { text: `No crash report for ${
|
|
69
|
+
return { text: `No crash report for ${bundleId} on ${device.name} in the last hour. macOS writes one up to a minute after a crash (44 s measured).${output}\nNext: if the app just crashed, call app_logs {crashes: true} again in 30 s.` };
|
|
70
70
|
}
|
|
71
71
|
return { text: crashes.map((c) => [`crash at ${c.time} (${c.file})`, ...c.summary].join("\n")).join("\n\n") + output };
|
|
72
72
|
}
|
package/dist/tools/app-run.js
CHANGED
|
@@ -2,7 +2,7 @@ import { bundleIdOf, install } from "../engine/apps.js";
|
|
|
2
2
|
import { answer, claimProject, currentJob, startJob } from "../engine/jobs.js";
|
|
3
3
|
import { bootDevice, findDevice, listDevices, pickDevice } from "../engine/simctl.js";
|
|
4
4
|
import { heldDevice, holdDevice, setDeviceEnv } from "../engine/state.js";
|
|
5
|
-
import { build, builtApp, formatIssue, knownFix, noDestination, projectFor, remember } from "../engine/xcode.js";
|
|
5
|
+
import { build, builtApp, formatIssue, knownFix, noDestination, projectFor, remember, targetTooNew } from "../engine/xcode.js";
|
|
6
6
|
import { pickXcode } from "../engine/xcodes.js";
|
|
7
7
|
import { grantAll, setPermissionMode } from "../engine/prompts.js";
|
|
8
8
|
import { ToolError } from "../errors.js";
|
|
@@ -74,7 +74,7 @@ export const appRun = {
|
|
|
74
74
|
// Finding the project (xcodebuild -list) and booting count toward the 40 s answer too: all of it is the job.
|
|
75
75
|
const job = startJob("Build and run", async (progress) => {
|
|
76
76
|
progress.step("finding the project");
|
|
77
|
-
const { project, scheme, dir, note, xcode: kept } = await projectFor(args.path, args.scheme);
|
|
77
|
+
const { project, scheme, dir, note, xcode: kept } = await projectFor(args.path, args.scheme, progress);
|
|
78
78
|
const asked = args.xcode ?? kept;
|
|
79
79
|
const xcode = await pickXcode(asked);
|
|
80
80
|
const other = xcode && !xcode.default ? xcode : undefined;
|
|
@@ -99,6 +99,13 @@ export const appRun = {
|
|
|
99
99
|
onCancel: (cancel) => progress.setCancel(cancel),
|
|
100
100
|
};
|
|
101
101
|
let result = await build(project, scheme, device.udid, options);
|
|
102
|
+
// A deployment target later than the phone's iOS leaves no destination either, and no build would install.
|
|
103
|
+
const tooNew = !result.ok && noDestination(result) ? await targetTooNew(project, scheme, device, "app_run again", other) : undefined;
|
|
104
|
+
if (tooNew) {
|
|
105
|
+
progress.setCancel(undefined);
|
|
106
|
+
progress.setLog(result.log);
|
|
107
|
+
return { text: `${note}Build of ${scheme} failed: ${tooNew}`, isError: true };
|
|
108
|
+
}
|
|
102
109
|
// An Xcode older than the phone's iOS cannot name it: it builds for any Simulator, and the app installs all the same.
|
|
103
110
|
let anySimulator = false;
|
|
104
111
|
if (!result.ok && other && noDestination(result)) {
|
|
@@ -125,7 +132,7 @@ export const appRun = {
|
|
|
125
132
|
if (!app)
|
|
126
133
|
return { text: `Built ${scheme} in ${seconds} s, but found no .app for the Simulator. Full log: ${result.log}`, isError: true };
|
|
127
134
|
const bundleId = await bundleIdOf(app);
|
|
128
|
-
await install(device.udid, app);
|
|
135
|
+
await install(device.udid, app, device.runtime);
|
|
129
136
|
const keep = asked && xcode && !xcode.default ? asked : undefined;
|
|
130
137
|
await remember(dir, { project: project.path, scheme, bundleId, ...(keep ? { xcode: keep } : {}) });
|
|
131
138
|
// Granted by default, so no prompt stops the agent; "ask" leaves iOS's prompts for a test of them.
|
package/dist/tools/cloud.js
CHANGED
|
@@ -3,16 +3,24 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { account, cloudState, setCloudState, signIn } from "../cloud/account.js";
|
|
5
5
|
import { CloudError } from "../cloud/api.js";
|
|
6
|
-
import {
|
|
6
|
+
import { keepFiles } from "../cloud/files.js";
|
|
7
|
+
import { forward, stopNotice } from "../cloud/forward.js";
|
|
7
8
|
import { describeRequest, Tunnel } from "../cloud/tunnel.js";
|
|
8
9
|
import { randomKey } from "../cloud/sync.js";
|
|
10
|
+
import { onSignalExit } from "../engine/jobs.js";
|
|
9
11
|
import { ToolError } from "../errors.js";
|
|
10
12
|
import { setting } from "../settings.js";
|
|
11
13
|
/** How long start waits for the phone before answering that it is still starting (an MCP call has about a minute). */
|
|
12
14
|
const READY_WAIT_MS = 40_000;
|
|
13
|
-
const ACTIONS = ["start", "status", "stop", "run", "tunnel"];
|
|
15
|
+
const ACTIONS = ["start", "status", "extend", "stop", "run", "tunnel"];
|
|
14
16
|
const dollars = (micro) => `$${(Number(micro ?? 0) / 1e6).toFixed(2)}`;
|
|
15
|
-
const
|
|
17
|
+
const readPhone = async (api, id) => {
|
|
18
|
+
const read = await api.json("GET", `/api/v1/simulator/sessions/${id}`);
|
|
19
|
+
return { phone: read.session, links: (read.links ?? {}) };
|
|
20
|
+
};
|
|
21
|
+
const phoneOf = async (api, id) => (await readPhone(api, id)).phone;
|
|
22
|
+
const gone = (error) => error instanceof CloudError && (error.status === 404 || error.code === "session_not_found");
|
|
23
|
+
const watchLine = (links) => (links.view && links.control ? `\nA person can watch it at ${links.view} or use it at ${links.control}.` : "");
|
|
16
24
|
/** Waits for a phone to be ready, up to the time given. Answers it as it last read. */
|
|
17
25
|
async function untilReady(api, id, waitMs) {
|
|
18
26
|
let phone = await phoneOf(api, id);
|
|
@@ -22,7 +30,9 @@ async function untilReady(api, id, waitMs) {
|
|
|
22
30
|
}
|
|
23
31
|
return phone;
|
|
24
32
|
}
|
|
25
|
-
|
|
33
|
+
const DEVICE_NAMES = { iphone: "iPhone", ipad: "iPad" };
|
|
34
|
+
async function start(args, context) {
|
|
35
|
+
const device = typeof args.device === "string" ? args.device : undefined;
|
|
26
36
|
const signed = await signIn();
|
|
27
37
|
if ("link" in signed) {
|
|
28
38
|
return { text: `Sign in to NoMac for a cloud phone: open ${signed.link} and approve code ${signed.code}.\nNext: cloud start again once it is approved (headless: set NOMAC_API_KEY instead).` };
|
|
@@ -30,14 +40,23 @@ async function start(context) {
|
|
|
30
40
|
const { api } = signed;
|
|
31
41
|
const state = await cloudState();
|
|
32
42
|
if (state.session) {
|
|
33
|
-
|
|
43
|
+
// A phone NoMac no longer has is gone; any other failure (the network) is NoMac's answer, never a second phone.
|
|
44
|
+
const read = await readPhone(api, state.session).catch((error) => {
|
|
45
|
+
if (gone(error))
|
|
46
|
+
return undefined;
|
|
47
|
+
throw error;
|
|
48
|
+
});
|
|
49
|
+
const phone = read?.phone;
|
|
34
50
|
if (phone && (phone.state === "ready" || phone.state === "queued" || phone.state === "provisioning")) {
|
|
51
|
+
if (device && phone.device && phone.device !== device) {
|
|
52
|
+
throw new ToolError(`Cloud already has phone ${phone.id}, an ${DEVICE_NAMES[phone.device] ?? phone.device}.`, `cloud stop, then cloud start with device ${device}.`);
|
|
53
|
+
}
|
|
35
54
|
await setCloudState({ ...state, on: true });
|
|
36
|
-
return { text: `Cloud is on: phone ${phone.id} is ${phone.state}, ${dollars(phone.cost?.accrued_microunits)} so far. Every tool runs there; cloud stop ends it
|
|
55
|
+
return { text: `Cloud is on: phone ${phone.id} is ${phone.state}, ${dollars(phone.cost?.accrued_microunits)} so far. Every tool runs there; cloud stop ends it.${watchLine(read.links)}` };
|
|
37
56
|
}
|
|
38
57
|
}
|
|
39
58
|
const started = Date.now();
|
|
40
|
-
const created = await api.json("POST", "/api/v1/simulator/sessions", {}, `cli-start-${randomKey()}`);
|
|
59
|
+
const created = await api.json("POST", "/api/v1/simulator/sessions", device ? { device } : {}, `cli-start-${randomKey()}`);
|
|
41
60
|
const id = String(created.session.id);
|
|
42
61
|
await setCloudState({ on: true, session: id });
|
|
43
62
|
const phone = await untilReady(api, id, context.waitForJobs ? 5 * 60_000 : READY_WAIT_MS);
|
|
@@ -50,12 +69,18 @@ async function start(context) {
|
|
|
50
69
|
const why = phone.state === "queued" && phone.message ? ` ${phone.message}` : "";
|
|
51
70
|
return { text: `The cloud phone ${id} is still starting after ${((Date.now() - started) / 1000).toFixed(0)} s.${why}\nNext: cloud status, or any tool: it waits for the phone.` };
|
|
52
71
|
}
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
return { text: `Cloud phone ready in ${((Date.now() - started) / 1000).toFixed(1)} s: an iPhone Simulator on a NoMac Mac, $0.80 an hour by the minute.\nEvery tool now runs there; app_run and tests_run send the project first. cloud stop ends it.${watch}` };
|
|
72
|
+
const watch = watchLine((created.links ?? {}));
|
|
73
|
+
return { text: `Cloud phone ready in ${((Date.now() - started) / 1000).toFixed(1)} s: an ${DEVICE_NAMES[device ?? "iphone"]} Simulator on a NoMac Mac, $0.80 an hour by the minute.\nEvery tool now runs there; app_run and tests_run send the project first. cloud stop ends it.${watch}` };
|
|
56
74
|
}
|
|
57
75
|
/** The phone's tunnel to a backend on this machine, while this process runs (one at a time). */
|
|
58
76
|
let tunnel;
|
|
77
|
+
/** The tunnel's notice (its port changed), once: said after the next answer from the phone. */
|
|
78
|
+
export function tunnelNotice() {
|
|
79
|
+
const notice = tunnel?.notice;
|
|
80
|
+
if (tunnel)
|
|
81
|
+
tunnel.notice = undefined;
|
|
82
|
+
return notice;
|
|
83
|
+
}
|
|
59
84
|
const tunnelLine = (t) => t.state === "closed" ? `The tunnel closed${t.error ? `: ${t.error}` : ""}.` : `The phone reaches ${t.localHost}:${t.localPort} on this machine at http://127.0.0.1:${t.phonePort}${t.state === "connecting" ? " (reconnecting)" : ""}.`;
|
|
60
85
|
/**
|
|
61
86
|
* tunnel with port: the phone's apps reach that port on this machine (a local backend) at http://127.0.0.1:<its port>
|
|
@@ -76,7 +101,8 @@ async function openTunnel(args, context) {
|
|
|
76
101
|
if (!tunnel)
|
|
77
102
|
return { text: "No tunnel is open.\nNext: cloud tunnel with port, your backend's port on this machine." };
|
|
78
103
|
const recent = tunnel.requests.slice(-20).map((r) => describeRequest(r));
|
|
79
|
-
|
|
104
|
+
const notice = tunnelNotice();
|
|
105
|
+
return { text: `${tunnelLine(tunnel)} ${tunnel.requests.length} requests so far${recent.length ? `, the last:\n${recent.join("\n")}` : "."}${tunnel.error && tunnel.state !== "closed" ? `\nLast problem: ${tunnel.error}.` : ""}${notice ? `\n${notice}` : ""}` };
|
|
80
106
|
}
|
|
81
107
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
82
108
|
throw new ToolError(`${port} is not a port.`, "Pass port: your backend's port on this machine, like 8080.");
|
|
@@ -87,7 +113,7 @@ async function openTunnel(args, context) {
|
|
|
87
113
|
tunnel = undefined;
|
|
88
114
|
}
|
|
89
115
|
if (!tunnel) {
|
|
90
|
-
const opened = new Tunnel(api, state.session, port, "127.0.0.1", context.waitForJobs ? (r) => console.log(describeRequest(r, false)) : undefined);
|
|
116
|
+
const opened = new Tunnel(api, state.session, port, "127.0.0.1", context.waitForJobs ? (r) => console.log(describeRequest(r, false)) : undefined, context.waitForJobs ? (n) => console.log(n) : undefined);
|
|
91
117
|
await opened.start();
|
|
92
118
|
tunnel = opened;
|
|
93
119
|
}
|
|
@@ -113,55 +139,142 @@ async function status() {
|
|
|
113
139
|
const api = await account();
|
|
114
140
|
if (!api || !state.session)
|
|
115
141
|
return { text: "Cloud is on but has no phone yet. cloud start starts one." };
|
|
116
|
-
const phone = await
|
|
142
|
+
const { phone, links } = await readPhone(api, state.session);
|
|
117
143
|
const minutes = Math.ceil((phone.cost?.billable_seconds ?? 0) / 60);
|
|
118
144
|
if (phone.state === "queued")
|
|
119
145
|
return { text: `Cloud is on: phone ${phone.id} is waiting.${phone.message ? ` ${phone.message}` : ""}` };
|
|
120
|
-
|
|
146
|
+
const limit = phone;
|
|
147
|
+
const until = phone.state === "ready" && limit.ready_at && limit.limits?.max_duration_seconds ? new Date(Date.parse(limit.ready_at) + limit.limits.max_duration_seconds * 1000).toISOString().slice(11, 16) : undefined;
|
|
148
|
+
const stops = until ? ` It stops by ${until} UTC at the latest; cloud extend keeps it longer.` : "";
|
|
149
|
+
return { text: `Cloud is on: phone ${phone.id} is ${phone.state}; ${minutes} min so far, ${dollars(phone.cost?.accrued_microunits)} at $0.80 an hour.${stops}${tunnel ? `\n${tunnelLine(tunnel)}` : ""}${phone.state === "ready" ? watchLine(links) : ""}` };
|
|
121
150
|
}
|
|
122
|
-
|
|
151
|
+
/**
|
|
152
|
+
* The phone kept running longer: its time limit (and its spend cap with it) up by minutes, an hour by default (the
|
|
153
|
+
* command line takes the minutes: cloud extend 30), up to 8 hours in all. The account's credit still decides.
|
|
154
|
+
*/
|
|
155
|
+
async function extend(args) {
|
|
156
|
+
const state = await cloudState();
|
|
157
|
+
const api = await account();
|
|
158
|
+
if (!api || !state.on || !state.session)
|
|
159
|
+
throw new ToolError("Cloud has no phone to extend.", "cloud start.");
|
|
160
|
+
const minutes = typeof args.path === "string" && /^\d+$/.test(args.path) ? Number(args.path) : 60;
|
|
161
|
+
if (minutes < 1 || minutes > 480)
|
|
162
|
+
throw new ToolError(`${minutes} minutes is not an extension.`, "cloud extend with 1 to 480 minutes.");
|
|
163
|
+
let answer;
|
|
164
|
+
try {
|
|
165
|
+
answer = await api.json("POST", `/api/v1/simulator/sessions/${state.session}/extend`, { minutes });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (error instanceof CloudError && error.code === "session_at_longest")
|
|
169
|
+
throw new ToolError(`NoMac: ${error.message}`, "When it stops, cloud start: the next build is incremental.");
|
|
170
|
+
if (error instanceof CloudError && error.code === "session_not_ready")
|
|
171
|
+
throw new ToolError(`The cloud phone ${state.session} is not running, so it cannot be extended.`, "cloud status, or cloud start for a new one.");
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
const phone = answer.session;
|
|
175
|
+
const until = phone.ready_at && phone.limits?.max_duration_seconds ? new Date(Date.parse(phone.ready_at) + phone.limits.max_duration_seconds * 1000).toISOString().slice(11, 16) : undefined;
|
|
176
|
+
const runs = until ? `runs until ${until} UTC at the latest` : `has ${minutes} more minutes`;
|
|
177
|
+
return { text: `The cloud phone ${phone.id} ${runs}, if the account's credit lasts. cloud extend again near then, up to 8 hours in all.${stopNotice(answer)}` };
|
|
178
|
+
}
|
|
179
|
+
/** NoMac's links that open without signing in: a phone's view and control, its replay and its files. */
|
|
180
|
+
const OPEN_LINK = /https?:\/\/[^\s/]+\/(?:simulator-use\/(?:view|replay)|api\/v1\/simulator\/files)\/[A-Za-z0-9_.-]+/g;
|
|
181
|
+
/** A CI job, whose log and pull request comment can be public. */
|
|
182
|
+
const inCI = () => Boolean(process.env.CI || process.env.GITHUB_ACTIONS);
|
|
183
|
+
async function stop(options = {}) {
|
|
123
184
|
tunnel?.stop();
|
|
124
185
|
tunnel = undefined;
|
|
125
186
|
const state = await cloudState();
|
|
126
187
|
const api = await account();
|
|
127
|
-
|
|
128
|
-
|
|
188
|
+
if (!state.session || !api) {
|
|
189
|
+
await setCloudState({ on: false });
|
|
129
190
|
return { text: "Cloud is off: the tools run on this machine." };
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
191
|
+
}
|
|
192
|
+
// The phone is forgotten only once NoMac took the stop: a stop that did not reach it leaves the phone billing.
|
|
193
|
+
let stopped;
|
|
194
|
+
try {
|
|
195
|
+
stopped = await api.json("DELETE", `/api/v1/simulator/sessions/${state.session}`);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (!gone(error)) {
|
|
199
|
+
return { text: `Could not stop the cloud phone ${state.session}: ${error.message.replace(/\.$/, "")}. It runs, and bills, until its idle stop.\nNext: cloud stop again.`, isError: true };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
await setCloudState({ on: false });
|
|
135
203
|
let phone = await phoneOf(api, state.session).catch(() => undefined);
|
|
136
204
|
for (let i = 0; phone && !phone.cleanup_confirmed && i < 15; i++) {
|
|
137
205
|
await new Promise((r) => setTimeout(r, 2_000));
|
|
138
206
|
phone = await phoneOf(api, state.session).catch(() => undefined);
|
|
139
207
|
}
|
|
140
|
-
const
|
|
208
|
+
const cost = phone ? `after ${Math.ceil((phone.cost?.billable_seconds ?? 0) / 60)} min, ${dollars(phone.cost?.accrued_microunits)}` : "(its cost is on NoMac's dashboard)";
|
|
141
209
|
const replay = (stopped?.links ?? {}).replay;
|
|
142
|
-
|
|
210
|
+
// In CI the link stays out of the log and the comment, which can be public: the account's key reads it.
|
|
211
|
+
const replayLine = !replay ? "" : options.linkless
|
|
212
|
+
? `\nIts replay, the screen and every call, is kept 7 days. Its link opens without signing in, so it is left out here: GET https://nomac.app/api/v1/simulator/sessions/${state.session} with the account's key answers it (links.replay).`
|
|
213
|
+
: `\nIts replay, the screen and every call, opens at ${replay} in a minute (kept 7 days).`;
|
|
214
|
+
return { text: `Stopped the cloud phone ${cost}. The tools run on this machine again. A file link from its answers keeps working only if it was opened before this stop.${replayLine}` };
|
|
215
|
+
}
|
|
216
|
+
/** A phone stopped at once, as the process ends (a signal): no waiting for its cleanup. */
|
|
217
|
+
async function stopNow(session) {
|
|
218
|
+
const api = await account();
|
|
219
|
+
await api?.json("DELETE", `/api/v1/simulator/sessions/${session}`).catch(() => undefined);
|
|
220
|
+
await setCloudState({ on: false });
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The phone an MCP server started, stopped as the server ends (its client closed it, or a signal): it was that
|
|
224
|
+
* agent's alone, and would otherwise bill until its idle stop. A CLI command keeps its phone for the next one.
|
|
225
|
+
*/
|
|
226
|
+
export async function stopServerPhone() {
|
|
227
|
+
tunnel?.stop();
|
|
228
|
+
tunnel = undefined;
|
|
229
|
+
const { session } = await cloudState();
|
|
230
|
+
if (session)
|
|
231
|
+
await stopNow(session);
|
|
143
232
|
}
|
|
144
233
|
/**
|
|
145
234
|
* CI in one call: a phone, the project built and run, its tests with tests: true, a screenshot of its screen, and
|
|
146
235
|
* the phone stopped (unless cloud was already on). Fails when the build or a test fails.
|
|
147
236
|
*/
|
|
148
237
|
async function runOnce(args, context) {
|
|
238
|
+
// A run waits for a phone, the build and the tests, which takes minutes, past what an MCP call waits for; and its
|
|
239
|
+
// steps are tools an agent already has.
|
|
240
|
+
if (!context.waitForJobs) {
|
|
241
|
+
throw new ToolError("cloud run is for the command line and CI: it takes minutes, longer than a tool call waits. Nothing ran.", "cloud start, then app_run (tests_run for the tests), then cloud stop.");
|
|
242
|
+
}
|
|
149
243
|
const before = await cloudState();
|
|
150
244
|
const lines = [];
|
|
151
245
|
let failed = false;
|
|
152
246
|
let image;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
247
|
+
// The phone this run started: stopped at its end, and before the process ends on a signal (Ctrl-C, a cancelled
|
|
248
|
+
// CI job), so a run never leaves a phone billing.
|
|
249
|
+
let created;
|
|
250
|
+
let signingIn = false;
|
|
251
|
+
const startedHere = async () => {
|
|
252
|
+
const now = await cloudState().catch(() => undefined);
|
|
253
|
+
if (now?.session && now.session !== before.session)
|
|
254
|
+
created = now.session;
|
|
255
|
+
return created;
|
|
256
|
+
};
|
|
257
|
+
const release = onSignalExit(async () => {
|
|
258
|
+
const session = (await startedHere()) ?? (before.on ? undefined : (await cloudState()).session);
|
|
259
|
+
if (session)
|
|
260
|
+
await stopNow(session);
|
|
261
|
+
});
|
|
156
262
|
try {
|
|
263
|
+
const ensure = await start(args, { waitForJobs: true });
|
|
264
|
+
// Not signed in yet: nothing started, and the pending sign-in is kept for the next call.
|
|
265
|
+
if (/^Sign in to NoMac/.test(ensure.text)) {
|
|
266
|
+
signingIn = true;
|
|
267
|
+
return { text: ensure.text, isError: true };
|
|
268
|
+
}
|
|
157
269
|
const path = typeof args.path === "string" ? args.path : ".";
|
|
270
|
+
const scheme = typeof args.scheme === "string" && args.scheme ? { scheme: args.scheme } : {};
|
|
158
271
|
// A video of the whole run for the person reviewing it (the phone records up to its own limit).
|
|
159
272
|
const recording = await forward("screen_record", { action: "start" }, context);
|
|
160
|
-
const ran = await forward("app_run", { path }, { waitForJobs: true });
|
|
273
|
+
const ran = await forward("app_run", { path, ...scheme }, { waitForJobs: true });
|
|
161
274
|
lines.push(ran.text);
|
|
162
275
|
failed ||= Boolean(ran.isError);
|
|
163
276
|
if (!ran.isError && args.tests === true) {
|
|
164
|
-
const tested = await forward("tests_run", { path }, { waitForJobs: true });
|
|
277
|
+
const tested = await forward("tests_run", { path, ...scheme }, { waitForJobs: true });
|
|
165
278
|
lines.push(tested.text);
|
|
166
279
|
failed ||= Boolean(tested.isError);
|
|
167
280
|
}
|
|
@@ -193,28 +306,34 @@ async function runOnce(args, context) {
|
|
|
193
306
|
lines.push(`Recording: ${link}`);
|
|
194
307
|
}
|
|
195
308
|
}
|
|
309
|
+
await keepFiles(lines, out);
|
|
196
310
|
}
|
|
197
311
|
finally {
|
|
198
|
-
|
|
199
|
-
|
|
312
|
+
release();
|
|
313
|
+
if (!signingIn && ((await startedHere()) || !before.on))
|
|
314
|
+
lines.push((await stop({ linkless: inCI() })).text);
|
|
200
315
|
}
|
|
316
|
+
// In CI, a link that opens without signing in (one keepFiles could not save) stays out of the log and the comment.
|
|
317
|
+
const text = inCI() ? lines.join("\n\n").replace(OPEN_LINK, "(a link left out: CI's log can be public)") : lines.join("\n\n");
|
|
201
318
|
// The command line saved the screenshot above; an MCP client gets the picture itself.
|
|
202
|
-
return { text
|
|
319
|
+
return { text, ...(image && !context.waitForJobs ? { image } : {}), ...(failed ? { isError: true } : {}) };
|
|
203
320
|
}
|
|
204
321
|
export const cloud = {
|
|
205
322
|
name: "cloud",
|
|
206
323
|
title: "Cloud phone",
|
|
207
324
|
command: "cloud",
|
|
208
325
|
positional: ["action", "path"],
|
|
209
|
-
description: "A NoMac cloud iPhone
|
|
326
|
+
description: "A NoMac cloud iPhone or iPad Simulator, $0.80 an hour by the minute, for Linux, Windows, cloud agents, CI. start signs in (a link; NOMAC_API_KEY headless) and starts one: until stop every tool runs there, app_run and tests_run send the project. status: phone, cost. extend: an hour more. stop: ends it. run (CLI, CI): start, build and run path, tests, screenshot, recording, stop. tunnel: the phone reaches port here.",
|
|
210
327
|
inputSchema: {
|
|
211
328
|
type: "object",
|
|
212
329
|
properties: {
|
|
213
|
-
action: { type: "string", enum: ACTIONS, description: "
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
330
|
+
action: { type: "string", enum: ACTIONS, description: "what to do" },
|
|
331
|
+
device: { type: "string", enum: ["iphone", "ipad"], description: "start, run" },
|
|
332
|
+
port: { type: "number", description: "tunnel: a local port; 0 closes it" },
|
|
333
|
+
path: { type: "string", description: "run: project folder" },
|
|
334
|
+
tests: { type: "boolean", description: "run: tests too" },
|
|
335
|
+
scheme: { type: "string", description: "run: scheme" },
|
|
336
|
+
out: { type: "string", description: "run: output folder" },
|
|
218
337
|
},
|
|
219
338
|
required: ["action"],
|
|
220
339
|
additionalProperties: false,
|
|
@@ -226,9 +345,11 @@ export const cloud = {
|
|
|
226
345
|
try {
|
|
227
346
|
switch (args.action) {
|
|
228
347
|
case "start":
|
|
229
|
-
return await start(context);
|
|
348
|
+
return await start(args, context);
|
|
230
349
|
case "status":
|
|
231
350
|
return await status();
|
|
351
|
+
case "extend":
|
|
352
|
+
return await extend(args);
|
|
232
353
|
case "stop":
|
|
233
354
|
return await stop();
|
|
234
355
|
case "run":
|
package/dist/tools/device-use.js
CHANGED
|
@@ -13,7 +13,7 @@ export const deviceUse = {
|
|
|
13
13
|
inputSchema: {
|
|
14
14
|
type: "object",
|
|
15
15
|
properties: {
|
|
16
|
-
device: { type: "string", description: "Name, name with runtime (\"iPhone 17 Pro (iOS
|
|
16
|
+
device: { type: "string", description: "Name, name with runtime (\"iPhone 17 Pro (iOS 27.0)\") or UDID; default: the booted one, else the newest iPhone" },
|
|
17
17
|
appearance: { type: "string", enum: ["light", "dark"], description: "Light or dark mode" },
|
|
18
18
|
status_bar: { type: "string", enum: ["clean", "real"], description: "clean: 9:41 and full bars; real: undo" },
|
|
19
19
|
},
|