simulator-use 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -5
- package/dist/act/resolve.js +29 -2
- package/dist/cli.js +12 -0
- package/dist/cloud/account.js +110 -0
- package/dist/cloud/api.js +62 -0
- package/dist/cloud/forward.js +109 -0
- package/dist/cloud/sync.js +173 -0
- package/dist/cloud/tunnel.js +222 -0
- package/dist/cloud-hint.js +3 -3
- package/dist/engine/apps.js +21 -4
- package/dist/engine/native.js +15 -5
- package/dist/engine/prompts.js +71 -0
- package/dist/engine/simctl.js +6 -1
- package/dist/engine/state.js +32 -0
- package/dist/engine/testcases.js +45 -0
- package/dist/engine/xcode.js +82 -16
- 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 +60 -13
- package/dist/tools/cloud.js +247 -0
- package/dist/tools/device-use.js +1 -1
- package/dist/tools/devices.js +6 -2
- package/dist/tools/launch-app.js +30 -2
- package/dist/tools/tests-run.js +40 -8
- 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/hosted.js
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What NoMac Simulator's host asks of a phone's session server besides tool calls (SIMULATOR_USE_HOSTED). The
|
|
3
|
+
* server runs as the phone's pool user, the only user that may touch the account's workspace, so the sync's file
|
|
4
|
+
* work happens here: hostd checks every request's shape first (mac-protocol's sim operation) and passes it on.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile, spawn } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
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";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
12
|
+
import { Readable } from "node:stream";
|
|
13
|
+
import { pipeline } from "node:stream/promises";
|
|
14
|
+
import { run } from "./engine/exec.js";
|
|
15
|
+
import { closeEngines, engineCall } from "./engine/native.js";
|
|
16
|
+
import { cancelAllJobs } from "./engine/jobs.js";
|
|
17
|
+
import { listDevices, pickDevice } from "./engine/simctl.js";
|
|
18
|
+
import { heldDevice, stateDir } from "./engine/state.js";
|
|
19
|
+
import { normalizeRemote, SKIP_DIRS } from "./cloud/sync.js";
|
|
20
|
+
import { callTool } from "./registry.js";
|
|
21
|
+
const PROJECT = /^prj_[a-f0-9]{32}$/;
|
|
22
|
+
const SYNC = /^sync_[a-f0-9]{40}$/;
|
|
23
|
+
const SHA = /^[a-f0-9]{64}$/;
|
|
24
|
+
// A sync's pages wait here until its place; only the latest few are kept.
|
|
25
|
+
const KEEP_SYNCS = 8;
|
|
26
|
+
// place answers within this, or that it runs (hostd asks again).
|
|
27
|
+
let ANSWER_WITHIN_MS = 8_000;
|
|
28
|
+
export const setAnswerWithin = (ms) => (ANSWER_WITHIN_MS = ms);
|
|
29
|
+
let workspace;
|
|
30
|
+
const syncs = new Map();
|
|
31
|
+
const invalid = (message) => Object.assign(new Error(message), { code: -32602 });
|
|
32
|
+
const absolute = (value) => typeof value === "string" && value.startsWith("/") && !value.includes("\0") && !value.split("/").includes("..") && value.length <= 1024;
|
|
33
|
+
const safePath = (path) => typeof path === "string" && path.length > 0 && !path.includes("\0") && !path.includes("\\") && path.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
34
|
+
/** The host tells a warm server, at hand-over, the account's build cache and workspace. */
|
|
35
|
+
export function configure(params) {
|
|
36
|
+
for (const key of ["cache_dir", "workspace"])
|
|
37
|
+
if (params[key] !== undefined && !absolute(params[key]))
|
|
38
|
+
throw invalid(`${key} must be an absolute path`);
|
|
39
|
+
if (params.cache_dir !== undefined)
|
|
40
|
+
process.env.SIMULATOR_USE_CACHE_DIR = params.cache_dir;
|
|
41
|
+
if (params.workspace !== undefined)
|
|
42
|
+
workspace = params.workspace;
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
const srcRoot = () => {
|
|
46
|
+
if (!workspace)
|
|
47
|
+
throw Object.assign(new Error("no_workspace"), { code: -32000 });
|
|
48
|
+
return join(workspace, "src");
|
|
49
|
+
};
|
|
50
|
+
const indexFile = (project) => join(srcRoot(), ".nomac-sync", `${project}.json`);
|
|
51
|
+
async function readIndex(project) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(await readFile(indexFile(project), "utf8"));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* One page of the client's file list: answers the paths whose content this workspace lacks (new, changed, or
|
|
61
|
+
* gone from the folder since the last sync).
|
|
62
|
+
*/
|
|
63
|
+
async function manifest(params) {
|
|
64
|
+
const { id, project, page, pages, entries } = params;
|
|
65
|
+
if (typeof id !== "string" || !SYNC.test(id) || typeof project !== "string" || !PROJECT.test(project))
|
|
66
|
+
throw invalid("sync id or project");
|
|
67
|
+
if (!Number.isInteger(page) || !Number.isInteger(pages) || page < 0 || page >= pages || !Array.isArray(entries))
|
|
68
|
+
throw invalid("page");
|
|
69
|
+
for (const e of entries)
|
|
70
|
+
if (!e || !safePath(e.path) || !SHA.test(e.sha256) || !Number.isInteger(e.size) || !Number.isInteger(e.mtime_ms))
|
|
71
|
+
throw invalid("entry");
|
|
72
|
+
let sync = syncs.get(id);
|
|
73
|
+
if (!sync) {
|
|
74
|
+
sync = { project, pages: new Map(), total: pages, missing: new Set() };
|
|
75
|
+
syncs.set(id, sync);
|
|
76
|
+
while (syncs.size > KEEP_SYNCS)
|
|
77
|
+
syncs.delete(syncs.keys().next().value);
|
|
78
|
+
}
|
|
79
|
+
if (sync.project !== project || sync.total !== pages)
|
|
80
|
+
throw invalid("the sync's project or page count changed");
|
|
81
|
+
sync.pages.set(page, entries);
|
|
82
|
+
const index = await readIndex(project);
|
|
83
|
+
const root = join(srcRoot(), project);
|
|
84
|
+
const missing = [];
|
|
85
|
+
for (const e of entries) {
|
|
86
|
+
const known = index[e.path];
|
|
87
|
+
let present = false;
|
|
88
|
+
if (known && known.sha256 === e.sha256 && known.kind === (e.kind ?? "file")) {
|
|
89
|
+
present = await lstat(join(root, e.path)).then((st) => (known.kind === "symlink" ? st.isSymbolicLink() : st.isFile() && st.size === e.size), () => false);
|
|
90
|
+
}
|
|
91
|
+
if (!present) {
|
|
92
|
+
missing.push(e.path);
|
|
93
|
+
sync.missing.add(e.path);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { missing };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Writes a sync into the workspace once all its pages arrived: the archive of the missing files (a gzip tar, given
|
|
100
|
+
* inline or as a link), every file's mtime from the manifest (new mtimes alone make Xcode compile again), and the
|
|
101
|
+
* removal of files the last sync had and this one does not. Nothing else in the folder is touched (dependencies
|
|
102
|
+
* installed on the host, build output). Answers within a few seconds, or that it runs.
|
|
103
|
+
*/
|
|
104
|
+
async function place(params) {
|
|
105
|
+
const id = params.id;
|
|
106
|
+
const sync = typeof id === "string" ? syncs.get(id) : undefined;
|
|
107
|
+
if (!sync)
|
|
108
|
+
throw invalid("unknown sync: send its manifest first");
|
|
109
|
+
if (sync.pages.size !== sync.total)
|
|
110
|
+
throw invalid(`the manifest has ${sync.pages.size} of ${sync.total} pages`);
|
|
111
|
+
if (!sync.place) {
|
|
112
|
+
sync.place = { state: "running" };
|
|
113
|
+
const placed = sync.place;
|
|
114
|
+
write(sync, params.archive).then((result) => Object.assign(placed, { state: "done" }, result), (error) => Object.assign(placed, { state: "failed", error: error.message.slice(0, 300) }));
|
|
115
|
+
}
|
|
116
|
+
const deadline = Date.now() + ANSWER_WITHIN_MS;
|
|
117
|
+
while (sync.place.state === "running" && Date.now() < deadline)
|
|
118
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
119
|
+
return { ...sync.place };
|
|
120
|
+
}
|
|
121
|
+
/** An archive holds the files the manifest asked for and the folders they are in, nothing else. */
|
|
122
|
+
async function onlyAskedFor(file, missing) {
|
|
123
|
+
const { stdout } = await run("/usr/bin/tar", ["-tzf", file], { timeoutMs: 5 * 60_000 });
|
|
124
|
+
for (const line of stdout.split("\n")) {
|
|
125
|
+
const member = line.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
126
|
+
if (member === "" || member === "." || missing.has(member))
|
|
127
|
+
continue;
|
|
128
|
+
if (![...missing].some((path) => path.startsWith(`${member}/`)))
|
|
129
|
+
throw new Error(`archive_unexpected: ${member.slice(0, 200)} was not asked for`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Every file the manifest asked for is in the folder with the kind, size and content it announced. A sync whose
|
|
134
|
+
* archive lacked one (a client that split a large sync, or a broken archive) fails here, before the index says
|
|
135
|
+
* the workspace has it, so the next manifest asks for it again.
|
|
136
|
+
*/
|
|
137
|
+
async function asAnnounced(root, entries, missing) {
|
|
138
|
+
const byPath = new Map(entries.map((e) => [e.path, e]));
|
|
139
|
+
const wrong = [];
|
|
140
|
+
for (const path of missing) {
|
|
141
|
+
const e = byPath.get(path);
|
|
142
|
+
if (!e)
|
|
143
|
+
continue;
|
|
144
|
+
const at = join(root, path);
|
|
145
|
+
const st = await lstat(at).catch(() => undefined);
|
|
146
|
+
let why;
|
|
147
|
+
if (!st)
|
|
148
|
+
why = "not in the archive";
|
|
149
|
+
else if ((e.kind ?? "file") === "symlink") {
|
|
150
|
+
if (!st.isSymbolicLink())
|
|
151
|
+
why = "not a symbolic link";
|
|
152
|
+
else if (createHash("sha256").update(await readlink(at)).digest("hex") !== e.sha256)
|
|
153
|
+
why = "a different link";
|
|
154
|
+
}
|
|
155
|
+
else if (!st.isFile())
|
|
156
|
+
why = "not a file";
|
|
157
|
+
else if (st.size !== e.size)
|
|
158
|
+
why = `${st.size} bytes, not ${e.size}`;
|
|
159
|
+
else {
|
|
160
|
+
const hash = createHash("sha256");
|
|
161
|
+
await pipeline(createReadStream(at), hash);
|
|
162
|
+
if (hash.digest("hex") !== e.sha256)
|
|
163
|
+
why = "different content";
|
|
164
|
+
}
|
|
165
|
+
if (why)
|
|
166
|
+
wrong.push(`${path.slice(0, 200)} (${why})`);
|
|
167
|
+
}
|
|
168
|
+
if (wrong.length)
|
|
169
|
+
throw new Error(`archive_incomplete: ${wrong.length} of the ${missing.size} files asked for are not as the manifest said: ${wrong.slice(0, 3).join("; ")}`);
|
|
170
|
+
}
|
|
171
|
+
async function write(sync, archive) {
|
|
172
|
+
const root = join(srcRoot(), sync.project);
|
|
173
|
+
const work = join(srcRoot(), ".nomac-sync");
|
|
174
|
+
await mkdir(root, { recursive: true });
|
|
175
|
+
await mkdir(work, { recursive: true });
|
|
176
|
+
const entries = [...sync.pages.values()].flat();
|
|
177
|
+
if (archive) {
|
|
178
|
+
const file = join(work, `archive-${process.pid}-${Date.now()}.tar.gz`);
|
|
179
|
+
try {
|
|
180
|
+
if (typeof archive.base64 === "string") {
|
|
181
|
+
await writeFile(file, Buffer.from(archive.base64, "base64"));
|
|
182
|
+
}
|
|
183
|
+
else if (typeof archive.url === "string" && archive.url.startsWith("https://")) {
|
|
184
|
+
const response = await fetch(archive.url);
|
|
185
|
+
if (!response.ok || !response.body)
|
|
186
|
+
throw new Error(`archive_download_failed: ${response.status}`);
|
|
187
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(file));
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
throw new Error("archive_invalid");
|
|
191
|
+
}
|
|
192
|
+
if (typeof archive.sha256 === "string") {
|
|
193
|
+
const hash = createHash("sha256");
|
|
194
|
+
await pipeline(createReadStream(file), hash);
|
|
195
|
+
if (hash.digest("hex") !== archive.sha256)
|
|
196
|
+
throw new Error("archive_checksum_mismatch");
|
|
197
|
+
}
|
|
198
|
+
await onlyAskedFor(file, sync.missing);
|
|
199
|
+
// bsdtar keeps every entry inside root: it strips a leading / and refuses .. without -P.
|
|
200
|
+
await run("/usr/bin/tar", ["-xzf", file, "-C", root], { timeoutMs: 10 * 60_000 });
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
await unlink(file).catch(() => { });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// The index records only what is really here: every file the manifest asked for, as it announced it.
|
|
207
|
+
await asAnnounced(root, entries, sync.missing);
|
|
208
|
+
// Modes and mtimes are written only where they differ: reading is cheap, and a sync then costs what changed,
|
|
209
|
+
// not every file of the project (a host busy preparing a phone made 758 writes take 7 s, October 23).
|
|
210
|
+
for (const e of entries) {
|
|
211
|
+
const path = join(root, e.path);
|
|
212
|
+
const kind = e.kind ?? "file";
|
|
213
|
+
const st = await lstat(path).catch(() => undefined);
|
|
214
|
+
if (!st)
|
|
215
|
+
continue;
|
|
216
|
+
const mode = kind === "executable" ? 0o755 : 0o644;
|
|
217
|
+
if (kind !== "symlink" && (st.mode & 0o777) !== mode)
|
|
218
|
+
await chmod(path, mode).catch(() => { });
|
|
219
|
+
if (Math.floor(st.mtimeMs) !== e.mtime_ms) {
|
|
220
|
+
const when = new Date(e.mtime_ms);
|
|
221
|
+
await lutimes(path, when, when).catch(() => { });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const before = await readIndex(sync.project);
|
|
225
|
+
const now = new Set(entries.map((e) => e.path));
|
|
226
|
+
let removed = 0;
|
|
227
|
+
for (const path of Object.keys(before)) {
|
|
228
|
+
if (now.has(path) || !safePath(path))
|
|
229
|
+
continue;
|
|
230
|
+
await rm(join(root, path), { force: true });
|
|
231
|
+
removed++;
|
|
232
|
+
}
|
|
233
|
+
const index = {};
|
|
234
|
+
for (const e of entries)
|
|
235
|
+
index[e.path] = { size: e.size, mtime_ms: e.mtime_ms, sha256: e.sha256, kind: e.kind ?? "file" };
|
|
236
|
+
const tmp = `${indexFile(sync.project)}.tmp`;
|
|
237
|
+
await mkdir(dirname(tmp), { recursive: true });
|
|
238
|
+
await writeFile(tmp, JSON.stringify(index));
|
|
239
|
+
await rename(tmp, indexFile(sync.project));
|
|
240
|
+
return { root, written: sync.missing.size, removed };
|
|
241
|
+
}
|
|
242
|
+
/** A hosted method; anything else is not one. */
|
|
243
|
+
// ---- Answers and files ------------------------------------------------------------------------------------
|
|
244
|
+
const FILE = /^file_[a-f0-9]{40}$/;
|
|
245
|
+
const GET = /^get_[a-f0-9]{40}$/;
|
|
246
|
+
// The latest files named in answers, by id; older ones are forgotten.
|
|
247
|
+
const KEEP_FILES = 500;
|
|
248
|
+
const files = new Map();
|
|
249
|
+
/** The folders whose files an answer may name: the workspace, the build cache and simulator-use's own. */
|
|
250
|
+
function fileRoots() {
|
|
251
|
+
return [workspace, process.env.SIMULATOR_USE_CACHE_DIR, stateDir()].filter((r) => typeof r === "string" && r.startsWith("/"));
|
|
252
|
+
}
|
|
253
|
+
function fileId(path) {
|
|
254
|
+
const id = `file_${createHash("sha1").update(path).digest("hex")}`;
|
|
255
|
+
files.delete(id);
|
|
256
|
+
files.set(id, path);
|
|
257
|
+
while (files.size > KEEP_FILES)
|
|
258
|
+
files.delete(files.keys().next().value);
|
|
259
|
+
return id;
|
|
260
|
+
}
|
|
261
|
+
/** One answer text as NoMac's customer reads it (see hostedAnswer). */
|
|
262
|
+
export function hostedText(text) {
|
|
263
|
+
let out = text;
|
|
264
|
+
if (workspace) {
|
|
265
|
+
const src = `${workspace}/src/`;
|
|
266
|
+
// A path in a synced project reads relative to that project, as on the customer's own machine.
|
|
267
|
+
out = out.replace(new RegExp(`${escapeRegExp(src)}prj_[a-f0-9]{32}(/|\\b)`, "g"), (_m, slash) => (slash === "/" ? "" : "."));
|
|
268
|
+
}
|
|
269
|
+
const roots = fileRoots();
|
|
270
|
+
// Any other file of this host (a build log, a recording, saved logs) becomes an id NoMac turns into a link.
|
|
271
|
+
return out.replace(/\/[^\s'"`()<>,;]+/g, (candidate) => {
|
|
272
|
+
const path = candidate.replace(/[.:]+$/, "");
|
|
273
|
+
const trailing = candidate.slice(path.length);
|
|
274
|
+
if (!roots.some((root) => path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)))
|
|
275
|
+
return candidate;
|
|
276
|
+
try {
|
|
277
|
+
if (!existsSync(path) || !statSync(path).isFile())
|
|
278
|
+
return candidate;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return candidate;
|
|
282
|
+
}
|
|
283
|
+
// The name after the id, so the download keeps it: file_<id>/build-1792758883520.log.
|
|
284
|
+
return `${fileId(path)}/${basename(path)}${trailing}`;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* A tool's answer as a NoMac Simulator customer reads it (SIMULATOR_USE_HOSTED): paths in a synced project
|
|
289
|
+
* relative to it, and this host's other files as file ids, which NoMac's API turns into download links
|
|
290
|
+
* (simulator-use/get). The customer never reads a path of the host.
|
|
291
|
+
*/
|
|
292
|
+
export function hostedAnswer(result) {
|
|
293
|
+
return { ...result, content: result.content.map((c) => (c && typeof c === "object" && c.type === "text" ? { ...c, text: hostedText(String(c.text ?? "")) } : c)) };
|
|
294
|
+
}
|
|
295
|
+
// The latest uploads by id, so a repeated get answers its state; older ones are forgotten.
|
|
296
|
+
const KEEP_GETS = 100;
|
|
297
|
+
const gets = new Map();
|
|
298
|
+
/**
|
|
299
|
+
* Uploads a file an answer named to a link NoMac signed (a presigned PUT), for its download link. Answers within
|
|
300
|
+
* a few seconds, or that it runs (a recording takes longer): the same id answers its state, and starts nothing new.
|
|
301
|
+
*/
|
|
302
|
+
async function get(params) {
|
|
303
|
+
const { id, file, url } = params;
|
|
304
|
+
if (typeof id !== "string" || !GET.test(id) || typeof file !== "string" || !FILE.test(file) || typeof url !== "string" || !url.startsWith("https://"))
|
|
305
|
+
throw invalid("get id, file or url");
|
|
306
|
+
let got = gets.get(id);
|
|
307
|
+
if (!got) {
|
|
308
|
+
const path = files.get(file);
|
|
309
|
+
if (!path || !existsSync(path))
|
|
310
|
+
throw Object.assign(new Error("file_not_found"), { code: -32000 });
|
|
311
|
+
const started = { state: "running" };
|
|
312
|
+
gets.set(id, started);
|
|
313
|
+
while (gets.size > KEEP_GETS)
|
|
314
|
+
gets.delete(gets.keys().next().value);
|
|
315
|
+
upload(path, url).then((size) => Object.assign(started, { state: "done", size }), (error) => Object.assign(started, { state: "failed", error: error.message.slice(0, 300) }));
|
|
316
|
+
got = started;
|
|
317
|
+
}
|
|
318
|
+
const deadline = Date.now() + ANSWER_WITHIN_MS;
|
|
319
|
+
while (got.state === "running" && Date.now() < deadline)
|
|
320
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
321
|
+
return { ...got };
|
|
322
|
+
}
|
|
323
|
+
/** Streams the file to the link, never whole in memory: as many bytes as it had when the upload began. */
|
|
324
|
+
async function upload(path, url) {
|
|
325
|
+
const { size } = statSync(path);
|
|
326
|
+
const body = size === 0 ? new Uint8Array() : Readable.toWeb(createReadStream(path, { start: 0, end: size - 1 }));
|
|
327
|
+
const response = await fetch(url, { method: "PUT", body, headers: { "content-length": String(size) }, duplex: "half" });
|
|
328
|
+
await response.body?.cancel();
|
|
329
|
+
if (!response.ok)
|
|
330
|
+
throw new Error(`upload_failed: storage answered ${response.status}`);
|
|
331
|
+
return size;
|
|
332
|
+
}
|
|
333
|
+
// ---- A project from a git link ----------------------------------------------------------------------------
|
|
334
|
+
const CLONE = /^clone_[a-f0-9]{40}$/;
|
|
335
|
+
const REF = /^(?!-)[A-Za-z0-9._/-]{1,200}$/;
|
|
336
|
+
// The latest fetches by id, so a repeated clone answers its state; older ones are forgotten.
|
|
337
|
+
const KEEP_CLONES = 50;
|
|
338
|
+
const clones = new Map();
|
|
339
|
+
/**
|
|
340
|
+
* A repository into the workspace (NoMac's hosted MCP: app_run from a git link): fetched shallow at a branch, tag
|
|
341
|
+
* or commit into src/git_<hash of the remote>, so the same repository builds incrementally the next time (only
|
|
342
|
+
* changed files are rewritten). A token for a private repository goes to git through its environment, never in an
|
|
343
|
+
* argument another user could read, and is not kept. Answers within a few seconds, or that it runs.
|
|
344
|
+
*/
|
|
345
|
+
async function clone(params) {
|
|
346
|
+
const { id, url, ref, token } = params;
|
|
347
|
+
if (typeof id !== "string" || !CLONE.test(id))
|
|
348
|
+
throw invalid("clone id");
|
|
349
|
+
let job = clones.get(id);
|
|
350
|
+
if (!job) {
|
|
351
|
+
if (typeof url !== "string" || !gitUrl(url))
|
|
352
|
+
throw invalid("url: an https link to a git repository, without a user or password in it");
|
|
353
|
+
if (ref !== undefined && (typeof ref !== "string" || !REF.test(ref)))
|
|
354
|
+
throw invalid("ref: a branch, tag or commit");
|
|
355
|
+
if (token !== undefined && (typeof token !== "string" || !/^[\x21-\x7e]{1,4096}$/.test(token)))
|
|
356
|
+
throw invalid("token");
|
|
357
|
+
srcRoot();
|
|
358
|
+
const started = { state: "running" };
|
|
359
|
+
clones.set(id, started);
|
|
360
|
+
while (clones.size > KEEP_CLONES)
|
|
361
|
+
clones.delete(clones.keys().next().value);
|
|
362
|
+
fetchRepository(url, ref, token).then((result) => Object.assign(started, { state: "done" }, result), (error) => Object.assign(started, { state: "failed", error: error.message.slice(0, 300) }));
|
|
363
|
+
job = started;
|
|
364
|
+
}
|
|
365
|
+
const deadline = Date.now() + ANSWER_WITHIN_MS;
|
|
366
|
+
while (job.state === "running" && Date.now() < deadline)
|
|
367
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
368
|
+
return { ...job };
|
|
369
|
+
}
|
|
370
|
+
/** https only, with a host and a path, no user or password (a token goes in token), nothing a shell would read. */
|
|
371
|
+
export function gitUrl(url) {
|
|
372
|
+
if (url.length > 2048 || /\s/.test(url))
|
|
373
|
+
return false;
|
|
374
|
+
try {
|
|
375
|
+
const u = new URL(url);
|
|
376
|
+
return u.protocol === "https:" && Boolean(u.hostname) && u.pathname.length > 1 && !u.username && !u.password && !u.search && !u.hash;
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function fetchRepository(url, ref, token) {
|
|
383
|
+
const root = join(srcRoot(), `git_${createHash("sha256").update(normalizeRemote(url)).digest("hex").slice(0, 32)}`);
|
|
384
|
+
const git = (args, timeoutMs = 60_000) => gitRun(args, gitEnv(url, token), timeoutMs, token);
|
|
385
|
+
if (!existsSync(join(root, ".git"))) {
|
|
386
|
+
await mkdir(root, { recursive: true });
|
|
387
|
+
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
|
+
}
|
|
393
|
+
await git(["-C", root, "fetch", "--depth", "1", "--no-tags", "origin", ref ?? "HEAD"], 10 * 60_000);
|
|
394
|
+
await git(["-C", root, "checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"]);
|
|
395
|
+
if (existsSync(join(root, ".gitmodules")))
|
|
396
|
+
await git(["-C", root, "submodule", "update", "--init", "--recursive", "--depth", "1"], 10 * 60_000);
|
|
397
|
+
const commit = (await git(["-C", root, "rev-parse", "--short=12", "HEAD"])).trim();
|
|
398
|
+
return { root, commit };
|
|
399
|
+
}
|
|
400
|
+
/** git never asks, and a token goes as an HTTP header set from the environment (GIT_CONFIG_*), for this host only. */
|
|
401
|
+
export function gitEnv(url, token) {
|
|
402
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "/usr/bin/false" };
|
|
403
|
+
if (token) {
|
|
404
|
+
const { hostname } = new URL(url);
|
|
405
|
+
const user = hostname === "gitlab.com" ? "oauth2" : hostname === "bitbucket.org" ? "x-token-auth" : "x-access-token";
|
|
406
|
+
Object.assign(env, { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `http.https://${hostname}/.extraHeader`, GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${user}:${token}`).toString("base64")}` });
|
|
407
|
+
}
|
|
408
|
+
return env;
|
|
409
|
+
}
|
|
410
|
+
function gitRun(args, env, timeoutMs, token) {
|
|
411
|
+
return new Promise((resolve, reject) => {
|
|
412
|
+
execFile("git", args, { env, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
413
|
+
if (!error)
|
|
414
|
+
return resolve(stdout);
|
|
415
|
+
// git's own words, without the token if it ever printed it.
|
|
416
|
+
const said = (stderr || error.message).trim().split(token || "\0").join("***");
|
|
417
|
+
reject(new Error(`git ${args.find((a) => !a.startsWith("-") && a !== "-C" && !a.startsWith("/")) ?? ""}: ${error.killed ? "timed out" : said.split("\n").slice(-3).join(" ")}`));
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
// ---- A project or an app from an upload ---------------------------------------------------------------------
|
|
422
|
+
const FETCH = /^fetch_[a-f0-9]{40}$/;
|
|
423
|
+
const UPLOAD_NAME = /^[A-Za-z0-9._-]{1,80}$/;
|
|
424
|
+
// The latest fetches by id, so a repeated fetch answers its state; older ones are forgotten.
|
|
425
|
+
const KEEP_FETCHES = 50;
|
|
426
|
+
const fetches = new Map();
|
|
427
|
+
/**
|
|
428
|
+
* An archive uploaded to NoMac (a project without a git remote, or a .app built elsewhere), into the workspace at
|
|
429
|
+
* src/upload_<hash of its name>: downloaded from the link NoMac signed, its checksum checked, unpacked aside and
|
|
430
|
+
* copied in where it differs, so an unchanged file keeps its mtime and the next build stays incremental. A .tar.gz
|
|
431
|
+
* or a .zip. Answers within a few seconds, or that it runs.
|
|
432
|
+
*/
|
|
433
|
+
async function fetchUpload(params) {
|
|
434
|
+
const { id, url, sha256, name } = params;
|
|
435
|
+
if (typeof id !== "string" || !FETCH.test(id))
|
|
436
|
+
throw invalid("fetch id");
|
|
437
|
+
let job = fetches.get(id);
|
|
438
|
+
if (!job) {
|
|
439
|
+
if (typeof url !== "string" || !url.startsWith("https://"))
|
|
440
|
+
throw invalid("url");
|
|
441
|
+
if (typeof sha256 !== "string" || !/^[a-f0-9]{64}$/.test(sha256))
|
|
442
|
+
throw invalid("sha256");
|
|
443
|
+
if (name !== undefined && (typeof name !== "string" || !UPLOAD_NAME.test(name)))
|
|
444
|
+
throw invalid("name: letters, digits, dot, dash and underscore");
|
|
445
|
+
srcRoot();
|
|
446
|
+
const started = { state: "running" };
|
|
447
|
+
fetches.set(id, started);
|
|
448
|
+
while (fetches.size > KEEP_FETCHES)
|
|
449
|
+
fetches.delete(fetches.keys().next().value);
|
|
450
|
+
unpackUpload(url, sha256, name ?? "upload").then((result) => Object.assign(started, { state: "done" }, result), (error) => Object.assign(started, { state: "failed", error: error.message.slice(0, 300) }));
|
|
451
|
+
job = started;
|
|
452
|
+
}
|
|
453
|
+
const deadline = Date.now() + ANSWER_WITHIN_MS;
|
|
454
|
+
while (job.state === "running" && Date.now() < deadline)
|
|
455
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
456
|
+
return { ...job };
|
|
457
|
+
}
|
|
458
|
+
async function unpackUpload(url, sha256, name) {
|
|
459
|
+
const root = join(srcRoot(), `upload_${createHash("sha256").update(name).digest("hex").slice(0, 32)}`);
|
|
460
|
+
// Beside the workspace's projects, on the same volume, so a changed file is moved in, not copied.
|
|
461
|
+
await mkdir(srcRoot(), { recursive: true });
|
|
462
|
+
const work = await mkdtemp(join(srcRoot(), ".nomac-upload-"));
|
|
463
|
+
try {
|
|
464
|
+
const file = join(work, "archive");
|
|
465
|
+
const response = await fetch(url);
|
|
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)
|
|
472
|
+
throw new Error("upload_checksum_mismatch: the file is not the one its sha256 names");
|
|
473
|
+
const out = join(work, "out");
|
|
474
|
+
await mkdir(out);
|
|
475
|
+
const head = (await readFile(file)).subarray(0, 4);
|
|
476
|
+
if (head[0] === 0x50 && head[1] === 0x4b)
|
|
477
|
+
await run("/usr/bin/ditto", ["-x", "-k", file, out], { timeoutMs: 10 * 60_000 });
|
|
478
|
+
// bsdtar keeps every entry inside out: it strips a leading / and refuses .. without -P.
|
|
479
|
+
else if (head[0] === 0x1f && head[1] === 0x8b)
|
|
480
|
+
await run("/usr/bin/tar", ["-xzf", file, "-C", out], { timeoutMs: 10 * 60_000 });
|
|
481
|
+
else
|
|
482
|
+
throw new Error("upload_unreadable: send a .tar.gz or a .zip");
|
|
483
|
+
await mkdir(root, { recursive: true });
|
|
484
|
+
const { written, removed } = await mirror(out, root);
|
|
485
|
+
const app = await appIn(root);
|
|
486
|
+
return { root, ...(app ? { app } : {}), written, removed };
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
await rm(work, { recursive: true, force: true });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Makes to hold what from holds: a file whose content differs is moved in, the rest keep theirs and their mtimes;
|
|
494
|
+
* what to has beyond from goes, but build output and dependencies stay (they were made here).
|
|
495
|
+
*/
|
|
496
|
+
async function mirror(from, to) {
|
|
497
|
+
const files = new Set();
|
|
498
|
+
let written = 0;
|
|
499
|
+
const walk = async (dir) => {
|
|
500
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
501
|
+
const path = join(dir, entry.name);
|
|
502
|
+
const rel = path.slice(from.length + 1);
|
|
503
|
+
if (entry.isDirectory()) {
|
|
504
|
+
await walk(path);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
files.add(rel);
|
|
508
|
+
const target = join(to, rel);
|
|
509
|
+
if (await sameContent(path, target, entry.isSymbolicLink()))
|
|
510
|
+
continue;
|
|
511
|
+
await mkdir(dirname(target), { recursive: true });
|
|
512
|
+
await rm(target, { recursive: true, force: true });
|
|
513
|
+
await rename(path, target);
|
|
514
|
+
written++;
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
await walk(from);
|
|
518
|
+
let removed = 0;
|
|
519
|
+
const prune = async (dir) => {
|
|
520
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
521
|
+
const path = join(dir, entry.name);
|
|
522
|
+
if (entry.isDirectory()) {
|
|
523
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
524
|
+
await prune(path);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (!files.has(path.slice(to.length + 1))) {
|
|
528
|
+
await unlink(path);
|
|
529
|
+
removed++;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
await prune(to);
|
|
534
|
+
return { written, removed };
|
|
535
|
+
}
|
|
536
|
+
async function sameContent(a, b, link) {
|
|
537
|
+
try {
|
|
538
|
+
const [sa, sb] = await Promise.all([lstat(a), lstat(b)]);
|
|
539
|
+
if (link || sa.isSymbolicLink() || sb.isSymbolicLink())
|
|
540
|
+
return sa.isSymbolicLink() && sb.isSymbolicLink() && (await readlink(a)) === (await readlink(b));
|
|
541
|
+
if (sa.size !== sb.size || (sa.mode & 0o111) !== (sb.mode & 0o111))
|
|
542
|
+
return false;
|
|
543
|
+
const [ha, hb] = await Promise.all([a, b].map(async (p) => createHash("sha256").update(await readFile(p)).digest("hex")));
|
|
544
|
+
return ha === hb;
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/** The .app at the upload's top, or in its one top folder: what app_control install takes. */
|
|
551
|
+
async function appIn(root) {
|
|
552
|
+
const top = await readdir(root, { withFileTypes: true });
|
|
553
|
+
const app = top.find((e) => e.isDirectory() && e.name.endsWith(".app"));
|
|
554
|
+
if (app)
|
|
555
|
+
return join(root, app.name);
|
|
556
|
+
const folders = top.filter((e) => e.isDirectory() && !e.name.startsWith("."));
|
|
557
|
+
if (folders.length !== 1)
|
|
558
|
+
return undefined;
|
|
559
|
+
const inner = (await readdir(join(root, folders[0].name), { withFileTypes: true })).find((e) => e.isDirectory() && e.name.endsWith(".app"));
|
|
560
|
+
return inner ? join(root, folders[0].name, inner.name) : undefined;
|
|
561
|
+
}
|
|
562
|
+
// The replay's own recorder, the file it records and the calls so far.
|
|
563
|
+
let replay;
|
|
564
|
+
const REPLAY_CALLS = 2000;
|
|
565
|
+
/**
|
|
566
|
+
* What a call did, for the replay: the tool and its main argument, with typed text masked (fill #password …).
|
|
567
|
+
*/
|
|
568
|
+
export function callSummary(tool, args) {
|
|
569
|
+
if (tool === "ui_act" && Array.isArray(args.steps))
|
|
570
|
+
return args.steps.map((step) => String(step).replace(/^((?:fill|type)\b(?:\s+(?:#\S+|"[^"]*"|\S+))?)\s.*$/i, (_m, head) => (/^type/i.test(head) ? "type …" : `${head} …`))).join(" · ").slice(0, 200);
|
|
571
|
+
if (tool === "app_control")
|
|
572
|
+
return [args.action, args.action === "open_url" ? "(a link)" : args.app].filter(Boolean).join(" ").slice(0, 120);
|
|
573
|
+
if (tool === "app_run" || tool === "tests_run")
|
|
574
|
+
return tool;
|
|
575
|
+
return Object.keys(args).length ? `${tool} ${Object.keys(args).join(", ")}` : tool;
|
|
576
|
+
}
|
|
577
|
+
/** Notes a call for the replay, while one records. */
|
|
578
|
+
export function noteCall(tool, args, started, result) {
|
|
579
|
+
if (!replay || replay.log.length >= REPLAY_CALLS)
|
|
580
|
+
return;
|
|
581
|
+
const text = result.content.map((c) => (c && typeof c === "object" && c.type === "text" ? String(c.text ?? "") : "")).join("\n");
|
|
582
|
+
// ui_act's answer can repeat what was typed ("fill #password \"…\" failed"): its quoted parts never reach the list.
|
|
583
|
+
const first = hostedText(text.split("\n")[0] ?? "");
|
|
584
|
+
const said = tool === "ui_act" ? first.replace(/"[^"]*"|“[^”]*”/g, "…") : first;
|
|
585
|
+
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
|
+
}
|
|
587
|
+
// simctl's recorder, as screen_record's but its own process: the agent's recordings go on beside it, and the video
|
|
588
|
+
// keeps the wall clock, so the list's times are its times. Tests replace it.
|
|
589
|
+
let recordScreen = async (file) => {
|
|
590
|
+
const device = pickDevice(await listDevices(true), undefined, await heldDevice());
|
|
591
|
+
return spawn("xcrun", ["simctl", "io", device.udid, "recordVideo", "--codec=h264", "--force", file], { stdio: "ignore" });
|
|
592
|
+
};
|
|
593
|
+
export function setReplayRecorder(record) {
|
|
594
|
+
recordScreen = record;
|
|
595
|
+
}
|
|
596
|
+
/** Where stop leaves the replay (video.mp4 and log.json) for NoMac to take once the session has ended. */
|
|
597
|
+
export const replayDir = () => join(stateDir(), "replay");
|
|
598
|
+
/**
|
|
599
|
+
* The session's replay (NoMac Simulator, plan 10.7 C3): start records the phone's screen and lists every tool
|
|
600
|
+
* call; stop ends both and leaves the video and the list in replayDir(). NoMac uploads them after the session's
|
|
601
|
+
* end, so the upload is never the customer's time.
|
|
602
|
+
*/
|
|
603
|
+
async function replayOp(params) {
|
|
604
|
+
if (params.op === "start") {
|
|
605
|
+
if (replay)
|
|
606
|
+
return { recording: true };
|
|
607
|
+
const file = join(stateDir(), `replay-${Date.now()}.mp4`);
|
|
608
|
+
replay = { recorder: await recordScreen(file), file, started: Date.now(), log: [] };
|
|
609
|
+
return { recording: true };
|
|
610
|
+
}
|
|
611
|
+
if (params.op !== "stop")
|
|
612
|
+
throw invalid("op is start or stop");
|
|
613
|
+
const ending = replay;
|
|
614
|
+
replay = undefined;
|
|
615
|
+
if (!ending)
|
|
616
|
+
return { recording: false };
|
|
617
|
+
// SIGINT makes simctl write the video's index and close it; a recorder that hangs is killed after 8 s.
|
|
618
|
+
await new Promise((resolve) => {
|
|
619
|
+
if (ending.recorder.exitCode !== null)
|
|
620
|
+
return resolve();
|
|
621
|
+
const timer = setTimeout(() => { ending.recorder.kill("SIGKILL"); resolve(); }, 8_000);
|
|
622
|
+
ending.recorder.once("exit", () => { clearTimeout(timer); resolve(); });
|
|
623
|
+
ending.recorder.kill("SIGINT");
|
|
624
|
+
});
|
|
625
|
+
const seconds = Math.round((Date.now() - ending.started) / 1000);
|
|
626
|
+
const dir = replayDir();
|
|
627
|
+
await mkdir(dir, { recursive: true });
|
|
628
|
+
const video = existsSync(ending.file);
|
|
629
|
+
if (video)
|
|
630
|
+
await rename(ending.file, join(dir, "video.mp4"));
|
|
631
|
+
await writeFile(join(dir, "log.json"), JSON.stringify({ started_at: new Date(ending.started).toISOString(), seconds, video, calls: ending.log }));
|
|
632
|
+
return { recording: false, calls: ending.log.length, seconds, video };
|
|
633
|
+
}
|
|
634
|
+
/** Tests replace the phone's engine read. */
|
|
635
|
+
let readForTests;
|
|
636
|
+
export function setFirstRead(read) {
|
|
637
|
+
readForTests = read;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* The pool's first read of a phone just booted: the engine only, again until it reads, never AXe (whose own
|
|
641
|
+
* automation session beside a young engine's wedges the phone). Right after a boot the engine's first read fails
|
|
642
|
+
* after about 20 s and the next one works (research, section 13), so the pool reads at once instead of waiting 30 s.
|
|
643
|
+
*/
|
|
644
|
+
async function readable(params) {
|
|
645
|
+
const within = Math.min(Math.max(Number(params.within_ms) || 120_000, 1_000), 300_000);
|
|
646
|
+
const started = Date.now();
|
|
647
|
+
let read = readForTests;
|
|
648
|
+
if (!read) {
|
|
649
|
+
const device = pickDevice(await listDevices(true), undefined, await heldDevice());
|
|
650
|
+
read = (timeoutMs) => engineCall(device.udid, { op: "describe" }, timeoutMs);
|
|
651
|
+
}
|
|
652
|
+
let tries = 0, error = "";
|
|
653
|
+
while (Date.now() - started < within) {
|
|
654
|
+
tries++;
|
|
655
|
+
const answer = await read(Math.max(1_000, within - (Date.now() - started)));
|
|
656
|
+
if (answer?.ok)
|
|
657
|
+
return { readable: true, ms: Date.now() - started, tries };
|
|
658
|
+
error = answer?.error ?? "no engine";
|
|
659
|
+
// No engine yet (it would not start): it is tried again after a while; a read that failed, at once.
|
|
660
|
+
await new Promise((resolve) => setTimeout(resolve, answer ? 300 : 2_000));
|
|
661
|
+
}
|
|
662
|
+
return { readable: false, ms: Date.now() - started, tries, error: error.slice(0, 200) };
|
|
663
|
+
}
|
|
664
|
+
// ---- Saved phone states ------------------------------------------------------------------------------------
|
|
665
|
+
const STATE_ID = /^state_[a-f0-9]{40}$/;
|
|
666
|
+
const STATE_NAME = /^[A-Za-z0-9._-]{1,64}$/;
|
|
667
|
+
const KEEP_STATE_JOBS = 20;
|
|
668
|
+
/** What iOS rebuilds by itself: left out of a saved state. */
|
|
669
|
+
const REBUILT = ["Library/Caches", "Library/Logs", "tmp"];
|
|
670
|
+
/**
|
|
671
|
+
* The system's own assets (Siri voices, ML models: 1.8 GB of a 3.7 GB phone), the same on every phone of a model and
|
|
672
|
+
* iOS: left out of a saved state, and a restore keeps the phone's own.
|
|
673
|
+
*/
|
|
674
|
+
const SYSTEM_ASSETS = "private/var/MobileAsset";
|
|
675
|
+
/**
|
|
676
|
+
* Copies from into to with ditto (which keeps what iOS's files carry: extended attributes, permissions), leaving out
|
|
677
|
+
* the paths in skip (relative to from) without copying them first.
|
|
678
|
+
*/
|
|
679
|
+
export async function copyExcept(from, to, skip) {
|
|
680
|
+
await mkdir(to, { recursive: true });
|
|
681
|
+
for (const name of await readdir(from)) {
|
|
682
|
+
if (skip.includes(name))
|
|
683
|
+
continue;
|
|
684
|
+
const inside = skip.filter((p) => p.startsWith(`${name}/`)).map((p) => p.slice(name.length + 1));
|
|
685
|
+
if (inside.length)
|
|
686
|
+
await copyExcept(join(from, name), join(to, name), inside);
|
|
687
|
+
else
|
|
688
|
+
await run("/usr/bin/ditto", [join(from, name), join(to, name)], { timeoutMs: 10 * 60_000 });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* LaunchServices' databases name each app by its absolute path on the phone it was installed on: another pool user's
|
|
693
|
+
* phone, for a state saved there, so the app would not launch. A restore removes them (the .csstore files only) and
|
|
694
|
+
* iOS rebuilds them at boot from the apps installed.
|
|
695
|
+
*/
|
|
696
|
+
export async function forgetAppPaths(data) {
|
|
697
|
+
const dir = join(data, "var", "db", "lsd");
|
|
698
|
+
for (const name of await readdir(dir).catch(() => []))
|
|
699
|
+
if (name.endsWith(".csstore"))
|
|
700
|
+
await rm(join(dir, name), { force: true });
|
|
701
|
+
}
|
|
702
|
+
/** Removes everything under root except keep (a path inside it) and the folders that lead to it. */
|
|
703
|
+
export async function removeExcept(root, keep) {
|
|
704
|
+
const [first, ...rest] = keep.split("/");
|
|
705
|
+
for (const name of await readdir(root).catch(() => [])) {
|
|
706
|
+
if (name !== first)
|
|
707
|
+
await rm(join(root, name), { recursive: true, force: true });
|
|
708
|
+
else if (rest.length)
|
|
709
|
+
await removeExcept(join(root, name), rest.join("/"));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const stateJobs = new Map();
|
|
713
|
+
const statesDir = () => {
|
|
714
|
+
if (!workspace)
|
|
715
|
+
throw Object.assign(new Error("no_workspace"), { code: -32000 });
|
|
716
|
+
return join(workspace, "phone-states");
|
|
717
|
+
};
|
|
718
|
+
/** Where a phone keeps its apps, their data, its keychain and permissions. */
|
|
719
|
+
async function dataPathOf(udid) {
|
|
720
|
+
const { stdout } = await run("xcrun", ["simctl", "list", "-j", "devices"], { timeoutMs: 30_000 });
|
|
721
|
+
const devices = Object.values(JSON.parse(stdout).devices).flat();
|
|
722
|
+
const path = devices.find((d) => d.udid === udid)?.dataPath;
|
|
723
|
+
if (!path || !absolute(path))
|
|
724
|
+
throw new Error("the phone's data folder was not found");
|
|
725
|
+
return path;
|
|
726
|
+
}
|
|
727
|
+
/** The phone off, and its engine with it: a saved state is copied from (or to) a phone at rest. */
|
|
728
|
+
async function shutDown(udid) {
|
|
729
|
+
closeEngines(udid);
|
|
730
|
+
await run("xcrun", ["simctl", "shutdown", udid], { timeoutMs: 60_000 }).catch((error) => {
|
|
731
|
+
if (!/current state: Shutdown|Unable to shutdown device in current state/i.test(error.message))
|
|
732
|
+
throw error;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
/** The phone booted again and readable by the engine (its first read after a boot fails for a while). */
|
|
736
|
+
async function bootAgain(udid) {
|
|
737
|
+
await run("xcrun", ["simctl", "boot", udid], { timeoutMs: 60_000 }).catch((error) => {
|
|
738
|
+
if (!/current state: Booted/i.test(error.message))
|
|
739
|
+
throw error;
|
|
740
|
+
});
|
|
741
|
+
await run("xcrun", ["simctl", "bootstatus", udid, "-b"], { timeoutMs: 180_000 });
|
|
742
|
+
await readable({ within_ms: 120_000 });
|
|
743
|
+
}
|
|
744
|
+
const listStates = async () => {
|
|
745
|
+
const names = (await readdir(statesDir()).catch(() => [])).filter((n) => STATE_NAME.test(n) && !n.endsWith(".saving"));
|
|
746
|
+
const states = [];
|
|
747
|
+
for (const name of names) {
|
|
748
|
+
try {
|
|
749
|
+
states.push(JSON.parse(await readFile(join(statesDir(), name, "state.json"), "utf8")));
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
// A state being written, or not one of ours.
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return states.sort((a, b) => b.saved_at.localeCompare(a.saved_at));
|
|
756
|
+
};
|
|
757
|
+
async function saveState(name) {
|
|
758
|
+
const device = pickDevice(await listDevices(true), undefined, await heldDevice());
|
|
759
|
+
const data = await dataPathOf(device.udid);
|
|
760
|
+
const dir = join(statesDir(), name), saving = `${dir}.saving`;
|
|
761
|
+
await shutDown(device.udid);
|
|
762
|
+
try {
|
|
763
|
+
await rm(saving, { recursive: true, force: true });
|
|
764
|
+
await mkdir(saving, { recursive: true });
|
|
765
|
+
await copyExcept(data, join(saving, "data"), [...REBUILT, SYSTEM_ASSETS]);
|
|
766
|
+
const bytes = Number((await run("/usr/bin/du", ["-sk", saving], { timeoutMs: 60_000 })).stdout.split(/\s/)[0]) * 1024;
|
|
767
|
+
const meta = { name, bytes, saved_at: new Date().toISOString(), device: device.name, runtime: device.runtime };
|
|
768
|
+
await writeFile(join(saving, "state.json"), JSON.stringify(meta));
|
|
769
|
+
await rm(dir, { recursive: true, force: true });
|
|
770
|
+
await rename(saving, dir);
|
|
771
|
+
return { state: "done", bytes, device: `${device.name} (${device.runtime})` };
|
|
772
|
+
}
|
|
773
|
+
finally {
|
|
774
|
+
await rm(saving, { recursive: true, force: true });
|
|
775
|
+
await bootAgain(device.udid);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
async function restoreState(name) {
|
|
779
|
+
const dir = join(statesDir(), name);
|
|
780
|
+
let meta;
|
|
781
|
+
try {
|
|
782
|
+
meta = JSON.parse(await readFile(join(dir, "state.json"), "utf8"));
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
throw new Error(`no saved state named ${name}`);
|
|
786
|
+
}
|
|
787
|
+
const device = pickDevice(await listDevices(true), undefined, await heldDevice());
|
|
788
|
+
// A state goes back onto the phone it came from: the same model and iOS.
|
|
789
|
+
if (meta.device !== device.name || meta.runtime !== device.runtime)
|
|
790
|
+
throw new Error(`${name} was saved on ${meta.device} (${meta.runtime}); this phone is ${device.name} (${device.runtime})`);
|
|
791
|
+
const data = await dataPathOf(device.udid);
|
|
792
|
+
await shutDown(device.udid);
|
|
793
|
+
try {
|
|
794
|
+
await removeExcept(data, SYSTEM_ASSETS);
|
|
795
|
+
await run("/usr/bin/ditto", [join(dir, "data"), data], { timeoutMs: 10 * 60_000 });
|
|
796
|
+
await forgetAppPaths(data);
|
|
797
|
+
return { state: "done", bytes: meta.bytes, device: `${device.name} (${device.runtime})` };
|
|
798
|
+
}
|
|
799
|
+
finally {
|
|
800
|
+
await bootAgain(device.udid);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* The phone's state by name, in the account's workspace (NoMac Simulator plan 10.7 E4): save copies the phone's apps,
|
|
805
|
+
* their data, its keychain and permissions (the phone restarts); restore puts a saved state back onto a phone of the
|
|
806
|
+
* same model and iOS; list and delete manage them. save and restore answer within a while, or that they run; the same
|
|
807
|
+
* id reads their state.
|
|
808
|
+
*/
|
|
809
|
+
async function phoneState(params) {
|
|
810
|
+
const { id, action, name } = params;
|
|
811
|
+
if (typeof id !== "string" || !STATE_ID.test(id))
|
|
812
|
+
throw invalid("state id");
|
|
813
|
+
if (action === "list")
|
|
814
|
+
return { state: "done", states: await listStates() };
|
|
815
|
+
if (typeof name !== "string" || !STATE_NAME.test(name))
|
|
816
|
+
throw invalid("name: letters, digits, dots, dashes and underscores, up to 64");
|
|
817
|
+
if (action === "delete") {
|
|
818
|
+
await rm(join(statesDir(), name), { recursive: true, force: true });
|
|
819
|
+
return { state: "done", states: await listStates() };
|
|
820
|
+
}
|
|
821
|
+
if (action !== "save" && action !== "restore")
|
|
822
|
+
throw invalid("action is save, restore, list or delete");
|
|
823
|
+
let job = stateJobs.get(id);
|
|
824
|
+
if (!job) {
|
|
825
|
+
statesDir();
|
|
826
|
+
const started = { state: "running" };
|
|
827
|
+
stateJobs.set(id, started);
|
|
828
|
+
while (stateJobs.size > KEEP_STATE_JOBS)
|
|
829
|
+
stateJobs.delete(stateJobs.keys().next().value);
|
|
830
|
+
const began = Date.now();
|
|
831
|
+
(action === "save" ? saveState(name) : restoreState(name)).then(
|
|
832
|
+
// The whole of it: the copy and the phone booted and readable again.
|
|
833
|
+
(result) => Object.assign(started, result, { ms: Date.now() - began }), (error) => Object.assign(started, { state: "failed", error: error.message.slice(0, 300) }));
|
|
834
|
+
job = started;
|
|
835
|
+
}
|
|
836
|
+
const deadline = Date.now() + ANSWER_WITHIN_MS;
|
|
837
|
+
while (job.state === "running" && Date.now() < deadline)
|
|
838
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
839
|
+
return { ...job };
|
|
840
|
+
}
|
|
841
|
+
// ---- The pool's warm-up -----------------------------------------------------------------------------------
|
|
842
|
+
const WARM_BUNDLE = "app.nomac.prewarm";
|
|
843
|
+
const WARM_FILES = {
|
|
844
|
+
"project.yml": [
|
|
845
|
+
"name: Warm",
|
|
846
|
+
"options:",
|
|
847
|
+
" deploymentTarget:",
|
|
848
|
+
' iOS: "18.0"',
|
|
849
|
+
"targets:",
|
|
850
|
+
" Warm:",
|
|
851
|
+
" type: application",
|
|
852
|
+
" platform: iOS",
|
|
853
|
+
" sources: [Sources]",
|
|
854
|
+
" settings:",
|
|
855
|
+
" base:",
|
|
856
|
+
` PRODUCT_BUNDLE_IDENTIFIER: ${WARM_BUNDLE}`,
|
|
857
|
+
" GENERATE_INFOPLIST_FILE: YES",
|
|
858
|
+
" INFOPLIST_KEY_UILaunchScreen_Generation: YES",
|
|
859
|
+
" CODE_SIGNING_ALLOWED: NO",
|
|
860
|
+
' SWIFT_VERSION: "6.0"',
|
|
861
|
+
"",
|
|
862
|
+
].join("\n"),
|
|
863
|
+
"Sources/App.swift": ['import SwiftUI', "", "@main", "struct WarmApp: App {", ' var body: some Scene { WindowGroup { Text("Ready") } }', "}", ""].join("\n"),
|
|
864
|
+
};
|
|
865
|
+
let warming;
|
|
866
|
+
/**
|
|
867
|
+
* Before NoMac hands this phone out (plan section 8b, item 22): a one-screen app built for the phone, installed,
|
|
868
|
+
* launched and removed through app_run, as a customer's would be, so the customer's first build and install do
|
|
869
|
+
* not pay for Xcode and the phone's installer starting in a wiped home (measured on the M6: the first build 13 s
|
|
870
|
+
* instead of 22, the first install 1.4 s instead of 5). Nothing of it stays: the app is removed, its folder
|
|
871
|
+
* deleted. { stop: true } ends a warm-up that runs (a customer now waits on this phone).
|
|
872
|
+
*/
|
|
873
|
+
async function warm(params) {
|
|
874
|
+
if (params.stop === true) {
|
|
875
|
+
if (!warming)
|
|
876
|
+
return { ok: true, ms: 0, stopped: false };
|
|
877
|
+
cancelAllJobs();
|
|
878
|
+
return { ...(await warming), stopped: true };
|
|
879
|
+
}
|
|
880
|
+
if (workspace)
|
|
881
|
+
throw invalid("the warm-up runs before the hand-over");
|
|
882
|
+
warming ??= (async () => {
|
|
883
|
+
const started = Date.now();
|
|
884
|
+
const dir = await mkdtemp(join(tmpdir(), "simulator-use-warm-"));
|
|
885
|
+
try {
|
|
886
|
+
for (const [name, text] of Object.entries(WARM_FILES)) {
|
|
887
|
+
await mkdir(dirname(join(dir, name)), { recursive: true });
|
|
888
|
+
await writeFile(join(dir, name), text);
|
|
889
|
+
}
|
|
890
|
+
const ran = await callTool("app_run", { path: dir }, { waitForJobs: true });
|
|
891
|
+
const removed = await callTool("app_control", { action: "uninstall", app: WARM_BUNDLE });
|
|
892
|
+
const failed = ran.isError ? ran : removed.isError && !/not installed|No app/i.test(removed.text) ? removed : undefined;
|
|
893
|
+
return failed ? { ok: false, ms: Date.now() - started, error: failed.text.slice(0, 300) } : { ok: true, ms: Date.now() - started };
|
|
894
|
+
}
|
|
895
|
+
finally {
|
|
896
|
+
await rm(dir, { recursive: true, force: true });
|
|
897
|
+
}
|
|
898
|
+
})().finally(() => (warming = undefined));
|
|
899
|
+
return warming;
|
|
900
|
+
}
|
|
901
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
902
|
+
export async function hosted(method, params) {
|
|
903
|
+
switch (method) {
|
|
904
|
+
case "simulator-use/configure":
|
|
905
|
+
return configure(params);
|
|
906
|
+
case "simulator-use/get":
|
|
907
|
+
return get(params);
|
|
908
|
+
case "simulator-use/warm":
|
|
909
|
+
return warm(params);
|
|
910
|
+
case "simulator-use/readable":
|
|
911
|
+
return readable(params);
|
|
912
|
+
case "simulator-use/state":
|
|
913
|
+
return phoneState(params);
|
|
914
|
+
case "simulator-use/clone":
|
|
915
|
+
return clone(params);
|
|
916
|
+
case "simulator-use/fetch":
|
|
917
|
+
return fetchUpload(params);
|
|
918
|
+
case "simulator-use/replay":
|
|
919
|
+
return replayOp(params);
|
|
920
|
+
case "simulator-use/sync":
|
|
921
|
+
if (params.op === "manifest")
|
|
922
|
+
return manifest(params);
|
|
923
|
+
if (params.op === "place")
|
|
924
|
+
return place(params);
|
|
925
|
+
throw invalid("op is manifest or place");
|
|
926
|
+
}
|
|
927
|
+
throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
|
|
928
|
+
}
|