simulator-use 0.10.0 → 0.11.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 +9 -6
- 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/forward.js +46 -7
- 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/exec.js +1 -1
- 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 +165 -14
- 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 +212 -41
- 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.json +20 -7
- package/package.json +1 -1
- package/vendor/axe/simulator-use-engine +0 -0
- package/vendor/axe/simulator-use-pbset +0 -0
package/dist/hosted.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { execFile, spawn } from "node:child_process";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { createReadStream, createWriteStream, existsSync, statSync } from "node:fs";
|
|
9
|
-
import { chmod, lstat, lutimes, mkdir, mkdtemp, readdir, readFile, readlink, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
9
|
+
import { chmod, lstat, lutimes, mkdir, mkdtemp, open, readdir, readFile, readlink, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { basename, dirname, join, sep } from "node:path";
|
|
12
12
|
import { Readable } from "node:stream";
|
|
@@ -16,8 +16,10 @@ import { closeEngines, engineCall } from "./engine/native.js";
|
|
|
16
16
|
import { cancelAllJobs } from "./engine/jobs.js";
|
|
17
17
|
import { listDevices, pickDevice } from "./engine/simctl.js";
|
|
18
18
|
import { heldDevice, stateDir } from "./engine/state.js";
|
|
19
|
+
import { describeTarget, parseStep } from "./act/steps.js";
|
|
19
20
|
import { normalizeRemote, SKIP_DIRS } from "./cloud/sync.js";
|
|
20
21
|
import { callTool } from "./registry.js";
|
|
22
|
+
import { fetchState, uploadState } from "./state-archive.js";
|
|
21
23
|
const PROJECT = /^prj_[a-f0-9]{32}$/;
|
|
22
24
|
const SYNC = /^sync_[a-f0-9]{40}$/;
|
|
23
25
|
const SHA = /^[a-f0-9]{64}$/;
|
|
@@ -31,6 +33,22 @@ const syncs = new Map();
|
|
|
31
33
|
const invalid = (message) => Object.assign(new Error(message), { code: -32602 });
|
|
32
34
|
const absolute = (value) => typeof value === "string" && value.startsWith("/") && !value.includes("\0") && !value.split("/").includes("..") && value.length <= 1024;
|
|
33
35
|
const safePath = (path) => typeof path === "string" && path.length > 0 && !path.includes("\0") && !path.includes("\\") && path.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
36
|
+
/**
|
|
37
|
+
* Whether every folder on the way to rel inside root is a real folder (or not there yet): a sync or an upload never
|
|
38
|
+
* reads or writes through a link that code in the workspace left where a folder goes.
|
|
39
|
+
*/
|
|
40
|
+
export async function realFolders(root, rel) {
|
|
41
|
+
let at = root;
|
|
42
|
+
for (const part of rel.split("/").slice(0, -1)) {
|
|
43
|
+
at = join(at, part);
|
|
44
|
+
const st = await lstat(at).catch(() => undefined);
|
|
45
|
+
if (!st)
|
|
46
|
+
return true;
|
|
47
|
+
if (!st.isDirectory())
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
34
52
|
/** The host tells a warm server, at hand-over, the account's build cache and workspace. */
|
|
35
53
|
export function configure(params) {
|
|
36
54
|
for (const key of ["cache_dir", "workspace"])
|
|
@@ -85,7 +103,7 @@ async function manifest(params) {
|
|
|
85
103
|
for (const e of entries) {
|
|
86
104
|
const known = index[e.path];
|
|
87
105
|
let present = false;
|
|
88
|
-
if (known && known.sha256 === e.sha256 && known.kind === (e.kind ?? "file")) {
|
|
106
|
+
if (known && known.sha256 === e.sha256 && known.kind === (e.kind ?? "file") && (await realFolders(root, e.path))) {
|
|
89
107
|
present = await lstat(join(root, e.path)).then((st) => (known.kind === "symlink" ? st.isSymbolicLink() : st.isFile() && st.size === e.size), () => false);
|
|
90
108
|
}
|
|
91
109
|
if (!present) {
|
|
@@ -142,9 +160,12 @@ async function asAnnounced(root, entries, missing) {
|
|
|
142
160
|
if (!e)
|
|
143
161
|
continue;
|
|
144
162
|
const at = join(root, path);
|
|
145
|
-
const
|
|
163
|
+
const inside = await realFolders(root, path);
|
|
164
|
+
const st = inside ? await lstat(at).catch(() => undefined) : undefined;
|
|
146
165
|
let why;
|
|
147
|
-
if (!
|
|
166
|
+
if (!inside)
|
|
167
|
+
why = "a folder on its way is a link";
|
|
168
|
+
else if (!st)
|
|
148
169
|
why = "not in the archive";
|
|
149
170
|
else if ((e.kind ?? "file") === "symlink") {
|
|
150
171
|
if (!st.isSymbolicLink())
|
|
@@ -181,10 +202,7 @@ async function write(sync, archive) {
|
|
|
181
202
|
await writeFile(file, Buffer.from(archive.base64, "base64"));
|
|
182
203
|
}
|
|
183
204
|
else if (typeof archive.url === "string" && archive.url.startsWith("https://")) {
|
|
184
|
-
|
|
185
|
-
if (!response.ok || !response.body)
|
|
186
|
-
throw new Error(`archive_download_failed: ${response.status}`);
|
|
187
|
-
await pipeline(Readable.fromWeb(response.body), createWriteStream(file));
|
|
205
|
+
await download(archive.url, file, "archive");
|
|
188
206
|
}
|
|
189
207
|
else {
|
|
190
208
|
throw new Error("archive_invalid");
|
|
@@ -196,6 +214,9 @@ async function write(sync, archive) {
|
|
|
196
214
|
throw new Error("archive_checksum_mismatch");
|
|
197
215
|
}
|
|
198
216
|
await onlyAskedFor(file, sync.missing);
|
|
217
|
+
// The folders the manifest's files go in are real folders, as on the client: a link left in one's place goes.
|
|
218
|
+
for (const path of sync.missing)
|
|
219
|
+
await realFoldersIn(root, path);
|
|
199
220
|
// bsdtar keeps every entry inside root: it strips a leading / and refuses .. without -P.
|
|
200
221
|
await run("/usr/bin/tar", ["-xzf", file, "-C", root], { timeoutMs: 10 * 60_000 });
|
|
201
222
|
}
|
|
@@ -210,11 +231,14 @@ async function write(sync, archive) {
|
|
|
210
231
|
for (const e of entries) {
|
|
211
232
|
const path = join(root, e.path);
|
|
212
233
|
const kind = e.kind ?? "file";
|
|
234
|
+
if (!(await realFolders(root, e.path)))
|
|
235
|
+
continue;
|
|
213
236
|
const st = await lstat(path).catch(() => undefined);
|
|
214
237
|
if (!st)
|
|
215
238
|
continue;
|
|
216
239
|
const mode = kind === "executable" ? 0o755 : 0o644;
|
|
217
|
-
|
|
240
|
+
// chmod follows a link: only a file itself gets its mode.
|
|
241
|
+
if (kind !== "symlink" && st.isFile() && (st.mode & 0o777) !== mode)
|
|
218
242
|
await chmod(path, mode).catch(() => { });
|
|
219
243
|
if (Math.floor(st.mtimeMs) !== e.mtime_ms) {
|
|
220
244
|
const when = new Date(e.mtime_ms);
|
|
@@ -225,7 +249,7 @@ async function write(sync, archive) {
|
|
|
225
249
|
const now = new Set(entries.map((e) => e.path));
|
|
226
250
|
let removed = 0;
|
|
227
251
|
for (const path of Object.keys(before)) {
|
|
228
|
-
if (now.has(path) || !safePath(path))
|
|
252
|
+
if (now.has(path) || !safePath(path) || !(await realFolders(root, path)))
|
|
229
253
|
continue;
|
|
230
254
|
await rm(join(root, path), { force: true });
|
|
231
255
|
removed++;
|
|
@@ -306,7 +330,7 @@ async function get(params) {
|
|
|
306
330
|
let got = gets.get(id);
|
|
307
331
|
if (!got) {
|
|
308
332
|
const path = files.get(file);
|
|
309
|
-
if (!path || !
|
|
333
|
+
if (!path || !(await insideRoots(path)))
|
|
310
334
|
throw Object.assign(new Error("file_not_found"), { code: -32000 });
|
|
311
335
|
const started = { state: "running" };
|
|
312
336
|
gets.set(id, started);
|
|
@@ -320,6 +344,17 @@ async function get(params) {
|
|
|
320
344
|
await new Promise((r) => setTimeout(r, 50));
|
|
321
345
|
return { ...got };
|
|
322
346
|
}
|
|
347
|
+
/** A file itself (never a link), whose real path is inside the folders an answer may name. */
|
|
348
|
+
async function insideRoots(path) {
|
|
349
|
+
const st = await lstat(path).catch(() => undefined);
|
|
350
|
+
if (!st?.isFile())
|
|
351
|
+
return false;
|
|
352
|
+
const real = await realpath(path).catch(() => undefined);
|
|
353
|
+
if (!real)
|
|
354
|
+
return false;
|
|
355
|
+
const roots = await Promise.all(fileRoots().map((root) => realpath(root).catch(() => undefined)));
|
|
356
|
+
return roots.some((root) => root !== undefined && real.startsWith(`${root}${sep}`));
|
|
357
|
+
}
|
|
323
358
|
/** Streams the file to the link, never whole in memory: as many bytes as it had when the upload began. */
|
|
324
359
|
async function upload(path, url) {
|
|
325
360
|
const { size } = statSync(path);
|
|
@@ -382,14 +417,16 @@ export function gitUrl(url) {
|
|
|
382
417
|
async function fetchRepository(url, ref, token) {
|
|
383
418
|
const root = join(srcRoot(), `git_${createHash("sha256").update(normalizeRemote(url)).digest("hex").slice(0, 32)}`);
|
|
384
419
|
const git = (args, timeoutMs = 60_000) => gitRun(args, gitEnv(url, token), timeoutMs, token);
|
|
385
|
-
|
|
420
|
+
// A folder's git metadata is a folder of its own; anything else there (a file or a link pointing elsewhere) is not
|
|
421
|
+
// trusted, and the repository is fetched again from nothing.
|
|
422
|
+
const meta = await lstat(join(root, ".git")).catch(() => undefined);
|
|
423
|
+
if (meta && !meta.isDirectory())
|
|
424
|
+
await rm(root, { recursive: true, force: true });
|
|
425
|
+
if (!meta || !meta.isDirectory()) {
|
|
386
426
|
await mkdir(root, { recursive: true });
|
|
387
427
|
await git(["init", "-q", root]);
|
|
388
|
-
await git(["-C", root, "remote", "add", "origin", url]);
|
|
389
|
-
}
|
|
390
|
-
else {
|
|
391
|
-
await git(["-C", root, "remote", "set-url", "origin", url]);
|
|
392
428
|
}
|
|
429
|
+
await trustNothingIn(root, url);
|
|
393
430
|
await git(["-C", root, "fetch", "--depth", "1", "--no-tags", "origin", ref ?? "HEAD"], 10 * 60_000);
|
|
394
431
|
await git(["-C", root, "checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"]);
|
|
395
432
|
if (existsSync(join(root, ".gitmodules")))
|
|
@@ -397,14 +434,63 @@ async function fetchRepository(url, ref, token) {
|
|
|
397
434
|
const commit = (await git(["-C", root, "rev-parse", "--short=12", "HEAD"])).trim();
|
|
398
435
|
return { root, commit };
|
|
399
436
|
}
|
|
400
|
-
/**
|
|
437
|
+
/**
|
|
438
|
+
* The clone folder is reused, so code that ran in this workspace may have changed its git configuration: before each
|
|
439
|
+
* fetch the repository's config, and each submodule's, is written again from nothing but the values git needs (the
|
|
440
|
+
* remote's link, a submodule's worktree), so no setting there runs a program or sends the token anywhere. The link
|
|
441
|
+
* goes in the file, never in an argument. Hooks and the file monitor are off from the environment (gitEnv).
|
|
442
|
+
*/
|
|
443
|
+
export async function trustNothingIn(root, url) {
|
|
444
|
+
const gitDir = join(root, ".git");
|
|
445
|
+
await writeConfig(gitDir, { url });
|
|
446
|
+
// Submodules' own repositories, at any depth: .git/modules/<name>[/modules/<name>...].
|
|
447
|
+
const walk = async (dir) => {
|
|
448
|
+
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
449
|
+
if (!entry.isDirectory())
|
|
450
|
+
continue;
|
|
451
|
+
const sub = join(dir, entry.name);
|
|
452
|
+
if (existsSync(join(sub, "HEAD"))) {
|
|
453
|
+
const read = async (key) => (await gitRun(["config", "--file", join(sub, "config"), "--get", key], gitEnv(url, undefined), 10_000, undefined).catch(() => "")).trim();
|
|
454
|
+
const worktree = await read("core.worktree");
|
|
455
|
+
const remote = await read("remote.origin.url");
|
|
456
|
+
await writeConfig(sub, { worktree: worktree && !/[\n\r"\\]/.test(worktree) ? worktree : undefined, url: remote && gitUrl(remote) ? remote : undefined });
|
|
457
|
+
await walk(join(sub, "modules"));
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
await walk(sub);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
await walk(join(gitDir, "modules"));
|
|
465
|
+
}
|
|
466
|
+
async function writeConfig(gitDir, values) {
|
|
467
|
+
const lines = ["[core]", "\trepositoryformatversion = 0", "\tfilemode = true", "\tbare = false"];
|
|
468
|
+
if (values.worktree)
|
|
469
|
+
lines.push(`\tworktree = "${values.worktree}"`);
|
|
470
|
+
if (values.url)
|
|
471
|
+
lines.push('[remote "origin"]', `\turl = ${values.url}`, "\tfetch = +refs/heads/*:refs/remotes/origin/*");
|
|
472
|
+
const tmp = join(gitDir, `config.nomac-${process.pid}`);
|
|
473
|
+
await writeFile(tmp, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
474
|
+
await rename(tmp, join(gitDir, "config"));
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* git never asks, reads no configuration of the user or the system, runs no hook or file monitor, and fetches only
|
|
478
|
+
* over https; a token goes as an HTTP header set from the environment (GIT_CONFIG_*), for this host only.
|
|
479
|
+
*/
|
|
401
480
|
export function gitEnv(url, token) {
|
|
402
|
-
const
|
|
481
|
+
const config = [
|
|
482
|
+
["core.hooksPath", "/dev/null"],
|
|
483
|
+
["core.fsmonitor", "false"],
|
|
484
|
+
["protocol.allow", "never"],
|
|
485
|
+
["protocol.https.allow", "always"],
|
|
486
|
+
];
|
|
403
487
|
if (token) {
|
|
404
488
|
const { hostname } = new URL(url);
|
|
405
489
|
const user = hostname === "gitlab.com" ? "oauth2" : hostname === "bitbucket.org" ? "x-token-auth" : "x-access-token";
|
|
406
|
-
|
|
490
|
+
config.push([`http.https://${hostname}/.extraHeader`, `Authorization: Basic ${Buffer.from(`${user}:${token}`).toString("base64")}`]);
|
|
407
491
|
}
|
|
492
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "/usr/bin/false", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_COUNT: String(config.length) };
|
|
493
|
+
config.forEach(([key, value], i) => Object.assign(env, { [`GIT_CONFIG_KEY_${i}`]: key, [`GIT_CONFIG_VALUE_${i}`]: value }));
|
|
408
494
|
return env;
|
|
409
495
|
}
|
|
410
496
|
function gitRun(args, env, timeoutMs, token) {
|
|
@@ -462,17 +548,11 @@ async function unpackUpload(url, sha256, name) {
|
|
|
462
548
|
const work = await mkdtemp(join(srcRoot(), ".nomac-upload-"));
|
|
463
549
|
try {
|
|
464
550
|
const file = join(work, "archive");
|
|
465
|
-
|
|
466
|
-
if (!response.ok || !response.body)
|
|
467
|
-
throw new Error(`upload_download_failed: ${response.status}`);
|
|
468
|
-
await pipeline(Readable.fromWeb(response.body), createWriteStream(file));
|
|
469
|
-
const hash = createHash("sha256");
|
|
470
|
-
await pipeline(createReadStream(file), hash);
|
|
471
|
-
if (hash.digest("hex") !== sha256)
|
|
551
|
+
if ((await download(url, file, "upload")) !== sha256)
|
|
472
552
|
throw new Error("upload_checksum_mismatch: the file is not the one its sha256 names");
|
|
473
553
|
const out = join(work, "out");
|
|
474
554
|
await mkdir(out);
|
|
475
|
-
const head =
|
|
555
|
+
const head = await firstBytes(file, 4);
|
|
476
556
|
if (head[0] === 0x50 && head[1] === 0x4b)
|
|
477
557
|
await run("/usr/bin/ditto", ["-x", "-k", file, out], { timeoutMs: 10 * 60_000 });
|
|
478
558
|
// bsdtar keeps every entry inside out: it strips a leading / and refuses .. without -P.
|
|
@@ -489,6 +569,62 @@ async function unpackUpload(url, sha256, name) {
|
|
|
489
569
|
await rm(work, { recursive: true, force: true });
|
|
490
570
|
}
|
|
491
571
|
}
|
|
572
|
+
/** An upload or a sync's archive is at most this large, and its download takes at most this long. */
|
|
573
|
+
let DOWNLOAD_MAX_BYTES = 2 * 1024 ** 3;
|
|
574
|
+
const DOWNLOAD_MS = 30 * 60_000;
|
|
575
|
+
export const setDownloadLimit = (bytes) => (DOWNLOAD_MAX_BYTES = bytes);
|
|
576
|
+
/**
|
|
577
|
+
* Downloads a link NoMac signed to file, streamed (never whole in memory), within DOWNLOAD_MS and DOWNLOAD_MAX_BYTES;
|
|
578
|
+
* answers its sha256.
|
|
579
|
+
*/
|
|
580
|
+
async function download(url, file, what) {
|
|
581
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_MS) }).catch((error) => {
|
|
582
|
+
throw new Error(`${what}_download_failed: ${error.name === "TimeoutError" ? "timed out" : error.message}`);
|
|
583
|
+
});
|
|
584
|
+
if (!response.ok || !response.body)
|
|
585
|
+
throw new Error(`${what}_download_failed: ${response.status}`);
|
|
586
|
+
if (Number(response.headers.get("content-length") ?? 0) > DOWNLOAD_MAX_BYTES)
|
|
587
|
+
throw new Error(`${what}_too_large: at most ${Math.round(DOWNLOAD_MAX_BYTES / 1024 ** 2)} MiB`);
|
|
588
|
+
const hash = createHash("sha256");
|
|
589
|
+
let bytes = 0;
|
|
590
|
+
await pipeline(Readable.fromWeb(response.body), async function* (chunks) {
|
|
591
|
+
for await (const chunk of chunks) {
|
|
592
|
+
bytes += chunk.length;
|
|
593
|
+
if (bytes > DOWNLOAD_MAX_BYTES)
|
|
594
|
+
throw new Error(`${what}_too_large: at most ${Math.round(DOWNLOAD_MAX_BYTES / 1024 ** 2)} MiB`);
|
|
595
|
+
hash.update(chunk);
|
|
596
|
+
yield chunk;
|
|
597
|
+
}
|
|
598
|
+
}, createWriteStream(file)).catch((error) => {
|
|
599
|
+
throw error.message.startsWith(what) ? error : new Error(`${what}_download_failed: ${error.name === "TimeoutError" ? "timed out" : error.message}`);
|
|
600
|
+
});
|
|
601
|
+
return hash.digest("hex");
|
|
602
|
+
}
|
|
603
|
+
/** A file's first bytes, read alone. */
|
|
604
|
+
async function firstBytes(file, n) {
|
|
605
|
+
const handle = await open(file, "r");
|
|
606
|
+
try {
|
|
607
|
+
const buffer = Buffer.alloc(n);
|
|
608
|
+
const { bytesRead } = await handle.read(buffer, 0, n, 0);
|
|
609
|
+
return buffer.subarray(0, bytesRead);
|
|
610
|
+
}
|
|
611
|
+
finally {
|
|
612
|
+
await handle.close();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/** Each folder on the way to rel inside root becomes a real folder: a link or a file where one goes is removed first. */
|
|
616
|
+
async function realFoldersIn(root, rel) {
|
|
617
|
+
let at = root;
|
|
618
|
+
for (const part of rel.split("/").slice(0, -1)) {
|
|
619
|
+
at = join(at, part);
|
|
620
|
+
const st = await lstat(at).catch(() => undefined);
|
|
621
|
+
if (st?.isDirectory())
|
|
622
|
+
continue;
|
|
623
|
+
if (st)
|
|
624
|
+
await unlink(at);
|
|
625
|
+
await mkdir(at);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
492
628
|
/**
|
|
493
629
|
* Makes to hold what from holds: a file whose content differs is moved in, the rest keep theirs and their mtimes;
|
|
494
630
|
* what to has beyond from goes, but build output and dependencies stay (they were made here).
|
|
@@ -506,6 +642,7 @@ async function mirror(from, to) {
|
|
|
506
642
|
}
|
|
507
643
|
files.add(rel);
|
|
508
644
|
const target = join(to, rel);
|
|
645
|
+
await realFoldersIn(to, rel);
|
|
509
646
|
if (await sameContent(path, target, entry.isSymbolicLink()))
|
|
510
647
|
continue;
|
|
511
648
|
await mkdir(dirname(target), { recursive: true });
|
|
@@ -540,7 +677,11 @@ async function sameContent(a, b, link) {
|
|
|
540
677
|
return sa.isSymbolicLink() && sb.isSymbolicLink() && (await readlink(a)) === (await readlink(b));
|
|
541
678
|
if (sa.size !== sb.size || (sa.mode & 0o111) !== (sb.mode & 0o111))
|
|
542
679
|
return false;
|
|
543
|
-
const [ha, hb] = await Promise.all([a, b].map(async (p) =>
|
|
680
|
+
const [ha, hb] = await Promise.all([a, b].map(async (p) => {
|
|
681
|
+
const hash = createHash("sha256");
|
|
682
|
+
await pipeline(createReadStream(p), hash);
|
|
683
|
+
return hash.digest("hex");
|
|
684
|
+
}));
|
|
544
685
|
return ha === hb;
|
|
545
686
|
}
|
|
546
687
|
catch {
|
|
@@ -562,26 +703,69 @@ async function appIn(root) {
|
|
|
562
703
|
// The replay's own recorder, the file it records and the calls so far.
|
|
563
704
|
let replay;
|
|
564
705
|
const REPLAY_CALLS = 2000;
|
|
706
|
+
/** A ui_act step as the replay lists it, from the parsed step: what it acted on, never what it typed or a line it could not read. */
|
|
707
|
+
function stepSummary(line) {
|
|
708
|
+
const step = parseStep(String(line));
|
|
709
|
+
if (typeof step === "string")
|
|
710
|
+
return "(a step that did not parse)";
|
|
711
|
+
switch (step.op) {
|
|
712
|
+
case "fill":
|
|
713
|
+
return `fill ${describeTarget(step.target)} …`;
|
|
714
|
+
case "type":
|
|
715
|
+
return "type …";
|
|
716
|
+
case "scroll":
|
|
717
|
+
case "swipe":
|
|
718
|
+
return [step.op, step.direction, ...(step.target ? [describeTarget(step.target)] : [])].join(" ");
|
|
719
|
+
case "key":
|
|
720
|
+
return `key ${step.key}`;
|
|
721
|
+
case "back":
|
|
722
|
+
case "home":
|
|
723
|
+
return step.op;
|
|
724
|
+
default:
|
|
725
|
+
return `${step.op} ${describeTarget(step.target)}`;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
565
728
|
/**
|
|
566
|
-
* What a call did, for the replay: the tool and its main argument,
|
|
729
|
+
* What a call did, for the replay: the tool and its main argument, built from what was asked (a ui_act step's action and
|
|
730
|
+
* target), so typed text and links never reach the list.
|
|
567
731
|
*/
|
|
568
732
|
export function callSummary(tool, args) {
|
|
569
733
|
if (tool === "ui_act" && Array.isArray(args.steps))
|
|
570
|
-
return args.steps.map(
|
|
734
|
+
return args.steps.map(stepSummary).join(" · ").slice(0, 200);
|
|
571
735
|
if (tool === "app_control")
|
|
572
736
|
return [args.action, args.action === "open_url" ? "(a link)" : args.app].filter(Boolean).join(" ").slice(0, 120);
|
|
573
737
|
if (tool === "app_run" || tool === "tests_run")
|
|
574
738
|
return tool;
|
|
575
739
|
return Object.keys(args).length ? `${tool} ${Object.keys(args).join(", ")}` : tool;
|
|
576
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* What a call answered, for the replay: for ui_act, how many steps were done or which one failed, never its answer's
|
|
743
|
+
* words (they can repeat what a field shows); for a link, that it opened; for logs, that they were read (an app's logs
|
|
744
|
+
* can hold anything). Other tools' first line.
|
|
745
|
+
*/
|
|
746
|
+
export function callAnswer(tool, args, text, failed) {
|
|
747
|
+
const first = hostedText(text.split(/\r?\n|\r|\u2028|\u2029/)[0] ?? "");
|
|
748
|
+
if (tool === "ui_act") {
|
|
749
|
+
const done = /^(\d+) steps? done in ([\d.]+) s$/.exec(first);
|
|
750
|
+
if (done && !failed)
|
|
751
|
+
return `${done[1]} step${done[1] === "1" ? "" : "s"} done in ${done[2]} s`;
|
|
752
|
+
const step = /^Step (\d+)\b/.exec(first) ?? /^Stopped before step (\d+)\b/.exec(first);
|
|
753
|
+
if (step)
|
|
754
|
+
return first.startsWith("Stopped") ? `Stopped before step ${step[1]}: out of time` : `Step ${step[1]} failed`;
|
|
755
|
+
return failed ? "failed" : "done";
|
|
756
|
+
}
|
|
757
|
+
if (tool === "app_control" && args.action === "open_url")
|
|
758
|
+
return failed ? "Could not open the link." : "Opened the link.";
|
|
759
|
+
if (tool === "app_logs")
|
|
760
|
+
return failed ? "Could not read the app's logs." : "Read the app's logs.";
|
|
761
|
+
return first;
|
|
762
|
+
}
|
|
577
763
|
/** Notes a call for the replay, while one records. */
|
|
578
764
|
export function noteCall(tool, args, started, result) {
|
|
579
765
|
if (!replay || replay.log.length >= REPLAY_CALLS)
|
|
580
766
|
return;
|
|
581
767
|
const text = result.content.map((c) => (c && typeof c === "object" && c.type === "text" ? String(c.text ?? "") : "")).join("\n");
|
|
582
|
-
|
|
583
|
-
const first = hostedText(text.split("\n")[0] ?? "");
|
|
584
|
-
const said = tool === "ui_act" ? first.replace(/"[^"]*"|“[^”]*”/g, "…") : first;
|
|
768
|
+
const said = callAnswer(tool, args, text, Boolean(result.isError));
|
|
585
769
|
replay.log.push({ at_ms: started - replay.started, tool, what: callSummary(tool, args), ms: Date.now() - started, ok: !result.isError, said: said.slice(0, 200) });
|
|
586
770
|
}
|
|
587
771
|
// simctl's recorder, as screen_record's but its own process: the agent's recordings go on beside it, and the video
|
|
@@ -754,11 +938,12 @@ const listStates = async () => {
|
|
|
754
938
|
}
|
|
755
939
|
return states.sort((a, b) => b.saved_at.localeCompare(a.saved_at));
|
|
756
940
|
};
|
|
757
|
-
async function saveState(name) {
|
|
941
|
+
async function saveState(name, upload) {
|
|
758
942
|
const device = pickDevice(await listDevices(true), undefined, await heldDevice());
|
|
759
943
|
const data = await dataPathOf(device.udid);
|
|
760
944
|
const dir = join(statesDir(), name), saving = `${dir}.saving`;
|
|
761
945
|
await shutDown(device.udid);
|
|
946
|
+
let uploading, result;
|
|
762
947
|
try {
|
|
763
948
|
await rm(saving, { recursive: true, force: true });
|
|
764
949
|
await mkdir(saving, { recursive: true });
|
|
@@ -768,22 +953,36 @@ async function saveState(name) {
|
|
|
768
953
|
await writeFile(join(saving, "state.json"), JSON.stringify(meta));
|
|
769
954
|
await rm(dir, { recursive: true, force: true });
|
|
770
955
|
await rename(saving, dir);
|
|
771
|
-
|
|
956
|
+
// Kept in NoMac's storage too, so it restores on any phone: it uploads while the phone boots again.
|
|
957
|
+
uploading = upload ? uploadState(statesDir(), name, upload.url, upload.key, tmpdir()) : undefined;
|
|
958
|
+
uploading?.catch(() => { });
|
|
959
|
+
result = { state: "done", bytes, device: `${device.name} (${device.runtime})`, saved_at: meta.saved_at };
|
|
772
960
|
}
|
|
773
961
|
finally {
|
|
774
962
|
await rm(saving, { recursive: true, force: true });
|
|
775
963
|
await bootAgain(device.udid);
|
|
776
964
|
}
|
|
965
|
+
if (uploading) {
|
|
966
|
+
await uploading.catch((error) => { throw new Error(`saved on this phone, but not kept for other phones: ${error.message}`); });
|
|
967
|
+
result.uploaded = true;
|
|
968
|
+
}
|
|
969
|
+
return result;
|
|
777
970
|
}
|
|
778
|
-
async function restoreState(name) {
|
|
971
|
+
async function restoreState(name, fetch) {
|
|
779
972
|
const dir = join(statesDir(), name);
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
973
|
+
const local = async () => JSON.parse(await readFile(join(dir, "state.json"), "utf8"));
|
|
974
|
+
let meta = await local().catch(() => undefined);
|
|
975
|
+
// A copy here is the state only if it is the same save; otherwise the account's copy in NoMac's storage comes down.
|
|
976
|
+
let fetched = false;
|
|
977
|
+
if (fetch && meta?.saved_at !== fetch.savedAt) {
|
|
978
|
+
await fetchState(statesDir(), name, fetch.url, fetch.key);
|
|
979
|
+
meta = await local();
|
|
980
|
+
if (meta.saved_at !== fetch.savedAt)
|
|
981
|
+
throw new Error(`the stored ${name} is not the save asked for`);
|
|
982
|
+
fetched = true;
|
|
783
983
|
}
|
|
784
|
-
|
|
984
|
+
if (!meta)
|
|
785
985
|
throw new Error(`no saved state named ${name}`);
|
|
786
|
-
}
|
|
787
986
|
const device = pickDevice(await listDevices(true), undefined, await heldDevice());
|
|
788
987
|
// A state goes back onto the phone it came from: the same model and iOS.
|
|
789
988
|
if (meta.device !== device.name || meta.runtime !== device.runtime)
|
|
@@ -794,7 +993,7 @@ async function restoreState(name) {
|
|
|
794
993
|
await removeExcept(data, SYSTEM_ASSETS);
|
|
795
994
|
await run("/usr/bin/ditto", [join(dir, "data"), data], { timeoutMs: 10 * 60_000 });
|
|
796
995
|
await forgetAppPaths(data);
|
|
797
|
-
return { state: "done", bytes: meta.bytes, device: `${device.name} (${device.runtime})
|
|
996
|
+
return { state: "done", bytes: meta.bytes, device: `${device.name} (${device.runtime})`, ...(fetch ? { fetched } : {}) };
|
|
798
997
|
}
|
|
799
998
|
finally {
|
|
800
999
|
await bootAgain(device.udid);
|
|
@@ -807,7 +1006,14 @@ async function restoreState(name) {
|
|
|
807
1006
|
* id reads their state.
|
|
808
1007
|
*/
|
|
809
1008
|
async function phoneState(params) {
|
|
810
|
-
const { id, action, name } = params;
|
|
1009
|
+
const { id, action, name, url, data_key: key, saved_at: savedAt } = params;
|
|
1010
|
+
// A save given a link and a key keeps the state in NoMac's storage too; a restore given them fetches it when this
|
|
1011
|
+
// workspace has no copy of that save (saved_at).
|
|
1012
|
+
const transfer = url === undefined && key === undefined ? undefined : typeof url === "string" && url.startsWith("https://") && typeof key === "string" && /^[A-Za-z0-9_-]{43}$/.test(key) ? { url, key } : null;
|
|
1013
|
+
if (transfer === null)
|
|
1014
|
+
throw invalid("a state's url (https) and data_key");
|
|
1015
|
+
if (transfer && action === "restore" && (typeof savedAt !== "string" || savedAt.length > 64))
|
|
1016
|
+
throw invalid("a restore's saved_at");
|
|
811
1017
|
if (typeof id !== "string" || !STATE_ID.test(id))
|
|
812
1018
|
throw invalid("state id");
|
|
813
1019
|
if (action === "list")
|
|
@@ -828,7 +1034,7 @@ async function phoneState(params) {
|
|
|
828
1034
|
while (stateJobs.size > KEEP_STATE_JOBS)
|
|
829
1035
|
stateJobs.delete(stateJobs.keys().next().value);
|
|
830
1036
|
const began = Date.now();
|
|
831
|
-
(action === "save" ? saveState(name) : restoreState(name)).then(
|
|
1037
|
+
(action === "save" ? saveState(name, transfer) : restoreState(name, transfer ? { ...transfer, savedAt: savedAt } : undefined)).then(
|
|
832
1038
|
// The whole of it: the copy and the phone booted and readable again.
|
|
833
1039
|
(result) => Object.assign(started, result, { ms: Date.now() - began }), (error) => Object.assign(started, { state: "failed", error: error.message.slice(0, 300) }));
|
|
834
1040
|
job = started;
|
package/dist/registry.js
CHANGED
|
@@ -2,11 +2,12 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { cloudState } from "./cloud/account.js";
|
|
3
3
|
import { forward } from "./cloud/forward.js";
|
|
4
4
|
import { ToolError } from "./errors.js";
|
|
5
|
+
import { simulatorMissing } from "./engine/simctl.js";
|
|
5
6
|
import { setting } from "./settings.js";
|
|
6
7
|
import { appControl } from "./tools/app-control.js";
|
|
7
8
|
import { appLogs } from "./tools/app-logs.js";
|
|
8
9
|
import { appRun } from "./tools/app-run.js";
|
|
9
|
-
import { cloud } from "./tools/cloud.js";
|
|
10
|
+
import { cloud, tunnelNotice } from "./tools/cloud.js";
|
|
10
11
|
import { deviceUse } from "./tools/device-use.js";
|
|
11
12
|
import { devices } from "./tools/devices.js";
|
|
12
13
|
import { jobCancel, jobStatus } from "./tools/jobs.js";
|
|
@@ -15,6 +16,8 @@ import { testsRun } from "./tools/tests-run.js";
|
|
|
15
16
|
import { uiAct } from "./tools/ui-act.js";
|
|
16
17
|
import { uiLook } from "./tools/ui-look.js";
|
|
17
18
|
export const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
19
|
+
/** Tools that run without a Simulator on this machine: cloud mode itself, and following a job. */
|
|
20
|
+
const NO_SIMULATOR_NEEDED = new Set(["cloud", "job_status", "job_cancel"]);
|
|
18
21
|
/** Every tool, in the order clients list them. The MCP server and the CLI are both built from this. */
|
|
19
22
|
export const TOOLS = [devices, deviceUse, appRun, uiLook, uiAct, appControl, appLogs, testsRun, jobStatus, jobCancel, screenRecord, cloud];
|
|
20
23
|
/** How the tools fit together, sent once to MCP clients. */
|
|
@@ -68,8 +71,17 @@ export async function callTool(name, args = {}, context = { waitForJobs: false }
|
|
|
68
71
|
return { text: problem, isError: true };
|
|
69
72
|
try {
|
|
70
73
|
// 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
|
-
|
|
74
|
+
if (tool !== cloud && !setting("HOSTED") && (await cloudState()).on) {
|
|
75
|
+
const out = await forward(tool.name, args, context);
|
|
76
|
+
const notice = tunnelNotice();
|
|
77
|
+
return notice ? { ...out, text: `${out.text}\n${notice}` } : out;
|
|
78
|
+
}
|
|
79
|
+
// Where the Simulator can't run, every tool that needs it says so, and how to get a cloud phone.
|
|
80
|
+
if (!NO_SIMULATOR_NEEDED.has(tool.name) && !setting("HOSTED")) {
|
|
81
|
+
const missing = await simulatorMissing();
|
|
82
|
+
if (missing)
|
|
83
|
+
throw missing;
|
|
84
|
+
}
|
|
73
85
|
return await tool.run(args, context);
|
|
74
86
|
}
|
|
75
87
|
catch (error) {
|