simulator-use 0.9.1 → 0.10.1
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/dist/look/read.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
2
|
import { AccessibilityStarting, describeUi } from "../engine/axe.js";
|
|
3
3
|
import { engineCall } from "../engine/native.js";
|
|
4
|
+
import { setting } from "../settings.js";
|
|
4
5
|
import { render, snapshot } from "./snapshot.js";
|
|
5
6
|
/** Right after a launch the app's accessibility tree is empty for a moment (seen on Xcode 27). */
|
|
6
7
|
const NOT_READY_RETRIES = 4;
|
|
@@ -48,13 +49,18 @@ export async function settle(udid, fromApp) {
|
|
|
48
49
|
// With the engine: the screen buffer says when the screen stopped changing (up to 0.8 s for the first change
|
|
49
50
|
// after an input, then 150 ms without one), and one read follows; a newly opened app's shell is read again
|
|
50
51
|
// until its contents arrive (up to 2.5 s).
|
|
52
|
+
const started = Date.now();
|
|
51
53
|
const answer = await engineCall(udid, { op: "settle", first_change_ms: 800, quiet_ms: 150, timeout_ms: SETTLE_TIMEOUT_MS }, SETTLE_TIMEOUT_MS + 5_000);
|
|
52
54
|
if (answer?.ok) {
|
|
55
|
+
const quiet = Date.now();
|
|
53
56
|
let snap = (await readScreen(udid, false)).snap;
|
|
54
|
-
|
|
57
|
+
let reads = 1;
|
|
58
|
+
for (const deadline = Date.now() + 2_500; opening(snap, fromApp) && Date.now() < deadline; reads++) {
|
|
55
59
|
await sleep(300);
|
|
56
60
|
snap = (await readScreen(udid, false)).snap;
|
|
57
61
|
}
|
|
62
|
+
if (setting("DEBUG"))
|
|
63
|
+
process.stderr.write(`simulator-use: settle ${quiet - started} ms${answer.settled === false ? " (still changing)" : ""}, ${reads} read(s) ${Date.now() - quiet} ms\n`);
|
|
58
64
|
return { snap, settled: answer.settled !== false };
|
|
59
65
|
}
|
|
60
66
|
const deadline = Date.now() + SETTLE_TIMEOUT_MS;
|
package/dist/mcp.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
7
7
|
import { cancelAllJobs } from "./engine/jobs.js";
|
|
8
|
+
import { hosted, hostedAnswer, noteCall } from "./hosted.js";
|
|
8
9
|
import { callTool, INSTRUCTIONS, toContent, TOOLS, VERSION } from "./registry.js";
|
|
10
|
+
import { setting } from "./settings.js";
|
|
9
11
|
const STATELESS = "2026-07-28";
|
|
10
12
|
const HANDSHAKE_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
11
13
|
export const SUPPORTED_VERSIONS = [STATELESS, ...HANDSHAKE_VERSIONS];
|
|
@@ -28,9 +30,18 @@ async function result(method, params, stateless) {
|
|
|
28
30
|
case "tools/call": {
|
|
29
31
|
const name = typeof params.name === "string" ? params.name : "";
|
|
30
32
|
const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
|
|
31
|
-
|
|
33
|
+
const started = Date.now();
|
|
34
|
+
const out = toContent(await callTool(name, args));
|
|
35
|
+
// On NoMac Simulator's host the customer never reads a path of the host, and the replay lists the call (src/hosted.ts).
|
|
36
|
+
if (!setting("HOSTED"))
|
|
37
|
+
return out;
|
|
38
|
+
noteCall(name, args, started, out);
|
|
39
|
+
return hostedAnswer(out);
|
|
32
40
|
}
|
|
33
41
|
default:
|
|
42
|
+
// NoMac Simulator's host asks a hosted server for more than tool calls (src/hosted.ts).
|
|
43
|
+
if (method.startsWith("simulator-use/") && setting("HOSTED"))
|
|
44
|
+
return hosted(method, params);
|
|
34
45
|
throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
|
|
35
46
|
}
|
|
36
47
|
}
|
package/dist/registry.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { cloudState } from "./cloud/account.js";
|
|
3
|
+
import { forward } from "./cloud/forward.js";
|
|
2
4
|
import { ToolError } from "./errors.js";
|
|
5
|
+
import { setting } from "./settings.js";
|
|
3
6
|
import { appControl } from "./tools/app-control.js";
|
|
4
7
|
import { appLogs } from "./tools/app-logs.js";
|
|
5
8
|
import { appRun } from "./tools/app-run.js";
|
|
9
|
+
import { cloud } from "./tools/cloud.js";
|
|
6
10
|
import { deviceUse } from "./tools/device-use.js";
|
|
7
11
|
import { devices } from "./tools/devices.js";
|
|
8
12
|
import { jobCancel, jobStatus } from "./tools/jobs.js";
|
|
@@ -12,7 +16,7 @@ import { uiAct } from "./tools/ui-act.js";
|
|
|
12
16
|
import { uiLook } from "./tools/ui-look.js";
|
|
13
17
|
export const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
14
18
|
/** Every tool, in the order clients list them. The MCP server and the CLI are both built from this. */
|
|
15
|
-
export const TOOLS = [devices, deviceUse, appRun, uiLook, uiAct, appControl, appLogs, testsRun, jobStatus, jobCancel, screenRecord];
|
|
19
|
+
export const TOOLS = [devices, deviceUse, appRun, uiLook, uiAct, appControl, appLogs, testsRun, jobStatus, jobCancel, screenRecord, cloud];
|
|
16
20
|
/** How the tools fit together, sent once to MCP clients. */
|
|
17
21
|
export const INSTRUCTIONS = [
|
|
18
22
|
"simulator-use drives iOS Simulators on this Mac.",
|
|
@@ -63,6 +67,9 @@ export async function callTool(name, args = {}, context = { waitForJobs: false }
|
|
|
63
67
|
if (problem)
|
|
64
68
|
return { text: problem, isError: true };
|
|
65
69
|
try {
|
|
70
|
+
// With cloud on, every other tool runs on the NoMac phone (src/cloud/forward.ts).
|
|
71
|
+
if (tool !== cloud && !setting("HOSTED") && (await cloudState()).on)
|
|
72
|
+
return await forward(tool.name, args, context);
|
|
66
73
|
return await tool.run(args, context);
|
|
67
74
|
}
|
|
68
75
|
catch (error) {
|
|
@@ -2,12 +2,16 @@ 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 { currentApp, heldDevice, setCurrentApp } from "../engine/state.js";
|
|
5
|
+
import { currentApp, heldDevice, setCurrentApp, setDeviceTimeZone } from "../engine/state.js";
|
|
6
6
|
import { ToolError } from "../errors.js";
|
|
7
7
|
import { readScreen } from "../look/read.js";
|
|
8
8
|
import { render } from "../look/snapshot.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
9
|
+
import { grantAll, openInPrompt, setPermissionMode } from "../engine/prompts.js";
|
|
10
|
+
import { launchAndLook, tapPrompt } from "./launch-app.js";
|
|
11
|
+
const ACTIONS = ["launch", "install", "terminate", "uninstall", "open_url", "grant", "revoke", "reset_permissions", "location", "push", "appearance", "locale", "timezone", "text_size", "status_bar"];
|
|
12
|
+
/** simctl ui content_size's sizes. */
|
|
13
|
+
const TEXT_SIZES = ["extra-small", "small", "medium", "large", "extra-large", "extra-extra-large", "extra-extra-extra-large",
|
|
14
|
+
"accessibility-medium", "accessibility-large", "accessibility-extra-large", "accessibility-extra-extra-large", "accessibility-extra-extra-extra-large"];
|
|
11
15
|
/** What simctl privacy takes (its help, plus camera, which it also accepts). */
|
|
12
16
|
const PERMISSIONS = ["all", "calendar", "camera", "contacts", "contacts-limited", "location", "location-always", "media-library", "microphone", "motion", "photos", "photos-add", "reminders", "siri"];
|
|
13
17
|
/** What `value` holds for each action that needs one. */
|
|
@@ -17,7 +21,33 @@ const VALUE = {
|
|
|
17
21
|
revoke: "a permission, as for grant",
|
|
18
22
|
location: '"latitude,longitude", or "clear"',
|
|
19
23
|
push: 'the notification text, or a JSON payload with "aps"',
|
|
24
|
+
appearance: '"light" or "dark"',
|
|
25
|
+
locale: 'a language and region, like "fr_FR", "pt-BR" or "zh-Hans_CN"',
|
|
26
|
+
timezone: 'an IANA time zone, like "Europe/Paris", or "clear"',
|
|
27
|
+
text_size: `one of ${TEXT_SIZES.join(", ")}`,
|
|
28
|
+
status_bar: '"clean" (9:41, full signal and battery, for screenshots) or "clear"',
|
|
20
29
|
};
|
|
30
|
+
/** A locale as iOS keeps it: the language for AppleLanguages ("pt-BR") and the locale for AppleLocale ("pt_BR"). */
|
|
31
|
+
export function localeParts(value) {
|
|
32
|
+
const m = /^\s*([a-z]{2,3})(?:[-_]([A-Z][a-z]{3}))?(?:[-_]([A-Z]{2}|\d{3}))?\s*$/.exec(value);
|
|
33
|
+
if (!m)
|
|
34
|
+
throw new ToolError(`"${value}" is not a language and region.`, 'For example "fr_FR", "pt-BR", "zh-Hans_CN" or "en".');
|
|
35
|
+
const [, lang, script, region] = m;
|
|
36
|
+
return { language: [lang, script, region].filter(Boolean).join("-"), locale: [[lang, script].filter(Boolean).join("-"), region].filter(Boolean).join("_") };
|
|
37
|
+
}
|
|
38
|
+
/** An IANA time zone this machine knows (Intl refuses any other). */
|
|
39
|
+
export function timeZoneName(value) {
|
|
40
|
+
const zone = value.trim();
|
|
41
|
+
try {
|
|
42
|
+
new Intl.DateTimeFormat("en", { timeZone: zone });
|
|
43
|
+
if (/^[A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)*$/.test(zone))
|
|
44
|
+
return zone;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Not a time zone.
|
|
48
|
+
}
|
|
49
|
+
throw new ToolError(`"${value}" is not a time zone.`, 'Use an IANA name, like "Europe/Paris" or "America/New_York".');
|
|
50
|
+
}
|
|
21
51
|
/** A push from plain text, or the agent's own JSON payload, checked the way simctl will. */
|
|
22
52
|
export function pushJson(value) {
|
|
23
53
|
const trimmed = value.trim();
|
|
@@ -39,13 +69,13 @@ export function pushJson(value) {
|
|
|
39
69
|
export const appControl = {
|
|
40
70
|
name: "app_control",
|
|
41
71
|
title: "Control an app",
|
|
42
|
-
description: "Launch, install, stop or remove an app on the Simulator; open a URL or deep link; grant, revoke or reset a permission; set the location; send a push. launch answers with the app's first screen; later calls default to the app launched last.",
|
|
72
|
+
description: "Launch, install, stop or remove an app on the Simulator; open a URL or deep link; grant, revoke or reset a permission; set the location; send a push; set the phone's appearance, locale, time zone, text size or a clean status bar. launch answers with the app's first screen; later calls default to the app launched last.",
|
|
43
73
|
inputSchema: {
|
|
44
74
|
type: "object",
|
|
45
75
|
properties: {
|
|
46
76
|
action: { type: "string", enum: ACTIONS, description: "What to do" },
|
|
47
77
|
app: { type: "string", description: "Bundle id, or for install the path to a Simulator .app" },
|
|
48
|
-
value: { type: "string", description: "URL, permission,
|
|
78
|
+
value: { type: "string", description: "URL, permission, lat,long, push text/JSON, light/dark, locale (fr_FR), time zone (Europe/Paris), text size or clean/clear, per action" },
|
|
49
79
|
device: { type: "string", description: "Name or UDID; default: the one in use" },
|
|
50
80
|
},
|
|
51
81
|
required: ["action"],
|
|
@@ -72,13 +102,16 @@ export const appControl = {
|
|
|
72
102
|
await install(device.udid, path);
|
|
73
103
|
const info = await appInfo(device.udid, bundleId);
|
|
74
104
|
await setCurrentApp(device.udid, { bundleId, executable: info?.executable });
|
|
75
|
-
|
|
105
|
+
const refused = await grantAll(device.udid, bundleId);
|
|
106
|
+
const granted = refused ? ` Its permissions could not be granted (${refused}).` : " Its permissions are granted, so no prompt stops it (reset_permissions brings iOS's prompts back).";
|
|
107
|
+
return { text: `Installed ${info?.name ?? bundleId} (${bundleId}) on ${label}.${granted} Next: app_control {action: "launch"}.` };
|
|
76
108
|
}
|
|
77
109
|
// Every other action works on an installed app: the one named, or the one launched last.
|
|
78
110
|
const bundleId = app ?? (await currentApp(device.udid))?.bundleId;
|
|
79
111
|
if (bundleId && !BUNDLE_ID.test(bundleId))
|
|
80
112
|
throw new ToolError(`"${bundleId}" is not a bundle id.`, "A bundle id looks like com.example.app; install takes the path to a .app.");
|
|
81
|
-
|
|
113
|
+
// The phone's own settings need no app.
|
|
114
|
+
const needsApp = !["open_url", "location", "reset_permissions", "appearance", "locale", "timezone", "text_size", "status_bar"].includes(action);
|
|
82
115
|
if (needsApp && !bundleId)
|
|
83
116
|
throw new ToolError(`${action} needs app: the bundle id.`, "Pass app, or launch the app first.");
|
|
84
117
|
switch (action) {
|
|
@@ -94,17 +127,28 @@ export const appControl = {
|
|
|
94
127
|
await stopLogStream(device.udid, bundleId);
|
|
95
128
|
await simctlAction(["uninstall", device.udid, bundleId], `remove ${bundleId}`);
|
|
96
129
|
return { text: `Removed ${bundleId} and its data from ${label}.` };
|
|
97
|
-
case "open_url":
|
|
130
|
+
case "open_url": {
|
|
98
131
|
await simctlAction(["openurl", device.udid, value], `open ${value}`);
|
|
99
|
-
|
|
132
|
+
let snap = (await readScreen(device.udid)).snap;
|
|
133
|
+
// A link that opens another app makes iOS ask "Open in “App”?": the agent asked for exactly that.
|
|
134
|
+
const asked = openInPrompt(snap);
|
|
135
|
+
if (asked)
|
|
136
|
+
snap = await tapPrompt(device, "Open");
|
|
137
|
+
return { text: `Opened ${value}.${asked ? ` iOS asked "${asked}": answered Open.` : ""}\n${render(snap, { device: label })}` };
|
|
138
|
+
}
|
|
100
139
|
case "grant":
|
|
101
140
|
case "revoke":
|
|
102
141
|
if (!PERMISSIONS.includes(value))
|
|
103
142
|
throw new ToolError(`Unknown permission "${value}".`, `Permissions: ${PERMISSIONS.join(", ")}.`);
|
|
104
143
|
await simctlAction(["privacy", device.udid, action, value, bundleId], `${action} ${value}`);
|
|
144
|
+
// A permission taken away is for testing iOS's prompt: launches stop answering prompts for this app.
|
|
145
|
+
if (action === "revoke")
|
|
146
|
+
await setPermissionMode(device.udid, bundleId, "ask");
|
|
105
147
|
return { text: `${action === "grant" ? "Granted" : "Revoked"} ${value} for ${bundleId}. The app may have been stopped: iOS does that when a permission changes.` };
|
|
106
148
|
case "reset_permissions":
|
|
107
149
|
await simctlAction(["privacy", device.udid, "reset", value ?? "all", ...(bundleId ? [bundleId] : [])], "reset permissions");
|
|
150
|
+
if (bundleId)
|
|
151
|
+
await setPermissionMode(device.udid, bundleId, "ask");
|
|
108
152
|
return { text: `Reset ${value ?? "all"} permissions${bundleId ? ` for ${bundleId}` : ""}: the app asks again on next use.` };
|
|
109
153
|
case "location": {
|
|
110
154
|
if (value.trim() === "clear") {
|
|
@@ -120,6 +164,47 @@ export const appControl = {
|
|
|
120
164
|
case "push":
|
|
121
165
|
await pushPayload(device.udid, bundleId, pushJson(value));
|
|
122
166
|
return { text: `Sent a push to ${bundleId}.` };
|
|
167
|
+
case "appearance": {
|
|
168
|
+
const mode = value.trim().toLowerCase();
|
|
169
|
+
if (mode !== "light" && mode !== "dark")
|
|
170
|
+
throw new ToolError(`Appearance is "light" or "dark", not "${value}".`);
|
|
171
|
+
await simctlAction(["ui", device.udid, "appearance", mode], "set the appearance");
|
|
172
|
+
return { text: `${label} is in ${mode} mode.` };
|
|
173
|
+
}
|
|
174
|
+
case "locale": {
|
|
175
|
+
const { language, locale } = localeParts(value);
|
|
176
|
+
await simctlAction(["spawn", device.udid, "defaults", "write", "-g", "AppleLanguages", "-array", language], "set the language");
|
|
177
|
+
await simctlAction(["spawn", device.udid, "defaults", "write", "-g", "AppleLocale", "-string", locale], "set the region");
|
|
178
|
+
return { text: `${label}'s language and region are ${language} (${locale}). Apps read them when they launch: relaunch the app (app_control launch).` };
|
|
179
|
+
}
|
|
180
|
+
case "timezone": {
|
|
181
|
+
if (value.trim() === "clear") {
|
|
182
|
+
await setDeviceTimeZone(device.udid, undefined);
|
|
183
|
+
return { text: `Apps launched from now on run in this Mac's time zone again; relaunch the app (app_control launch).` };
|
|
184
|
+
}
|
|
185
|
+
const zone = timeZoneName(value);
|
|
186
|
+
await setDeviceTimeZone(device.udid, zone);
|
|
187
|
+
return { text: `Apps simulator-use launches from now on run in ${zone}: relaunch the app (app_control launch). The status bar's clock keeps this Mac's time.` };
|
|
188
|
+
}
|
|
189
|
+
case "text_size": {
|
|
190
|
+
const size = value.trim().toLowerCase();
|
|
191
|
+
if (!TEXT_SIZES.includes(size))
|
|
192
|
+
throw new ToolError(`"${value}" is not a text size.`, `Use one of: ${TEXT_SIZES.join(", ")}.`);
|
|
193
|
+
await simctlAction(["ui", device.udid, "content_size", size], "set the text size");
|
|
194
|
+
return { text: `${label}'s text size is ${size}.` };
|
|
195
|
+
}
|
|
196
|
+
case "status_bar": {
|
|
197
|
+
const mode = value.trim().toLowerCase();
|
|
198
|
+
if (mode === "clear") {
|
|
199
|
+
await simctlAction(["status_bar", device.udid, "clear"], "clear the status bar");
|
|
200
|
+
return { text: "The status bar shows the real time, signal and battery again." };
|
|
201
|
+
}
|
|
202
|
+
if (mode !== "clean")
|
|
203
|
+
throw new ToolError(`status_bar is "clean" or "clear", not "${value}".`);
|
|
204
|
+
await simctlAction(["status_bar", device.udid, "override", "--time", "9:41", "--dataNetwork", "wifi", "--wifiMode", "active", "--wifiBars", "3",
|
|
205
|
+
"--cellularMode", "active", "--cellularBars", "4", "--batteryState", "charged", "--batteryLevel", "100"], "set the status bar");
|
|
206
|
+
return { text: "The status bar shows 9:41, full signal and a full battery, as App Store screenshots do." };
|
|
207
|
+
}
|
|
123
208
|
default:
|
|
124
209
|
throw new ToolError(`Unknown action "${action}". Actions: ${ACTIONS.join(", ")}.`);
|
|
125
210
|
}
|
package/dist/tools/app-run.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
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
|
-
import { heldDevice, holdDevice } from "../engine/state.js";
|
|
5
|
-
import { build, builtApp, formatIssue, knownFix, projectFor, remember } from "../engine/xcode.js";
|
|
4
|
+
import { heldDevice, holdDevice, setDeviceEnv } from "../engine/state.js";
|
|
5
|
+
import { build, builtApp, formatIssue, knownFix, noDestination, projectFor, remember } from "../engine/xcode.js";
|
|
6
|
+
import { pickXcode } from "../engine/xcodes.js";
|
|
7
|
+
import { grantAll, setPermissionMode } from "../engine/prompts.js";
|
|
6
8
|
import { ToolError } from "../errors.js";
|
|
7
9
|
import { launchAndLook } from "./launch-app.js";
|
|
8
10
|
import { setting } from "../settings.js";
|
|
@@ -26,18 +28,41 @@ export async function runDevice(wanted) {
|
|
|
26
28
|
}
|
|
27
29
|
return target ?? pickDevice(devices, undefined, heldDevice());
|
|
28
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* app_run's env: "API_URL=http://127.0.0.1:20199 MODE=test" as the variables the app reads from its environment, as
|
|
33
|
+
* Xcode's scheme gives them; "" clears them. Undefined: the device keeps what it had.
|
|
34
|
+
*/
|
|
35
|
+
export function launchEnv(value) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (typeof value !== "string")
|
|
39
|
+
throw new ToolError("env must be text: KEY=VALUE pairs, space-separated.");
|
|
40
|
+
const env = {};
|
|
41
|
+
for (const pair of value.split(/\s+/).filter(Boolean)) {
|
|
42
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]{0,63})=(\S{0,2048})$/.exec(pair);
|
|
43
|
+
if (!m)
|
|
44
|
+
throw new ToolError(`env: "${pair.slice(0, 80)}" is not KEY=VALUE.`, 'Pass env like "API_URL=http://127.0.0.1:20199 MODE=test" (values without spaces).');
|
|
45
|
+
env[m[1]] = m[2];
|
|
46
|
+
}
|
|
47
|
+
if (Object.keys(env).length > 20)
|
|
48
|
+
throw new ToolError("env takes up to 20 variables.");
|
|
49
|
+
return env;
|
|
50
|
+
}
|
|
29
51
|
export const appRun = {
|
|
30
52
|
name: "app_run",
|
|
31
53
|
title: "Build and run the app",
|
|
32
|
-
description: "Build the Xcode project in a folder for the Simulator, install
|
|
54
|
+
description: "Build the Xcode project in a folder for the Simulator, install and launch it, and answer with its first screen. Errors as file:line. A build past 40 s answers with a job for job_status. Later calls reuse the project.",
|
|
33
55
|
inputSchema: {
|
|
34
56
|
type: "object",
|
|
35
57
|
properties: {
|
|
36
|
-
path: { type: "string", description: "Project folder, .xcodeproj or .xcworkspace; default:
|
|
37
|
-
scheme: { type: "string", description: "Default: the only scheme, or
|
|
58
|
+
path: { type: "string", description: "Project folder, .xcodeproj or .xcworkspace; default: here" },
|
|
59
|
+
scheme: { type: "string", description: "Default: the only scheme, or one named like the project" },
|
|
38
60
|
configuration: { type: "string", enum: ["Debug", "Release"], description: "Default: Debug" },
|
|
39
61
|
clean: { type: "boolean", description: "Build from scratch" },
|
|
40
62
|
device: { type: "string", description: "Name or UDID; default: the one in use" },
|
|
63
|
+
permissions: { type: "string", enum: ["grant", "ask"], description: "grant (default) answers prompts; ask: iOS's prompts stay" },
|
|
64
|
+
xcode: { type: "string", description: "27.1, beta or default (kept)" },
|
|
65
|
+
env: { type: "string", description: "App env: \"K=V K2=V2\" (kept)" },
|
|
41
66
|
},
|
|
42
67
|
additionalProperties: false,
|
|
43
68
|
},
|
|
@@ -45,10 +70,14 @@ export const appRun = {
|
|
|
45
70
|
command: "run",
|
|
46
71
|
positional: ["path"],
|
|
47
72
|
async run(args, context) {
|
|
73
|
+
const env = launchEnv(args.env);
|
|
48
74
|
// Finding the project (xcodebuild -list) and booting count toward the 40 s answer too: all of it is the job.
|
|
49
75
|
const job = startJob("Build and run", async (progress) => {
|
|
50
76
|
progress.step("finding the project");
|
|
51
|
-
const { project, scheme, dir, note } = await projectFor(args.path, args.scheme);
|
|
77
|
+
const { project, scheme, dir, note, xcode: kept } = await projectFor(args.path, args.scheme);
|
|
78
|
+
const asked = args.xcode ?? kept;
|
|
79
|
+
const xcode = await pickXcode(asked);
|
|
80
|
+
const other = xcode && !xcode.default ? xcode : undefined;
|
|
52
81
|
job.title = `Build and run ${scheme}`;
|
|
53
82
|
const busy = await claimProject(project.path, currentJob(job.id));
|
|
54
83
|
if (typeof busy === "number")
|
|
@@ -60,13 +89,23 @@ export const appRun = {
|
|
|
60
89
|
const device = await runDevice(args.device);
|
|
61
90
|
holdDevice(device.udid);
|
|
62
91
|
const label = `${device.name} (${device.runtime})`;
|
|
63
|
-
|
|
64
|
-
|
|
92
|
+
const withXcode = other ? ` with Xcode ${other.version}${other.beta ? " beta" : ""}` : "";
|
|
93
|
+
progress.step(`building ${scheme} for ${label}${withXcode}`);
|
|
94
|
+
const options = {
|
|
65
95
|
configuration,
|
|
66
96
|
clean: Boolean(args.clean),
|
|
97
|
+
xcode: other,
|
|
67
98
|
onLine: (line) => line.trim() && progress.line(line.length > 200 ? `${line.slice(0, 199)}…` : line),
|
|
68
99
|
onCancel: (cancel) => progress.setCancel(cancel),
|
|
69
|
-
}
|
|
100
|
+
};
|
|
101
|
+
let result = await build(project, scheme, device.udid, options);
|
|
102
|
+
// An Xcode older than the phone's iOS cannot name it: it builds for any Simulator, and the app installs all the same.
|
|
103
|
+
let anySimulator = false;
|
|
104
|
+
if (!result.ok && other && noDestination(result)) {
|
|
105
|
+
progress.step(`Xcode ${other.version} does not know ${device.runtime}: building for any Simulator`);
|
|
106
|
+
result = await build(project, scheme, device.udid, { ...options, destination: "generic/platform=iOS Simulator" });
|
|
107
|
+
anySimulator = true;
|
|
108
|
+
}
|
|
70
109
|
// Past the build there is nothing to stop: install and launch take seconds.
|
|
71
110
|
progress.setCancel(undefined);
|
|
72
111
|
progress.setLog(result.log);
|
|
@@ -82,16 +121,23 @@ export const appRun = {
|
|
|
82
121
|
return { text: `${note}Build of ${scheme} failed after ${seconds} s ${found}${fix ? `\nNext: ${fix}` : ""}${quoted}`, isError: true };
|
|
83
122
|
}
|
|
84
123
|
progress.step("installing");
|
|
85
|
-
const app = builtApp(project, scheme, configuration);
|
|
124
|
+
const app = builtApp(project, scheme, configuration, other);
|
|
86
125
|
if (!app)
|
|
87
126
|
return { text: `Built ${scheme} in ${seconds} s, but found no .app for the Simulator. Full log: ${result.log}`, isError: true };
|
|
88
127
|
const bundleId = await bundleIdOf(app);
|
|
89
128
|
await install(device.udid, app);
|
|
90
|
-
|
|
129
|
+
const keep = asked && xcode && !xcode.default ? asked : undefined;
|
|
130
|
+
await remember(dir, { project: project.path, scheme, bundleId, ...(keep ? { xcode: keep } : {}) });
|
|
131
|
+
// Granted by default, so no prompt stops the agent; "ask" leaves iOS's prompts for a test of them.
|
|
132
|
+
const refused = args.permissions === "ask" ? (await setPermissionMode(device.udid, bundleId, "ask"), undefined) : await grantAll(device.udid, bundleId);
|
|
91
133
|
progress.step("launching");
|
|
134
|
+
if (env)
|
|
135
|
+
await setDeviceEnv(device.udid, env);
|
|
92
136
|
const look = await launchAndLook(device, bundleId);
|
|
93
137
|
const made = project.generated ? ` (generated ${project.name}.xcodeproj with XcodeGen first)` : "";
|
|
94
|
-
|
|
138
|
+
const grant = refused ? `\nIts permissions could not be granted (${refused}).` : "";
|
|
139
|
+
const built = `${withXcode}${anySimulator ? ` (for any Simulator: it does not know ${device.runtime})` : ""}`;
|
|
140
|
+
return { text: `${note}Built ${scheme}${built} in ${seconds} s${result.warnings ? ` with ${result.warnings} warnings` : ""}${made}, installed ${bundleId}.${grant}\n${look}` };
|
|
95
141
|
});
|
|
96
142
|
return answer(job, context.waitForJobs);
|
|
97
143
|
},
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { account, cloudState, setCloudState, signIn } from "../cloud/account.js";
|
|
5
|
+
import { CloudError } from "../cloud/api.js";
|
|
6
|
+
import { forward } from "../cloud/forward.js";
|
|
7
|
+
import { describeRequest, Tunnel } from "../cloud/tunnel.js";
|
|
8
|
+
import { randomKey } from "../cloud/sync.js";
|
|
9
|
+
import { ToolError } from "../errors.js";
|
|
10
|
+
import { setting } from "../settings.js";
|
|
11
|
+
/** How long start waits for the phone before answering that it is still starting (an MCP call has about a minute). */
|
|
12
|
+
const READY_WAIT_MS = 40_000;
|
|
13
|
+
const ACTIONS = ["start", "status", "stop", "run", "tunnel"];
|
|
14
|
+
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;
|
|
16
|
+
/** Waits for a phone to be ready, up to the time given. Answers it as it last read. */
|
|
17
|
+
async function untilReady(api, id, waitMs) {
|
|
18
|
+
let phone = await phoneOf(api, id);
|
|
19
|
+
for (const deadline = Date.now() + waitMs; !["ready", "failed", "stopped", "stopping"].includes(phone.state) && Date.now() < deadline;) {
|
|
20
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
21
|
+
phone = await phoneOf(api, id);
|
|
22
|
+
}
|
|
23
|
+
return phone;
|
|
24
|
+
}
|
|
25
|
+
async function start(context) {
|
|
26
|
+
const signed = await signIn();
|
|
27
|
+
if ("link" in signed) {
|
|
28
|
+
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).` };
|
|
29
|
+
}
|
|
30
|
+
const { api } = signed;
|
|
31
|
+
const state = await cloudState();
|
|
32
|
+
if (state.session) {
|
|
33
|
+
const phone = await phoneOf(api, state.session).catch(() => undefined);
|
|
34
|
+
if (phone && (phone.state === "ready" || phone.state === "queued" || phone.state === "provisioning")) {
|
|
35
|
+
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.` };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const started = Date.now();
|
|
40
|
+
const created = await api.json("POST", "/api/v1/simulator/sessions", {}, `cli-start-${randomKey()}`);
|
|
41
|
+
const id = String(created.session.id);
|
|
42
|
+
await setCloudState({ on: true, session: id });
|
|
43
|
+
const phone = await untilReady(api, id, context.waitForJobs ? 5 * 60_000 : READY_WAIT_MS);
|
|
44
|
+
if (phone.state !== "ready") {
|
|
45
|
+
if (["failed", "stopped", "stopping"].includes(phone.state)) {
|
|
46
|
+
await setCloudState({ on: false });
|
|
47
|
+
throw new ToolError(`The cloud phone did not start: ${phone.failure_code ?? phone.state}.`, "Call cloud start again; report_issue on NoMac's MCP if it keeps failing.");
|
|
48
|
+
}
|
|
49
|
+
// Waiting for a free phone, NoMac says how many there are and its place.
|
|
50
|
+
const why = phone.state === "queued" && phone.message ? ` ${phone.message}` : "";
|
|
51
|
+
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
|
+
}
|
|
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}` };
|
|
56
|
+
}
|
|
57
|
+
/** The phone's tunnel to a backend on this machine, while this process runs (one at a time). */
|
|
58
|
+
let tunnel;
|
|
59
|
+
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
|
+
/**
|
|
61
|
+
* tunnel with port: the phone's apps reach that port on this machine (a local backend) at http://127.0.0.1:<its port>
|
|
62
|
+
* on the phone; without port: what it carried; port 0: closes it. On the command line it runs until Ctrl-C, printing
|
|
63
|
+
* each request.
|
|
64
|
+
*/
|
|
65
|
+
async function openTunnel(args, context) {
|
|
66
|
+
const state = await cloudState();
|
|
67
|
+
const api = await account();
|
|
68
|
+
if (args.port === 0) {
|
|
69
|
+
const had = tunnel;
|
|
70
|
+
tunnel?.stop();
|
|
71
|
+
tunnel = undefined;
|
|
72
|
+
return { text: had ? "Closed the phone's tunnel." : "No tunnel is open." };
|
|
73
|
+
}
|
|
74
|
+
const port = typeof args.port === "number" ? args.port : typeof args.path === "string" && /^\d+$/.test(args.path) ? Number(args.path) : undefined;
|
|
75
|
+
if (port === undefined) {
|
|
76
|
+
if (!tunnel)
|
|
77
|
+
return { text: "No tunnel is open.\nNext: cloud tunnel with port, your backend's port on this machine." };
|
|
78
|
+
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}.` : ""}` };
|
|
80
|
+
}
|
|
81
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
82
|
+
throw new ToolError(`${port} is not a port.`, "Pass port: your backend's port on this machine, like 8080.");
|
|
83
|
+
if (!api || !state.on || !state.session)
|
|
84
|
+
throw new ToolError("Cloud has no phone.", "Call cloud start first.");
|
|
85
|
+
if (tunnel && (tunnel.session !== state.session || tunnel.localPort !== port || tunnel.state === "closed")) {
|
|
86
|
+
tunnel.stop();
|
|
87
|
+
tunnel = undefined;
|
|
88
|
+
}
|
|
89
|
+
if (!tunnel) {
|
|
90
|
+
const opened = new Tunnel(api, state.session, port, "127.0.0.1", context.waitForJobs ? (r) => console.log(describeRequest(r, false)) : undefined);
|
|
91
|
+
await opened.start();
|
|
92
|
+
tunnel = opened;
|
|
93
|
+
}
|
|
94
|
+
const url = `http://127.0.0.1:${tunnel.phonePort}`;
|
|
95
|
+
const text = `${tunnelLine(tunnel)}\nPoint the app's backend there: app_run env "API_URL=${url}" for an app that reads it from ProcessInfo, or its Debug configuration. It works while the phone runs; cloud tunnel shows the requests it carried, cloud tunnel with port 0 closes it.`;
|
|
96
|
+
if (!context.waitForJobs)
|
|
97
|
+
return { text };
|
|
98
|
+
// The command line keeps it open, printing each request, until Ctrl-C.
|
|
99
|
+
console.log(`${text}\nRequests as they pass (Ctrl-C closes it):`);
|
|
100
|
+
await new Promise((resolve) => {
|
|
101
|
+
process.once("SIGINT", resolve);
|
|
102
|
+
process.once("SIGTERM", resolve);
|
|
103
|
+
});
|
|
104
|
+
const carried = tunnel.requests.length;
|
|
105
|
+
tunnel.stop();
|
|
106
|
+
tunnel = undefined;
|
|
107
|
+
return { text: `Closed the tunnel after ${carried} request${carried === 1 ? "" : "s"}.` };
|
|
108
|
+
}
|
|
109
|
+
async function status() {
|
|
110
|
+
const state = await cloudState();
|
|
111
|
+
if (!state.on)
|
|
112
|
+
return { text: "Cloud is off: the tools run on this machine. cloud start runs them on a NoMac phone." };
|
|
113
|
+
const api = await account();
|
|
114
|
+
if (!api || !state.session)
|
|
115
|
+
return { text: "Cloud is on but has no phone yet. cloud start starts one." };
|
|
116
|
+
const phone = await phoneOf(api, state.session);
|
|
117
|
+
const minutes = Math.ceil((phone.cost?.billable_seconds ?? 0) / 60);
|
|
118
|
+
if (phone.state === "queued")
|
|
119
|
+
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)}` : ""}` };
|
|
121
|
+
}
|
|
122
|
+
async function stop() {
|
|
123
|
+
tunnel?.stop();
|
|
124
|
+
tunnel = undefined;
|
|
125
|
+
const state = await cloudState();
|
|
126
|
+
const api = await account();
|
|
127
|
+
await setCloudState({ on: false });
|
|
128
|
+
if (!state.session || !api)
|
|
129
|
+
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
|
+
});
|
|
135
|
+
let phone = await phoneOf(api, state.session).catch(() => undefined);
|
|
136
|
+
for (let i = 0; phone && !phone.cleanup_confirmed && i < 15; i++) {
|
|
137
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
138
|
+
phone = await phoneOf(api, state.session).catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
const minutes = Math.ceil((phone?.cost?.billable_seconds ?? 0) / 60);
|
|
141
|
+
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).` : ""}` };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* CI in one call: a phone, the project built and run, its tests with tests: true, a screenshot of its screen, and
|
|
146
|
+
* the phone stopped (unless cloud was already on). Fails when the build or a test fails.
|
|
147
|
+
*/
|
|
148
|
+
async function runOnce(args, context) {
|
|
149
|
+
const before = await cloudState();
|
|
150
|
+
const lines = [];
|
|
151
|
+
let failed = false;
|
|
152
|
+
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 };
|
|
156
|
+
try {
|
|
157
|
+
const path = typeof args.path === "string" ? args.path : ".";
|
|
158
|
+
// A video of the whole run for the person reviewing it (the phone records up to its own limit).
|
|
159
|
+
const recording = await forward("screen_record", { action: "start" }, context);
|
|
160
|
+
const ran = await forward("app_run", { path }, { waitForJobs: true });
|
|
161
|
+
lines.push(ran.text);
|
|
162
|
+
failed ||= Boolean(ran.isError);
|
|
163
|
+
if (!ran.isError && args.tests === true) {
|
|
164
|
+
const tested = await forward("tests_run", { path }, { waitForJobs: true });
|
|
165
|
+
lines.push(tested.text);
|
|
166
|
+
failed ||= Boolean(tested.isError);
|
|
167
|
+
}
|
|
168
|
+
const out = typeof args.out === "string" ? args.out : tmpdir();
|
|
169
|
+
if (!ran.isError) {
|
|
170
|
+
const look = await forward("ui_look", { screenshot: true }, context);
|
|
171
|
+
image = look.image;
|
|
172
|
+
if (image && context.waitForJobs) {
|
|
173
|
+
const file = join(out, `simulator-use-cloud-${Date.now()}.${image.mimeType === "image/png" ? "png" : "jpg"}`);
|
|
174
|
+
await writeFile(file, image.data);
|
|
175
|
+
lines.push(`Screenshot: ${file}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!recording.isError) {
|
|
179
|
+
const stopped = await forward("screen_record", { action: "stop" }, context);
|
|
180
|
+
const link = /https?:\/\/\S+\/api\/v1\/simulator\/files\/[A-Za-z0-9_.-]+/.exec(stopped.text)?.[0];
|
|
181
|
+
if (link && context.waitForJobs) {
|
|
182
|
+
const response = await fetch(link);
|
|
183
|
+
if (response.ok) {
|
|
184
|
+
const file = join(out, `simulator-use-cloud-${Date.now()}.mp4`);
|
|
185
|
+
await writeFile(file, Buffer.from(await response.arrayBuffer()));
|
|
186
|
+
lines.push(`Recording: ${file}`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
lines.push(`Recording: ${link}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (link) {
|
|
193
|
+
lines.push(`Recording: ${link}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
if (!before.on)
|
|
199
|
+
lines.push((await stop()).text);
|
|
200
|
+
}
|
|
201
|
+
// 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 } : {}) };
|
|
203
|
+
}
|
|
204
|
+
export const cloud = {
|
|
205
|
+
name: "cloud",
|
|
206
|
+
title: "Cloud phone",
|
|
207
|
+
command: "cloud",
|
|
208
|
+
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.",
|
|
210
|
+
inputSchema: {
|
|
211
|
+
type: "object",
|
|
212
|
+
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" },
|
|
218
|
+
},
|
|
219
|
+
required: ["action"],
|
|
220
|
+
additionalProperties: false,
|
|
221
|
+
},
|
|
222
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
223
|
+
async run(args, context) {
|
|
224
|
+
if (setting("HOSTED"))
|
|
225
|
+
throw new ToolError("This is a NoMac phone already.", "Call the other tools directly.");
|
|
226
|
+
try {
|
|
227
|
+
switch (args.action) {
|
|
228
|
+
case "start":
|
|
229
|
+
return await start(context);
|
|
230
|
+
case "status":
|
|
231
|
+
return await status();
|
|
232
|
+
case "stop":
|
|
233
|
+
return await stop();
|
|
234
|
+
case "run":
|
|
235
|
+
return await runOnce(args, context);
|
|
236
|
+
case "tunnel":
|
|
237
|
+
return await openTunnel(args, context);
|
|
238
|
+
}
|
|
239
|
+
throw new ToolError(`Unknown action "${String(args.action)}".`, `Actions: ${ACTIONS.join(", ")}.`);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
if (error instanceof CloudError)
|
|
243
|
+
throw new ToolError(`NoMac: ${error.message}`, error.code === "insufficient_credit" ? "Add credit at https://nomac.app/dashboard, then cloud start again." : "Call cloud status, or try again in a minute.");
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
};
|