simulator-use 0.10.1 → 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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
3
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
4
|
+
import { mkdtemp, rename, rm, stat } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Readable, Transform } from "node:stream";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { createGunzip, createGzip } from "node:zlib";
|
|
9
|
+
/**
|
|
10
|
+
* A saved phone state as NoMac keeps it in its storage (October 25, 2026), so any phone of the same model and iOS
|
|
11
|
+
* restores it, on any host: its folder as a tar, gzipped, in AES-256-GCM frames of up to 4 MiB under the state's own
|
|
12
|
+
* key (NoMac's, given for one transfer). Each frame is authenticated with its index and whether it is the last, so an
|
|
13
|
+
* archive cut short, reordered or changed is refused before anything of it is kept.
|
|
14
|
+
*/
|
|
15
|
+
const MAGIC = Buffer.from("NMS1");
|
|
16
|
+
const FRAME = 4 * 1024 * 1024;
|
|
17
|
+
const NONCE = 12;
|
|
18
|
+
const TAG = 16;
|
|
19
|
+
/** The longest a frame may claim: a corrupt length never makes the reader hold gigabytes. */
|
|
20
|
+
const MOST = FRAME + 64 * 1024;
|
|
21
|
+
const aad = (index, last) => {
|
|
22
|
+
const b = Buffer.alloc(MAGIC.length + 9);
|
|
23
|
+
MAGIC.copy(b);
|
|
24
|
+
b.writeBigUInt64BE(BigInt(index), MAGIC.length);
|
|
25
|
+
b[MAGIC.length + 8] = last ? 1 : 0;
|
|
26
|
+
return b;
|
|
27
|
+
};
|
|
28
|
+
export function stateKey(key) {
|
|
29
|
+
const raw = Buffer.from(key, "base64url");
|
|
30
|
+
if (raw.length !== 32 || raw.toString("base64url") !== key)
|
|
31
|
+
throw new Error("invalid_state_key");
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
34
|
+
/** Plain bytes in, sealed frames out. The last frame is sealed as last at the end, empty if need be. */
|
|
35
|
+
export function sealer(key) {
|
|
36
|
+
let pending = Buffer.alloc(0), index = 0, started = false;
|
|
37
|
+
const frame = (t, plain, last) => {
|
|
38
|
+
const nonce = randomBytes(NONCE);
|
|
39
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
40
|
+
cipher.setAAD(aad(index++, last));
|
|
41
|
+
const body = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
42
|
+
const length = Buffer.alloc(4);
|
|
43
|
+
length.writeUInt32BE(plain.length);
|
|
44
|
+
t.push(Buffer.concat([length, nonce, body, cipher.getAuthTag()]));
|
|
45
|
+
};
|
|
46
|
+
return new Transform({
|
|
47
|
+
transform(chunk, _enc, done) {
|
|
48
|
+
if (!started) {
|
|
49
|
+
this.push(MAGIC);
|
|
50
|
+
started = true;
|
|
51
|
+
}
|
|
52
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
53
|
+
// A full frame goes out only once more data follows it, so the one at the end is the one sealed as last.
|
|
54
|
+
while (pending.length > FRAME) {
|
|
55
|
+
frame(this, pending.subarray(0, FRAME), false);
|
|
56
|
+
pending = pending.subarray(FRAME);
|
|
57
|
+
}
|
|
58
|
+
done();
|
|
59
|
+
},
|
|
60
|
+
flush(done) {
|
|
61
|
+
if (!started)
|
|
62
|
+
this.push(MAGIC);
|
|
63
|
+
frame(this, pending, true);
|
|
64
|
+
done();
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Sealed frames in, plain bytes out: refused at the first frame that does not open, or an end with no last frame. */
|
|
69
|
+
export function opener(key) {
|
|
70
|
+
let buffered = Buffer.alloc(0), index = 0, header = false, ended = false;
|
|
71
|
+
return new Transform({
|
|
72
|
+
transform(chunk, _enc, done) {
|
|
73
|
+
buffered = buffered.length ? Buffer.concat([buffered, chunk]) : chunk;
|
|
74
|
+
try {
|
|
75
|
+
if (!header) {
|
|
76
|
+
if (buffered.length < MAGIC.length)
|
|
77
|
+
return done();
|
|
78
|
+
if (!buffered.subarray(0, MAGIC.length).equals(MAGIC))
|
|
79
|
+
throw new Error("not a NoMac phone state");
|
|
80
|
+
buffered = buffered.subarray(MAGIC.length);
|
|
81
|
+
header = true;
|
|
82
|
+
}
|
|
83
|
+
for (;;) {
|
|
84
|
+
if (buffered.length < 4)
|
|
85
|
+
break;
|
|
86
|
+
const length = buffered.readUInt32BE(0);
|
|
87
|
+
if (length > MOST)
|
|
88
|
+
throw new Error("a frame claims too much");
|
|
89
|
+
const whole = 4 + NONCE + length + TAG;
|
|
90
|
+
if (buffered.length < whole)
|
|
91
|
+
break;
|
|
92
|
+
if (ended)
|
|
93
|
+
throw new Error("data after the last frame");
|
|
94
|
+
const nonce = buffered.subarray(4, 4 + NONCE), body = buffered.subarray(4 + NONCE, 4 + NONCE + length), tag = buffered.subarray(4 + NONCE + length, whole);
|
|
95
|
+
// A frame opens as the last or as not: which, its sealing says.
|
|
96
|
+
let plain;
|
|
97
|
+
for (const last of [false, true]) {
|
|
98
|
+
try {
|
|
99
|
+
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
|
100
|
+
decipher.setAAD(aad(index, last));
|
|
101
|
+
decipher.setAuthTag(tag);
|
|
102
|
+
plain = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
103
|
+
ended = last;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
catch { /* the other way, or neither */ }
|
|
107
|
+
}
|
|
108
|
+
if (!plain)
|
|
109
|
+
throw new Error("the state does not open with its key (changed, or another state's)");
|
|
110
|
+
index++;
|
|
111
|
+
buffered = buffered.subarray(whole);
|
|
112
|
+
if (plain.length)
|
|
113
|
+
this.push(plain);
|
|
114
|
+
}
|
|
115
|
+
done();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
done(error);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
flush(done) {
|
|
122
|
+
done(!header || !ended || buffered.length ? new Error("the state is cut short") : undefined);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** tar as a stream, its errors and exit status kept: a tar that fails mid-way fails the pipeline. */
|
|
127
|
+
function tar(args, cwd) {
|
|
128
|
+
const child = spawn("/usr/bin/tar", args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
129
|
+
let stderr = "";
|
|
130
|
+
child.stderr.on("data", (d) => { stderr = (stderr + d).slice(-2000); });
|
|
131
|
+
const exited = new Promise((resolve, reject) => {
|
|
132
|
+
child.on("error", reject);
|
|
133
|
+
child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`tar exited ${code}: ${stderr.trim().split("\n").pop() ?? ""}`))));
|
|
134
|
+
});
|
|
135
|
+
return { child, exited };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The state named name, in statesDir, sealed under key and PUT to url (a link NoMac signed). It is sealed into a file
|
|
139
|
+
* in tmp first, since the storage takes a PUT of a known length. Answers the bytes sent.
|
|
140
|
+
*/
|
|
141
|
+
export async function uploadState(statesDir, name, url, key, tmp) {
|
|
142
|
+
const dir = await mkdtemp(join(tmp, "state-")), sealed = join(dir, `${name}.nms`);
|
|
143
|
+
try {
|
|
144
|
+
const packing = tar(["-cf", "-", name], statesDir);
|
|
145
|
+
packing.child.stdin.end();
|
|
146
|
+
try {
|
|
147
|
+
await pipeline(packing.child.stdout, createGzip({ level: 1 }), sealer(stateKey(key)), createWriteStream(sealed, { mode: 0o600 }));
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
packing.child.kill();
|
|
151
|
+
await packing.exited.catch(() => { });
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
await packing.exited;
|
|
155
|
+
const { size } = await stat(sealed);
|
|
156
|
+
const body = Readable.toWeb(createReadStream(sealed));
|
|
157
|
+
const response = await fetch(url, { method: "PUT", body, headers: { "content-length": String(size) }, duplex: "half" });
|
|
158
|
+
await response.body?.cancel();
|
|
159
|
+
if (!response.ok)
|
|
160
|
+
throw new Error(`the state's upload failed: storage answered ${response.status}`);
|
|
161
|
+
return size;
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The state at url (sealed under key) unpacked into statesDir as name, replacing a copy there only once all of it
|
|
169
|
+
* opened: it is unpacked beside it first, then moved into place.
|
|
170
|
+
*/
|
|
171
|
+
export async function fetchState(statesDir, name, url, key) {
|
|
172
|
+
const response = await fetch(url);
|
|
173
|
+
if (!response.ok || !response.body)
|
|
174
|
+
throw new Error(`the state's download failed: storage answered ${response.status}`);
|
|
175
|
+
const into = await mkdtemp(join(statesDir, `.${name}.fetching-`));
|
|
176
|
+
try {
|
|
177
|
+
// bsdtar keeps entries inside into: no absolute paths, no .., no writes through a symlink.
|
|
178
|
+
const unpacking = tar(["-xf", "-"], into);
|
|
179
|
+
try {
|
|
180
|
+
await pipeline(Readable.fromWeb(response.body), opener(stateKey(key)), createGunzip(), unpacking.child.stdin);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
// Stopped and gone before its folder is removed, so nothing is written into it after.
|
|
184
|
+
unpacking.child.kill();
|
|
185
|
+
await unpacking.exited.catch(() => { });
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
await unpacking.exited;
|
|
189
|
+
await stat(join(into, name, "state.json"));
|
|
190
|
+
await rm(join(statesDir, name), { recursive: true, force: true });
|
|
191
|
+
await rename(join(into, name), join(statesDir, name));
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
await rm(into, { recursive: true, force: true }).catch(() => { });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -2,12 +2,26 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { resolve as resolvePath } from "node:path";
|
|
3
3
|
import { appInfo, BUNDLE_ID, bundleIdOf, install, pushPayload, simctlAction, stopLogStream } from "../engine/apps.js";
|
|
4
4
|
import { listDevices, pickDevice } from "../engine/simctl.js";
|
|
5
|
+
import { engineCall } from "../engine/native.js";
|
|
5
6
|
import { currentApp, heldDevice, setCurrentApp, setDeviceTimeZone } from "../engine/state.js";
|
|
6
7
|
import { ToolError } from "../errors.js";
|
|
8
|
+
import { setting } from "../settings.js";
|
|
7
9
|
import { readScreen } from "../look/read.js";
|
|
8
10
|
import { render } from "../look/snapshot.js";
|
|
9
11
|
import { grantAll, openInPrompt, setPermissionMode } from "../engine/prompts.js";
|
|
10
12
|
import { launchAndLook, tapPrompt } from "./launch-app.js";
|
|
13
|
+
/**
|
|
14
|
+
* A link opens through the engine, which reads it from its input: simctl openurl takes it as an argument, which every
|
|
15
|
+
* user of a Mac can read, so a shared host (SIMULATOR_USE_HOSTED) never falls back to it.
|
|
16
|
+
*/
|
|
17
|
+
export async function openUrl(udid, url) {
|
|
18
|
+
const answer = await engineCall(udid, { op: "open_url", url }, 20_000);
|
|
19
|
+
if (answer?.ok)
|
|
20
|
+
return;
|
|
21
|
+
if (setting("HOSTED"))
|
|
22
|
+
throw new ToolError(`Could not open the link${answer?.error ? `: ${answer.error}` : ""}.`, "Check it is a full link with its scheme (https://, or the app's own), then try again.");
|
|
23
|
+
await simctlAction(["openurl", udid, url], "open the link");
|
|
24
|
+
}
|
|
11
25
|
const ACTIONS = ["launch", "install", "terminate", "uninstall", "open_url", "grant", "revoke", "reset_permissions", "location", "push", "appearance", "locale", "timezone", "text_size", "status_bar"];
|
|
12
26
|
/** simctl ui content_size's sizes. */
|
|
13
27
|
const TEXT_SIZES = ["extra-small", "small", "medium", "large", "extra-large", "extra-extra-large", "extra-extra-extra-large",
|
|
@@ -99,7 +113,7 @@ export const appControl = {
|
|
|
99
113
|
if (!path || !existsSync(path))
|
|
100
114
|
throw new ToolError("install needs app: the path to a .app built for the Simulator.", "Build with xcodebuild -sdk iphonesimulator; the .app is under Build/Products/Debug-iphonesimulator.");
|
|
101
115
|
const bundleId = await bundleIdOf(path);
|
|
102
|
-
await install(device.udid, path);
|
|
116
|
+
await install(device.udid, path, device.runtime);
|
|
103
117
|
const info = await appInfo(device.udid, bundleId);
|
|
104
118
|
await setCurrentApp(device.udid, { bundleId, executable: info?.executable });
|
|
105
119
|
const refused = await grantAll(device.udid, bundleId);
|
|
@@ -128,13 +142,13 @@ export const appControl = {
|
|
|
128
142
|
await simctlAction(["uninstall", device.udid, bundleId], `remove ${bundleId}`);
|
|
129
143
|
return { text: `Removed ${bundleId} and its data from ${label}.` };
|
|
130
144
|
case "open_url": {
|
|
131
|
-
await
|
|
145
|
+
await openUrl(device.udid, value);
|
|
132
146
|
let snap = (await readScreen(device.udid)).snap;
|
|
133
147
|
// A link that opens another app makes iOS ask "Open in “App”?": the agent asked for exactly that.
|
|
134
148
|
const asked = openInPrompt(snap);
|
|
135
149
|
if (asked)
|
|
136
150
|
snap = await tapPrompt(device, "Open");
|
|
137
|
-
return { text: `Opened
|
|
151
|
+
return { text: `Opened the link.${asked ? ` iOS asked "${asked}": answered Open.` : ""}\n${render(snap, { device: label })}` };
|
|
138
152
|
}
|
|
139
153
|
case "grant":
|
|
140
154
|
case "revoke":
|
package/dist/tools/app-logs.js
CHANGED
|
@@ -64,9 +64,9 @@ export const appLogs = {
|
|
|
64
64
|
// Swift's own message ("App.swift:64: Fatal error: ...") goes to stderr, not into the report: show both.
|
|
65
65
|
const last = await readFile(await consoleFile(device.udid, bundleId), "utf8").then((t) => cleanConsole(t).slice(-5).map(clip), () => []);
|
|
66
66
|
const output = last.length ? `\nThe app's last output:\n${last.join("\n")}` : "";
|
|
67
|
-
const crashes = await crashReports(executable, Date.now() - Math.max(seconds, 3_600) * 1000);
|
|
67
|
+
const crashes = await crashReports(executable, Date.now() - Math.max(seconds, 3_600) * 1000, { bundleId, udid: device.udid });
|
|
68
68
|
if (!crashes.length) {
|
|
69
|
-
return { text: `No crash report for ${
|
|
69
|
+
return { text: `No crash report for ${bundleId} on ${device.name} in the last hour. macOS writes one up to a minute after a crash (44 s measured).${output}\nNext: if the app just crashed, call app_logs {crashes: true} again in 30 s.` };
|
|
70
70
|
}
|
|
71
71
|
return { text: crashes.map((c) => [`crash at ${c.time} (${c.file})`, ...c.summary].join("\n")).join("\n\n") + output };
|
|
72
72
|
}
|
package/dist/tools/app-run.js
CHANGED
|
@@ -2,7 +2,7 @@ import { bundleIdOf, install } from "../engine/apps.js";
|
|
|
2
2
|
import { answer, claimProject, currentJob, startJob } from "../engine/jobs.js";
|
|
3
3
|
import { bootDevice, findDevice, listDevices, pickDevice } from "../engine/simctl.js";
|
|
4
4
|
import { heldDevice, holdDevice, setDeviceEnv } from "../engine/state.js";
|
|
5
|
-
import { build, builtApp, formatIssue, knownFix, noDestination, projectFor, remember } from "../engine/xcode.js";
|
|
5
|
+
import { build, builtApp, formatIssue, knownFix, noDestination, projectFor, remember, targetTooNew } from "../engine/xcode.js";
|
|
6
6
|
import { pickXcode } from "../engine/xcodes.js";
|
|
7
7
|
import { grantAll, setPermissionMode } from "../engine/prompts.js";
|
|
8
8
|
import { ToolError } from "../errors.js";
|
|
@@ -74,7 +74,7 @@ export const appRun = {
|
|
|
74
74
|
// Finding the project (xcodebuild -list) and booting count toward the 40 s answer too: all of it is the job.
|
|
75
75
|
const job = startJob("Build and run", async (progress) => {
|
|
76
76
|
progress.step("finding the project");
|
|
77
|
-
const { project, scheme, dir, note, xcode: kept } = await projectFor(args.path, args.scheme);
|
|
77
|
+
const { project, scheme, dir, note, xcode: kept } = await projectFor(args.path, args.scheme, progress);
|
|
78
78
|
const asked = args.xcode ?? kept;
|
|
79
79
|
const xcode = await pickXcode(asked);
|
|
80
80
|
const other = xcode && !xcode.default ? xcode : undefined;
|
|
@@ -99,6 +99,13 @@ export const appRun = {
|
|
|
99
99
|
onCancel: (cancel) => progress.setCancel(cancel),
|
|
100
100
|
};
|
|
101
101
|
let result = await build(project, scheme, device.udid, options);
|
|
102
|
+
// A deployment target later than the phone's iOS leaves no destination either, and no build would install.
|
|
103
|
+
const tooNew = !result.ok && noDestination(result) ? await targetTooNew(project, scheme, device, "app_run again", other) : undefined;
|
|
104
|
+
if (tooNew) {
|
|
105
|
+
progress.setCancel(undefined);
|
|
106
|
+
progress.setLog(result.log);
|
|
107
|
+
return { text: `${note}Build of ${scheme} failed: ${tooNew}`, isError: true };
|
|
108
|
+
}
|
|
102
109
|
// An Xcode older than the phone's iOS cannot name it: it builds for any Simulator, and the app installs all the same.
|
|
103
110
|
let anySimulator = false;
|
|
104
111
|
if (!result.ok && other && noDestination(result)) {
|
|
@@ -125,7 +132,7 @@ export const appRun = {
|
|
|
125
132
|
if (!app)
|
|
126
133
|
return { text: `Built ${scheme} in ${seconds} s, but found no .app for the Simulator. Full log: ${result.log}`, isError: true };
|
|
127
134
|
const bundleId = await bundleIdOf(app);
|
|
128
|
-
await install(device.udid, app);
|
|
135
|
+
await install(device.udid, app, device.runtime);
|
|
129
136
|
const keep = asked && xcode && !xcode.default ? asked : undefined;
|
|
130
137
|
await remember(dir, { project: project.path, scheme, bundleId, ...(keep ? { xcode: keep } : {}) });
|
|
131
138
|
// Granted by default, so no prompt stops the agent; "ask" leaves iOS's prompts for a test of them.
|