simframe 0.1.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +237 -155
- package/native/ocr.swift +29 -0
- package/package.json +2 -1
- package/src/actions.js +244 -0
- package/src/analyze.js +59 -0
- package/src/cli.js +198 -15
- package/src/daemon.js +139 -9
- package/src/index.js +382 -15
- package/src/input.js +221 -0
- package/src/intent.js +100 -0
- package/src/mcp.js +284 -22
- package/src/ocr.js +71 -0
- package/src/screenmap.js +229 -0
- package/src/simctl.js +26 -1
package/src/actions.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The batch engine. A flow that would cost one model round trip per tap runs
|
|
2
|
+
// here as a single call: act, wait for the screen to settle, assert, repeat.
|
|
3
|
+
// Waiting uses a baseline captured BEFORE each action, which is the whole
|
|
4
|
+
// reason these scripts are reliable rather than racy.
|
|
5
|
+
import * as api from './index.js';
|
|
6
|
+
import * as input from './input.js';
|
|
7
|
+
import * as intent from './intent.js';
|
|
8
|
+
import { launchApp, openUrl, setPasteboard, terminateApp } from './simctl.js';
|
|
9
|
+
|
|
10
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
const MAX_PAUSE_MS = 5000;
|
|
12
|
+
|
|
13
|
+
const ACTION_STEPS = new Set([
|
|
14
|
+
'tap', 'tapAt', 'type', 'paste', 'swipe', 'scroll', 'button', 'key',
|
|
15
|
+
'launch', 'terminate', 'openUrl', 'confirm', 'chooseAny',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/** Accept both `{tap: "Save"}` shorthand and `{action: "tap", target: "Save"}`. */
|
|
19
|
+
export function normalizeStep(raw) {
|
|
20
|
+
if (typeof raw === 'string') return { action: raw };
|
|
21
|
+
if (raw.action) return { ...raw };
|
|
22
|
+
const [key] = Object.keys(raw);
|
|
23
|
+
if (!key) throw new Error('empty step');
|
|
24
|
+
// Siblings like timeoutMs sit alongside the shorthand key and must survive.
|
|
25
|
+
const { [key]: value, ...rest } = raw;
|
|
26
|
+
const inline = value && typeof value === 'object' && !Array.isArray(value) ? value : { value };
|
|
27
|
+
return { ...rest, ...inline, action: key };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runScript(
|
|
31
|
+
deviceQuery,
|
|
32
|
+
{ steps, autoSettle = true, stableMs = 500, timeoutMs = 8000, continueOnError = false, options } = {},
|
|
33
|
+
) {
|
|
34
|
+
if (!Array.isArray(steps) || !steps.length) throw new Error('a script needs at least one step');
|
|
35
|
+
const { device } = await api.ensureDaemon(deviceQuery, options);
|
|
36
|
+
const udid = device.udid;
|
|
37
|
+
const startedAt = Date.now();
|
|
38
|
+
|
|
39
|
+
const needsInput = steps.some((s) => ACTION_STEPS.has(normalizeStep(s).action));
|
|
40
|
+
if (needsInput) {
|
|
41
|
+
const driver = await input.detectDriver();
|
|
42
|
+
if (!driver.available) throw new Error(driver.reason);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let geometry = null;
|
|
46
|
+
const screen = async () => (geometry ??= await input.screenInfo(udid));
|
|
47
|
+
|
|
48
|
+
const results = [];
|
|
49
|
+
const frames = [];
|
|
50
|
+
let failed = false;
|
|
51
|
+
|
|
52
|
+
for (const [i, raw] of steps.entries()) {
|
|
53
|
+
const step = normalizeStep(raw);
|
|
54
|
+
const stepStart = Date.now();
|
|
55
|
+
// The baseline for "did the screen react" must predate the action itself.
|
|
56
|
+
const before = (await api.getState(deviceQuery, { options })).state.hash;
|
|
57
|
+
try {
|
|
58
|
+
const detail = await runStep(deviceQuery, udid, step, { screen, options, frames });
|
|
59
|
+
let settled = null;
|
|
60
|
+
if (autoSettle && ACTION_STEPS.has(step.action)) {
|
|
61
|
+
const w = await api.waitFor(deviceQuery, {
|
|
62
|
+
mode: 'settle',
|
|
63
|
+
since: before,
|
|
64
|
+
stableMs: step.stableMs ?? stableMs,
|
|
65
|
+
timeoutMs: step.timeoutMs ?? timeoutMs,
|
|
66
|
+
options,
|
|
67
|
+
});
|
|
68
|
+
settled = {
|
|
69
|
+
ok: w.satisfied,
|
|
70
|
+
waitedMs: w.waitedMs,
|
|
71
|
+
sawChange: w.sawChange,
|
|
72
|
+
stalled: Boolean(w.stalled),
|
|
73
|
+
noVisibleChange: Boolean(w.noVisibleChange),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const note = settled?.noVisibleChange ? ' [no visible change]' : '';
|
|
77
|
+
results.push({
|
|
78
|
+
index: i,
|
|
79
|
+
action: step.action,
|
|
80
|
+
ok: true,
|
|
81
|
+
ms: Date.now() - stepStart,
|
|
82
|
+
detail: `${detail}${note}`,
|
|
83
|
+
settled,
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
results.push({ index: i, action: step.action, ok: false, ms: Date.now() - stepStart, error: err.message });
|
|
87
|
+
failed = true;
|
|
88
|
+
if (!continueOnError) break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
device,
|
|
94
|
+
results,
|
|
95
|
+
ok: !failed,
|
|
96
|
+
totalMs: Date.now() - startedAt,
|
|
97
|
+
ranSteps: results.length,
|
|
98
|
+
totalSteps: steps.length,
|
|
99
|
+
frames,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function runStep(deviceQuery, udid, step, ctx) {
|
|
104
|
+
switch (step.action) {
|
|
105
|
+
case 'tap': {
|
|
106
|
+
const query = step.value ?? step.target ?? step.label;
|
|
107
|
+
// Screen memory first: a familiar screen needs no tree read and no OCR.
|
|
108
|
+
const found = await api.locate(deviceQuery, query, { index: step.index, refresh: step.refresh });
|
|
109
|
+
await input.tapPoint(udid, found.target.x, found.target.y, { durationMs: step.durationMs });
|
|
110
|
+
return `tapped "${found.target.label}" at ${found.target.x},${found.target.y} (${found.from}${found.from === 'memory' ? ` d=${found.distance}` : ''}, via ${found.target.source})`;
|
|
111
|
+
}
|
|
112
|
+
case 'tapAt': {
|
|
113
|
+
const geo = await ctx.screen();
|
|
114
|
+
let { x, y } = step;
|
|
115
|
+
if (step.space === 'image') {
|
|
116
|
+
const frame = await api.getFrame(deviceQuery, { options: ctx.options });
|
|
117
|
+
({ x, y } = input.imageToPoints({ x, y }, {
|
|
118
|
+
imageWidth: frame.width,
|
|
119
|
+
imageHeight: frame.height,
|
|
120
|
+
pointWidth: geo.pointWidth,
|
|
121
|
+
pointHeight: geo.pointHeight,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
await input.tapPoint(udid, x, y, { durationMs: step.durationMs });
|
|
125
|
+
return `tapped ${x},${y}`;
|
|
126
|
+
}
|
|
127
|
+
case 'type': {
|
|
128
|
+
if (step.into) {
|
|
129
|
+
const { node } = await input.tapLabel(udid, step.into, { index: step.index });
|
|
130
|
+
await sleep(150);
|
|
131
|
+
await input.typeText(udid, step.text ?? step.value);
|
|
132
|
+
return `typed into "${node.label ?? step.into}"`;
|
|
133
|
+
}
|
|
134
|
+
await input.typeText(udid, step.text ?? step.value);
|
|
135
|
+
return 'typed text';
|
|
136
|
+
}
|
|
137
|
+
case 'paste': {
|
|
138
|
+
// Long strings are much faster on the pasteboard than through the keyboard.
|
|
139
|
+
await setPasteboard(udid, step.text ?? step.value);
|
|
140
|
+
if (step.into) await input.tapLabel(udid, step.into, { index: step.index, durationMs: 900 });
|
|
141
|
+
return 'placed text on the pasteboard';
|
|
142
|
+
}
|
|
143
|
+
case 'swipe': {
|
|
144
|
+
const from = { x: step.from?.[0] ?? step.from?.x, y: step.from?.[1] ?? step.from?.y };
|
|
145
|
+
const to = { x: step.to?.[0] ?? step.to?.x, y: step.to?.[1] ?? step.to?.y };
|
|
146
|
+
await input.swipe(udid, from, to, { durationMs: step.durationMs });
|
|
147
|
+
return `swiped ${from.x},${from.y} -> ${to.x},${to.y}`;
|
|
148
|
+
}
|
|
149
|
+
case 'scroll': {
|
|
150
|
+
const geo = await ctx.screen();
|
|
151
|
+
const dir = String(step.value ?? step.direction ?? 'down').toLowerCase();
|
|
152
|
+
const midX = Math.round(geo.pointWidth / 2);
|
|
153
|
+
const midY = Math.round(geo.pointHeight / 2);
|
|
154
|
+
const span = Math.round(geo.pointHeight * 0.3);
|
|
155
|
+
const moves = {
|
|
156
|
+
down: [{ x: midX, y: midY + span }, { x: midX, y: midY - span }],
|
|
157
|
+
up: [{ x: midX, y: midY - span }, { x: midX, y: midY + span }],
|
|
158
|
+
left: [{ x: midX + span, y: midY }, { x: midX - span, y: midY }],
|
|
159
|
+
right: [{ x: midX - span, y: midY }, { x: midX + span, y: midY }],
|
|
160
|
+
};
|
|
161
|
+
if (!moves[dir]) throw new Error(`unknown scroll direction "${dir}"`);
|
|
162
|
+
await input.swipe(udid, moves[dir][0], moves[dir][1], { durationMs: step.durationMs ?? 250 });
|
|
163
|
+
return `scrolled ${dir}`;
|
|
164
|
+
}
|
|
165
|
+
case 'button':
|
|
166
|
+
await input.pressButton(udid, step.value ?? step.name);
|
|
167
|
+
return `pressed ${step.value ?? step.name}`;
|
|
168
|
+
case 'key':
|
|
169
|
+
await input.pressKey(udid, step.value ?? step.code);
|
|
170
|
+
return `pressed key ${step.value ?? step.code}`;
|
|
171
|
+
case 'launch':
|
|
172
|
+
await launchApp(udid, step.value ?? step.bundleId);
|
|
173
|
+
return `launched ${step.value ?? step.bundleId}`;
|
|
174
|
+
case 'terminate':
|
|
175
|
+
await terminateApp(udid, step.value ?? step.bundleId);
|
|
176
|
+
return `terminated ${step.value ?? step.bundleId}`;
|
|
177
|
+
case 'openUrl':
|
|
178
|
+
await openUrl(udid, step.value ?? step.url);
|
|
179
|
+
return `opened ${step.value ?? step.url}`;
|
|
180
|
+
|
|
181
|
+
// Human-level steps: act on what is on screen without reasoning about it.
|
|
182
|
+
case 'confirm': {
|
|
183
|
+
const geo = await ctx.screen();
|
|
184
|
+
const r = await intent.confirm(udid, { geo });
|
|
185
|
+
return `confirmed via "${r.label}" at ${r.point.x},${r.point.y}`;
|
|
186
|
+
}
|
|
187
|
+
case 'chooseAny': {
|
|
188
|
+
const geo = await ctx.screen();
|
|
189
|
+
const r = await intent.chooseAny(udid, { prefer: step.value ?? step.prefer, geo });
|
|
190
|
+
return `chose "${r.label}" of ${r.optionCount} options`;
|
|
191
|
+
}
|
|
192
|
+
case 'settle': {
|
|
193
|
+
const w = await api.waitFor(deviceQuery, {
|
|
194
|
+
mode: step.mode ?? 'stable',
|
|
195
|
+
stableMs: step.stableMs ?? 500,
|
|
196
|
+
timeoutMs: step.timeoutMs ?? 8000,
|
|
197
|
+
options: ctx.options,
|
|
198
|
+
});
|
|
199
|
+
if (!w.satisfied) throw new Error(w.stalled ? w.live.note : `screen did not settle within ${w.waitedMs}ms`);
|
|
200
|
+
return `settled after ${w.waitedMs}ms`;
|
|
201
|
+
}
|
|
202
|
+
case 'waitText': {
|
|
203
|
+
const target = step.value ?? step.text;
|
|
204
|
+
const limit = Date.now() + (step.timeoutMs ?? 8000);
|
|
205
|
+
let lastError = 'never appeared';
|
|
206
|
+
while (Date.now() < limit) {
|
|
207
|
+
try {
|
|
208
|
+
const node = input.matchElement(await input.describeAll(udid), target, { index: step.index });
|
|
209
|
+
return `"${node.label ?? target}" appeared`;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
lastError = err.message;
|
|
212
|
+
}
|
|
213
|
+
await sleep(250);
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`waited ${step.timeoutMs ?? 8000}ms for "${target}": ${lastError}`);
|
|
216
|
+
}
|
|
217
|
+
case 'assertText': {
|
|
218
|
+
const target = step.value ?? step.text;
|
|
219
|
+
const node = input.matchElement(await input.describeAll(udid), target, { index: step.index });
|
|
220
|
+
return `"${node.label ?? target}" is on screen`;
|
|
221
|
+
}
|
|
222
|
+
case 'assertGone': {
|
|
223
|
+
const target = step.value ?? step.text;
|
|
224
|
+
try {
|
|
225
|
+
input.matchElement(await input.describeAll(udid), target, { index: step.index });
|
|
226
|
+
} catch {
|
|
227
|
+
return `"${target}" is gone`;
|
|
228
|
+
}
|
|
229
|
+
throw new Error(`"${target}" is still on screen`);
|
|
230
|
+
}
|
|
231
|
+
case 'look': {
|
|
232
|
+
const frame = await api.getFrame(deviceQuery, { detail: step.detail ?? 'normal', options: ctx.options });
|
|
233
|
+
ctx.frames.push({ label: step.label ?? `step frame`, png: frame.png });
|
|
234
|
+
return `captured a frame (${frame.width}x${frame.height})`;
|
|
235
|
+
}
|
|
236
|
+
case 'pause': {
|
|
237
|
+
const ms = Math.min(MAX_PAUSE_MS, Number(step.value ?? step.ms ?? 0));
|
|
238
|
+
await sleep(ms);
|
|
239
|
+
return `paused ${ms}ms`;
|
|
240
|
+
}
|
|
241
|
+
default:
|
|
242
|
+
throw new Error(`unknown step "${step.action}"`);
|
|
243
|
+
}
|
|
244
|
+
}
|
package/src/analyze.js
CHANGED
|
@@ -63,3 +63,62 @@ export function regionMap(deltas, cols = REGION_COLS) {
|
|
|
63
63
|
}
|
|
64
64
|
return lines.join('\n');
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
/** Signatures live in state.json, so they are stored as compact hex. */
|
|
68
|
+
export function signatureToHex(sig) {
|
|
69
|
+
return sig.map((v) => v.toString(16).padStart(2, '0')).join('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function hexToSignature(hex) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (let i = 0; i < hex.length; i += 2) out.push(parseInt(hex.slice(i, i + 2), 16));
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A hash of the screen's LAYOUT rather than its content.
|
|
80
|
+
*
|
|
81
|
+
* The frame hash changes whenever any pixel group changes — a clock digit, a new
|
|
82
|
+
* row of data — which makes it useless as a key for "have I seen this screen
|
|
83
|
+
* before?". This one skips the status bar and thresholds a coarse grid, so two
|
|
84
|
+
* visits to the same list with different rows still land close together, while a
|
|
85
|
+
* genuinely different screen lands far away.
|
|
86
|
+
*/
|
|
87
|
+
export const LAYOUT_COLS = 12;
|
|
88
|
+
export const LAYOUT_ROWS = 24;
|
|
89
|
+
export const LAYOUT_TOP_SKIP = 0.07; // the status bar, as a fraction of height
|
|
90
|
+
|
|
91
|
+
export function layoutHash(bmp) {
|
|
92
|
+
const top = Math.floor(bmp.height * LAYOUT_TOP_SKIP);
|
|
93
|
+
const cropped = {
|
|
94
|
+
width: bmp.width,
|
|
95
|
+
height: bmp.height - top,
|
|
96
|
+
data: bmp.data.subarray(top * bmp.width * 4),
|
|
97
|
+
};
|
|
98
|
+
// A difference hash: each bit compares a cell with its right-hand neighbour.
|
|
99
|
+
// Thresholding against a global mean collapses low-contrast app screens onto
|
|
100
|
+
// the same value; local gradients stay discriminative.
|
|
101
|
+
const { gray } = grayGrid(cropped, LAYOUT_COLS + 1, LAYOUT_ROWS);
|
|
102
|
+
const bits = [];
|
|
103
|
+
for (let r = 0; r < LAYOUT_ROWS; r++) {
|
|
104
|
+
for (let c = 0; c < LAYOUT_COLS; c++) {
|
|
105
|
+
const i = r * (LAYOUT_COLS + 1) + c;
|
|
106
|
+
bits.push(gray[i] > gray[i + 1] ? 1 : 0);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
let hex = '';
|
|
110
|
+
for (let i = 0; i < bits.length; i += 4) {
|
|
111
|
+
hex += (bits[i] | (bits[i + 1] << 1) | (bits[i + 2] << 2) | (bits[i + 3] << 3)).toString(16);
|
|
112
|
+
}
|
|
113
|
+
return hex;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const BITS = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4];
|
|
117
|
+
|
|
118
|
+
/** Hamming distance between two hex hashes of equal length. */
|
|
119
|
+
export function hashDistance(a, b) {
|
|
120
|
+
if (!a || !b || a.length !== b.length) return Infinity;
|
|
121
|
+
let d = 0;
|
|
122
|
+
for (let i = 0; i < a.length; i++) d += BITS[(parseInt(a[i], 16) ^ parseInt(b[i], 16)) & 0xf];
|
|
123
|
+
return d;
|
|
124
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -3,7 +3,9 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { runDaemon, DEFAULTS } from './daemon.js';
|
|
5
5
|
import { bootedDevices, listDevices, resolveDevice } from './simctl.js';
|
|
6
|
+
import * as actions from './actions.js';
|
|
6
7
|
import * as api from './index.js';
|
|
8
|
+
import * as input from './input.js';
|
|
7
9
|
import * as store from './store.js';
|
|
8
10
|
|
|
9
11
|
const USAGE = `simframe — always-warm iOS Simulator frames
|
|
@@ -14,8 +16,13 @@ const USAGE = `simframe — always-warm iOS Simulator frames
|
|
|
14
16
|
simframe status [device] show daemon and newest-frame status
|
|
15
17
|
simframe frame [device] write the newest frame to a file
|
|
16
18
|
simframe state [device] print frame metadata and the change map
|
|
17
|
-
simframe
|
|
19
|
+
simframe mark [device] print the current frame hash, to use as --since
|
|
20
|
+
simframe wait [device] wait for the screen to react (see --mode)
|
|
18
21
|
simframe strip [device] write a contact sheet of recent frames
|
|
22
|
+
simframe recall [device] what happened in the last minute (--ago=<ms> for a frame)
|
|
23
|
+
simframe ui [device] read the screen as an accessibility tree
|
|
24
|
+
simframe tap <label> tap an element by its accessibility label
|
|
25
|
+
simframe do <script.json> run a scripted flow (see below)
|
|
19
26
|
simframe devices list simulators
|
|
20
27
|
simframe doctor check that this machine can capture
|
|
21
28
|
|
|
@@ -25,9 +32,28 @@ Options
|
|
|
25
32
|
--detail=low|normal|high|full or --detail=<max pixels>
|
|
26
33
|
--fps=<n> capture rate while the screen is moving (default ${DEFAULTS.fps})
|
|
27
34
|
--count=<n> frames in a strip (default 5)
|
|
35
|
+
--since=<hash|seq> compare against this frame (see: simframe mark)
|
|
36
|
+
--mode=settle|change|stable what wait waits for (default settle)
|
|
28
37
|
--stable-ms=<n> settle window for wait (default 600)
|
|
29
38
|
--timeout-ms=<n> give up after this long (default 8000)
|
|
39
|
+
--force let stop kill a loop another client is using
|
|
30
40
|
--json machine-readable output
|
|
41
|
+
|
|
42
|
+
A script is a JSON array of steps, run in one go with a settle between each:
|
|
43
|
+
|
|
44
|
+
[{"tap":"Assets"},{"tap":"Add Asset"},
|
|
45
|
+
{"type":{"into":"Name","text":"Fryer 3"}},
|
|
46
|
+
{"tap":"Save"},{"waitText":"Saved","timeoutMs":5000}]
|
|
47
|
+
|
|
48
|
+
Input needs idb (brew tap facebook/fb && brew install idb-companion,
|
|
49
|
+
then pipx install fb-idb). Observation works without it.
|
|
50
|
+
|
|
51
|
+
The reliable pattern around an action is:
|
|
52
|
+
|
|
53
|
+
H=$(simframe mark)
|
|
54
|
+
...tap, launch or navigate...
|
|
55
|
+
simframe wait --since=$H --mode=settle
|
|
56
|
+
simframe state --since=$H
|
|
31
57
|
`;
|
|
32
58
|
|
|
33
59
|
function parseArgs(argv) {
|
|
@@ -99,8 +125,16 @@ async function main() {
|
|
|
99
125
|
: []
|
|
100
126
|
: [(await resolveDevice(device)).udid];
|
|
101
127
|
let stopped = 0;
|
|
102
|
-
|
|
103
|
-
|
|
128
|
+
let inUse = 0;
|
|
129
|
+
for (const udid of targets) {
|
|
130
|
+
const result = api.stopDaemon(udid, { force: Boolean(flags.force) });
|
|
131
|
+
if (result === 'in-use') inUse++;
|
|
132
|
+
else if (result) stopped++;
|
|
133
|
+
}
|
|
134
|
+
console.log(
|
|
135
|
+
`stopped ${stopped} daemon${stopped === 1 ? '' : 's'}` +
|
|
136
|
+
(inUse ? `; left ${inUse} in use by another client (pass --force to stop anyway)` : ''),
|
|
137
|
+
);
|
|
104
138
|
return;
|
|
105
139
|
}
|
|
106
140
|
|
|
@@ -147,30 +181,71 @@ async function main() {
|
|
|
147
181
|
return;
|
|
148
182
|
}
|
|
149
183
|
|
|
150
|
-
case '
|
|
184
|
+
case 'mark': {
|
|
151
185
|
const res = await api.getState(device, { options });
|
|
186
|
+
console.log(res.state.hash);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case 'state': {
|
|
191
|
+
const res = await api.getState(device, { since: flags.since, options });
|
|
152
192
|
if (flags.json) {
|
|
153
|
-
console.log(JSON.stringify({ ...res.state, ageMs: res.ageMs }, null, 2));
|
|
193
|
+
console.log(JSON.stringify({ ...res.state, history: undefined, ageMs: res.ageMs, since: res.since, live: res.live }, null, 2));
|
|
154
194
|
} else {
|
|
155
195
|
const s = res.state;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
);
|
|
196
|
+
const out = [];
|
|
197
|
+
if (!res.live.ok) out.push(`WARNING: ${res.live.note}`);
|
|
198
|
+
out.push(`${res.device.name} frame #${s.seq} age ${res.ageMs}ms ${s.width}x${s.height}`);
|
|
199
|
+
out.push(`hash ${s.hash} stable ${s.stableForMs}ms`);
|
|
200
|
+
if (res.since?.kind === 'history') {
|
|
201
|
+
out.push(
|
|
202
|
+
res.since.changed
|
|
203
|
+
? `CHANGED since ${res.since.ageMs}ms ago: ${(res.since.diff * 100).toFixed(1)}% of the screen`
|
|
204
|
+
: `unchanged since ${res.since.ageMs}ms ago`,
|
|
205
|
+
res.since.map,
|
|
206
|
+
);
|
|
207
|
+
} else if (res.since?.kind === 'coarse') {
|
|
208
|
+
out.push(`${res.since.changed ? 'CHANGED' : 'unchanged'} since your baseline (too old for a detailed diff)`);
|
|
209
|
+
} else if (res.since?.kind === 'unmatched') {
|
|
210
|
+
out.push(`baseline ${res.since.requested} is not in the buffered history`);
|
|
211
|
+
} else {
|
|
212
|
+
out.push(
|
|
213
|
+
s.firstFrame
|
|
214
|
+
? 'first frame of this capture loop — nothing to compare against yet'
|
|
215
|
+
: `change vs the previous frame only: ${(s.diff * 100).toFixed(1)}%`,
|
|
216
|
+
);
|
|
217
|
+
if (!s.firstFrame) out.push(res.map);
|
|
218
|
+
}
|
|
219
|
+
console.log(out.join('\n'));
|
|
160
220
|
}
|
|
161
221
|
return;
|
|
162
222
|
}
|
|
163
223
|
|
|
164
224
|
case 'wait': {
|
|
165
225
|
const res = await api.waitFor(device, {
|
|
166
|
-
mode: flags.mode || (flags.change ? 'change' : '
|
|
226
|
+
mode: flags.mode || (flags.change ? 'change' : 'settle'),
|
|
227
|
+
since: flags.since,
|
|
167
228
|
stableMs: num(flags.stableMs, 600),
|
|
168
229
|
timeoutMs: num(flags.timeoutMs, 8000),
|
|
169
230
|
options,
|
|
170
231
|
});
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
232
|
+
if (res.satisfied) {
|
|
233
|
+
console.log(
|
|
234
|
+
`${res.mode === 'change' ? 'changed' : 'settled'} after ${res.waitedMs}ms — frame #${res.state.seq}` +
|
|
235
|
+
(res.changedBeforeWait ? ' (change had already happened before the call)' : ''),
|
|
236
|
+
);
|
|
237
|
+
} else if (res.noVisibleChange) {
|
|
238
|
+
console.log(
|
|
239
|
+
`no visible change after ${res.waitedMs}ms — screen stable, nothing moved (the action may have had no visible effect)`,
|
|
240
|
+
);
|
|
241
|
+
} else if (res.stalled) {
|
|
242
|
+
console.log(`capture stalled after ${res.waitedMs}ms — ${res.live.note}`);
|
|
243
|
+
} else {
|
|
244
|
+
console.log(
|
|
245
|
+
`timed out after ${res.waitedMs}ms — no ${res.mode === 'change' ? 'change' : 'settle'}` +
|
|
246
|
+
(res.sawChange ? '' : '; if the change happened before this call, pass `--since` from `simframe mark`'),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
174
249
|
process.exitCode = res.satisfied ? 0 : 1;
|
|
175
250
|
return;
|
|
176
251
|
}
|
|
@@ -189,6 +264,100 @@ async function main() {
|
|
|
189
264
|
return;
|
|
190
265
|
}
|
|
191
266
|
|
|
267
|
+
case 'recall': {
|
|
268
|
+
if (flags.ago != null) {
|
|
269
|
+
const res = await api.getFrameAt(device, { msAgo: num(flags.ago), options });
|
|
270
|
+
const out = flags.out || path.join(process.cwd(), 'simframe-recall.png');
|
|
271
|
+
fs.writeFileSync(out, res.png);
|
|
272
|
+
console.log(
|
|
273
|
+
`${out} — frame #${res.seq} from ${Math.round(res.actualMsAgo)}ms ago ` +
|
|
274
|
+
`(memory reaches back ${Math.round(res.oldestMsAgo / 1000)}s)`,
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const res = await api.getTimeline(device, { spanMs: num(flags.spanMs, 60_000), options });
|
|
279
|
+
if (flags.json) {
|
|
280
|
+
console.log(JSON.stringify({ ...res, state: undefined }, null, 2));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
console.log(
|
|
284
|
+
`${res.device.name} — remembering ${Math.round(res.coveredMs / 1000)}s, ${res.buffered} frames buffered`,
|
|
285
|
+
);
|
|
286
|
+
if (!res.events.length) {
|
|
287
|
+
console.log(`nothing changed; still for ${Math.round(res.idleForMs / 1000)}s`);
|
|
288
|
+
} else {
|
|
289
|
+
for (const e of res.events) {
|
|
290
|
+
console.log(
|
|
291
|
+
` ${(e.startedMsAgo / 1000).toFixed(1)}s ago ${e.level === 'major' ? 'screen changed' : 'small change '} ` +
|
|
292
|
+
`${(e.magnitude * 100).toFixed(0)}% over ${(e.durationMs / 1000).toFixed(1)}s`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
console.log(`still for ${(res.idleForMs / 1000).toFixed(1)}s`);
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
case 'ui': {
|
|
301
|
+
const { device: dev } = await api.ensureDaemon(device, options);
|
|
302
|
+
const driver = await input.detectDriver();
|
|
303
|
+
if (!driver.available) {
|
|
304
|
+
process.stderr.write(`${driver.reason}\n`);
|
|
305
|
+
process.exitCode = 1;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
let nodes = await input.describeAll(dev.udid);
|
|
309
|
+
if (flags.filter) {
|
|
310
|
+
const q = String(flags.filter).toLowerCase();
|
|
311
|
+
nodes = nodes.filter((n) => [n.label, n.value, n.identifier].filter(Boolean).join(' ').toLowerCase().includes(q));
|
|
312
|
+
}
|
|
313
|
+
if (flags.json) {
|
|
314
|
+
console.log(JSON.stringify(nodes.map(({ raw, ...n }) => n), null, 2));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
for (const n of nodes) {
|
|
318
|
+
const c = input.centerOf(n);
|
|
319
|
+
console.log(
|
|
320
|
+
`${(n.type || '?').padEnd(14)} ${String(`${c.x},${c.y}`).padEnd(10)} ` +
|
|
321
|
+
`${[n.label, n.value && `= ${n.value}`, n.identifier && `#${n.identifier}`].filter(Boolean).join(' ') || '(unlabelled)'}`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case 'tap': {
|
|
328
|
+
const label = positional[0];
|
|
329
|
+
if (!label) throw new Error('usage: simframe tap <label>');
|
|
330
|
+
const res = await actions.runScript(flags.device, {
|
|
331
|
+
steps: [{ tap: label, index: flags.index != null ? num(flags.index) : undefined }],
|
|
332
|
+
options,
|
|
333
|
+
});
|
|
334
|
+
const step = res.results[0];
|
|
335
|
+
if (!step.ok) throw new Error(step.error);
|
|
336
|
+
console.log(`${step.detail}${step.settled?.ok ? `, settled in ${step.settled.waitedMs}ms` : ''}`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
case 'do': {
|
|
341
|
+
const file = positional[0];
|
|
342
|
+
if (!file) throw new Error('usage: simframe do <script.json>');
|
|
343
|
+
const steps = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
344
|
+
const res = await actions.runScript(flags.device, {
|
|
345
|
+
steps,
|
|
346
|
+
autoSettle: flags.autoSettle !== 'false',
|
|
347
|
+
stableMs: num(flags.stableMs, 500),
|
|
348
|
+
timeoutMs: num(flags.timeoutMs, 8000),
|
|
349
|
+
continueOnError: Boolean(flags.continueOnError),
|
|
350
|
+
options,
|
|
351
|
+
});
|
|
352
|
+
for (const r of res.results) {
|
|
353
|
+
const settle = r.settled ? (r.settled.ok ? ` (settled ${r.settled.waitedMs}ms)` : ' (never settled)') : '';
|
|
354
|
+
console.log(`${r.ok ? 'ok ' : 'FAIL'} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}${settle}`);
|
|
355
|
+
}
|
|
356
|
+
console.log(`${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps in ${res.totalMs}ms`);
|
|
357
|
+
process.exitCode = res.ok ? 0 : 1;
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
192
361
|
case 'devices': {
|
|
193
362
|
const all = await listDevices();
|
|
194
363
|
const shown = flags.all ? all : all.filter((d) => d.state === 'Booted');
|
|
@@ -231,6 +400,15 @@ async function doctor() {
|
|
|
231
400
|
} catch (err) {
|
|
232
401
|
add('sips', false, err.message);
|
|
233
402
|
}
|
|
403
|
+
const driver = await input.detectDriver();
|
|
404
|
+
add('input driver (idb)', driver.available, driver.available ? driver.version : driver.reason);
|
|
405
|
+
try {
|
|
406
|
+
const ocr = await import('./ocr.js');
|
|
407
|
+
const built = await ocr.ensureBinary();
|
|
408
|
+
add('on-device OCR', built.available, built.available ? 'available' : built.reason);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
add('on-device OCR', false, err.message);
|
|
411
|
+
}
|
|
234
412
|
try {
|
|
235
413
|
const booted = await bootedDevices();
|
|
236
414
|
add('booted simulator', booted.length > 0, booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
|
|
@@ -243,8 +421,13 @@ async function doctor() {
|
|
|
243
421
|
add('capture', false, err.message);
|
|
244
422
|
}
|
|
245
423
|
|
|
246
|
-
for (const c of checks)
|
|
247
|
-
|
|
424
|
+
for (const c of checks) {
|
|
425
|
+
const mark = c.ok ? 'ok ' : c.name.startsWith('input driver') ? 'none' : 'FAIL';
|
|
426
|
+
console.log(`${mark} ${c.name.padEnd(18)} ${c.detail}`);
|
|
427
|
+
}
|
|
428
|
+
// Input is optional: simframe is still useful as a pure observer.
|
|
429
|
+
const required = checks.filter((c) => !c.name.startsWith('input driver'));
|
|
430
|
+
process.exitCode = required.every((c) => c.ok) ? 0 : 1;
|
|
248
431
|
}
|
|
249
432
|
|
|
250
433
|
main().catch((err) => {
|