@g9tm/app-pilot-cli 0.0.0-stage → 0.1.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/LICENSE +16 -0
- package/bin/g9pilot.mjs +1594 -0
- package/package.json +26 -4
- package/src/design.mjs +346 -0
- package/src/visual.mjs +401 -0
- package/README.md +0 -3
package/bin/g9pilot.mjs
ADDED
|
@@ -0,0 +1,1594 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// g9pilot — the G9AppPilot controller CLI (pilot/AiGuides/04).
|
|
3
|
+
//
|
|
4
|
+
// g9pilot hello [--url http://127.0.0.1:8799]
|
|
5
|
+
// g9pilot call <command> [json] signed; prints the answer
|
|
6
|
+
// g9pilot trace [list|<id>] [--out <dir>] [--crash --serial <adb serial> | --simulator <udid>] | trace --file <trace.json>
|
|
7
|
+
// a failure trace from the device's black box (default the newest): prints what
|
|
8
|
+
// most likely went wrong and writes trace.json + index.html (a static viewer) to --out;
|
|
9
|
+
// --crash adds the OS's crash log (an app that died cannot report it itself)
|
|
10
|
+
// g9pilot os <what> … --serial <adb serial> | --simulator <udid> the device around the app (the stage scope's OS half):
|
|
11
|
+
// os crashlogs [--package <id>] [--lines 400] the OS crash buffer (logcat -b crash; the simulator's reports)
|
|
12
|
+
// os permission grant|revoke <permission> --package <id> pm grant / simctl privacy (android.permission.CAMERA, camera…)
|
|
13
|
+
// os location <lat> <lon> a fake position (emulator geo fix; simulator location set)
|
|
14
|
+
// os net offline|online the whole device network (airplane mode): native traffic too
|
|
15
|
+
// os dialog allow|deny answers the system permission dialog on screen (Android)
|
|
16
|
+
// os video <seconds> --out <dir> records the screen (Android: pieces under screenrecord's 180 s
|
|
17
|
+
// limit, each pulled; simulator: simctl io recordVideo, SIGINT)
|
|
18
|
+
// os doze on|off|step Android Doze (deviceidle force-idle / unforce + battery reset)
|
|
19
|
+
// os standby <active|working_set|frequent|rare|restricted> --package <id> Android App Standby bucket
|
|
20
|
+
// os appearance dark|light the system theme (cmd uimode night / simctl ui appearance)
|
|
21
|
+
// os fontscale <1.3 | extra-large …> the system text size (settings font_scale / simctl content_size)
|
|
22
|
+
// os notify <payload.json> --package <id> a push to the app (simulator: simctl push; Android: refused —
|
|
23
|
+
// `cmd notification post` posts as the shell, not the app)
|
|
24
|
+
// g9pilot status --brief one line per configured device: app, version, page ready, keys (for a Skill)
|
|
25
|
+
// g9pilot events [--since N] [--kinds a,b] follow the event stream
|
|
26
|
+
// g9pilot proxy --listen 8799 --target http://127.0.0.1:18799
|
|
27
|
+
// a local signing proxy: an unchanged runner talks to --listen, every request
|
|
28
|
+
// reaches the device signed (the key never leaves this process)
|
|
29
|
+
// g9pilot keys new --kid <id> a fresh ES256 pair: the public key is printed, the private key goes ONLY to
|
|
30
|
+
// --out <file> (or nowhere: you store it yourself from the printed instructions)
|
|
31
|
+
// g9pilot keys public the public key of the configured private key
|
|
32
|
+
// g9pilot conformance [--json] the protocol-2 conformance suite against --url (exit 1 on any failure)
|
|
33
|
+
// g9pilot savepoint save|load|list|delete [<name>] [--serial emulator-5554] [--forward 8797]
|
|
34
|
+
// level 0 of a savepoint: the WHOLE Android emulator (app, data, OS) as a snapshot
|
|
35
|
+
// (`adb emu avd snapshot`); load takes seconds and the app comes back as it was
|
|
36
|
+
// g9pilot savepoint save|load|list <name> --level app --package <id> [--serial <any device>] [--dir <folder>]
|
|
37
|
+
// level 1: the APP's data (files, databases, preferences) of a debuggable build,
|
|
38
|
+
// to a tar on this machine and back (`run-as`); the app is stopped first.
|
|
39
|
+
// Every level-1 save: [--server <database / environment> --server-commit <sha>] →
|
|
40
|
+
// <name>.meta.json beside the tar (the server the app's data belongs to)
|
|
41
|
+
// g9pilot savepoint save|load|list|delete <name> --simulator <udid>
|
|
42
|
+
// level 0 on the iOS simulator: the whole device (simctl clone; a load copies the
|
|
43
|
+
// clone's data back with APFS clones, so the device keeps its UDID); on the Mac
|
|
44
|
+
// g9pilot savepoint save|load|list <name> --level app --simulator <udid> --package <bundle id> [--dir <folder>]
|
|
45
|
+
// the same on the iOS simulator: Documents + Library of the data container
|
|
46
|
+
// (not Caches, SplashBoard, tmp); run on the Mac
|
|
47
|
+
// g9pilot savepoint save|load|list <name> --level app --via bridge --package <id> [--device <iPhone udid> |
|
|
48
|
+
// --simulator <udid> | --serial <s>] the APP packs it at its next launch (before
|
|
49
|
+
// it opens its data) and one file moves: the iPhone path (devicectl copies files only)
|
|
50
|
+
// g9pilot tokens gen | approve [--snapshot <file>] [--dry-run]
|
|
51
|
+
// design tokens (DTCG files in g9pilot.json "tokens") → the app's generated module;
|
|
52
|
+
// the device's design.snapshot written back into the files (no commit)
|
|
53
|
+
// g9pilot catalog shoot --serial <s> | --simulator <udid> [--approve-new] [--only A,B] [--variant catalog]
|
|
54
|
+
// the app's component catalog (catalog.list / catalog.show) in every studio mode,
|
|
55
|
+
// each state against its reference <component>.<state> (variant <variant>-m<mode>)
|
|
56
|
+
// g9pilot visual check <name> --serial <adb serial> | --simulator <udid> | --file <png> [--variant android-light-fa]
|
|
57
|
+
// the screen against its approved reference (lossless OS capture, masks for a clock
|
|
58
|
+
// or a live map; g9pilot.json "visual"): exit 0 match, 1 differs (a .diff.png beside
|
|
59
|
+
// it), 2 no reference yet or another size
|
|
60
|
+
// g9pilot visual approve <name> | visual list the last capture becomes the reference (no commit) | what exists
|
|
61
|
+
// g9pilot studio [--port 47901] the local studio: the device's screen beside its tokens, live edits, approve
|
|
62
|
+
// g9pilot mcp an MCP server on stdio for agents (packages/mcp): twelve tools over this bridge;
|
|
63
|
+
// `claude mcp add app-pilot -- g9pilot mcp --config <g9pilot.json>`
|
|
64
|
+
// g9pilot session send <summary.json> a session summary (format g9/pilot-session) to the hub of g9pilot.json "hub";
|
|
65
|
+
// proxy, mcp, studio and relay send theirs themselves when a session ends (10 minutes
|
|
66
|
+
// without a command, G9PILOT_SESSION_IDLE_MS, or the process's end) — command names and
|
|
67
|
+
// counts only, never an argument
|
|
68
|
+
// g9pilot relay [--name <name>] the devices in g9pilot.json "devices" offered to the hub's studio page: the hub
|
|
69
|
+
// adapter's long poll; each request is run here, signed here, studio commands only
|
|
70
|
+
// (design tokens, a screenshot, the app's identity)
|
|
71
|
+
// g9pilot approvals list [--status approved] | apply <DA-…> [--dry-run]
|
|
72
|
+
// the design approvals made on the hub's studio page: apply writes one into the token
|
|
73
|
+
// files here (g9pilot.json "tokens") and regenerates them, then reports «applied»;
|
|
74
|
+
// never a commit
|
|
75
|
+
// g9pilot studio --emit <dir> [--header Name=value] the studio page as a hub serves it (studio.fa.html,
|
|
76
|
+
// studio.en.html, assets/studio.js); --header = what the hub wants on a write
|
|
77
|
+
// g9pilot host --listen 47900 [--bind 0.0.0.0] (g9pilot.json "reserve": {hold, release, idleMinutes} books a shared
|
|
78
|
+
// device on first use and frees it when idle; a refused booking is HTTP 423)
|
|
79
|
+
// a relay for the devices attached to THIS machine (adb serials, g9pilot.json
|
|
80
|
+
// "hostDevices" such as a booted simulator): /devices lists them, /d/<name>/…
|
|
81
|
+
// reaches one, signed requests pass unchanged (no key here). A token
|
|
82
|
+
// (G9PILOT_HOST_TOKEN, or --token-env) guards it; off the loopback it is required.
|
|
83
|
+
// --host <name> --device <name> any command above, through a relay named in g9pilot.json "hosts"
|
|
84
|
+
// g9pilot dev [--cmd "npm run dev"] [--cwd app] [--port 3000] [--bundle main.lynx.bundle] [--serial <adb serial>]
|
|
85
|
+
// [--watch-flows [tag:@smoke | <flow id>]] after each rebuild: the page ready again, settled, then the
|
|
86
|
+
// runner (g9pilot.json "runner") replays them; one ✓/✗ line per change
|
|
87
|
+
// live development: starts the dev server, waits for the bundle, `adb reverse`s the
|
|
88
|
+
// port to every Android device (or --serial), then sends the signed dev.server so the
|
|
89
|
+
// app loads from it (HMR, state kept). Ctrl+C puts the app back on its own bundle.
|
|
90
|
+
// The dev server is told its port in G9PILOT_DEV_PORT.
|
|
91
|
+
//
|
|
92
|
+
// Configuration: g9pilot.json in the working directory (or --config), all optional:
|
|
93
|
+
// { "url": "http://127.0.0.1:8799", "kid": "owner-…", "key": { "env": "G9PILOT_KEY", "file": "…",
|
|
94
|
+
// "windows": "<Credential Manager target>", "macos": { "service": "…", "account": "…" } },
|
|
95
|
+
// "hosts": { "<name>": { "url": "http://…:47900", "tokenEnv": "G9PILOT_HOST_TOKEN" } },
|
|
96
|
+
// "hostDevices": [{ "name": "…", "url": "http://127.0.0.1:8796" }], "devicePort": 8799 }
|
|
97
|
+
// The private key is read from env/file/OS store and is never printed.
|
|
98
|
+
import fs from 'node:fs';
|
|
99
|
+
import http from 'node:http';
|
|
100
|
+
import os from 'node:os';
|
|
101
|
+
import path from 'node:path';
|
|
102
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
103
|
+
import { runConformance } from '@g9tm/app-pilot-client/conformance';
|
|
104
|
+
import { explainTrace, generateKeyPair, PilotClient, publicKeyOf, loadHub, readSummary, runRelay, sessionRecorder, signRequest, tallyCalls, traceViewerHtml } from '@g9tm/app-pilot-client';
|
|
105
|
+
import { loadPrivateKey } from '@g9tm/app-pilot-client/keys';
|
|
106
|
+
import { studioHtml, studioScript, studioServer, tokenSets, tokensApprove, tokensGen } from '../src/design.mjs';
|
|
107
|
+
import { approveVisual, captureOsPng, checkVisual, listVisual, visualSettings } from '../src/visual.mjs';
|
|
108
|
+
|
|
109
|
+
const argv = process.argv.slice(2);
|
|
110
|
+
const flags = {};
|
|
111
|
+
const positional = [];
|
|
112
|
+
for (let i = 0; i < argv.length; i++) {
|
|
113
|
+
const a = argv[i];
|
|
114
|
+
if (a.startsWith('--')) {
|
|
115
|
+
const k = a.slice(2);
|
|
116
|
+
const next = argv[i + 1];
|
|
117
|
+
if (next === undefined || next.startsWith('--')) flags[k] = true;
|
|
118
|
+
else flags[k] = argv[++i];
|
|
119
|
+
} else positional.push(a);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const configPath = flags.config ?? path.join(process.cwd(), 'g9pilot.json');
|
|
123
|
+
const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf8')) : {};
|
|
124
|
+
const kid = flags.kid ?? config.kid;
|
|
125
|
+
|
|
126
|
+
/** `--host <name> --device <name>`: the relay's URL for that device, and the relay's token header (never signed). */
|
|
127
|
+
function viaHost() {
|
|
128
|
+
if (!flags.host) return { base: null, headers: {} };
|
|
129
|
+
const host = config.hosts?.[String(flags.host)];
|
|
130
|
+
if (!host?.url) fail(`No host "${flags.host}" in g9pilot.json "hosts".`);
|
|
131
|
+
if (!flags.device) fail('--host needs --device <name> (g9pilot host lists them at /devices).');
|
|
132
|
+
const token = process.env[host.tokenEnv ?? 'G9PILOT_HOST_TOKEN'];
|
|
133
|
+
return { base: `${String(host.url).replace(/\/+$/, '')}/d/${encodeURIComponent(String(flags.device))}`, headers: token ? { 'x-g9p-host-token': token } : {} };
|
|
134
|
+
}
|
|
135
|
+
const relay = viaHost();
|
|
136
|
+
const url = String(relay.base ?? flags.url ?? (flags.port ? `http://127.0.0.1:${flags.port}` : (config.url ?? 'http://127.0.0.1:8799')));
|
|
137
|
+
const clientFor = (opts = {}) => new PilotClient({ base: url, kid, extraHeaders: relay.headers, ...opts });
|
|
138
|
+
/** A session record for a long-running command; its device is the g9pilot.json device of that address, else the address. */
|
|
139
|
+
let hubLoad = null;
|
|
140
|
+
/** The hub of g9pilot.json "hub" (its adapter, loaded once), or null. */
|
|
141
|
+
const hub = () => (hubLoad ??= loadHub(config, path.dirname(path.resolve(configPath))));
|
|
142
|
+
const recorder = (tool, address) => sessionRecorder({ tool, kid, device: (config.devices ?? []).find((d) => d.url === address)?.name ?? address, hub: config.hub ? hub() : null, enabled: Boolean(config.hub) });
|
|
143
|
+
|
|
144
|
+
function key({ required = true } = {}) {
|
|
145
|
+
const found = loadPrivateKey(config.key ?? {});
|
|
146
|
+
if (!found && required) {
|
|
147
|
+
fail('No private key: set G9PILOT_KEY, or name a file / Credential Manager target / keychain item in g9pilot.json "key".');
|
|
148
|
+
}
|
|
149
|
+
return found;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fail(message, code = 2) {
|
|
153
|
+
process.stderr.write(`g9pilot: ${message}\n`);
|
|
154
|
+
process.exit(code);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const print = (v) => process.stdout.write(`${typeof v === 'string' ? v : JSON.stringify(v, null, 2)}\n`);
|
|
158
|
+
|
|
159
|
+
async function main() {
|
|
160
|
+
const [cmd, ...rest] = positional;
|
|
161
|
+
switch (cmd) {
|
|
162
|
+
case 'hello': {
|
|
163
|
+
print(await clientFor().hello());
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
case 'call': {
|
|
167
|
+
const [command, argsJson] = rest;
|
|
168
|
+
if (!command) fail('call needs a command: g9pilot call qa.ping');
|
|
169
|
+
const k = key({ required: !flags.unsigned });
|
|
170
|
+
const client = clientFor({ privateKey: k?.key, leaseId: flags.lease ?? null });
|
|
171
|
+
const answer = await client.call(command, argsJson ? JSON.parse(argsJson) : {}, { timeoutMs: Number(flags.timeout ?? 30_000) });
|
|
172
|
+
print(answer);
|
|
173
|
+
process.exitCode = answer?.ok ? 0 : 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
case 'trace': {
|
|
177
|
+
// A trace already on disk (a runner's failure bundle, <report>/failures/<flow>.trace.json): explained and viewed
|
|
178
|
+
// without a device.
|
|
179
|
+
if (flags.file) {
|
|
180
|
+
const trace = JSON.parse(fs.readFileSync(String(flags.file), 'utf8'));
|
|
181
|
+
const out = path.resolve(String(flags.out ?? path.join(path.dirname(String(flags.file)), `${path.basename(String(flags.file), '.json')}-viewer`)));
|
|
182
|
+
fs.mkdirSync(out, { recursive: true });
|
|
183
|
+
fs.writeFileSync(path.join(out, 'index.html'), traceViewerHtml(trace));
|
|
184
|
+
print({ ...explainTrace(trace), written: out });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const client = clientFor({ privateKey: key({ required: !flags.unsigned })?.key });
|
|
188
|
+
const [which] = rest;
|
|
189
|
+
if (which === 'list') {
|
|
190
|
+
const answer = await client.call('trace.list');
|
|
191
|
+
print(answer.ok ? answer.result.traces : answer);
|
|
192
|
+
process.exitCode = answer.ok ? 0 : 1;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const answer = await client.call('trace.get', which ? { id: which } : {});
|
|
196
|
+
if (!answer.ok) {
|
|
197
|
+
print(answer);
|
|
198
|
+
process.exitCode = 1;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const trace = answer.result;
|
|
202
|
+
if (flags.crash) trace.sections = { ...(trace.sections ?? {}), osCrashLog: crashLog({ lines: 200 }) };
|
|
203
|
+
const e = explainTrace(trace);
|
|
204
|
+
const out = path.resolve(String(flags.out ?? `pilot-trace-${trace.id}`));
|
|
205
|
+
fs.mkdirSync(out, { recursive: true });
|
|
206
|
+
fs.writeFileSync(path.join(out, 'trace.json'), JSON.stringify(trace, null, 2));
|
|
207
|
+
fs.writeFileSync(path.join(out, 'index.html'), traceViewerHtml(trace));
|
|
208
|
+
print({ ...e, written: out });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
case 'os':
|
|
212
|
+
return osCommand(rest);
|
|
213
|
+
case 'status': {
|
|
214
|
+
const targets = config.devices ?? [{ name: 'default', url }];
|
|
215
|
+
const lines = [];
|
|
216
|
+
for (const t of targets) {
|
|
217
|
+
try {
|
|
218
|
+
const h = await new PilotClient({ base: t.url }).hello({ timeoutMs: 3000 });
|
|
219
|
+
lines.push(`${t.name}: ${h.app} ${h.versionName} (${h.environment}, ${h.platform ?? '?'}) page ${h.jsReady ? 'ready' : 'NOT ready'}, protocol ${h.protocol ?? 1}, keys ${JSON.stringify(h.kids ?? [])}, signatures ${h.signatureRequired ? 'required' : 'optional'}${h.devtool ? ', devtool' : ''}`);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
lines.push(`${t.name}: no bridge at ${t.url} (${e.message})`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const k = key({ required: false });
|
|
225
|
+
lines.push(`key: ${k ? `${kid ?? '(kid from the hello)'} from ${k.source}` : 'none configured'}`);
|
|
226
|
+
print(flags.brief ? lines.join('\n') : { devices: lines });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
case 'events': {
|
|
230
|
+
const k = key();
|
|
231
|
+
const client = clientFor({ privateKey: k.key });
|
|
232
|
+
const kinds = flags.kinds ? new Set(String(flags.kinds).split(',')) : null;
|
|
233
|
+
client.events({
|
|
234
|
+
since: Number(flags.since ?? 0),
|
|
235
|
+
onEvent: (e) => {
|
|
236
|
+
if (!kinds || kinds.has(e.kind)) print(JSON.stringify(e));
|
|
237
|
+
},
|
|
238
|
+
onError: (e) => process.stderr.write(`events: ${e.message}\n`),
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
case 'session': {
|
|
243
|
+
const [sub, file] = rest;
|
|
244
|
+
if (sub !== 'send' || !file) fail('session send <summary.json>');
|
|
245
|
+
print(await (await hubOrFail()).sendSession(readSummary(String(file))));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
case 'relay':
|
|
249
|
+
return relayCommand();
|
|
250
|
+
case 'approvals':
|
|
251
|
+
return approvals(rest);
|
|
252
|
+
case 'proxy':
|
|
253
|
+
return proxy();
|
|
254
|
+
case 'host':
|
|
255
|
+
return host();
|
|
256
|
+
case 'dev':
|
|
257
|
+
return dev();
|
|
258
|
+
case 'mcp':
|
|
259
|
+
return mcp();
|
|
260
|
+
case 'savepoint':
|
|
261
|
+
return savepoint(rest);
|
|
262
|
+
case 'tokens': {
|
|
263
|
+
const sets = tokenSets(config, path.dirname(path.resolve(configPath)));
|
|
264
|
+
const [sub] = rest;
|
|
265
|
+
if (sub === 'gen') {
|
|
266
|
+
const written = tokensGen(sets);
|
|
267
|
+
print(written.length ? `generated ${written.join(', ')}` : 'the generated modules are up to date');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (sub === 'approve') {
|
|
271
|
+
let snapshot;
|
|
272
|
+
if (flags.snapshot) snapshot = JSON.parse(fs.readFileSync(String(flags.snapshot), 'utf8'));
|
|
273
|
+
else {
|
|
274
|
+
const client = clientFor({ privateKey: key({ required: !flags.unsigned })?.key });
|
|
275
|
+
const answer = await client.call('design.snapshot');
|
|
276
|
+
if (!answer.ok) fail(`design.snapshot: ${answer.error?.message ?? JSON.stringify(answer.error)}`);
|
|
277
|
+
snapshot = answer.result;
|
|
278
|
+
}
|
|
279
|
+
print(tokensApprove(sets, snapshot, { dryRun: flags['dry-run'] === true, ...approveFollowUps() }));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
fail('tokens gen | tokens approve [--snapshot <file>] [--dry-run]');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
case 'catalog': {
|
|
286
|
+
// The app's live component catalog against its references (plan v2 P1-19): for each studio mode (g9pilot.json
|
|
287
|
+
// studio.modes; none = as it is), each component and state the app lists (catalog.list) is shown alone
|
|
288
|
+
// (catalog.show), the OS picture taken (lossless, --serial / --simulator) and compared with its reference
|
|
289
|
+
// `<component>.<state>` in the variant `<--variant or catalog>-m<mode>`. A new one or a changed one is LISTED;
|
|
290
|
+
// `--approve-new` takes the first pictures as the references (a baseline: look at them first).
|
|
291
|
+
if (rest[0] !== 'shoot') fail('catalog shoot [--serial <s> | --simulator <udid>] [--approve-new] [--only Component[,…]]');
|
|
292
|
+
const settings0 = visualSettings(config, path.dirname(path.resolve(configPath)), flags);
|
|
293
|
+
const client = clientFor({ privateKey: key({ required: !flags.unsigned })?.key });
|
|
294
|
+
const call = async (command, args = {}) => {
|
|
295
|
+
const r = await client.call(command, args, { timeoutMs: 30_000 });
|
|
296
|
+
if (!r?.ok) throw new Error(`${command}: ${r?.error ?? 'no answer'}`);
|
|
297
|
+
return r.result;
|
|
298
|
+
};
|
|
299
|
+
const list = (await call('catalog.list')).components;
|
|
300
|
+
const only = flags.only ? String(flags.only).split(',') : null;
|
|
301
|
+
const modes = config.studio?.modes?.length ? config.studio.modes : [{ name: 'as it is', commands: [] }];
|
|
302
|
+
const rows = [];
|
|
303
|
+
try {
|
|
304
|
+
for (const [mi, mode] of modes.entries()) {
|
|
305
|
+
for (const cmd of mode.commands ?? []) await call(cmd.command, cmd.args ?? {});
|
|
306
|
+
const settings = { ...settings0, variant: `${flags.variant ?? 'catalog'}-m${mi}` };
|
|
307
|
+
for (const entry of list) {
|
|
308
|
+
if (only && !only.includes(entry.id)) continue;
|
|
309
|
+
for (const state of entry.states) {
|
|
310
|
+
await call('catalog.show', { component: entry.id, state });
|
|
311
|
+
await call('ui.waitIdle', {}).catch(() => null);
|
|
312
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
313
|
+
const png = captureOsPng({ serial: flags.serial, simulator: flags.simulator, adb: String(flags.adb ?? process.env.ADB ?? 'adb') });
|
|
314
|
+
const name = `${entry.id}.${state}`;
|
|
315
|
+
let r = checkVisual(settings, name, png);
|
|
316
|
+
if (r.verdict === 'new' && flags['approve-new']) {
|
|
317
|
+
approveVisual(settings, name);
|
|
318
|
+
r = { ...r, verdict: 'new → reference' };
|
|
319
|
+
}
|
|
320
|
+
rows.push({ mode: mode.name, name, verdict: r.verdict, ...(r.ratio !== undefined ? { ratio: r.ratio } : {}), ...(r.diffImage ? { diff: r.diffImage } : {}) });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} finally {
|
|
325
|
+
await call('catalog.show', {}).catch(() => null);
|
|
326
|
+
await call('dev.close', {}).catch(() => null);
|
|
327
|
+
}
|
|
328
|
+
const count = (v) => rows.filter((x) => x.verdict.startsWith(v)).length;
|
|
329
|
+
print(JSON.stringify({ shots: rows.length, match: count('match'), differs: count('differs'), new: count('new'), size: count('size'), rows: rows.filter((x) => x.verdict !== 'match') }, null, 2));
|
|
330
|
+
process.exitCode = count('differs') || count('size') ? 1 : 0;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
case 'visual': {
|
|
334
|
+
const settings = visualSettings(config, path.dirname(path.resolve(configPath)), flags);
|
|
335
|
+
const [sub, name] = rest;
|
|
336
|
+
if (sub === 'list') {
|
|
337
|
+
const rows = listVisual(settings);
|
|
338
|
+
print(rows.length ? rows.map((r) => `${r.variant}/${r.name}${r.pending ? ' (a capture waits for approval)' : ''}`).join(String.fromCharCode(10)) : `no references under ${settings.dir}`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (!name) fail('visual check <name> [--serial s | --simulator udid | --file shot.png] | visual approve <name> | visual list');
|
|
342
|
+
if (sub === 'approve') {
|
|
343
|
+
print(`approved: ${approveVisual(settings, String(name))}`);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (sub !== 'check') fail(`visual ${sub}: check, approve or list`);
|
|
347
|
+
const png = flags.file ? fs.readFileSync(String(flags.file)) : captureOsPng({ serial: flags.serial, simulator: flags.simulator, adb: String(flags.adb ?? process.env.ADB ?? 'adb') });
|
|
348
|
+
const r = checkVisual(settings, String(name), png);
|
|
349
|
+
print(JSON.stringify(r, null, 2));
|
|
350
|
+
if (r.verdict === 'differs') process.exitCode = 1;
|
|
351
|
+
else if (r.verdict !== 'match') process.exitCode = 2;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
case 'studio': {
|
|
355
|
+
// The same page for a hub's studio (one UI, two transports): its files, written where the hub serves them.
|
|
356
|
+
if (flags.emit) {
|
|
357
|
+
const dir = path.resolve(String(flags.emit));
|
|
358
|
+
fs.mkdirSync(path.join(dir, 'assets'), { recursive: true });
|
|
359
|
+
for (const lang of ['fa', 'en']) fs.writeFileSync(path.join(dir, `studio.${lang}.html`), studioHtml(lang === 'fa' ? 'استودیوی طراحی' : 'Design studio', { mode: 'hub', lang, scriptUrl: '/assets/studio.js', headers: Object.fromEntries([].concat(flags.header ?? []).map((h) => String(h).split('=')).filter(([k, v]) => k && v)) }));
|
|
360
|
+
fs.writeFileSync(path.join(dir, 'assets', 'studio.js'), studioScript());
|
|
361
|
+
print(`wrote studio.fa.html, studio.en.html, assets/studio.js under ${dir}`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const sets = tokenSets(config, path.dirname(path.resolve(configPath)));
|
|
365
|
+
const client = clientFor({ privateKey: key({ required: !flags.unsigned })?.key });
|
|
366
|
+
tallyCalls(client, recorder('studio', url));
|
|
367
|
+
const port = Number(flags.port ?? 47901);
|
|
368
|
+
// Several devices at once (plan v2 P1-17): --devices all | a,b (names in g9pilot.json "devices"), each its own
|
|
369
|
+
// client; a token change goes to all. `studio.modes`: the states each grid shot is taken in. A reference overlay
|
|
370
|
+
// is `g9pilot visual`: the device entry's `serial` / `simulator` gives the OS picture to compare.
|
|
371
|
+
const listed = config.devices ?? [];
|
|
372
|
+
const chosen = flags.devices ? (flags.devices === 'all' ? listed : listed.filter((d) => String(flags.devices).split(',').includes(d.name))) : [];
|
|
373
|
+
const pk = key({ required: !flags.unsigned })?.key;
|
|
374
|
+
const devices = chosen.map((d) => {
|
|
375
|
+
const c = new PilotClient({ base: String(d.url).replace(/\/+$/, ''), kid, privateKey: pk });
|
|
376
|
+
return { name: d.name, entry: d, call: (command, args) => c.call(command, args, { timeoutMs: 20_000 }) };
|
|
377
|
+
});
|
|
378
|
+
const settings = config.visual ? visualSettings(config, path.dirname(path.resolve(configPath)), flags) : null;
|
|
379
|
+
const reference = settings
|
|
380
|
+
? (name, refName) => {
|
|
381
|
+
const d = devices.find((x) => x.name === name)?.entry ?? {};
|
|
382
|
+
const png = captureOsPng({ serial: d.serial ?? flags.serial, simulator: d.simulator ?? flags.simulator, adb: String(flags.adb ?? process.env.ADB ?? 'adb') });
|
|
383
|
+
const r = checkVisual(settings, refName, png);
|
|
384
|
+
const diffFile = r.diffImage && fs.existsSync(r.diffImage) ? r.diffImage : null;
|
|
385
|
+
return { verdict: r.verdict, ratio: r.ratio ?? null, diff: diffFile ? fs.readFileSync(diffFile).toString('base64') : null };
|
|
386
|
+
}
|
|
387
|
+
: null;
|
|
388
|
+
studioServer({ port, call: (command, args) => client.call(command, args, { timeoutMs: 20_000 }), devices: devices.length ? devices : null, modes: config.studio?.modes ?? [], reference, approve: (snapshot, o) => tokensApprove(sets, snapshot, { ...o, ...approveFollowUps() }) });
|
|
389
|
+
print(`G9AppPilot Studio: http://127.0.0.1:${port} (${devices.length ? devices.map((d) => d.name).join(', ') : `device ${url}`}; ${(config.studio?.modes ?? []).length} mode(s); Ctrl+C ends it)`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
case 'conformance': {
|
|
393
|
+
const k = key({ required: false });
|
|
394
|
+
const report = await runConformance({ base: url, privateKey: k?.key, kid, fetchImpl: (u, o = {}) => fetch(u, { ...o, headers: { ...relay.headers, ...(o.headers ?? {}) } }) });
|
|
395
|
+
if (flags.json) print(report);
|
|
396
|
+
else {
|
|
397
|
+
for (const c of report.checks) print(`${c.skipped ? 'SKIP' : c.ok ? 'PASS' : 'FAIL'} ${c.name}${c.detail ? ` (${c.detail})` : ''}`);
|
|
398
|
+
print(`${report.app}: ${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped`);
|
|
399
|
+
}
|
|
400
|
+
process.exitCode = report.failed ? 1 : 0;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
case 'keys': {
|
|
404
|
+
const [sub] = rest;
|
|
405
|
+
if (sub === 'new') {
|
|
406
|
+
if (!flags.kid) fail('keys new --kid <id>');
|
|
407
|
+
const pair = generateKeyPair();
|
|
408
|
+
if (flags.out) fs.writeFileSync(String(flags.out), pair.privateKey, { mode: 0o600 });
|
|
409
|
+
print({ kid: flags.kid, publicKey: pair.publicKey, privateKey: flags.out ? `written to ${flags.out}` : 'NOT kept — pass --out <file> to keep it' });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (sub === 'public') {
|
|
413
|
+
print(publicKeyOf(key().key));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
fail('keys new --kid <id> [--out file] | keys public');
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
default:
|
|
420
|
+
fail('commands: hello, call <command> [json], status [--brief], events, proxy --listen <port> --target <url>, host --listen <port>, dev, conformance, keys new|public', cmd ? 2 : 0);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The hub of g9pilot.json "hub", or the reason there is none (session send, relay and approvals need it). */
|
|
425
|
+
async function hubOrFail() {
|
|
426
|
+
const h = await hub();
|
|
427
|
+
if (!h) fail('g9pilot.json has no "hub" (an adapter: "hub": {"adapter": "<path or package>", …its options}).');
|
|
428
|
+
if (h.error) fail(h.error);
|
|
429
|
+
return h;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** `g9pilot relay`: this machine's devices for the hub's studio page, until Ctrl+C. */
|
|
433
|
+
async function relayCommand() {
|
|
434
|
+
const h = await hubOrFail();
|
|
435
|
+
const k = key();
|
|
436
|
+
const targets = config.devices?.length ? config.devices : [{ name: 'default', url }];
|
|
437
|
+
const devices = [];
|
|
438
|
+
for (const t of targets) {
|
|
439
|
+
const client = new PilotClient({ base: t.url, privateKey: k.key, kid, extraHeaders: relay.headers });
|
|
440
|
+
let hello = null;
|
|
441
|
+
try {
|
|
442
|
+
const h = await client.hello({ timeoutMs: 3000 });
|
|
443
|
+
hello = { app: h.app, appVersion: h.versionName, platform: h.platform ?? null };
|
|
444
|
+
} catch {
|
|
445
|
+
continue; // a device that does not answer is not offered
|
|
446
|
+
}
|
|
447
|
+
devices.push({ name: t.name, hello, call: (command, args) => client.call(command, args, { timeoutMs: 20_000 }) });
|
|
448
|
+
}
|
|
449
|
+
if (!devices.length) fail(`none of the devices in g9pilot.json answers (${targets.map((t) => t.url).join(', ')})`);
|
|
450
|
+
const tally = recorder('relay', devices[0].name);
|
|
451
|
+
const stop = new AbortController();
|
|
452
|
+
process.once('SIGINT', () => stop.abort());
|
|
453
|
+
process.stderr.write(`g9pilot relay: ${devices.map((d) => `${d.name} (${d.hello.app} ${d.hello.appVersion})`).join(', ')} → ${h.url} (key ${k.source}; Ctrl+C ends it)\n`);
|
|
454
|
+
await runRelay({ poll: h.relayPoll, name: String(flags.name ?? os.hostname()), devices, signal: stop.signal, onRequest: (_d, command, ok) => tally.record(command, { ok }), log: (l) => process.stderr.write(`${l}\n`) });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** `g9pilot approvals list | apply <id>`: the hub's design approvals into this checkout's token files (never a commit). */
|
|
458
|
+
async function approvals([sub, id]) {
|
|
459
|
+
const h = await hubOrFail();
|
|
460
|
+
if (sub === 'list') {
|
|
461
|
+
const r = await h.approvals.list({ status: String(flags.status ?? 'approved'), branch: flags.branch ? String(flags.branch) : '' });
|
|
462
|
+
print(r.approvals.length ? r.approvals.map((a) => `${a.id} ${a.status} ${a.branch ?? '—'} ${a.app ?? ''} ${a.appVersion ?? ''} by ${a.by} ${a.changes.map((c) => `${c.id}: ${c.from} → ${c.to}`).join('; ')}`).join('\n') : 'no design approval in that state');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// A person committed what an apply wrote (the /pilot-approve skill asks first): the approval says so, with the commit.
|
|
466
|
+
if (sub === 'committed' && id) {
|
|
467
|
+
await h.approvals.setStatus({ id, status: 'committed', ...(flags.commit ? { commit: String(flags.commit) } : {}) });
|
|
468
|
+
print(`${id}: committed${flags.commit ? ` (${flags.commit})` : ''}`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (sub !== 'apply' || !id) fail('approvals list [--status approved|applied|committed|rejected] [--branch b] | approvals apply <DA-…> [--dry-run] | approvals committed <DA-…> [--commit <sha>]');
|
|
472
|
+
const a = await h.approvals.get(id);
|
|
473
|
+
if (a.status !== 'approved' && !flags.again) fail(`${id} is ${a.status}, not approved (--again to apply it once more)`);
|
|
474
|
+
if (a.branch && h.branch && a.branch !== h.branch) process.stderr.write(`g9pilot: ${id} was approved on ${a.branch}; this checkout is on ${h.branch}\n`);
|
|
475
|
+
const sets = tokenSets(config, path.dirname(path.resolve(configPath)));
|
|
476
|
+
const result = tokensApprove(sets, { changes: a.changes }, { dryRun: flags['dry-run'] === true, ...approveFollowUps() });
|
|
477
|
+
print(result);
|
|
478
|
+
if (flags['dry-run']) return;
|
|
479
|
+
await h.approvals.setStatus({ id, status: 'applied', files: result.files, tokens: result.changed.length, modules: result.generated.length });
|
|
480
|
+
print(`${id}: applied (the files changed; nothing is committed)`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** A signing proxy: the unchanged runner (protocol 1, unsigned) talks to --listen; the device gets signed requests. */
|
|
484
|
+
function proxy() {
|
|
485
|
+
const listen = Number(flags.listen ?? 8799);
|
|
486
|
+
const target = String(flags.target ?? 'http://127.0.0.1:18799').replace(/\/+$/, '');
|
|
487
|
+
const k = key();
|
|
488
|
+
const client = new PilotClient({ base: target, privateKey: k.key, kid });
|
|
489
|
+
const tally = recorder('proxy', target);
|
|
490
|
+
const server = http.createServer(async (req, res) => {
|
|
491
|
+
const t0 = Date.now();
|
|
492
|
+
const chunks = [];
|
|
493
|
+
for await (const c of req) chunks.push(c);
|
|
494
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
495
|
+
const reqPath = (req.url ?? '/').split('?')[0];
|
|
496
|
+
try {
|
|
497
|
+
let headers = {};
|
|
498
|
+
const open = req.method === 'GET' && (reqPath === '/' || reqPath === '/qa' || reqPath === '/pilot');
|
|
499
|
+
if (!open) {
|
|
500
|
+
const hello = client.helloCache ?? (await client.hello());
|
|
501
|
+
headers = signRequest({ privateKey: k.key, kid: kid ?? hello.kids?.[0], appId: hello.app, method: req.method, path: reqPath, body, now: Date.now() + Math.round(client.offsetMs) });
|
|
502
|
+
}
|
|
503
|
+
const send = (h) =>
|
|
504
|
+
fetch(`${target}${req.url}`, {
|
|
505
|
+
method: req.method,
|
|
506
|
+
headers: { 'content-type': req.headers['content-type'] ?? 'application/json', ...(req.headers['last-event-id'] ? { 'last-event-id': req.headers['last-event-id'] } : {}), ...h },
|
|
507
|
+
body: req.method === 'GET' ? undefined : body,
|
|
508
|
+
});
|
|
509
|
+
let upstream = await send(headers);
|
|
510
|
+
// The device clock moved (a restored emulator snapshot, a phone that slept): correct from the time it reports and
|
|
511
|
+
// send once more, as the client does — a proxy that did not left every request of the session refused.
|
|
512
|
+
if (!open && upstream.status === 401) {
|
|
513
|
+
const refused = await upstream.clone().json().catch(() => null);
|
|
514
|
+
if (refused?.code === 'CLOCK_SKEW' && typeof refused.deviceTime === 'number') {
|
|
515
|
+
client.offsetMs = refused.deviceTime - Date.now();
|
|
516
|
+
const hello = client.helloCache ?? (await client.hello());
|
|
517
|
+
const again = signRequest({ privateKey: k.key, kid: kid ?? hello.kids?.[0], appId: hello.app, method: req.method, path: reqPath, body, now: Date.now() + Math.round(client.offsetMs) });
|
|
518
|
+
upstream = await send(again);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (open) {
|
|
522
|
+
// The hello says what a client behind the proxy needs: no token and no signature of its own (the proxy signs —
|
|
523
|
+
// a client that read `signatureRequired` used to look for a key it does not need, and failed without one).
|
|
524
|
+
const hello = await upstream.json();
|
|
525
|
+
client.helloCache = hello;
|
|
526
|
+
if (typeof hello.deviceTime === 'number') client.offsetMs = hello.deviceTime - Date.now();
|
|
527
|
+
res.writeHead(upstream.status, { 'content-type': 'application/json' });
|
|
528
|
+
res.end(JSON.stringify({ ...hello, tokenRequired: false, signatureRequired: false, signedBy: 'g9pilot proxy' }));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
// The session's record: a command's name and how it ended (never its arguments or its answer).
|
|
532
|
+
const command = req.method === 'POST' && (reqPath === '/qa' || reqPath === '/pilot') ? (() => { try { return JSON.parse(body).command; } catch { return null; } })() : null;
|
|
533
|
+
const verdict = command || upstream.status === 401 ? upstream.clone().json().catch(() => null) : null;
|
|
534
|
+
res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/json' });
|
|
535
|
+
if (upstream.body) for await (const chunk of upstream.body) res.write(chunk);
|
|
536
|
+
res.end();
|
|
537
|
+
const answer = await verdict;
|
|
538
|
+
if (upstream.status === 401) tally.refused(answer?.code ?? 'REFUSED');
|
|
539
|
+
else if (command) { tally.hello(client.helloCache); tally.record(command, { ok: upstream.ok && answer?.ok !== false, ms: Date.now() - t0 }); }
|
|
540
|
+
if (upstream.status === 401) client.helloCache = null; // a key or clock change: read the hello again next time
|
|
541
|
+
} catch (e) {
|
|
542
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
543
|
+
res.end(JSON.stringify({ ok: false, error: `g9pilot proxy: the device at ${target} did not answer (${e.message})`, code: 'PROXY_UPSTREAM', category: 'automation' }));
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
server.listen(listen, '127.0.0.1', () => process.stderr.write(`g9pilot proxy: 127.0.0.1:${listen} → ${target} (key ${k.source})\n`));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The relay for the devices attached to this machine. It holds no key: a signed request passes through unchanged (the
|
|
551
|
+
* signature covers the device's path, `/qa`, which is what the device receives). Android devices are reached through
|
|
552
|
+
* `adb forward` (a local port per serial, made on first use); a booted simulator shares this machine's loopback, so it is
|
|
553
|
+
* named in "hostDevices" with its URL. Server-Sent Events stream through.
|
|
554
|
+
*/
|
|
555
|
+
function host() {
|
|
556
|
+
const listen = Number(flags.listen ?? 47900);
|
|
557
|
+
const bind = String(flags.bind ?? '127.0.0.1');
|
|
558
|
+
const token = process.env[String(flags['token-env'] ?? 'G9PILOT_HOST_TOKEN')] ?? '';
|
|
559
|
+
const loopback = bind === '127.0.0.1' || bind === '::1' || bind === 'localhost';
|
|
560
|
+
if (!loopback && token.length < 16) fail(`host: binding ${bind} needs a token of at least 16 characters (G9PILOT_HOST_TOKEN or --token-env).`);
|
|
561
|
+
const devicePort = Number(flags['device-port'] ?? config.devicePort ?? 8799);
|
|
562
|
+
const adb = String(flags.adb ?? process.env.ADB ?? 'adb');
|
|
563
|
+
const forwards = new Map();
|
|
564
|
+
let nextPort = Number(flags['forward-base'] ?? listen + 10);
|
|
565
|
+
|
|
566
|
+
const adbSerials = () => {
|
|
567
|
+
try {
|
|
568
|
+
return execFileSync(adb, ['devices'], { encoding: 'utf8', timeout: 10_000 })
|
|
569
|
+
.split('\n')
|
|
570
|
+
.slice(1)
|
|
571
|
+
.map((l) => l.trim().split(/\s+/))
|
|
572
|
+
.filter((p) => p[1] === 'device')
|
|
573
|
+
.map((p) => p[0]);
|
|
574
|
+
} catch {
|
|
575
|
+
return [];
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
const fixed = () => config.hostDevices ?? [];
|
|
579
|
+
const devices = () => [...adbSerials().map((serial) => ({ name: serial, kind: 'android' })), ...fixed().map((d) => ({ name: d.name, kind: d.kind ?? 'url' }))];
|
|
580
|
+
const urlFor = (name) => {
|
|
581
|
+
const f = fixed().find((d) => d.name === name);
|
|
582
|
+
if (f) return String(f.url).replace(/\/+$/, '');
|
|
583
|
+
if (!adbSerials().includes(name)) return null;
|
|
584
|
+
let port = forwards.get(name);
|
|
585
|
+
if (!port) {
|
|
586
|
+
port = nextPort++;
|
|
587
|
+
execFileSync(adb, ['-s', name, 'forward', `tcp:${port}`, `tcp:${devicePort}`], { timeout: 10_000 });
|
|
588
|
+
forwards.set(name, port);
|
|
589
|
+
}
|
|
590
|
+
return `http://127.0.0.1:${port}`;
|
|
591
|
+
};
|
|
592
|
+
const deny = (res, status, code, error) => {
|
|
593
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
594
|
+
res.end(JSON.stringify({ ok: false, error, code, category: 'auth' }));
|
|
595
|
+
};
|
|
596
|
+
// A shared device is reserved before it is handed out (plan v2 P1-14): g9pilot.json "reserve": {hold: [cmd…],
|
|
597
|
+
// release: [cmd…], idleMinutes, resources?} with {device} {resource} {minutes} {id} filled in — whoever runs the lab names its own booking
|
|
598
|
+
// (this tool knows none). The id the hold prints (`"id":"…"` or the last word) is what release gets; a refused hold
|
|
599
|
+
// answers 423 with the booking tool's own words; idle devices (and all of them when the host stops) are released.
|
|
600
|
+
const reserve = config.reserve?.hold ? config.reserve : null;
|
|
601
|
+
const holds = new Map();
|
|
602
|
+
const idleMs = Number(reserve?.idleMinutes ?? 15) * 60_000;
|
|
603
|
+
const runReserve = (cmd, vars) => {
|
|
604
|
+
const [file, ...args] = cmd.map((x) => String(x).replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? '')));
|
|
605
|
+
try {
|
|
606
|
+
return { ok: true, out: execFileSync(file === 'node' ? process.execPath : file, args, { encoding: 'utf8', timeout: 60_000 }) };
|
|
607
|
+
} catch (e) {
|
|
608
|
+
return { ok: false, out: `${e.stdout ?? ''}${e.stderr ?? ''}`.trim() || e.message };
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
const hold = (name) => {
|
|
612
|
+
if (!reserve) return null;
|
|
613
|
+
const known = holds.get(name);
|
|
614
|
+
if (known) {
|
|
615
|
+
known.lastUse = Date.now();
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
// {resource}: what the booking calls this device — reserve.resources[name], the fixed device's own `resource`,
|
|
619
|
+
// reserve.resources['*'], else its name.
|
|
620
|
+
const resource = reserve.resources?.[name] ?? fixed().find((d) => d.name === name)?.resource ?? (config.devices ?? []).find((d) => d.name === name)?.resource ?? reserve.resources?.['*'] ?? name;
|
|
621
|
+
const r = runReserve(reserve.hold, { device: name, resource, minutes: Math.ceil(idleMs / 60_000) + 5 });
|
|
622
|
+
if (!r.ok) return `The device ${name} could not be reserved: ${r.out.slice(-300)}`;
|
|
623
|
+
const id = /"id"\s*:\s*\\?"([^"\\]+)/.exec(r.out)?.[1] ?? r.out.trim().split(/\s+/).pop();
|
|
624
|
+
holds.set(name, { id, lastUse: Date.now() });
|
|
625
|
+
process.stderr.write(`g9pilot host: ${name} reserved (${id})\n`);
|
|
626
|
+
return null;
|
|
627
|
+
};
|
|
628
|
+
const release = (name) => {
|
|
629
|
+
const h = holds.get(name);
|
|
630
|
+
if (!h) return;
|
|
631
|
+
holds.delete(name);
|
|
632
|
+
if (reserve.release) runReserve(reserve.release, { device: name, id: h.id });
|
|
633
|
+
process.stderr.write(`g9pilot host: ${name} released (${h.id})\n`);
|
|
634
|
+
};
|
|
635
|
+
if (reserve) {
|
|
636
|
+
setInterval(() => {
|
|
637
|
+
for (const [name, h] of holds) if (Date.now() - h.lastUse > idleMs) release(name);
|
|
638
|
+
}, Math.min(30_000, idleMs)).unref();
|
|
639
|
+
const stop = () => {
|
|
640
|
+
for (const name of [...holds.keys()]) release(name);
|
|
641
|
+
process.exit(0);
|
|
642
|
+
};
|
|
643
|
+
process.on('SIGINT', stop);
|
|
644
|
+
process.on('SIGTERM', stop);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const server = http.createServer(async (req, res) => {
|
|
648
|
+
if (token && req.headers['x-g9p-host-token'] !== token) return deny(res, 401, 'HOST_TOKEN', 'This relay needs its token (x-g9p-host-token).');
|
|
649
|
+
const [pathOnly, query = ''] = (req.url ?? '/').split('?');
|
|
650
|
+
if (pathOnly === '/devices') {
|
|
651
|
+
const list = [];
|
|
652
|
+
for (const d of devices()) {
|
|
653
|
+
let hello = null;
|
|
654
|
+
try {
|
|
655
|
+
const u = urlFor(d.name);
|
|
656
|
+
hello = u ? await (await fetch(`${u}/qa`, { signal: AbortSignal.timeout(2500) })).json() : null;
|
|
657
|
+
} catch {
|
|
658
|
+
hello = null;
|
|
659
|
+
}
|
|
660
|
+
list.push({ ...d, bridge: hello ? { app: hello.app, protocol: hello.protocol, jsReady: hello.jsReady, signatureRequired: hello.signatureRequired } : null });
|
|
661
|
+
}
|
|
662
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
663
|
+
return res.end(JSON.stringify({ ok: true, devices: list }));
|
|
664
|
+
}
|
|
665
|
+
const m = pathOnly.match(/^\/d\/([^/]+)(\/.*)$/);
|
|
666
|
+
if (!m) return deny(res, 404, 'NOT_FOUND', 'Paths: /devices, /d/<device>/qa, /d/<device>/pilot, /d/<device>/pilot/events.');
|
|
667
|
+
const name = decodeURIComponent(m[1]);
|
|
668
|
+
let target;
|
|
669
|
+
try {
|
|
670
|
+
target = urlFor(name);
|
|
671
|
+
} catch (e) {
|
|
672
|
+
return deny(res, 502, 'HOST_FORWARD', `adb forward for ${name} failed: ${e.message}`);
|
|
673
|
+
}
|
|
674
|
+
if (!target) return deny(res, 404, 'NO_DEVICE', `No device "${name}" on this host.`);
|
|
675
|
+
const refused = hold(name);
|
|
676
|
+
if (refused) return deny(res, 423, 'DEVICE_RESERVED', refused);
|
|
677
|
+
const chunks = [];
|
|
678
|
+
for await (const c of req) chunks.push(c);
|
|
679
|
+
const headers = {};
|
|
680
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
681
|
+
if ((k.startsWith('x-g9p-') && k !== 'x-g9p-host-token') || k === 'content-type' || k === 'accept' || k === 'last-event-id') headers[k] = v;
|
|
682
|
+
}
|
|
683
|
+
const ctrl = new AbortController();
|
|
684
|
+
res.on('close', () => ctrl.abort());
|
|
685
|
+
try {
|
|
686
|
+
const upstream = await fetch(`${target}${m[2]}${query ? `?${query}` : ''}`, {
|
|
687
|
+
method: req.method,
|
|
688
|
+
headers,
|
|
689
|
+
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Buffer.concat(chunks),
|
|
690
|
+
signal: ctrl.signal,
|
|
691
|
+
});
|
|
692
|
+
res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/json', 'cache-control': 'no-cache' });
|
|
693
|
+
if (upstream.body) for await (const chunk of upstream.body) res.write(chunk);
|
|
694
|
+
res.end();
|
|
695
|
+
} catch (e) {
|
|
696
|
+
if (!ctrl.signal.aborted) deny(res, 502, 'HOST_UPSTREAM', `The device ${name} did not answer (${e.message}).`);
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
server.listen(listen, bind, () => process.stderr.write(`g9pilot host: ${bind}:${listen}${token ? ' (token required)' : ''}; devices: ${devices().map((d) => d.name).join(', ') || 'none yet'}\n`));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Live development. The page's dev server runs on THIS machine; each device reaches it on its own loopback (`adb reverse`
|
|
704
|
+
* on Android, the Mac's loopback for a simulator), so the bundle's HMR client, which connects back to the URL it was
|
|
705
|
+
* built with, finds it too. The bridge command `dev.server` (admin: it changes what code runs) points the app at it.
|
|
706
|
+
*/
|
|
707
|
+
async function dev() {
|
|
708
|
+
const port = Number(flags.port ?? config.dev?.port ?? 3000);
|
|
709
|
+
const bundle = String(flags.bundle ?? config.dev?.bundle ?? 'main.lynx.bundle');
|
|
710
|
+
const cwd = path.resolve(String(flags.cwd ?? config.dev?.cwd ?? '.'));
|
|
711
|
+
const command = String(flags.cmd ?? config.dev?.cmd ?? 'npm run dev');
|
|
712
|
+
const bundleUrl = `http://127.0.0.1:${port}/${bundle}`;
|
|
713
|
+
const k = key();
|
|
714
|
+
const client = clientFor({ privateKey: k.key });
|
|
715
|
+
const log = (line) => process.stderr.write(`g9pilot dev: ${line}\n`);
|
|
716
|
+
|
|
717
|
+
const { spawn } = await import('node:child_process');
|
|
718
|
+
const server = spawn(command, { cwd, shell: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, G9PILOT_DEV_PORT: String(port) } });
|
|
719
|
+
const relayOut = (stream, out) => stream.on('data', (d) => out.write(String(d).replace(/^/gm, ' │ ')));
|
|
720
|
+
relayOut(server.stdout, process.stdout);
|
|
721
|
+
relayOut(server.stderr, process.stderr);
|
|
722
|
+
// --watch-flows [target] (plan v2 P1-14): after every rebuild the dev server reports (its own «built / compiled /
|
|
723
|
+
// ready» line, `dev.rebuilt` in g9pilot.json to name another), the app takes the hot update, settles, and the runner
|
|
724
|
+
// replays the flows (default `tag:@smoke`) — a change that breaks a basic path says so within seconds of the save.
|
|
725
|
+
const watchTarget = flags['watch-flows'] ? (flags['watch-flows'] === true ? 'tag:@smoke' : String(flags['watch-flows'])) : null;
|
|
726
|
+
if (watchTarget) {
|
|
727
|
+
const runner = config.runner?.path ? config.runner : null;
|
|
728
|
+
if (!runner) fail('--watch-flows needs a runner (g9pilot.json "runner": {path, args}).');
|
|
729
|
+
const rebuilt = new RegExp(config.dev?.rebuilt ?? '(built|compiled|rebuilt|ready) in [\\d.]+ ?m?s', 'i');
|
|
730
|
+
let timer = null;
|
|
731
|
+
let running = false;
|
|
732
|
+
let again = false;
|
|
733
|
+
let first = true;
|
|
734
|
+
const replay = async () => {
|
|
735
|
+
if (running) {
|
|
736
|
+
again = true;
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
running = true;
|
|
740
|
+
const t0 = Date.now();
|
|
741
|
+
// A change the hot update cannot take reloads the whole page: wait for the page to declare itself again (the
|
|
742
|
+
// hello's jsReady), then for it to settle — the first try ran the flow into «the page has not called ready()».
|
|
743
|
+
for (const until = Date.now() + 45_000; Date.now() < until; ) {
|
|
744
|
+
const hello = await fetch(`${url}/qa`, { signal: AbortSignal.timeout(2000) }).then((r) => r.json()).catch(() => null);
|
|
745
|
+
if (hello?.jsReady) break;
|
|
746
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
747
|
+
}
|
|
748
|
+
await clientFor({ privateKey: key().key }).call('ui.waitIdle', {}, { timeoutMs: 20_000 }).catch(() => null);
|
|
749
|
+
const { spawn: spawnRun } = await import('node:child_process');
|
|
750
|
+
// Each replay's report in its own temporary folder — never the runner's default beside the project.
|
|
751
|
+
const reportDir = fs.mkdtempSync(path.join((await import('node:os')).tmpdir(), 'g9pilot-watch-'));
|
|
752
|
+
const child = spawnRun(process.execPath, [runner.path, 'run', watchTarget, ...(runner.args ?? []), '--quiet', '--report', reportDir], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
753
|
+
let out = '';
|
|
754
|
+
child.stdout.on('data', (d) => (out += d));
|
|
755
|
+
child.stderr.on('data', (d) => (out += d));
|
|
756
|
+
child.on('close', (code) => {
|
|
757
|
+
const summary = out.trim().split(/\r?\n/).filter(Boolean).slice(-2).join(' · ');
|
|
758
|
+
process.stderr.write(`g9pilot dev: ${code === 0 ? '✓' : '✗'} ${watchTarget} after the change — ${summary} (${Math.round((Date.now() - t0) / 100) / 10} s)\n`);
|
|
759
|
+
running = false;
|
|
760
|
+
if (again) {
|
|
761
|
+
again = false;
|
|
762
|
+
void replay();
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
};
|
|
766
|
+
const onLine = (d) => {
|
|
767
|
+
if (!rebuilt.test(String(d))) return;
|
|
768
|
+
if (first) {
|
|
769
|
+
first = false; // the first build is the one the app loads at start, not a change
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
clearTimeout(timer);
|
|
773
|
+
timer = setTimeout(() => void replay(), Number(config.dev?.settleMs ?? 2500));
|
|
774
|
+
};
|
|
775
|
+
server.stdout.on('data', onLine);
|
|
776
|
+
server.stderr.on('data', onLine);
|
|
777
|
+
}
|
|
778
|
+
server.on('exit', (code) => {
|
|
779
|
+
log(`the dev server stopped (${code})`);
|
|
780
|
+
process.exit(code ?? 1);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const deadline = Date.now() + Number(flags.timeout ?? 180_000);
|
|
784
|
+
for (;;) {
|
|
785
|
+
const ok = await fetch(bundleUrl, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
|
|
786
|
+
.then((r) => r.ok)
|
|
787
|
+
.catch(() => false);
|
|
788
|
+
if (ok) break;
|
|
789
|
+
if (Date.now() > deadline) {
|
|
790
|
+
server.kill();
|
|
791
|
+
fail(`the dev server did not serve ${bundleUrl} in time`);
|
|
792
|
+
}
|
|
793
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
794
|
+
}
|
|
795
|
+
log(`serving ${bundleUrl}`);
|
|
796
|
+
|
|
797
|
+
const adb = String(flags.adb ?? process.env.ADB ?? 'adb');
|
|
798
|
+
let serials = flags.serial ? [String(flags.serial)] : [];
|
|
799
|
+
if (!serials.length) {
|
|
800
|
+
try {
|
|
801
|
+
serials = execFileSync(adb, ['devices'], { encoding: 'utf8', timeout: 10_000 })
|
|
802
|
+
.split('\n')
|
|
803
|
+
.slice(1)
|
|
804
|
+
.map((l) => l.trim().split(/\s+/))
|
|
805
|
+
.filter((p) => p[1] === 'device')
|
|
806
|
+
.map((p) => p[0]);
|
|
807
|
+
} catch {
|
|
808
|
+
serials = [];
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
for (const serial of serials) {
|
|
812
|
+
try {
|
|
813
|
+
execFileSync(adb, ['-s', serial, 'reverse', `tcp:${port}`, `tcp:${port}`], { timeout: 10_000 });
|
|
814
|
+
log(`${serial}: adb reverse tcp:${port}`);
|
|
815
|
+
} catch (e) {
|
|
816
|
+
log(`${serial}: adb reverse failed (${e.message})`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
// A reverse port can vanish mid-session (another tool restarts the adb server: 2026-11-05 the app then failed «Fetch
|
|
820
|
+
// template resource failed … no bundle»). Put it back whenever it is gone, every 5 s, quietly.
|
|
821
|
+
const keepReverse = setInterval(() => {
|
|
822
|
+
for (const serial of serials) {
|
|
823
|
+
try {
|
|
824
|
+
const list = execFileSync(adb, ['-s', serial, 'reverse', '--list'], { encoding: 'utf8', timeout: 5_000 });
|
|
825
|
+
if (!list.includes(`tcp:${port}`)) {
|
|
826
|
+
execFileSync(adb, ['-s', serial, 'reverse', `tcp:${port}`, `tcp:${port}`], { timeout: 5_000 });
|
|
827
|
+
log(`${serial}: adb reverse tcp:${port} was gone — set again`);
|
|
828
|
+
}
|
|
829
|
+
} catch {
|
|
830
|
+
// the device went away; the next round tries again
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}, 5_000);
|
|
834
|
+
keepReverse.unref?.();
|
|
835
|
+
|
|
836
|
+
const point = async (url) => {
|
|
837
|
+
const answer = await client.call('dev.server', { url }, { timeoutMs: 20_000 });
|
|
838
|
+
if (!answer?.ok) log(`dev.server: ${answer?.code ?? ''} ${answer?.error ?? JSON.stringify(answer)}`);
|
|
839
|
+
return answer;
|
|
840
|
+
};
|
|
841
|
+
const answer = await point(bundleUrl);
|
|
842
|
+
if (answer?.ok) log(`the app loads ${bundleUrl}: save a file and it changes in place${watchTarget ? `, then ${watchTarget} replays` : ''} (Ctrl+C ends).`);
|
|
843
|
+
|
|
844
|
+
let ending = false;
|
|
845
|
+
const end = async () => {
|
|
846
|
+
if (ending) return;
|
|
847
|
+
ending = true;
|
|
848
|
+
if (!flags.keep) {
|
|
849
|
+
await point(null).catch(() => undefined);
|
|
850
|
+
log('the app is back on its own bundle');
|
|
851
|
+
}
|
|
852
|
+
server.kill();
|
|
853
|
+
process.exit(0);
|
|
854
|
+
};
|
|
855
|
+
process.on('SIGINT', end);
|
|
856
|
+
process.on('SIGTERM', end);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Level 0 of a savepoint: an Android emulator's own snapshot (the app, its data, the OS, the clock), through the emulator
|
|
861
|
+
* console (`adb emu avd snapshot …`). A real phone or a simulator has no such thing: level 1 (the app's data) is for them.
|
|
862
|
+
*/
|
|
863
|
+
function savepoint([sub, name]) {
|
|
864
|
+
const adb = String(flags.adb ?? process.env.ADB ?? 'adb');
|
|
865
|
+
const serial = String(flags.serial ?? 'emulator-5554');
|
|
866
|
+
if (flags.level === 'app' && (flags.via === 'bridge' || flags.device)) return bridgeAppSavepoint(sub, name);
|
|
867
|
+
if (flags.level === 'app' && flags.simulator) return simulatorAppSavepoint(String(flags.simulator), sub, name);
|
|
868
|
+
if (flags.simulator) return simulatorSavepoint(String(flags.simulator), sub, name);
|
|
869
|
+
if (flags.level === 'app') return appSavepoint(adb, serial, sub, name);
|
|
870
|
+
if (!serial.startsWith('emulator-')) fail(`savepoint: ${serial} is not an emulator — level 0 exists only on an Android emulator.`);
|
|
871
|
+
const emu = (args, timeout = 180_000) => execFileSync(adb, ['-s', serial, 'emu', 'avd', 'snapshot', ...args], { encoding: 'utf8', timeout }).trim();
|
|
872
|
+
const named = () => {
|
|
873
|
+
if (!name || !/^[A-Za-z0-9._-]{1,64}$/.test(name)) fail('savepoint needs a name: letters, digits, . _ - (at most 64).');
|
|
874
|
+
return name;
|
|
875
|
+
};
|
|
876
|
+
const started = Date.now();
|
|
877
|
+
const report = (what, out) => {
|
|
878
|
+
const failed = /KO|error|fail/i.test(out);
|
|
879
|
+
print({ ok: !failed, what, name: name ?? null, serial, ms: Date.now() - started, ...(flags.clockNote ? { clock: flags.clockNote } : {}), ...(flags.gpuNote ? { note: flags.gpuNote } : {}), emulator: out.split(/\r?\n/).filter((l) => l && l !== 'OK').slice(0, 6) });
|
|
880
|
+
process.exitCode = failed ? 1 : 0;
|
|
881
|
+
};
|
|
882
|
+
switch (sub) {
|
|
883
|
+
case 'save':
|
|
884
|
+
return report('saved', emu(['save', named()]));
|
|
885
|
+
case 'load': {
|
|
886
|
+
const out = emu(['load', named()]);
|
|
887
|
+
// The emulator drops off adb while it swaps its state, and the forwards with it (measured: «device offline», the
|
|
888
|
+
// bridge unreachable until a new forward): wait for it, then make them again (--forward 8797[,8798]).
|
|
889
|
+
try {
|
|
890
|
+
execFileSync(adb, ['-s', serial, 'wait-for-device'], { timeout: 60_000 });
|
|
891
|
+
} catch {
|
|
892
|
+
// reported by the caller's next command
|
|
893
|
+
}
|
|
894
|
+
// The snapshot brings its own clock back (measured: 191 s behind after a 3-minute-old snapshot). Asked to sync from
|
|
895
|
+
// the network, else set with su (an image that has it); a Play image has neither — then the offset is reported, and
|
|
896
|
+
// the bridge's clients correct for it (CLOCK_SKEW → deviceTime). A token the server issued may still look old.
|
|
897
|
+
let clock = 'unknown';
|
|
898
|
+
try {
|
|
899
|
+
const now = Math.floor(Date.now() / 1000);
|
|
900
|
+
execFileSync(adb, ['-s', serial, 'shell', `cmd network_time_update_service force_refresh >/dev/null 2>&1; su 0 toybox date -u @${now} >/dev/null 2>&1; true`], { timeout: 20_000 });
|
|
901
|
+
const after = Number(execFileSync(adb, ['-s', serial, 'shell', 'date +%s'], { encoding: 'utf8', timeout: 10_000 }).trim());
|
|
902
|
+
const behind = Math.round(Date.now() / 1000 - after);
|
|
903
|
+
clock = Math.abs(behind) <= 5 ? 'in step with this machine' : `${behind} s behind this machine (no network time and no su on this image; clients correct for it)`;
|
|
904
|
+
} catch {
|
|
905
|
+
clock = 'unknown';
|
|
906
|
+
}
|
|
907
|
+
flags.clockNote = clock;
|
|
908
|
+
for (const port of String(flags.forward ?? '').split(',').filter(Boolean)) {
|
|
909
|
+
try {
|
|
910
|
+
execFileSync(adb, ['-s', serial, 'forward', `tcp:${port}`, `tcp:${port}`], { timeout: 15_000 });
|
|
911
|
+
} catch {
|
|
912
|
+
// ditto
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
flags.gpuNote = 'A restored snapshot does not always bring the GPU back: on a host-GPU emulator a GL-heavy app (a map) has taken the emulator down at its next draw. Use --level app for such an app: it is also about twice as fast (measured 19 s / 11 s against 43 s / 25 s on a 3 GB emulator).';
|
|
916
|
+
return report('loaded', out);
|
|
917
|
+
}
|
|
918
|
+
case 'delete':
|
|
919
|
+
return report('deleted', emu(['delete', named()]));
|
|
920
|
+
case 'list':
|
|
921
|
+
return report('list', emu(['list'], 30_000));
|
|
922
|
+
default:
|
|
923
|
+
fail('savepoint save|load|list|delete <name> [--serial emulator-5554]');
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Level 1 of a savepoint: the app's own data folder (`run-as`, so a debuggable build), streamed to
|
|
929
|
+
* `<dir>/<package>/<name>.tar` on this machine and back. The app is stopped before either, so no database is copied
|
|
930
|
+
* half-written; after a load it starts from that data. Works on a real phone as on an emulator.
|
|
931
|
+
*/
|
|
932
|
+
/**
|
|
933
|
+
* Beside a level-1 savepoint `<name>.tar`: `<name>.meta.json` — when, what, from where, and the SERVER its data belongs
|
|
934
|
+
* to (plan v2 P1-11: `--server <database or environment>` and `--server-commit <sha>`, given by whoever knows them — a
|
|
935
|
+
* runner, a test hub, a person). An app's data names rows a server holds: loaded against another server it may mean
|
|
936
|
+
* nothing, so the pair is written down. Returns the metadata.
|
|
937
|
+
*/
|
|
938
|
+
function writeSavepointMeta(file, meta) {
|
|
939
|
+
const full = {
|
|
940
|
+
format: 'g9pilot/savepoint-meta',
|
|
941
|
+
at: new Date().toISOString(),
|
|
942
|
+
...meta,
|
|
943
|
+
server: flags.server || flags['server-commit'] ? { name: flags.server ? String(flags.server) : null, commit: flags['server-commit'] ? String(flags['server-commit']) : null } : null,
|
|
944
|
+
};
|
|
945
|
+
fs.writeFileSync(file.replace(/\.tar$/, '.meta.json'), JSON.stringify(full, null, 2));
|
|
946
|
+
return full;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** The entry names of a tar (ustar headers, 512-byte blocks): enough to prove what a savepoint holds. */
|
|
950
|
+
export function tarEntries(buf) {
|
|
951
|
+
const names = [];
|
|
952
|
+
let at = 0;
|
|
953
|
+
while (at + 512 <= buf.length) {
|
|
954
|
+
const header = buf.subarray(at, at + 512);
|
|
955
|
+
if (header.every((b) => b === 0)) break;
|
|
956
|
+
const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/s, '');
|
|
957
|
+
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0.*$/s, '');
|
|
958
|
+
const size = parseInt(header.subarray(124, 136).toString('utf8').replace(/\0.*$/s, '').trim() || '0', 8);
|
|
959
|
+
const type = String.fromCharCode(header[156] || 48);
|
|
960
|
+
if (type !== 'L' && type !== 'x' && type !== 'g') names.push(prefix ? `${prefix}/${name}` : name);
|
|
961
|
+
at += 512 + Math.ceil(size / 512) * 512;
|
|
962
|
+
}
|
|
963
|
+
return names;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async function appSavepoint(adb, serial, sub, name) {
|
|
967
|
+
const pkg = flags.package;
|
|
968
|
+
if (!pkg || !/^[A-Za-z][\w]*(\.[A-Za-z][\w]*)+$/.test(String(pkg))) fail('--level app needs --package <application id>.');
|
|
969
|
+
const dir = path.resolve(String(flags.dir ?? path.join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.g9pilot', 'savepoints')), String(pkg));
|
|
970
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
971
|
+
if (sub === 'list') {
|
|
972
|
+
print({ ok: true, dir, savepoints: fs.readdirSync(dir).filter((f) => f.endsWith('.tar')).map((f) => ({ name: f.slice(0, -4), bytes: fs.statSync(path.join(dir, f)).size })) });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (!name || !/^[A-Za-z0-9._-]{1,64}$/.test(name)) fail('savepoint needs a name: letters, digits, . _ - (at most 64).');
|
|
976
|
+
const file = path.join(dir, `${name}.tar`);
|
|
977
|
+
const { spawnSync } = await import('node:child_process');
|
|
978
|
+
const started = Date.now();
|
|
979
|
+
spawnSync(adb, ['-s', serial, 'shell', 'am', 'force-stop', pkg], { timeout: 20_000 });
|
|
980
|
+
// Only what the app owns and needs again: not its caches (any path with «cache» in it), not the code cache. A cache
|
|
981
|
+
// folder may also be out of run-as's reach (SELinux; measured on API 36: `d?????????`), and toybox tar STOPS at the
|
|
982
|
+
// first unreadable entry — the first savepoint lost shared_prefs (the session) that way and still said «saved».
|
|
983
|
+
const parts = 'files databases shared_prefs no_backup';
|
|
984
|
+
const keep = `$(ls -d ${parts} 2>/dev/null)`;
|
|
985
|
+
if (sub === 'save') {
|
|
986
|
+
const r = spawnSync(adb, ['-s', serial, 'exec-out', `run-as ${pkg} sh -c 'cd /data/data/${pkg} && tar -cf - --exclude="*cache*" ${keep}'`], { maxBuffer: 2 * 1024 ** 3, timeout: 10 * 60_000 });
|
|
987
|
+
if (r.status !== 0 || !r.stdout?.length) fail(`savepoint save: run-as failed (${String(r.stderr || '').trim().slice(0, 200) || 'no data'}) — a debuggable build is needed.`);
|
|
988
|
+
// Proof, not hope: the tar holds every entry the device has (the same selection).
|
|
989
|
+
const listed = spawnSync(adb, ['-s', serial, 'shell', `run-as ${pkg} sh -c 'cd /data/data/${pkg} && find ${keep} ! -path "*cache*" 2>/dev/null | wc -l'`], { encoding: 'utf8', timeout: 60_000 });
|
|
990
|
+
const onDevice = Number(String(listed.stdout).trim());
|
|
991
|
+
const inTar = tarEntries(r.stdout);
|
|
992
|
+
if (Number.isFinite(onDevice) && inTar.length < onDevice) {
|
|
993
|
+
fail(`savepoint save: the tar holds ${inTar.length} of ${onDevice} entries — the device refused part of the app's data (last in the tar: ${inTar.at(-1) ?? 'none'}). Nothing was saved.`);
|
|
994
|
+
}
|
|
995
|
+
fs.writeFileSync(file, r.stdout);
|
|
996
|
+
const meta = writeSavepointMeta(file, { level: 'app', name, package: pkg, serial, entries: inTar.length, bytes: r.stdout.length });
|
|
997
|
+
print({ ok: true, what: 'saved', level: 'app', name, package: pkg, serial, file, bytes: r.stdout.length, entries: inTar.length, server: meta.server, ms: Date.now() - started });
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (sub === 'load') {
|
|
1001
|
+
if (!fs.existsSync(file)) fail(`No savepoint ${name} for ${pkg} in ${dir}.`);
|
|
1002
|
+
const clear = `run-as ${pkg} sh -c 'cd /data/data/${pkg} && rm -rf ${parts}'`;
|
|
1003
|
+
spawnSync(adb, ['-s', serial, 'shell', clear], { timeout: 60_000 });
|
|
1004
|
+
const tar = fs.readFileSync(file);
|
|
1005
|
+
const r = spawnSync(adb, ['-s', serial, 'exec-in', `run-as ${pkg} sh -c 'cd /data/data/${pkg} && tar -xf -'`], { input: tar, timeout: 10 * 60_000 });
|
|
1006
|
+
if (r.status !== 0) fail(`savepoint load: ${String(r.stderr || '').trim().slice(0, 200)}`);
|
|
1007
|
+
const expected = tarEntries(tar).length;
|
|
1008
|
+
const listed = spawnSync(adb, ['-s', serial, 'shell', `run-as ${pkg} sh -c 'cd /data/data/${pkg} && find ${keep} ! -path "*cache*" 2>/dev/null | wc -l'`], { encoding: 'utf8', timeout: 60_000 });
|
|
1009
|
+
const onDevice = Number(String(listed.stdout).trim());
|
|
1010
|
+
if (Number.isFinite(onDevice) && onDevice < expected) fail(`savepoint load: ${onDevice} of ${expected} entries arrived on the device — load it again.`);
|
|
1011
|
+
print({ ok: true, what: 'loaded', level: 'app', name, package: pkg, serial, bytes: fs.statSync(file).size, ms: Date.now() - started, next: 'start the app' });
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
fail('savepoint save|load|list <name> --level app --package <id>');
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Level 0 on the iOS simulator (plan v2 P1-10, research R5): the WHOLE device — every app, its data, the keychain, the
|
|
1019
|
+
* settings. Save = shut down + `simctl clone` (APFS copy-on-write: seconds, little disk). simctl cannot restore INTO a
|
|
1020
|
+
* device and a clone has its own UDID, so load = shut down + the clone's `data` folder copied back over the original's
|
|
1021
|
+
* with APFS clones (`cp -c`) + boot: the device keeps its UDID, which the lab and every flow name. Run on the Mac.
|
|
1022
|
+
*/
|
|
1023
|
+
async function simulatorSavepoint(udid, sub, name) {
|
|
1024
|
+
const { spawnSync } = await import('node:child_process');
|
|
1025
|
+
const os = await import('node:os');
|
|
1026
|
+
const devices = path.join(os.homedir(), 'Library', 'Developer', 'CoreSimulator', 'Devices');
|
|
1027
|
+
const prefix = `g9pilot-sp:${udid.slice(0, 8)}:`;
|
|
1028
|
+
const all = () => JSON.parse(run('xcrun', ['simctl', 'list', 'devices', '--json'])).devices;
|
|
1029
|
+
const clones = () => Object.values(all()).flat().filter((d) => String(d.name).startsWith(prefix)).map((d) => ({ name: d.name.slice(prefix.length), udid: d.udid }));
|
|
1030
|
+
const state = () => Object.values(all()).flat().find((d) => d.udid === udid)?.state ?? null;
|
|
1031
|
+
if (!state()) fail(`No simulator ${udid}.`);
|
|
1032
|
+
if (sub === 'list') return print({ ok: true, level: 0, simulator: udid, savepoints: clones() });
|
|
1033
|
+
if (!name || !/^[A-Za-z0-9._-]{1,48}$/.test(name)) fail('savepoint needs a name: letters, digits, . _ - (at most 48).');
|
|
1034
|
+
const started = Date.now();
|
|
1035
|
+
const shutdown = () => {
|
|
1036
|
+
if (state() !== 'Shutdown') spawnSync('xcrun', ['simctl', 'shutdown', udid], { timeout: 120_000 });
|
|
1037
|
+
};
|
|
1038
|
+
const boot = () => {
|
|
1039
|
+
spawnSync('xcrun', ['simctl', 'boot', udid], { timeout: 120_000 });
|
|
1040
|
+
spawnSync('xcrun', ['simctl', 'bootstatus', udid, '-b'], { timeout: 300_000 });
|
|
1041
|
+
};
|
|
1042
|
+
const existing = clones().find((c) => c.name === name);
|
|
1043
|
+
if (sub === 'save') {
|
|
1044
|
+
const wasBooted = state() === 'Booted';
|
|
1045
|
+
shutdown();
|
|
1046
|
+
const t0 = Date.now();
|
|
1047
|
+
if (existing) spawnSync('xcrun', ['simctl', 'delete', existing.udid], { timeout: 120_000 });
|
|
1048
|
+
const r = spawnSync('xcrun', ['simctl', 'clone', udid, `${prefix}${name}`], { encoding: 'utf8', timeout: 600_000 });
|
|
1049
|
+
if (r.status !== 0) fail(`simctl clone: ${String(r.stderr).trim().slice(0, 300)}`);
|
|
1050
|
+
const cloneMs = Date.now() - t0;
|
|
1051
|
+
if (wasBooted) boot();
|
|
1052
|
+
return print({ ok: true, what: 'saved', level: 0, simulator: udid, name, clone: String(r.stdout).trim(), cloneMs, ms: Date.now() - started, rebooted: wasBooted });
|
|
1053
|
+
}
|
|
1054
|
+
if (sub === 'load') {
|
|
1055
|
+
if (!existing) fail(`No level-0 savepoint ${name} of ${udid} (g9pilot savepoint list --simulator ${udid}).`);
|
|
1056
|
+
shutdown();
|
|
1057
|
+
const t0 = Date.now();
|
|
1058
|
+
const dst = path.join(devices, udid, 'data');
|
|
1059
|
+
const src = path.join(devices, existing.udid, 'data');
|
|
1060
|
+
if (!fs.existsSync(src)) fail(`The clone's data folder is missing: ${src}`);
|
|
1061
|
+
// Beside the original, never over it: the swap happens only after a good copy (the first try removed the original
|
|
1062
|
+
// first and a refused file left the lab simulator with a partial copy — 2026-11-04). Some of the simulator's own
|
|
1063
|
+
// caches cannot be copied by a user (Library/Caches/locationd: «Operation not permitted»); they rebuild themselves,
|
|
1064
|
+
// so a refusal there is accepted — anywhere else the load stops and the device stays as it was.
|
|
1065
|
+
const fresh = `${dst}.g9new`;
|
|
1066
|
+
const old = `${dst}.g9old`;
|
|
1067
|
+
fs.rmSync(fresh, { recursive: true, force: true });
|
|
1068
|
+
const r = spawnSync('cp', ['-cRp', src, fresh], { encoding: 'utf8', timeout: 600_000, maxBuffer: 64 * 1024 * 1024 });
|
|
1069
|
+
const refused = String(r.stderr ?? '').split('\n').filter(Boolean);
|
|
1070
|
+
const serious = refused.filter((l) => !/\/data\/Library\/Caches\//.test(l));
|
|
1071
|
+
if (!fs.existsSync(fresh) || serious.length) {
|
|
1072
|
+
fs.rmSync(fresh, { recursive: true, force: true });
|
|
1073
|
+
fail(`cp -c refused ${serious.length} file(s) outside the caches; the device is unchanged: ${serious.slice(0, 3).join(' | ').slice(0, 400)}`);
|
|
1074
|
+
}
|
|
1075
|
+
fs.rmSync(old, { recursive: true, force: true });
|
|
1076
|
+
fs.renameSync(dst, old);
|
|
1077
|
+
fs.renameSync(fresh, dst);
|
|
1078
|
+
fs.rmSync(old, { recursive: true, force: true });
|
|
1079
|
+
const copyMs = Date.now() - t0;
|
|
1080
|
+
const t1 = Date.now();
|
|
1081
|
+
boot();
|
|
1082
|
+
return print({ ok: true, what: 'loaded', level: 0, simulator: udid, name, copyMs, cachesSkipped: refused.length, bootMs: Date.now() - t1, ms: Date.now() - started, next: 'start the app' });
|
|
1083
|
+
}
|
|
1084
|
+
if (sub === 'delete') {
|
|
1085
|
+
if (!existing) fail(`No level-0 savepoint ${name} of ${udid}.`);
|
|
1086
|
+
spawnSync('xcrun', ['simctl', 'delete', existing.udid], { timeout: 120_000 });
|
|
1087
|
+
return print({ ok: true, what: 'deleted', level: 0, simulator: udid, name });
|
|
1088
|
+
}
|
|
1089
|
+
fail('savepoint save|load|list|delete <name> --simulator <udid>');
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Level 1 on the iOS simulator (plan v2 P1-08, research R5): the app's data container (`simctl get_app_container … data`)
|
|
1094
|
+
* is a folder of this Mac, so it is a tar of Documents and Library — not Library/Caches, not the launch snapshots
|
|
1095
|
+
* (SplashBoard), not tmp — to `<dir>/<bundle id>/<name>.tar` and back. The app is terminated first; after a load it
|
|
1096
|
+
* starts from that data. The Keychain is not in the container: a session kept there stays as it is on this simulator.
|
|
1097
|
+
*/
|
|
1098
|
+
async function simulatorAppSavepoint(udid, sub, name) {
|
|
1099
|
+
const bundle = flags.package ?? flags.bundle;
|
|
1100
|
+
if (!bundle || !/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)+$/.test(String(bundle))) fail('--level app --simulator <udid> needs --package <bundle id>.');
|
|
1101
|
+
const dir = path.resolve(String(flags.dir ?? path.join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.g9pilot', 'savepoints')), String(bundle));
|
|
1102
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1103
|
+
if (sub === 'list') {
|
|
1104
|
+
print({ ok: true, dir, savepoints: fs.readdirSync(dir).filter((f) => f.endsWith('.tar')).map((f) => ({ name: f.slice(0, -4), bytes: fs.statSync(path.join(dir, f)).size })) });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (!name || !/^[A-Za-z0-9._-]{1,64}$/.test(name)) fail('savepoint needs a name: letters, digits, . _ - (at most 64).');
|
|
1108
|
+
const file = path.join(dir, `${name}.tar`);
|
|
1109
|
+
const { spawnSync } = await import('node:child_process');
|
|
1110
|
+
const started = Date.now();
|
|
1111
|
+
const container = run('xcrun', ['simctl', 'get_app_container', udid, String(bundle), 'data']).trim();
|
|
1112
|
+
if (!container || !fs.existsSync(container)) fail(`savepoint: ${bundle} is not installed on simulator ${udid}.`);
|
|
1113
|
+
spawnSync('xcrun', ['simctl', 'terminate', udid, String(bundle)], { timeout: 20_000 });
|
|
1114
|
+
const SKIP = ['Library/Caches', 'Library/SplashBoard', 'Library/Saved Application State'];
|
|
1115
|
+
const skipped = (rel) => SKIP.some((x) => rel === x || rel.startsWith(`${x}/`));
|
|
1116
|
+
// Every entry (folders too) the savepoint should hold — the proof both ways, as on Android.
|
|
1117
|
+
const entries = () => {
|
|
1118
|
+
const out = [];
|
|
1119
|
+
const walk = (rel) => {
|
|
1120
|
+
for (const e of fs.readdirSync(path.join(container, rel), { withFileTypes: true })) {
|
|
1121
|
+
const r = `${rel}/${e.name}`;
|
|
1122
|
+
if (skipped(r)) continue;
|
|
1123
|
+
out.push(r);
|
|
1124
|
+
if (e.isDirectory()) walk(r);
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
for (const top of ['Documents', 'Library']) {
|
|
1128
|
+
if (!fs.existsSync(path.join(container, top))) continue;
|
|
1129
|
+
out.push(top);
|
|
1130
|
+
walk(top);
|
|
1131
|
+
}
|
|
1132
|
+
return out;
|
|
1133
|
+
};
|
|
1134
|
+
// Preferences live in cfprefsd: flush its copy into the file before the tar, and hand the file back to it after a
|
|
1135
|
+
// load (a replaced plist alone can be overwritten by the cached domain).
|
|
1136
|
+
const prefs = path.join(container, 'Library', 'Preferences', `${bundle}.plist`);
|
|
1137
|
+
if (sub === 'save') {
|
|
1138
|
+
// The app's domain is keyed by its file in the container (a bare bundle id is another domain outside it).
|
|
1139
|
+
const flushed = path.join(container, 'tmp', `g9pilot-prefs-${process.pid}.plist`);
|
|
1140
|
+
fs.mkdirSync(path.dirname(flushed), { recursive: true });
|
|
1141
|
+
const ex = spawnSync('xcrun', ['simctl', 'spawn', udid, 'defaults', 'export', prefs.replace(/\.plist$/, ''), flushed], { timeout: 30_000 });
|
|
1142
|
+
if (ex.status === 0 && fs.existsSync(flushed)) fs.copyFileSync(flushed, prefs);
|
|
1143
|
+
fs.rmSync(flushed, { force: true });
|
|
1144
|
+
const want = entries();
|
|
1145
|
+
const r = spawnSync('tar', ['-cf', '-', '-C', container, ...SKIP.flatMap((x) => ['--exclude', x]), ...want.filter((x) => !x.includes('/'))], { maxBuffer: 2 * 1024 ** 3, timeout: 10 * 60_000, env: { ...process.env, COPYFILE_DISABLE: '1' } }); // no AppleDouble ._ files
|
|
1146
|
+
if (r.status !== 0 || !r.stdout?.length) fail(`savepoint save: tar failed (${String(r.stderr || '').trim().slice(0, 200)})`);
|
|
1147
|
+
const inTar = tarEntries(r.stdout);
|
|
1148
|
+
if (inTar.length < want.length) fail(`savepoint save: the tar holds ${inTar.length} of ${want.length} entries. Nothing was saved.`);
|
|
1149
|
+
fs.writeFileSync(file, r.stdout);
|
|
1150
|
+
const meta = writeSavepointMeta(file, { level: 'app', name, package: bundle, simulator: udid, entries: inTar.length, bytes: r.stdout.length });
|
|
1151
|
+
print({ ok: true, what: 'saved', level: 'app', name, package: bundle, simulator: udid, file, bytes: r.stdout.length, entries: inTar.length, server: meta.server, ms: Date.now() - started });
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (sub === 'load') {
|
|
1155
|
+
if (!fs.existsSync(file)) fail(`No savepoint ${name} for ${bundle} in ${dir}.`);
|
|
1156
|
+
// Out with what the app has now (but the skipped caches), in with the savepoint.
|
|
1157
|
+
for (const top of ['Documents', 'Library']) {
|
|
1158
|
+
const at = path.join(container, top);
|
|
1159
|
+
if (!fs.existsSync(at)) continue;
|
|
1160
|
+
for (const e of fs.readdirSync(at)) if (!skipped(`${top}/${e}`)) fs.rmSync(path.join(at, e), { recursive: true, force: true });
|
|
1161
|
+
}
|
|
1162
|
+
const tar = fs.readFileSync(file);
|
|
1163
|
+
const r = spawnSync('tar', ['-xf', '-', '-C', container], { input: tar, timeout: 10 * 60_000 });
|
|
1164
|
+
if (r.status !== 0) fail(`savepoint load: ${String(r.stderr || '').trim().slice(0, 200)}`);
|
|
1165
|
+
const expected = tarEntries(tar).length;
|
|
1166
|
+
const now = entries().length;
|
|
1167
|
+
if (now < expected) fail(`savepoint load: ${now} of ${expected} entries arrived — load it again.`);
|
|
1168
|
+
if (fs.existsSync(prefs)) spawnSync('xcrun', ['simctl', 'spawn', udid, 'defaults', 'import', prefs.replace(/\.plist$/, ''), prefs], { timeout: 30_000 });
|
|
1169
|
+
else spawnSync('xcrun', ['simctl', 'spawn', udid, 'defaults', 'delete', prefs.replace(/\.plist$/, '')], { timeout: 30_000 });
|
|
1170
|
+
print({ ok: true, what: 'loaded', level: 'app', name, package: bundle, simulator: udid, bytes: tar.length, entries: expected, ms: Date.now() - started, next: 'start the app' });
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
fail('savepoint save|load|list <name> --level app --simulator <udid> --package <bundle id>');
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Level 1 packed BY THE APP (`--via bridge`, and always for an iPhone: `--device <udid>`): the bridge does the tar at
|
|
1178
|
+
* the app's next launch, before the app opens its data (PilotState.kt / G9PilotState.m), and this side moves ONE file
|
|
1179
|
+
* by the device's own channel — devicectl on an iPhone (it copies files, not folders), the container folder on the
|
|
1180
|
+
* simulator, run-as on Android. `state.cmd` carries a nonce that comes back in `state.done`.
|
|
1181
|
+
*/
|
|
1182
|
+
async function bridgeAppSavepoint(sub, name) {
|
|
1183
|
+
const id = String(flags.package ?? flags.bundle ?? '');
|
|
1184
|
+
if (!/^[A-Za-z][\w-]*(\.[A-Za-z][\w-]*)+$/.test(id)) fail('--level app --via bridge needs --package <application / bundle id>.');
|
|
1185
|
+
const dir = path.resolve(String(flags.dir ?? path.join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.g9pilot', 'savepoints')), id);
|
|
1186
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1187
|
+
if (sub === 'list') {
|
|
1188
|
+
print({ ok: true, dir, savepoints: fs.readdirSync(dir).filter((f) => f.endsWith('.tar')).map((f) => ({ name: f.slice(0, -4), bytes: fs.statSync(path.join(dir, f)).size })) });
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (!name || !/^[A-Za-z0-9._-]{1,64}$/.test(name)) fail('savepoint needs a name: letters, digits, . _ - (at most 64).');
|
|
1192
|
+
const file = path.join(dir, `${name}.tar`);
|
|
1193
|
+
const { spawnSync } = await import('node:child_process');
|
|
1194
|
+
const os = await import('node:os');
|
|
1195
|
+
const ch = bridgeChannel(id, spawnSync, os);
|
|
1196
|
+
const started = Date.now();
|
|
1197
|
+
const nonce = Math.random().toString(36).slice(2, 12);
|
|
1198
|
+
const waitDone = (what) => {
|
|
1199
|
+
for (const until = Date.now() + Number(flags.timeout ?? 120_000); Date.now() < until; ) {
|
|
1200
|
+
const raw = ch.get('state.done');
|
|
1201
|
+
if (raw) {
|
|
1202
|
+
try {
|
|
1203
|
+
const d = JSON.parse(raw.toString('utf8'));
|
|
1204
|
+
if (d.nonce === nonce && d.what === what) return d;
|
|
1205
|
+
} catch {
|
|
1206
|
+
// being written
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1_000);
|
|
1210
|
+
}
|
|
1211
|
+
fail(`savepoint ${what}: the app did not answer in time (a debug build whose G9AppPilot packs savepoints at launch — PilotState / G9PilotState — and calls applyPendingState or install at launch?)`);
|
|
1212
|
+
};
|
|
1213
|
+
if (sub === 'save') {
|
|
1214
|
+
ch.put('state.cmd', Buffer.from(`export ${nonce}\n`));
|
|
1215
|
+
ch.relaunch();
|
|
1216
|
+
const done = waitDone('export');
|
|
1217
|
+
if (!done.ok) fail(`savepoint save: ${done.error}`);
|
|
1218
|
+
const tar = ch.get('state.tar');
|
|
1219
|
+
if (!tar?.length) fail('savepoint save: state.tar could not be copied off the device.');
|
|
1220
|
+
const entries = tarEntries(tar).length;
|
|
1221
|
+
if (entries !== done.entries) fail(`savepoint save: the copy holds ${entries} of the ${done.entries} entries the app packed. Nothing was saved.`);
|
|
1222
|
+
fs.writeFileSync(file, tar);
|
|
1223
|
+
const meta = writeSavepointMeta(file, { level: 'app', via: 'bridge', channel: ch.kind, name, package: id, entries, bytes: tar.length });
|
|
1224
|
+
print({ ok: true, what: 'saved', level: 'app', via: 'bridge', channel: ch.kind, name, package: id, file, bytes: tar.length, entries, server: meta.server, packMs: done.ms, ms: Date.now() - started });
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
if (sub === 'load') {
|
|
1228
|
+
if (!fs.existsSync(file)) fail(`No savepoint ${name} for ${id} in ${dir}.`);
|
|
1229
|
+
const tar = fs.readFileSync(file);
|
|
1230
|
+
ch.put('state-in.tar', tar);
|
|
1231
|
+
ch.put('state.cmd', Buffer.from(`import ${nonce}\n`));
|
|
1232
|
+
ch.relaunch();
|
|
1233
|
+
const done = waitDone('import');
|
|
1234
|
+
if (!done.ok) fail(`savepoint load: ${done.error}`);
|
|
1235
|
+
const expected = tarEntries(tar).length;
|
|
1236
|
+
if (done.entries !== expected) fail(`savepoint load: the app applied ${done.entries} of ${expected} entries — load it again.`);
|
|
1237
|
+
print({ ok: true, what: 'loaded', level: 'app', via: 'bridge', channel: ch.kind, name, package: id, bytes: tar.length, entries: expected, applyMs: done.ms, ms: Date.now() - started, note: 'the app is running from that data now' });
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
fail('savepoint save|load|list <name> --level app --via bridge --package <id> [--device <udid> | --simulator <udid> | --serial <s>]');
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/** One file in and out of the bridge's work folder, and a relaunch, by the device's own channel. */
|
|
1244
|
+
function bridgeChannel(id, spawnSync, os) {
|
|
1245
|
+
if (flags.device) {
|
|
1246
|
+
const udid = String(flags.device);
|
|
1247
|
+
const remote = (f) => `tmp/g9pilot/${f}`;
|
|
1248
|
+
const ctl = (args, timeout = 120_000) => spawnSync('xcrun', ['devicectl', ...args], { timeout, encoding: 'utf8' });
|
|
1249
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'g9pilot-'));
|
|
1250
|
+
const where = ['--device', udid, '--domain-type', 'appDataContainer', '--domain-identifier', id, '--user', 'mobile'];
|
|
1251
|
+
return {
|
|
1252
|
+
kind: 'devicectl',
|
|
1253
|
+
put(f, bytes) {
|
|
1254
|
+
const local = path.join(scratch, f);
|
|
1255
|
+
fs.writeFileSync(local, bytes);
|
|
1256
|
+
const r = ctl(['device', 'copy', 'to', ...where, '--source', local, '--destination', remote(f)], 15 * 60_000);
|
|
1257
|
+
if (r.status !== 0) fail(`devicectl copy to: ${String(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
1258
|
+
},
|
|
1259
|
+
get(f) {
|
|
1260
|
+
const local = path.join(scratch, `in-${f}`);
|
|
1261
|
+
fs.rmSync(local, { force: true });
|
|
1262
|
+
const r = ctl(['device', 'copy', 'from', ...where, '--source', remote(f), '--destination', local], 15 * 60_000);
|
|
1263
|
+
return r.status === 0 && fs.existsSync(local) ? fs.readFileSync(local) : null;
|
|
1264
|
+
},
|
|
1265
|
+
relaunch() {
|
|
1266
|
+
const r = ctl(['device', 'process', 'launch', '--device', udid, '--terminate-existing', id]);
|
|
1267
|
+
if (r.status !== 0) fail(`devicectl launch: ${String(r.stderr || r.stdout).trim().slice(-300)}`);
|
|
1268
|
+
},
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
if (flags.simulator) {
|
|
1272
|
+
const udid = String(flags.simulator);
|
|
1273
|
+
const container = run('xcrun', ['simctl', 'get_app_container', udid, id, 'data']).trim();
|
|
1274
|
+
const work = path.join(container, 'tmp', 'g9pilot');
|
|
1275
|
+
fs.mkdirSync(work, { recursive: true });
|
|
1276
|
+
return {
|
|
1277
|
+
kind: 'simulator',
|
|
1278
|
+
put: (f, bytes) => fs.writeFileSync(path.join(work, f), bytes),
|
|
1279
|
+
get: (f) => (fs.existsSync(path.join(work, f)) ? fs.readFileSync(path.join(work, f)) : null),
|
|
1280
|
+
relaunch() {
|
|
1281
|
+
spawnSync('xcrun', ['simctl', 'terminate', udid, id], { timeout: 20_000 });
|
|
1282
|
+
const r = spawnSync('xcrun', ['simctl', 'launch', udid, id], { timeout: 60_000, encoding: 'utf8' });
|
|
1283
|
+
if (r.status !== 0) fail(`simctl launch: ${String(r.stderr).trim().slice(-300)}`);
|
|
1284
|
+
},
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
const adb = String(flags.adb ?? process.env.ADB ?? 'adb');
|
|
1288
|
+
const serial = String(flags.serial ?? 'emulator-5554');
|
|
1289
|
+
const work = `/data/data/${id}/cache/g9pilot`;
|
|
1290
|
+
return {
|
|
1291
|
+
kind: 'run-as',
|
|
1292
|
+
put(f, bytes) {
|
|
1293
|
+
const r = spawnSync(adb, ['-s', serial, 'exec-in', `run-as ${id} sh -c 'mkdir -p ${work} && cat > ${work}/${f}'`], { input: bytes, timeout: 10 * 60_000 });
|
|
1294
|
+
if (r.status !== 0) fail(`run-as write: ${String(r.error?.message ?? r.stderr).trim().slice(0, 200)} — a debuggable build is needed.`);
|
|
1295
|
+
},
|
|
1296
|
+
get(f) {
|
|
1297
|
+
const r = spawnSync(adb, ['-s', serial, 'exec-out', `run-as ${id} sh -c 'cat ${work}/${f} 2>/dev/null'`], { maxBuffer: 2 * 1024 ** 3, timeout: 10 * 60_000 });
|
|
1298
|
+
return r.status === 0 && r.stdout?.length ? r.stdout : null;
|
|
1299
|
+
},
|
|
1300
|
+
relaunch() {
|
|
1301
|
+
spawnSync(adb, ['-s', serial, 'shell', 'am', 'force-stop', id], { timeout: 20_000 });
|
|
1302
|
+
spawnSync(adb, ['-s', serial, 'shell', 'monkey', '-p', id, '-c', 'android.intent.category.LAUNCHER', '1'], { timeout: 30_000 });
|
|
1303
|
+
},
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// ---- the OS around the app (g9pilot os …) ----------------------------------------------------------------------------
|
|
1308
|
+
|
|
1309
|
+
function deviceTarget() {
|
|
1310
|
+
if (flags.simulator) return { kind: 'simulator', udid: String(flags.simulator) };
|
|
1311
|
+
return { kind: 'android', adb: String(flags.adb ?? process.env.ADB ?? 'adb'), serial: String(flags.serial ?? 'emulator-5554') };
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const run = (file, args, opts = {}) => execFileSync(file, args, { encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024, ...opts });
|
|
1315
|
+
const adbOf = (t) => (args, opts) => run(t.adb, ['-s', t.serial, ...args], opts);
|
|
1316
|
+
|
|
1317
|
+
/** The OS's crash records for the app (or all), newest last. */
|
|
1318
|
+
function crashLog({ lines = Number(flags.lines ?? 400), pkg = flags.package } = {}) {
|
|
1319
|
+
const t = deviceTarget();
|
|
1320
|
+
if (t.kind === 'android') {
|
|
1321
|
+
const out = adbOf(t)(['logcat', '-b', 'crash', '-d', '-v', 'time']);
|
|
1322
|
+
const all = out.split(/\r?\n/).filter(Boolean);
|
|
1323
|
+
const mine = pkg ? all.filter((l, i) => l.includes(String(pkg)) || (i > 0 && /^\S+ \S+ [EF]\/AndroidRuntime/.test(l))) : all;
|
|
1324
|
+
return { source: 'logcat -b crash', count: mine.length, lines: mine.slice(-lines) };
|
|
1325
|
+
}
|
|
1326
|
+
if (process.platform !== 'darwin') fail('os crashlogs --simulator runs on the Mac that hosts the simulator (or through `--host`).');
|
|
1327
|
+
const dir = path.join(process.env.HOME ?? '', 'Library', 'Logs', 'DiagnosticReports');
|
|
1328
|
+
const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /\.(ips|crash)$/.test(f) && (!pkg || f.toLowerCase().includes(String(pkg).split('.').pop().toLowerCase()))) : [];
|
|
1329
|
+
files.sort((a, b) => fs.statSync(path.join(dir, a)).mtimeMs - fs.statSync(path.join(dir, b)).mtimeMs);
|
|
1330
|
+
const newest = files.slice(-3).map((f) => ({ file: f, head: fs.readFileSync(path.join(dir, f), 'utf8').split('\n').slice(0, lines).join('\n') }));
|
|
1331
|
+
return { source: dir, count: files.length, reports: newest };
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** Answers the system permission dialog by its buttons' ids (the permission controller's own), with a real tap. */
|
|
1335
|
+
function answerDialog(t, allow) {
|
|
1336
|
+
const adb = adbOf(t);
|
|
1337
|
+
adb(['shell', 'uiautomator', 'dump', '/sdcard/g9pilot-ui.xml']);
|
|
1338
|
+
const xml = adb(['exec-out', 'cat', '/sdcard/g9pilot-ui.xml']);
|
|
1339
|
+
const ids = allow
|
|
1340
|
+
? ['permission_allow_foreground_only_button', 'permission_allow_button', 'permission_allow_one_time_button', 'permission_allow_always_button']
|
|
1341
|
+
: ['permission_deny_button', 'permission_deny_and_dont_ask_again_button'];
|
|
1342
|
+
for (const id of ids) {
|
|
1343
|
+
const m = new RegExp(`resource-id="[^"]*:id/${id}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`).exec(xml);
|
|
1344
|
+
if (!m) continue;
|
|
1345
|
+
const [x, y] = [Math.round((+m[1] + +m[3]) / 2), Math.round((+m[2] + +m[4]) / 2)];
|
|
1346
|
+
adb(['shell', 'input', 'tap', String(x), String(y)]);
|
|
1347
|
+
return { answered: allow ? 'allow' : 'deny', button: id, at: { x, y } };
|
|
1348
|
+
}
|
|
1349
|
+
return { answered: null, note: 'No system permission dialog is on screen.' };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** One OS action; returns its result or throws (the CLI prints it, MCP's pilot_stage `os.*` returns it). */
|
|
1353
|
+
export function osAction(what, a = {}) {
|
|
1354
|
+
const t = deviceTarget();
|
|
1355
|
+
const sim = t.kind === 'simulator';
|
|
1356
|
+
const simctl = (x) => run('xcrun', ['simctl', ...x]);
|
|
1357
|
+
const bad = (m) => {
|
|
1358
|
+
throw new Error(m);
|
|
1359
|
+
};
|
|
1360
|
+
switch (what) {
|
|
1361
|
+
case 'crashlogs':
|
|
1362
|
+
return crashLog({ lines: Number(a.lines ?? flags.lines ?? 400), pkg: a.package ?? flags.package });
|
|
1363
|
+
case 'permission': {
|
|
1364
|
+
const { verb, permission } = a;
|
|
1365
|
+
const pkg = a.package ?? flags.package;
|
|
1366
|
+
if (!['grant', 'revoke'].includes(verb) || !permission || !pkg) bad('os permission grant|revoke <permission> --package <id>');
|
|
1367
|
+
if (sim) simctl(['privacy', t.udid, verb, permission, String(pkg)]);
|
|
1368
|
+
else adbOf(t)(['shell', 'pm', verb, String(pkg), permission]);
|
|
1369
|
+
return { [verb === 'grant' ? 'granted' : 'revoked']: permission, package: pkg, ...(verb === 'revoke' && !sim ? { note: 'Android stops an app whose runtime permission is revoked' } : {}) };
|
|
1370
|
+
}
|
|
1371
|
+
case 'location': {
|
|
1372
|
+
const lat = Number(a.lat);
|
|
1373
|
+
const lon = Number(a.lon);
|
|
1374
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) bad('os location <lat> <lon> (degrees)');
|
|
1375
|
+
if (sim) simctl(['location', t.udid, 'set', `${lat},${lon}`]);
|
|
1376
|
+
else if (t.serial.startsWith('emulator-')) adbOf(t)(['emu', 'geo', 'fix', String(lon), String(lat)]);
|
|
1377
|
+
else bad('A real Android phone takes a fake position only from a mock-location app; use an emulator.');
|
|
1378
|
+
return { location: { lat, lon } };
|
|
1379
|
+
}
|
|
1380
|
+
case 'net': {
|
|
1381
|
+
const { state } = a;
|
|
1382
|
+
if (!['offline', 'online'].includes(state)) bad('os net offline|online');
|
|
1383
|
+
if (sim) bad('The simulator shares the Mac’s network; shape the page’s requests with net.shape instead.');
|
|
1384
|
+
adbOf(t)(['shell', 'cmd', 'connectivity', 'airplane-mode', state === 'offline' ? 'enable' : 'disable']);
|
|
1385
|
+
return { network: state, note: 'the whole device: the sync hub and map tiles too (net.shape shapes only the page’s fetch)' };
|
|
1386
|
+
}
|
|
1387
|
+
case 'dialog': {
|
|
1388
|
+
const { answer } = a;
|
|
1389
|
+
if (!['allow', 'deny'].includes(answer)) bad('os dialog allow|deny');
|
|
1390
|
+
if (sim) bad('The simulator’s dialogs: grant first with `os permission grant` (simctl privacy).');
|
|
1391
|
+
return answerDialog(t, answer === 'allow');
|
|
1392
|
+
}
|
|
1393
|
+
case 'video': {
|
|
1394
|
+
const seconds = Math.max(1, Math.min(3600, Math.round(Number(a.seconds ?? 30))));
|
|
1395
|
+
const out = String(a.out ?? flags.out ?? '.');
|
|
1396
|
+
fs.mkdirSync(out, { recursive: true });
|
|
1397
|
+
if (sim) return recordSimulatorVideo(t.udid, seconds, out);
|
|
1398
|
+
return recordAndroidVideo(t, seconds, out);
|
|
1399
|
+
}
|
|
1400
|
+
case 'doze': {
|
|
1401
|
+
const { state } = a;
|
|
1402
|
+
if (sim) bad('Doze is Android only; iOS has no equivalent a test can switch.');
|
|
1403
|
+
const adb = adbOf(t);
|
|
1404
|
+
if (state === 'on') {
|
|
1405
|
+
adb(['shell', 'dumpsys', 'battery', 'unplug']);
|
|
1406
|
+
adb(['shell', 'dumpsys', 'deviceidle', 'enable', 'all']);
|
|
1407
|
+
adb(['shell', 'dumpsys', 'deviceidle', 'force-idle']);
|
|
1408
|
+
} else if (state === 'off') {
|
|
1409
|
+
adb(['shell', 'dumpsys', 'deviceidle', 'unforce']);
|
|
1410
|
+
adb(['shell', 'dumpsys', 'battery', 'reset']);
|
|
1411
|
+
} else if (state === 'step') {
|
|
1412
|
+
adb(['shell', 'dumpsys', 'deviceidle', 'step']);
|
|
1413
|
+
} else bad('os doze on|off|step');
|
|
1414
|
+
const mState = /mState=(\w+)/.exec(adb(['shell', 'dumpsys', 'deviceidle']))?.[1] ?? null;
|
|
1415
|
+
return { doze: state, mState, ...(state === 'on' ? { undo: 'os doze off (it also resets the battery state)' } : {}) };
|
|
1416
|
+
}
|
|
1417
|
+
case 'standby': {
|
|
1418
|
+
const { bucket } = a;
|
|
1419
|
+
const pkg = a.package ?? flags.package;
|
|
1420
|
+
if (sim) bad('App Standby buckets are Android only.');
|
|
1421
|
+
if (!['active', 'working_set', 'frequent', 'rare', 'restricted'].includes(bucket) || !pkg) bad('os standby active|working_set|frequent|rare|restricted --package <id>');
|
|
1422
|
+
adbOf(t)(['shell', 'am', 'set-standby-bucket', String(pkg), bucket]);
|
|
1423
|
+
return { package: pkg, bucket: String(adbOf(t)(['shell', 'am', 'get-standby-bucket', String(pkg)])).trim() };
|
|
1424
|
+
}
|
|
1425
|
+
case 'appearance': {
|
|
1426
|
+
const { mode } = a;
|
|
1427
|
+
if (!['dark', 'light'].includes(mode)) bad('os appearance dark|light');
|
|
1428
|
+
if (sim) simctl(['ui', t.udid, 'appearance', mode]);
|
|
1429
|
+
else adbOf(t)(['shell', 'cmd', 'uimode', 'night', mode === 'dark' ? 'yes' : 'no']);
|
|
1430
|
+
return { appearance: mode };
|
|
1431
|
+
}
|
|
1432
|
+
case 'fontscale': {
|
|
1433
|
+
const { value } = a;
|
|
1434
|
+
const SIZES = ['extra-small', 'small', 'medium', 'large', 'extra-large', 'extra-extra-large', 'extra-extra-extra-large', 'accessibility-medium', 'accessibility-large', 'accessibility-extra-large', 'accessibility-extra-extra-large', 'accessibility-extra-extra-extra-large'];
|
|
1435
|
+
const n = Number(value);
|
|
1436
|
+
if (sim) {
|
|
1437
|
+
// iOS takes a category; a number is mapped (1 = large, the default).
|
|
1438
|
+
const category = SIZES.includes(value) ? value : Number.isFinite(n) ? (n <= 0.85 ? 'small' : n <= 1 ? 'large' : n <= 1.15 ? 'extra-large' : n <= 1.3 ? 'extra-extra-large' : 'extra-extra-extra-large') : null;
|
|
1439
|
+
if (!category) bad(`os fontscale <number | ${SIZES.join(' | ')}>`);
|
|
1440
|
+
simctl(['ui', t.udid, 'content_size', category]);
|
|
1441
|
+
return { contentSize: category };
|
|
1442
|
+
}
|
|
1443
|
+
if (!Number.isFinite(n) || n < 0.5 || n > 2) bad('os fontscale <0.5 … 2> on Android (1 = normal)');
|
|
1444
|
+
adbOf(t)(['shell', 'settings', 'put', 'system', 'font_scale', String(n)]);
|
|
1445
|
+
return { fontScale: n, undo: 'os fontscale 1' };
|
|
1446
|
+
}
|
|
1447
|
+
case 'notify': {
|
|
1448
|
+
const file = a.payload;
|
|
1449
|
+
const pkg = a.package ?? flags.package;
|
|
1450
|
+
if (!sim) bad('Android: `cmd notification post` posts as the shell, not as the app — a test of the app\'s own notification needs an app command that posts one locally.');
|
|
1451
|
+
if (!file || !fs.existsSync(String(file)) || !pkg) bad('os notify <payload.json> --package <bundle id> (an APNs payload: {"aps": {"alert": …}})');
|
|
1452
|
+
simctl(['push', t.udid, String(pkg), String(file)]);
|
|
1453
|
+
return { pushed: String(file), package: pkg };
|
|
1454
|
+
}
|
|
1455
|
+
default:
|
|
1456
|
+
return bad('os crashlogs | permission | location | net | dialog | video | doze | standby | appearance | fontscale | notify — see the header of g9pilot.mjs');
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Android screen video in pieces: screenrecord stops at 180 s (its own limit), so a longer recording is consecutive
|
|
1462
|
+
* pieces of at most 175 s, each pulled to `out` (not joined — the list says the order). Research R4.
|
|
1463
|
+
*/
|
|
1464
|
+
function recordAndroidVideo(t, seconds, out) {
|
|
1465
|
+
const adb = adbOf(t);
|
|
1466
|
+
const pieces = [];
|
|
1467
|
+
let left = seconds;
|
|
1468
|
+
for (let i = 0; left > 0; i++) {
|
|
1469
|
+
const length = Math.min(175, left);
|
|
1470
|
+
const remote = `/sdcard/g9pilot-video-${i}.mp4`;
|
|
1471
|
+
adb(['shell', 'screenrecord', '--time-limit', String(length), remote], { timeout: (length + 30) * 1000 });
|
|
1472
|
+
const local = path.join(out, `video-${String(i).padStart(2, '0')}.mp4`);
|
|
1473
|
+
run(t.adb, ['-s', t.serial, 'pull', remote, local], { timeout: 120_000 });
|
|
1474
|
+
adb(['shell', 'rm', '-f', remote]);
|
|
1475
|
+
pieces.push(local);
|
|
1476
|
+
left -= length;
|
|
1477
|
+
}
|
|
1478
|
+
return { video: pieces, seconds, note: pieces.length > 1 ? 'consecutive pieces (screenrecord stops at 180 s), in order' : undefined };
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/** The simulator's screen: simctl io recordVideo until SIGINT. */
|
|
1482
|
+
function recordSimulatorVideo(udid, seconds, out) {
|
|
1483
|
+
const file = path.join(out, 'video.mp4');
|
|
1484
|
+
const child = spawn('xcrun', ['simctl', 'io', udid, 'recordVideo', '--codec=h264', '--force', file], { stdio: 'ignore' });
|
|
1485
|
+
const until = Date.now() + seconds * 1000;
|
|
1486
|
+
while (Date.now() < until) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
1487
|
+
child.kill('SIGINT');
|
|
1488
|
+
return { video: [file], seconds, note: 'written when simctl finishes (a second or two after SIGINT)' };
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function osCommand([what, ...args]) {
|
|
1492
|
+
const named =
|
|
1493
|
+
{
|
|
1494
|
+
permission: { verb: args[0], permission: args[1] },
|
|
1495
|
+
location: { lat: args[0], lon: args[1] },
|
|
1496
|
+
net: { state: args[0] },
|
|
1497
|
+
dialog: { answer: args[0] },
|
|
1498
|
+
video: { seconds: args[0] },
|
|
1499
|
+
doze: { state: args[0] },
|
|
1500
|
+
standby: { bucket: args[0] },
|
|
1501
|
+
appearance: { mode: args[0] },
|
|
1502
|
+
fontscale: { value: args[0] },
|
|
1503
|
+
notify: { payload: args[0] },
|
|
1504
|
+
}[what] ?? {};
|
|
1505
|
+
try {
|
|
1506
|
+
print({ ok: true, ...osAction(what, named) });
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* What an approval does after writing (plan v2 P1-18): g9pilot.json `tokens.afterApprove` (each [cmd, …args], from the
|
|
1514
|
+
* token root) and `tokens.references` — reference names re-checked on this device (`g9pilot visual`, the OS picture of
|
|
1515
|
+
* --serial / --simulator); a changed one is listed for a person, never approved.
|
|
1516
|
+
*/
|
|
1517
|
+
function approveFollowUps() {
|
|
1518
|
+
const after = Array.isArray(config.tokens?.afterApprove) ? config.tokens.afterApprove : [];
|
|
1519
|
+
const names = Array.isArray(config.tokens?.references) ? config.tokens.references : [];
|
|
1520
|
+
const check = names.length && config.visual
|
|
1521
|
+
? () => {
|
|
1522
|
+
const settings = visualSettings(config, path.dirname(path.resolve(configPath)), flags);
|
|
1523
|
+
let png;
|
|
1524
|
+
try {
|
|
1525
|
+
png = captureOsPng({ serial: flags.serial, simulator: flags.simulator, adb: String(flags.adb ?? process.env.ADB ?? 'adb') });
|
|
1526
|
+
} catch (e) {
|
|
1527
|
+
return [{ name: '*', verdict: 'no picture', note: e.message }];
|
|
1528
|
+
}
|
|
1529
|
+
return names.map((name) => {
|
|
1530
|
+
const r = checkVisual(settings, String(name), png);
|
|
1531
|
+
return { name, verdict: r.verdict, ...(r.verdict === 'differs' ? { next: `look at ${r.diffImage}, then g9pilot visual approve ${name}` } : {}) };
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
: null;
|
|
1535
|
+
return { after, check };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/** The MCP server (packages/mcp) over this bridge, on stdio. Logs go to stderr: stdout is the protocol. */
|
|
1539
|
+
async function mcp() {
|
|
1540
|
+
const { createTools, serve } = await import('@g9tm/app-pilot-mcp');
|
|
1541
|
+
const k = key({ required: false });
|
|
1542
|
+
const client = clientFor({ privateKey: k?.key });
|
|
1543
|
+
tallyCalls(client, recorder('mcp', url));
|
|
1544
|
+
const runner = config.runner?.path ? config.runner : null;
|
|
1545
|
+
const runFlow = runner
|
|
1546
|
+
? async (spec, file, { onStep = null, signal = null } = {}) => {
|
|
1547
|
+
// Not spawnSync: the MCP server keeps answering (tasks/get, other tools) while a flow runs; each «· step n/m»
|
|
1548
|
+
// line of the runner's --progress is one progress notification; a cancelled task kills the run.
|
|
1549
|
+
const os = await import('node:os');
|
|
1550
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'g9pilot-flow-'));
|
|
1551
|
+
const flowFile = file ?? path.join(dir, `${spec.id ?? 'inline'}.flow.json`);
|
|
1552
|
+
if (!file) fs.writeFileSync(flowFile, JSON.stringify(spec, null, 2));
|
|
1553
|
+
const report = path.join(dir, 'report');
|
|
1554
|
+
const r = await new Promise((resolve) => {
|
|
1555
|
+
const child = spawn(process.execPath, [runner.path, 'run', flowFile, ...(runner.args ?? []), '--progress', '--report', report], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1556
|
+
let stdout = '';
|
|
1557
|
+
let stderr = '';
|
|
1558
|
+
let pending = '';
|
|
1559
|
+
const timer = setTimeout(() => child.kill(), 30 * 60_000);
|
|
1560
|
+
const onAbort = () => child.kill();
|
|
1561
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1562
|
+
child.stdout.on('data', (d) => {
|
|
1563
|
+
stdout += d;
|
|
1564
|
+
pending += d;
|
|
1565
|
+
const lines = pending.split(/\r?\n/);
|
|
1566
|
+
pending = lines.pop() ?? '';
|
|
1567
|
+
for (const line of lines) {
|
|
1568
|
+
const m = /· step (\d+)\/(\d+) \(([^)]*)\)/.exec(line);
|
|
1569
|
+
if (m) onStep?.(Number(m[1]), `step ${m[1]}/${m[2]} (${m[3]})`);
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
1573
|
+
child.on('close', (status) => {
|
|
1574
|
+
clearTimeout(timer);
|
|
1575
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1576
|
+
resolve({ status: signal?.aborted ? 130 : status, stdout, stderr });
|
|
1577
|
+
});
|
|
1578
|
+
});
|
|
1579
|
+
let verdict = null;
|
|
1580
|
+
try {
|
|
1581
|
+
verdict = JSON.parse(fs.readFileSync(path.join(report, 'run.json'), 'utf8')).flows?.[0]?.verdict ?? null;
|
|
1582
|
+
} catch {
|
|
1583
|
+
verdict = null;
|
|
1584
|
+
}
|
|
1585
|
+
return { ok: r.status === 0, verdict, report, error: r.status === 0 ? null : String(r.stderr || r.stdout).slice(-600) };
|
|
1586
|
+
}
|
|
1587
|
+
: null;
|
|
1588
|
+
const tools = createTools({ client, devices: config.devices ?? [], confirmWrites: config.mcp?.confirmWrites !== false, runFlow, osAction });
|
|
1589
|
+
serve({ tools });
|
|
1590
|
+
process.stderr.write(`g9pilot mcp: ${tools.length} tools over ${url}${k ? ` (key ${k.source})` : ' (no key: an unsigned bridge only)'}
|
|
1591
|
+
`);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
main().catch((e) => fail(e.stack ?? String(e), 1));
|