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.
@@ -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 simctlAction(["openurl", device.udid, value], `open ${value}`);
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 ${value}.${asked ? ` iOS asked "${asked}": answered Open.` : ""}\n${render(snap, { device: label })}` };
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":
@@ -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 ${executable} 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.` };
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
  }
@@ -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.
@@ -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 { forward } from "../cloud/forward.js";
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 phoneOf = async (api, id) => (await api.json("GET", `/api/v1/simulator/sessions/${id}`)).session;
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
- async function start(context) {
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
- const phone = await phoneOf(api, state.session).catch(() => undefined);
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 links = (created.links ?? {});
54
- const watch = links.view && links.control ? `\nA person can watch it at ${links.view} or use it at ${links.control}.` : "";
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
- 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}.` : ""}` };
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 phoneOf(api, state.session);
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
- 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.${tunnel ? `\n${tunnelLine(tunnel)}` : ""}` };
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
- async function stop() {
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
- await setCloudState({ on: false });
128
- if (!state.session || !api)
188
+ if (!state.session || !api) {
189
+ await setCloudState({ on: false });
129
190
  return { text: "Cloud is off: the tools run on this machine." };
130
- const stopped = await api.json("DELETE", `/api/v1/simulator/sessions/${state.session}`).catch((error) => {
131
- if (!(error instanceof CloudError && error.status === 404))
132
- throw error;
133
- return undefined;
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 minutes = Math.ceil((phone?.cost?.billable_seconds ?? 0) / 60);
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
- return { text: `Stopped the cloud phone after ${minutes} min, ${dollars(phone?.cost?.accrued_microunits)}. The tools run on this machine again.${replay ? `\nIts replay, the screen and every call, opens at ${replay} in a minute (kept 7 days).` : ""}` };
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
- const ensure = await start({ waitForJobs: true });
154
- if (/^Sign in to NoMac/.test(ensure.text))
155
- return { text: ensure.text, isError: true };
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
- if (!before.on)
199
- lines.push((await stop()).text);
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: lines.join("\n\n"), ...(image && !context.waitForJobs ? { image } : {}), ...(failed ? { isError: true } : {}) };
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 Simulator on a Mac with Xcode, $0.80 an hour by the minute: for Linux, Windows, cloud agents and CI. start signs in (a link the first time; NOMAC_API_KEY headless) and starts a phone: until stop, every tool runs there and app_run and tests_run send the project first. status: phone and cost. stop: ends it. run: start, build and run path, its tests with tests, a screenshot and a recording, stop, in one call. tunnel: the phone reaches port on this machine.",
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: "start, status, stop, run, or tunnel" },
214
- port: { type: "number", description: "tunnel: a backend's port here; 0 closes it" },
215
- path: { type: "string", description: "run: project folder; default: here" },
216
- tests: { type: "boolean", description: "run: its tests too" },
217
- out: { type: "string", description: "run (command line): screenshot folder; default: temp" },
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":
@@ -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 26.5)\") or UDID; default: the booted one, else the newest iPhone" },
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
  },