simframe 0.6.2 → 0.7.2
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 +112 -8
- package/native/simframed/Sources/SimframeCore/CaptureRecovery.swift +34 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +20 -0
- package/native/simframed/Sources/simframed/main.swift +30 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +39 -0
- package/package.json +4 -2
- package/scripts/analyse-fingerprint.mjs +149 -0
- package/scripts/bench-flow.mjs +1 -1
- package/scripts/check-package.mjs +15 -0
- package/scripts/ci-memory.mjs +12 -0
- package/scripts/eval-ax-tier.mjs +191 -0
- package/scripts/eval-fingerprint.mjs +161 -6
- package/skills/simframe/SKILL.md +10 -3
- package/src/actions.js +64 -14
- package/src/cli.js +125 -32
- package/src/daemon.js +42 -1
- package/src/engine.js +19 -6
- package/src/fingerprint.js +29 -1
- package/src/index.js +92 -13
- package/src/input.js +93 -0
- package/src/mcp.js +6 -6
- package/src/navigate.js +8 -1
- package/src/platform/android.js +997 -0
- package/src/platform/host.js +15 -0
- package/src/platform/index.js +263 -0
- package/src/{simctl.js → platform/ios.js} +111 -18
- package/src/store.js +32 -0
package/src/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { runDaemon, DEFAULTS } from './daemon.js';
|
|
5
|
-
import { bootedDevices, listDevices, resolveDevice } from './
|
|
5
|
+
import { bootedDevices, capabilitiesFor, listDevices, PLATFORMS, resolveDevice, toolchainChecks } from './platform/index.js';
|
|
6
6
|
import * as actions from './actions.js';
|
|
7
7
|
import * as api from './index.js';
|
|
8
8
|
import * as input from './input.js';
|
|
@@ -51,8 +51,8 @@ Options
|
|
|
51
51
|
--json machine-readable output — on every command
|
|
52
52
|
--out=<file> output path for frame/strip/recall
|
|
53
53
|
--detail=low|normal|high|full or --detail=<max pixels>
|
|
54
|
-
--engine=simframed|
|
|
55
|
-
--fps=<n> capture rate while the screen is moving (
|
|
54
|
+
--engine=simframed|screenshot capture engine (default: the fastest the device has)
|
|
55
|
+
--fps=<n> capture rate while the screen is moving (screenshot engine only)
|
|
56
56
|
--count=<n> frames in a strip (default 5)
|
|
57
57
|
--since=<hash|seq> compare against this frame (see: simframe mark)
|
|
58
58
|
--mode=settle|change|stable what wait waits for (default settle)
|
|
@@ -86,14 +86,39 @@ The reliable pattern around an action is:
|
|
|
86
86
|
simframe state --since=$H
|
|
87
87
|
`;
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Flags that take a value, so `--flag value` can mean what it looks like.
|
|
91
|
+
*
|
|
92
|
+
* Deliberately not every flag: `--json`, `--refresh` and friends have a bare
|
|
93
|
+
* form, and letting those swallow the next argument would turn
|
|
94
|
+
* `simframe tap --refresh Save` into a tap on nothing.
|
|
95
|
+
*/
|
|
96
|
+
const VALUE_FLAGS = new Set([
|
|
97
|
+
'ago', 'count', 'detail', 'device', 'durationMs', 'engine', 'filter', 'fps', 'index', 'maxDim',
|
|
98
|
+
'mode', 'out', 'ringSize', 'since', 'spanMs', 'stableMs', 'timeoutMs',
|
|
99
|
+
]);
|
|
100
|
+
|
|
89
101
|
function parseArgs(argv) {
|
|
90
102
|
const flags = {};
|
|
91
103
|
const positional = [];
|
|
92
|
-
for (
|
|
104
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
105
|
+
const arg = argv[i];
|
|
93
106
|
if (arg.startsWith('--')) {
|
|
94
107
|
const [key, value] = arg.slice(2).split('=');
|
|
95
108
|
const camel = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
96
|
-
|
|
109
|
+
if (value !== undefined) {
|
|
110
|
+
flags[camel] = value;
|
|
111
|
+
} else if (VALUE_FLAGS.has(camel) && argv[i + 1] != null && !argv[i + 1].startsWith('--')) {
|
|
112
|
+
// `--device X` as well as `--device=X`. Only for flags whose bare form
|
|
113
|
+
// means nothing: `simframe doctor --device B55AB0AE` used to set
|
|
114
|
+
// `device` to `true`, push the udid to positional, and resolve the
|
|
115
|
+
// literal string "true" — and doctor's own advice is to name a device
|
|
116
|
+
// with --device, which is exactly how someone would write it.
|
|
117
|
+
flags[camel] = argv[i + 1];
|
|
118
|
+
i += 1;
|
|
119
|
+
} else {
|
|
120
|
+
flags[camel] = true;
|
|
121
|
+
}
|
|
97
122
|
} else {
|
|
98
123
|
positional.push(arg);
|
|
99
124
|
}
|
|
@@ -177,18 +202,21 @@ async function main() {
|
|
|
177
202
|
case 'start': {
|
|
178
203
|
const { device: dev, state, started } = await api.ensureDaemon(device, options);
|
|
179
204
|
const engineModule = await import('./engine.js');
|
|
180
|
-
const
|
|
205
|
+
const best = capabilitiesFor(dev.udid).captureEngines[0];
|
|
206
|
+
const running = engineModule.runningEngine(dev.udid) ?? best;
|
|
181
207
|
console.log(
|
|
182
208
|
`${started ? 'started' : 'already running'} — ${dev.name} (${dev.runtime}) ` +
|
|
183
209
|
`engine=${running} frame #${state.seq} ${state.width}x${state.height}`,
|
|
184
210
|
);
|
|
185
211
|
// Say which engine, and if it is the slow one, say why. A downgrade that
|
|
186
|
-
// prints nothing is how this shipped broken twice.
|
|
187
|
-
if
|
|
212
|
+
// prints nothing is how this shipped broken twice. But it is only a
|
|
213
|
+
// downgrade if this platform has something better: the screenshot loop is
|
|
214
|
+
// the whole of Android's capture, not a fallback from anything.
|
|
215
|
+
if (running !== best) {
|
|
188
216
|
const why = api.fallbackReason(dev.udid);
|
|
189
217
|
console.log(
|
|
190
|
-
`WARN engine
|
|
191
|
-
(why ?
|
|
218
|
+
`WARN engine=${running} — roughly 30x slower per frame than ${best}. ` +
|
|
219
|
+
(why ? `${best} unavailable: ${why}` : 'reason unrecorded; run simframe doctor'),
|
|
192
220
|
);
|
|
193
221
|
if (Boolean(flags.strict) || process.env.SIMFRAME_STRICT === '1') {
|
|
194
222
|
console.error('--strict: refusing to run on a degraded engine');
|
|
@@ -697,7 +725,7 @@ async function main() {
|
|
|
697
725
|
if (flags.json) {
|
|
698
726
|
console.log(JSON.stringify(shown, null, 2));
|
|
699
727
|
} else if (!shown.length) {
|
|
700
|
-
console.log('no booted
|
|
728
|
+
console.log('no booted devices (pass --all to list every device)');
|
|
701
729
|
} else {
|
|
702
730
|
for (const d of shown) console.log(`${d.state === 'Booted' ? '●' : '○'} ${d.name} ${d.runtime} ${d.udid}`);
|
|
703
731
|
}
|
|
@@ -748,11 +776,10 @@ async function doctor({ json = false, strict = false, device } = {}) {
|
|
|
748
776
|
|
|
749
777
|
add('node', 'ok', process.version);
|
|
750
778
|
const { execFileSync } = await import('node:child_process');
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
}
|
|
779
|
+
// The active backend names its own prerequisites — doctor renders them and
|
|
780
|
+
// does not know what they are. On iOS that is xcrun; on Android it will be
|
|
781
|
+
// adb, and this line will not change.
|
|
782
|
+
for (const check of toolchainChecks()) add(check.name, check.level, check.detail);
|
|
756
783
|
try {
|
|
757
784
|
execFileSync('sips', ['--version'], { encoding: 'utf8', stdio: 'pipe' });
|
|
758
785
|
add('sips', 'ok', 'available');
|
|
@@ -798,11 +825,40 @@ async function doctor({ json = false, strict = false, device } = {}) {
|
|
|
798
825
|
const wanted = await resolveDevice(device);
|
|
799
826
|
booted = booted.filter((d) => d.udid === wanted.udid);
|
|
800
827
|
}
|
|
801
|
-
|
|
828
|
+
// Which devices get *probed*, as opposed to listed. The probes below start
|
|
829
|
+
// a capture loop and read frames, and doctor used to do that to every
|
|
830
|
+
// booted device on the host. On a shared machine that means starting a
|
|
831
|
+
// daemon on a colleague's simulator and capturing their screen to answer a
|
|
832
|
+
// question about this one. Listing is free and stays; probing is not, so
|
|
833
|
+
// without --device it goes to a device already running its own capture loop
|
|
834
|
+
// (nothing new is started), or to the only booted device, and otherwise to
|
|
835
|
+
// none, with a line saying which flag would pick one.
|
|
836
|
+
let probed = booted;
|
|
837
|
+
if (!device && booted.length > 1) {
|
|
838
|
+
probed = booted.filter((d) => engineModule.runningEngine(d.udid));
|
|
839
|
+
if (probed.length !== 1) {
|
|
840
|
+
probed = [];
|
|
841
|
+
add('device probes', 'warn',
|
|
842
|
+
`${booted.length} devices are booted and none is clearly yours — name one with --device ` +
|
|
843
|
+
'to check its capture, input and accessibility layers',
|
|
844
|
+
{ key: 'probes.skipped', value: booted.length });
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
// `deviceNoun` earns its place here: one platform's devices are called by
|
|
848
|
+
// its own word, and a mixed set by the neutral one. An emulator reported as
|
|
849
|
+
// a "booted simulator" is the same small lie as an emulator reported as
|
|
850
|
+
// having an idb input driver.
|
|
851
|
+
const nouns = [...new Set(booted.map((d) => capabilitiesFor(d.udid) && PLATFORMS[d.platform].deviceNoun))];
|
|
852
|
+
add(`booted ${nouns.length === 1 ? nouns[0] : 'device'}`, booted.length ? 'ok' : 'warn',
|
|
802
853
|
booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
|
|
803
|
-
for (const d of
|
|
854
|
+
for (const d of probed) {
|
|
804
855
|
const input = await import('./input.js');
|
|
805
856
|
const control = await import('./control.js');
|
|
857
|
+
// What this device's platform can do at all. Without asking, doctor
|
|
858
|
+
// described an Android emulator in iOS terms — "input driver: idb" about
|
|
859
|
+
// a tool that has never spoken to one.
|
|
860
|
+
const caps = capabilitiesFor(d.udid);
|
|
861
|
+
const bestEngine = caps.captureEngines[0];
|
|
806
862
|
// Start the engine before asking which engine is in use. Reading it first
|
|
807
863
|
// reports `simctl` on any machine where nothing happens to be running
|
|
808
864
|
// yet — a warning about a downgrade that has not occurred, and one that
|
|
@@ -813,27 +869,43 @@ async function doctor({ json = false, strict = false, device } = {}) {
|
|
|
813
869
|
// ensureDaemon waits for a frame; the control socket comes up a moment
|
|
814
870
|
// later. Asking immediately reports `idb` for a device whose own input
|
|
815
871
|
// path is seconds from ready — a race that would read as CI flake.
|
|
816
|
-
for (let i = 0; i < 40 && !control.available(d.udid); i += 1) {
|
|
872
|
+
for (let i = 0; i < 40 && caps.input.supported && !control.available(d.udid); i += 1) {
|
|
817
873
|
await new Promise((r) => setTimeout(r, 50));
|
|
818
874
|
}
|
|
819
|
-
const driver = await input.driverFor(d.udid, { refresh: true });
|
|
875
|
+
const driver = caps.input.supported ? await input.driverFor(d.udid, { refresh: true }) : null;
|
|
820
876
|
// Which engine is actually capturing, from the daemon's own record.
|
|
821
877
|
// `control.available` answers a different question — whether the input
|
|
822
878
|
// socket is up — and using it here reported simctl on a machine that was
|
|
823
879
|
// capturing with simframed perfectly well.
|
|
824
|
-
const captureEngine = engineModule.runningEngine(d.udid) ??
|
|
880
|
+
const captureEngine = engineModule.runningEngine(d.udid) ?? bestEngine;
|
|
825
881
|
const daemon = captureEngine === 'simframed';
|
|
826
|
-
const why = captureEngine ===
|
|
827
|
-
add(`capture engine (${d.name})`, captureEngine ===
|
|
828
|
-
captureEngine ===
|
|
829
|
-
?
|
|
830
|
-
:
|
|
882
|
+
const why = captureEngine === bestEngine ? null : api.fallbackReason(d.udid);
|
|
883
|
+
add(`capture engine (${d.name})`, captureEngine === bestEngine ? 'ok' : 'warn',
|
|
884
|
+
captureEngine === bestEngine
|
|
885
|
+
? captureEngine
|
|
886
|
+
: `${captureEngine} — roughly 30x slower per frame than ${bestEngine}${why ? `; ${bestEngine} unavailable: ${why}` : '. Run simframe start to see why'}`,
|
|
831
887
|
{ key: 'capture.engine', value: captureEngine });
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
888
|
+
// A layer this platform does not have yet is `optional`, the level that
|
|
889
|
+
// means "documented as absent" rather than "this machine is degraded".
|
|
890
|
+
if (!caps.input.supported) {
|
|
891
|
+
add(`input driver (${d.name})`, 'optional', caps.input.note, { key: 'input.driver', value: null });
|
|
892
|
+
} else {
|
|
893
|
+
// `warn` means this machine could be doing better and silently is not —
|
|
894
|
+
// which is true of idb on a simulator and false of the platform's own
|
|
895
|
+
// driver. The console is not a downgrade on Android; it is the only
|
|
896
|
+
// input path there is, and grading it a downgrade made `--strict` fail
|
|
897
|
+
// on a device that was working perfectly.
|
|
898
|
+
const best = driver.name === 'simframed' || driver.name === caps.input.via;
|
|
899
|
+
add(`input driver (${d.name})`, driver.available ? (best ? 'ok' : 'warn') : 'warn',
|
|
900
|
+
driver.available ? `${driver.name}: ${driver.version}` : driver.reason,
|
|
901
|
+
{ key: 'input.driver', value: driver.available ? driver.name : null });
|
|
902
|
+
}
|
|
835
903
|
add(`text recognition (${d.name})`, 'ok',
|
|
836
904
|
daemon ? 'simframed (in-process, off the framebuffer)' : 'sips + helper binary');
|
|
905
|
+
if (!caps.ax.supported) {
|
|
906
|
+
add(`accessibility tree (${d.name})`, 'optional', caps.ax.note, { key: 'ax.driver', value: null });
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
837
909
|
const ax = await input.axDriverFor(d.udid);
|
|
838
910
|
// idb here is a downgrade unless it was asked for. `warn` means this
|
|
839
911
|
// machine could be doing better and silently is not; a driver someone
|
|
@@ -843,12 +915,20 @@ async function doctor({ json = false, strict = false, device } = {}) {
|
|
|
843
915
|
ax.available ? `${ax.name}: ${ax.version}` : `unavailable: ${ax.reason}`,
|
|
844
916
|
{ key: 'ax.driver', value: ax.name });
|
|
845
917
|
}
|
|
846
|
-
if (
|
|
918
|
+
if (probed.length) {
|
|
847
919
|
const t0 = Date.now();
|
|
848
|
-
const res = await api.getFrame(
|
|
920
|
+
const res = await api.getFrame(probed[0].udid);
|
|
849
921
|
add('capture', 'ok',
|
|
850
922
|
`frame #${res.state.seq} ${res.width}x${res.height} in ${Date.now() - t0}ms (age ${res.ageMs}ms)`,
|
|
851
923
|
{ key: 'capture.frames', value: res.state.seq });
|
|
924
|
+
// A wedged device produces the same nothing as a quiet one, so doctor has
|
|
925
|
+
// to ask the capture loop rather than look at the frames. `fail`, not
|
|
926
|
+
// `warn`: nothing here is degraded-but-working, and the cure is a device
|
|
927
|
+
// restart that simframe deliberately does not perform.
|
|
928
|
+
for (const d of probed) {
|
|
929
|
+
const live = api.liveness(d.udid, (await api.getState(d.udid)).state);
|
|
930
|
+
if (live.stalled) add(`capture health (${d.name})`, 'fail', live.note, { key: 'capture.stalled', value: true });
|
|
931
|
+
}
|
|
852
932
|
}
|
|
853
933
|
} catch (err) {
|
|
854
934
|
add('capture', 'fail', err.message);
|
|
@@ -857,8 +937,21 @@ async function doctor({ json = false, strict = false, device } = {}) {
|
|
|
857
937
|
// doctor is a diagnostic, not a way to start things. If it had to start a
|
|
858
938
|
// daemon to answer "which engine is in use", it stops it again rather than
|
|
859
939
|
// leaving a detached process behind.
|
|
940
|
+
//
|
|
941
|
+
// And it says when it could not. This was `catch { /* best effort */ }`, and
|
|
942
|
+
// best effort silently failed: a stop refused because another client holds
|
|
943
|
+
// the device left a capture loop running on a machine somebody else was
|
|
944
|
+
// using, with doctor reporting a clean bill of health. A diagnostic that
|
|
945
|
+
// leaves something behind has to name it.
|
|
860
946
|
for (const udid of startedHere) {
|
|
861
|
-
try {
|
|
947
|
+
try {
|
|
948
|
+
await api.stopDaemon(udid);
|
|
949
|
+
} catch (err) {
|
|
950
|
+
add('cleanup', 'warn',
|
|
951
|
+
`started a capture loop on ${udid} to answer a question and could not stop it again ` +
|
|
952
|
+
`(${err.message}) — stop it with: simframe stop --device=${udid}`,
|
|
953
|
+
{ key: 'cleanup.left', value: udid });
|
|
954
|
+
}
|
|
862
955
|
}
|
|
863
956
|
|
|
864
957
|
const failed = checks.filter((c) => c.level === 'fail');
|
package/src/daemon.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
signatureToHex,
|
|
13
13
|
} from './analyze.js';
|
|
14
14
|
import * as store from './store.js';
|
|
15
|
-
import { isBootedSync, resize, screenshot } from './
|
|
15
|
+
import { isBootedSync, resize, screenshot } from './platform/index.js';
|
|
16
16
|
|
|
17
17
|
// Bump whenever the shape of state.json changes, so an upgraded client retires
|
|
18
18
|
// a capture loop left running by an older install instead of misreading it.
|
|
@@ -25,6 +25,18 @@ import { isBootedSync, resize, screenshot } from './simctl.js';
|
|
|
25
25
|
// through a flow. A unit test asserts these two constants match.
|
|
26
26
|
export const STATE_VERSION = 6;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* How many failed captures in a row mean this loop is wedged rather than
|
|
30
|
+
* unlucky.
|
|
31
|
+
*
|
|
32
|
+
* Four, against the ten that make it give up: far enough in that a single
|
|
33
|
+
* hiccup does not raise an alarm, early enough that a reader learns about it
|
|
34
|
+
* while the loop is still trying. The Swift daemon reaches the same conclusion
|
|
35
|
+
* differently — it counts re-resolves of the display port, because there a
|
|
36
|
+
* successful re-resolve resets the failure count and hides the loop.
|
|
37
|
+
*/
|
|
38
|
+
export const STALLED_AFTER_ERRORS = 4;
|
|
39
|
+
|
|
28
40
|
export const DEFAULTS = {
|
|
29
41
|
fps: 4,
|
|
30
42
|
idleFps: 1.5,
|
|
@@ -85,6 +97,7 @@ export async function runDaemon(device, options = {}) {
|
|
|
85
97
|
let ringIndex = [];
|
|
86
98
|
let lastChangeAt = Date.now();
|
|
87
99
|
let consecutiveErrors = 0;
|
|
100
|
+
let stalledSince = null;
|
|
88
101
|
let lastBootCheck = Date.now();
|
|
89
102
|
let running = true;
|
|
90
103
|
const stop = () => {
|
|
@@ -141,7 +154,17 @@ export async function runDaemon(device, options = {}) {
|
|
|
141
154
|
|
|
142
155
|
seq = nextSeq;
|
|
143
156
|
prevSignature = signature;
|
|
157
|
+
if (consecutiveErrors >= STALLED_AFTER_ERRORS) {
|
|
158
|
+
log('capture recovered on its own');
|
|
159
|
+
store.writeCaptureHealth(udid, null);
|
|
160
|
+
}
|
|
144
161
|
consecutiveErrors = 0;
|
|
162
|
+
// And the clock, which it did not. A second stall then kept the first
|
|
163
|
+
// one's timestamp — `stalledSince ??=` only fills a null — and reported
|
|
164
|
+
// "unreadable for 4 hours" about a wedge four seconds old. The Swift loop
|
|
165
|
+
// resets it (main.swift), which is what made this a missed line rather
|
|
166
|
+
// than a difference of opinion between the two engines.
|
|
167
|
+
stalledSince = null;
|
|
145
168
|
|
|
146
169
|
const hash = frameHash(bmp);
|
|
147
170
|
const layout = layoutHash(bmp);
|
|
@@ -199,7 +222,25 @@ export async function runDaemon(device, options = {}) {
|
|
|
199
222
|
} catch (err) {
|
|
200
223
|
consecutiveErrors++;
|
|
201
224
|
log(`capture error (${consecutiveErrors}): ${err.message}`);
|
|
225
|
+
// Say that capture is wedged rather than merely slow, and do nothing
|
|
226
|
+
// about it: the cure is a device restart, and that is the user's to make.
|
|
227
|
+
// Published rather than only logged, because a reader of `state` sees the
|
|
228
|
+
// last healthy frame with nothing in it to say the device stopped
|
|
229
|
+
// answering — the same frames a merely idle screen produces.
|
|
230
|
+
if (consecutiveErrors >= STALLED_AFTER_ERRORS) {
|
|
231
|
+
stalledSince ??= Date.now();
|
|
232
|
+
store.writeCaptureHealth(udid, {
|
|
233
|
+
stalled: true,
|
|
234
|
+
since: stalledSince,
|
|
235
|
+
at: Date.now(),
|
|
236
|
+
consecutiveFailures: consecutiveErrors,
|
|
237
|
+
reattaches: 0,
|
|
238
|
+
reason: err.message,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
202
241
|
if (consecutiveErrors >= 10) {
|
|
242
|
+
// Left published on purpose. The file is how a reader learns why this
|
|
243
|
+
// loop is not running any more.
|
|
203
244
|
log('exit: too many consecutive capture errors');
|
|
204
245
|
break;
|
|
205
246
|
}
|
package/src/engine.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// Chooses and starts the capture engine.
|
|
2
2
|
//
|
|
3
3
|
// Two exist: `simframed`, a Swift daemon that reads the framebuffer directly,
|
|
4
|
-
// and `
|
|
5
|
-
// daemon is the default because it is roughly thirty times
|
|
6
|
-
// loop stays reachable — a machine without a Swift toolchain,
|
|
7
|
-
// version where a private symbol has moved, still needs to work.
|
|
4
|
+
// and `screenshot`, the loop that asks the platform boundary for one frame at a
|
|
5
|
+
// time. The daemon is the default on iOS because it is roughly thirty times
|
|
6
|
+
// faster, but the loop stays reachable — a machine without a Swift toolchain,
|
|
7
|
+
// or an Xcode version where a private symbol has moved, still needs to work.
|
|
8
|
+
//
|
|
9
|
+
// The loop used to be called `simctl`, after the tool it shelled out to. It no
|
|
10
|
+
// longer shells out to anything in particular: on Android the same loop reaches
|
|
11
|
+
// the emulator console and captures a frame in ~41 ms, which is not `simctl` by
|
|
12
|
+
// any reading. `simctl` stays accepted as an alias, because it is in shipped
|
|
13
|
+
// meta.json files, in documentation and in people's shell history.
|
|
8
14
|
import { execFile, spawn } from 'node:child_process';
|
|
9
15
|
import fs from 'node:fs';
|
|
10
16
|
import path from 'node:path';
|
|
@@ -17,7 +23,14 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
|
17
23
|
const PACKAGE = path.join(HERE, '..', 'native', 'simframed');
|
|
18
24
|
const BINARY = path.join(PACKAGE, '.build', 'release', 'simframed');
|
|
19
25
|
|
|
20
|
-
export const ENGINES = ['simframed', '
|
|
26
|
+
export const ENGINES = ['simframed', 'screenshot'];
|
|
27
|
+
|
|
28
|
+
/** `simctl` was this engine's name until it ran on a second platform. */
|
|
29
|
+
const ENGINE_ALIASES = { simctl: 'screenshot' };
|
|
30
|
+
|
|
31
|
+
export function normalizeEngine(name) {
|
|
32
|
+
return ENGINE_ALIASES[name] ?? name;
|
|
33
|
+
}
|
|
21
34
|
|
|
22
35
|
export function binaryPath() {
|
|
23
36
|
return BINARY;
|
|
@@ -95,5 +108,5 @@ export function spawnDaemon(udid, { maxDim, minIntervalMs, idleExitMs } = {}) {
|
|
|
95
108
|
export function runningEngine(udid) {
|
|
96
109
|
const meta = store.readJson(path.join(store.deviceDir(udid), 'meta.json'));
|
|
97
110
|
if (!meta || !store.isProcessAlive(meta.pid)) return null;
|
|
98
|
-
return meta.options?.engine === 'simframed' ? 'simframed' : '
|
|
111
|
+
return meta.options?.engine === 'simframed' ? 'simframed' : 'screenshot';
|
|
99
112
|
}
|
package/src/fingerprint.js
CHANGED
|
@@ -20,8 +20,12 @@ import * as regions from './regions.js';
|
|
|
20
20
|
*
|
|
21
21
|
* 2 — elements with no visible footprint, and containers holding two or more
|
|
22
22
|
* others, no longer enter identity: only one sensor can see either.
|
|
23
|
+
* 3 — a chrome label must be a name: at least two letters, and not a URL. A
|
|
24
|
+
* browser's address bar put "== example.com" into a screen's identity, so
|
|
25
|
+
* a different page read as a different screen, and OCR's ":" and "+" read
|
|
26
|
+
* off icons were identities of their own.
|
|
23
27
|
*/
|
|
24
|
-
export const TOKEN_RULES_VERSION =
|
|
28
|
+
export const TOKEN_RULES_VERSION = 3;
|
|
25
29
|
|
|
26
30
|
/** Frames are quantised to this, so sub-pixel drift and a nudged row do not matter. */
|
|
27
31
|
export const GRID = 24;
|
|
@@ -97,12 +101,36 @@ const MONTHS = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
|
|
|
97
101
|
const WEEKDAYS = /\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*day?\b/i;
|
|
98
102
|
const DATE_LIKE = /\d{1,4}[/.-]\d{1,2}([/.-]\d{1,4})?|\b\d{1,2}:\d{2}\b/;
|
|
99
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A URL is the most volatile thing a nav bar can hold.
|
|
106
|
+
*
|
|
107
|
+
* Measured on Android, where a browser's address bar is chrome by every
|
|
108
|
+
* structural test there is: the screen's identity contained `"== example.com"`,
|
|
109
|
+
* so the same browser on a different page was a different screen, and every
|
|
110
|
+
* route through it broke on navigation. The `==` is OCR reading the lock icon.
|
|
111
|
+
*
|
|
112
|
+
* Matched after stripping the punctuation OCR decorates it with, and only when
|
|
113
|
+
* the whole label is the address — a sentence that happens to mention a domain
|
|
114
|
+
* is still a sentence.
|
|
115
|
+
*/
|
|
116
|
+
const URL_LIKE = /^(https?:\/\/|www\.)|^[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)*\.[a-z]{2,}(\/\S*)?$/i;
|
|
117
|
+
|
|
118
|
+
/** How many letters a name has to have. One is a glyph, not a name. */
|
|
119
|
+
const NAME_MIN_LETTERS = 2;
|
|
120
|
+
|
|
100
121
|
export function isVolatileLabel(label) {
|
|
101
122
|
const text = String(label ?? '').trim();
|
|
102
123
|
if (!text) return true;
|
|
103
124
|
if (MONTHS.test(text) || WEEKDAYS.test(text) || DATE_LIKE.test(text)) return true;
|
|
125
|
+
// Strip what OCR hangs off an icon before asking whether the rest is an
|
|
126
|
+
// address: the observed label was `== example.com`.
|
|
127
|
+
const bare = text.replace(/^[^\p{L}\p{N}]+/u, '').replace(/[^\p{L}\p{N}/]+$/u, '');
|
|
128
|
+
if (URL_LIKE.test(bare)) return true;
|
|
104
129
|
const letters = (text.match(/\p{L}/gu) ?? []).length;
|
|
105
130
|
const digits = (text.match(/\p{N}/gu) ?? []).length;
|
|
131
|
+
// A label with no word in it is not a name for anything. OCR reads `:`, `+`,
|
|
132
|
+
// `...` and `—` off icons, and each of those became an identity of its own.
|
|
133
|
+
if (letters < NAME_MIN_LETTERS) return true;
|
|
106
134
|
// Mostly digits: a count, a price, a phone number, an ID. "1020" and
|
|
107
135
|
// "+1 (111) 111-1111" are both this; "Assets" is not.
|
|
108
136
|
return digits > 0 && digits >= letters;
|
package/src/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import * as graph from './graph.js';
|
|
|
19
19
|
import * as matching from './matching.js';
|
|
20
20
|
import * as refs from './refs.js';
|
|
21
21
|
import * as screenmap from './screenmap.js';
|
|
22
|
-
import { resolveDevice, resize, screenshot } from './
|
|
22
|
+
import { capabilitiesFor, resolveDevice, resize, screenshot } from './platform/index.js';
|
|
23
23
|
import * as store from './store.js';
|
|
24
24
|
|
|
25
25
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -186,15 +186,24 @@ export function fallbackReason(udid) {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
|
-
* Start whichever engine was asked for
|
|
189
|
+
* Start whichever engine was asked for, out of the ones this device's platform
|
|
190
|
+
* has.
|
|
190
191
|
*
|
|
191
|
-
* simframed unless told otherwise: it reads the framebuffer
|
|
192
|
-
* roughly thirty times faster per frame
|
|
193
|
-
* `engine: '
|
|
194
|
-
*
|
|
192
|
+
* On iOS that is simframed unless told otherwise: it reads the framebuffer
|
|
193
|
+
* directly and is roughly thirty times faster per frame, and the screenshot
|
|
194
|
+
* loop stays reachable with `engine: 'screenshot'` for a machine with no Swift
|
|
195
|
+
* toolchain. On Android the loop is the only engine there is — and asking for
|
|
196
|
+
* simframed there is refused rather than attempted, because a Swift daemon
|
|
197
|
+
* built against CoreSimulator has nothing to say to an emulator, and the
|
|
198
|
+
* failure it produces says nothing useful about why.
|
|
195
199
|
*/
|
|
196
200
|
async function startEngine(udid, options) {
|
|
197
|
-
|
|
201
|
+
const supported = capabilitiesFor(udid).captureEngines;
|
|
202
|
+
const wanted = engine.normalizeEngine(options.engine ?? supported[0]);
|
|
203
|
+
if (!supported.includes(wanted)) {
|
|
204
|
+
throw new Error(`this device cannot run the ${wanted} capture engine — it supports ${supported.join(', ')}`);
|
|
205
|
+
}
|
|
206
|
+
if (wanted === 'simframed') {
|
|
198
207
|
const built = await engine.ensureBuilt();
|
|
199
208
|
if (built.ok) {
|
|
200
209
|
engineFallbackReason = null;
|
|
@@ -206,7 +215,7 @@ async function startEngine(udid, options) {
|
|
|
206
215
|
recordFallback(udid, engineFallbackReason);
|
|
207
216
|
}
|
|
208
217
|
spawnNodeDaemon(udid, options);
|
|
209
|
-
return '
|
|
218
|
+
return 'screenshot';
|
|
210
219
|
}
|
|
211
220
|
|
|
212
221
|
function spawnNodeDaemon(udid, options) {
|
|
@@ -214,9 +223,19 @@ function spawnNodeDaemon(udid, options) {
|
|
|
214
223
|
for (const key of ['fps', 'maxDim', 'ringSize', 'idleExitMs']) {
|
|
215
224
|
if (options[key] != null) args.push(`--${key}=${options[key]}`);
|
|
216
225
|
}
|
|
226
|
+
// stderr goes to the device's own log, the way the Swift daemon's does
|
|
227
|
+
// (engine.js). It was `stdio: 'ignore'`, so anything this loop said about
|
|
228
|
+
// itself went to /dev/null — including the Android backend's notice that the
|
|
229
|
+
// console capture path had failed and it had fallen back to adb at five times
|
|
230
|
+
// the latency. A silent fallback is the failure mode this project has been
|
|
231
|
+
// bitten by twice; it does not get a third time for want of a file handle.
|
|
232
|
+
// The directory may not exist yet on a first start, and `writeCaptureHealth`
|
|
233
|
+
// already taught this project that an unwritable path here fails silently.
|
|
234
|
+
fs.mkdirSync(store.paths(udid).dir, { recursive: true });
|
|
235
|
+
const log = fs.openSync(store.paths(udid).log, 'a');
|
|
217
236
|
const child = spawn(process.execPath, args, {
|
|
218
237
|
detached: true,
|
|
219
|
-
stdio: 'ignore',
|
|
238
|
+
stdio: ['ignore', log, log],
|
|
220
239
|
env: process.env,
|
|
221
240
|
});
|
|
222
241
|
child.unref();
|
|
@@ -289,12 +308,38 @@ export function changeLevel(diff) {
|
|
|
289
308
|
return 'none';
|
|
290
309
|
}
|
|
291
310
|
|
|
311
|
+
/** How long a stall has been going on, in words an agent can act on. */
|
|
312
|
+
function stallNote(health) {
|
|
313
|
+
const forMs = Math.max(0, Date.now() - (health.since ?? Date.now()));
|
|
314
|
+
const parts = [`capture: stalled — the display surface has been unreadable for ${Math.round(forMs / 1000)}s`];
|
|
315
|
+
if (health.reattaches) parts.push(`${health.reattaches} re-attach${health.reattaches === 1 ? '' : 'es'} did not help`);
|
|
316
|
+
if (health.reason) parts.push(String(health.reason).slice(0, 120));
|
|
317
|
+
// The cure is the user's to apply. Saying so is the difference between an
|
|
318
|
+
// agent that reports "the simulator is wedged" and one that retries a tap
|
|
319
|
+
// twenty times because nothing appeared to change.
|
|
320
|
+
parts.push('only restarting the device is known to cure it');
|
|
321
|
+
return parts.join('; ');
|
|
322
|
+
}
|
|
323
|
+
|
|
292
324
|
export function liveness(udid, state) {
|
|
293
325
|
const ageMs = Date.now() - state.capturedAt;
|
|
294
326
|
const { running } = daemonStatus(udid);
|
|
327
|
+
// A wedged device and a quiet one look identical from the frames alone: both
|
|
328
|
+
// produce nothing. The difference is that a wedged one is failing reads, and
|
|
329
|
+
// only the capture loop knows that, so it writes it down.
|
|
330
|
+
const health = store.captureHealth(udid);
|
|
331
|
+
const stalled = Boolean(health?.stalled);
|
|
295
332
|
if (!running) {
|
|
296
|
-
return {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
ageMs,
|
|
336
|
+
stalled,
|
|
337
|
+
note: stalled
|
|
338
|
+
? `the capture loop has died, and it was stalled before it did — ${stallNote(health)}`
|
|
339
|
+
: 'the capture loop has died; the frame you are looking at is the last one it wrote',
|
|
340
|
+
};
|
|
297
341
|
}
|
|
342
|
+
if (stalled) return { ok: false, ageMs, stalled: true, note: stallNote(health) };
|
|
298
343
|
// Frame age means "stalled" only for a fixed-rate loop.
|
|
299
344
|
//
|
|
300
345
|
// simframed captures on damage, so a screen that is genuinely still produces
|
|
@@ -306,9 +351,9 @@ export function liveness(udid, state) {
|
|
|
306
351
|
// daemon.
|
|
307
352
|
const damageDriven = engine.runningEngine(udid) === 'simframed';
|
|
308
353
|
if (!damageDriven && ageMs > STALE_FRAME_MS) {
|
|
309
|
-
return { ok: false, ageMs, note: `capture loop is stalled: newest frame is ${ageMs}ms old` };
|
|
354
|
+
return { ok: false, ageMs, stalled: false, note: `capture loop is stalled: newest frame is ${ageMs}ms old` };
|
|
310
355
|
}
|
|
311
|
-
return { ok: true, ageMs, note: null };
|
|
356
|
+
return { ok: true, ageMs, stalled: false, note: null };
|
|
312
357
|
}
|
|
313
358
|
|
|
314
359
|
/**
|
|
@@ -502,7 +547,16 @@ export async function waitFor(
|
|
|
502
547
|
// a button changing state. Waiting the full timeout for a change that
|
|
503
548
|
// will never be visible turns a 100ms action into a 12s one, so give up
|
|
504
549
|
// early and say so, rather than silently burning the clock.
|
|
505
|
-
|
|
550
|
+
//
|
|
551
|
+
// But "nothing changed" is a claim about something observed, and with no
|
|
552
|
+
// frame captured since this call began, nothing has been. The screenshot
|
|
553
|
+
// engine idles at 1.5 fps, so a 500 ms reaction window expired before the
|
|
554
|
+
// first new frame existed: a tap that opened a whole activity was
|
|
555
|
+
// reported as having no visible effect, and the text meant for the field
|
|
556
|
+
// it opened was typed into nothing. Damage-driven capture on iOS hid this
|
|
557
|
+
// by being fast.
|
|
558
|
+
const observedSomething = state.seq - startSeq >= 1;
|
|
559
|
+
if (!sawChange && observedSomething && Date.now() - startedAt > reactionMs && state.stableForMs >= stableMs) {
|
|
506
560
|
return done(false, { noVisibleChange: true });
|
|
507
561
|
}
|
|
508
562
|
|
|
@@ -724,6 +778,31 @@ export async function settledState(udid, { settleMs = MEMORY_SETTLE_MS, timeoutM
|
|
|
724
778
|
return { state, settled: false };
|
|
725
779
|
}
|
|
726
780
|
|
|
781
|
+
/**
|
|
782
|
+
* Read the screen with the perception layers pinned, and keep nothing.
|
|
783
|
+
*
|
|
784
|
+
* Every other path decides for itself which layers to read, which is right for
|
|
785
|
+
* doing work and useless for measuring: the question "what is the accessibility
|
|
786
|
+
* tier worth" needs the same frame read twice, once with it and once without.
|
|
787
|
+
* `persist: false` so measuring teaches the graph nothing.
|
|
788
|
+
*/
|
|
789
|
+
export async function readScreenWith(deviceQuery, { useAx = true, useOcr = true, options } = {}) {
|
|
790
|
+
const { device, state } = await ensureDaemon(deviceQuery, options);
|
|
791
|
+
const udid = device.udid;
|
|
792
|
+
const geo = await deviceGeometry(udid, state);
|
|
793
|
+
const entry = await screenmap.build(udid, {
|
|
794
|
+
hash: state.hash,
|
|
795
|
+
layoutHash: state.layoutHash,
|
|
796
|
+
fullFrame: await fullFrameFor(udid, state),
|
|
797
|
+
density: geo.density,
|
|
798
|
+
screen: { width: geo.pointWidth, height: geo.pointHeight },
|
|
799
|
+
useAx,
|
|
800
|
+
useOcr,
|
|
801
|
+
persist: false,
|
|
802
|
+
});
|
|
803
|
+
return { device, entry, points: { width: geo.pointWidth, height: geo.pointHeight } };
|
|
804
|
+
}
|
|
805
|
+
|
|
727
806
|
export async function locate(
|
|
728
807
|
deviceQuery,
|
|
729
808
|
query,
|