simframe 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -6
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +7 -2
- 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 +1 -1
- package/scripts/analyse-fingerprint.mjs +149 -0
- package/scripts/bench-flow.mjs +1 -1
- package/scripts/ci-memory.mjs +48 -1
- 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 +36 -5
- package/src/cli.js +62 -26
- package/src/daemon.js +36 -1
- package/src/engine.js +19 -6
- package/src/fingerprint.js +58 -0
- package/src/graph.js +89 -5
- package/src/index.js +81 -12
- package/src/input.js +52 -0
- package/src/mcp.js +6 -6
- package/src/platform/android.js +968 -0
- package/src/platform/host.js +15 -0
- package/src/platform/index.js +248 -0
- package/src/{simctl.js → platform/ios.js} +111 -18
- package/src/screenmap.js +1 -1
- 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)
|
|
@@ -177,18 +177,21 @@ async function main() {
|
|
|
177
177
|
case 'start': {
|
|
178
178
|
const { device: dev, state, started } = await api.ensureDaemon(device, options);
|
|
179
179
|
const engineModule = await import('./engine.js');
|
|
180
|
-
const
|
|
180
|
+
const best = capabilitiesFor(dev.udid).captureEngines[0];
|
|
181
|
+
const running = engineModule.runningEngine(dev.udid) ?? best;
|
|
181
182
|
console.log(
|
|
182
183
|
`${started ? 'started' : 'already running'} — ${dev.name} (${dev.runtime}) ` +
|
|
183
184
|
`engine=${running} frame #${state.seq} ${state.width}x${state.height}`,
|
|
184
185
|
);
|
|
185
186
|
// 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
|
|
187
|
+
// prints nothing is how this shipped broken twice. But it is only a
|
|
188
|
+
// downgrade if this platform has something better: the screenshot loop is
|
|
189
|
+
// the whole of Android's capture, not a fallback from anything.
|
|
190
|
+
if (running !== best) {
|
|
188
191
|
const why = api.fallbackReason(dev.udid);
|
|
189
192
|
console.log(
|
|
190
|
-
`WARN engine
|
|
191
|
-
(why ?
|
|
193
|
+
`WARN engine=${running} — roughly 30x slower per frame than ${best}. ` +
|
|
194
|
+
(why ? `${best} unavailable: ${why}` : 'reason unrecorded; run simframe doctor'),
|
|
192
195
|
);
|
|
193
196
|
if (Boolean(flags.strict) || process.env.SIMFRAME_STRICT === '1') {
|
|
194
197
|
console.error('--strict: refusing to run on a degraded engine');
|
|
@@ -697,7 +700,7 @@ async function main() {
|
|
|
697
700
|
if (flags.json) {
|
|
698
701
|
console.log(JSON.stringify(shown, null, 2));
|
|
699
702
|
} else if (!shown.length) {
|
|
700
|
-
console.log('no booted
|
|
703
|
+
console.log('no booted devices (pass --all to list every device)');
|
|
701
704
|
} else {
|
|
702
705
|
for (const d of shown) console.log(`${d.state === 'Booted' ? '●' : '○'} ${d.name} ${d.runtime} ${d.udid}`);
|
|
703
706
|
}
|
|
@@ -748,11 +751,10 @@ async function doctor({ json = false, strict = false, device } = {}) {
|
|
|
748
751
|
|
|
749
752
|
add('node', 'ok', process.version);
|
|
750
753
|
const { execFileSync } = await import('node:child_process');
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
}
|
|
754
|
+
// The active backend names its own prerequisites — doctor renders them and
|
|
755
|
+
// does not know what they are. On iOS that is xcrun; on Android it will be
|
|
756
|
+
// adb, and this line will not change.
|
|
757
|
+
for (const check of toolchainChecks()) add(check.name, check.level, check.detail);
|
|
756
758
|
try {
|
|
757
759
|
execFileSync('sips', ['--version'], { encoding: 'utf8', stdio: 'pipe' });
|
|
758
760
|
add('sips', 'ok', 'available');
|
|
@@ -798,11 +800,21 @@ async function doctor({ json = false, strict = false, device } = {}) {
|
|
|
798
800
|
const wanted = await resolveDevice(device);
|
|
799
801
|
booted = booted.filter((d) => d.udid === wanted.udid);
|
|
800
802
|
}
|
|
801
|
-
|
|
803
|
+
// `deviceNoun` earns its place here: one platform's devices are called by
|
|
804
|
+
// its own word, and a mixed set by the neutral one. An emulator reported as
|
|
805
|
+
// a "booted simulator" is the same small lie as an emulator reported as
|
|
806
|
+
// having an idb input driver.
|
|
807
|
+
const nouns = [...new Set(booted.map((d) => capabilitiesFor(d.udid) && PLATFORMS[d.platform].deviceNoun))];
|
|
808
|
+
add(`booted ${nouns.length === 1 ? nouns[0] : 'device'}`, booted.length ? 'ok' : 'warn',
|
|
802
809
|
booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
|
|
803
810
|
for (const d of booted) {
|
|
804
811
|
const input = await import('./input.js');
|
|
805
812
|
const control = await import('./control.js');
|
|
813
|
+
// What this device's platform can do at all. Without asking, doctor
|
|
814
|
+
// described an Android emulator in iOS terms — "input driver: idb" about
|
|
815
|
+
// a tool that has never spoken to one.
|
|
816
|
+
const caps = capabilitiesFor(d.udid);
|
|
817
|
+
const bestEngine = caps.captureEngines[0];
|
|
806
818
|
// Start the engine before asking which engine is in use. Reading it first
|
|
807
819
|
// reports `simctl` on any machine where nothing happens to be running
|
|
808
820
|
// yet — a warning about a downgrade that has not occurred, and one that
|
|
@@ -813,27 +825,43 @@ async function doctor({ json = false, strict = false, device } = {}) {
|
|
|
813
825
|
// ensureDaemon waits for a frame; the control socket comes up a moment
|
|
814
826
|
// later. Asking immediately reports `idb` for a device whose own input
|
|
815
827
|
// 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) {
|
|
828
|
+
for (let i = 0; i < 40 && caps.input.supported && !control.available(d.udid); i += 1) {
|
|
817
829
|
await new Promise((r) => setTimeout(r, 50));
|
|
818
830
|
}
|
|
819
|
-
const driver = await input.driverFor(d.udid, { refresh: true });
|
|
831
|
+
const driver = caps.input.supported ? await input.driverFor(d.udid, { refresh: true }) : null;
|
|
820
832
|
// Which engine is actually capturing, from the daemon's own record.
|
|
821
833
|
// `control.available` answers a different question — whether the input
|
|
822
834
|
// socket is up — and using it here reported simctl on a machine that was
|
|
823
835
|
// capturing with simframed perfectly well.
|
|
824
|
-
const captureEngine = engineModule.runningEngine(d.udid) ??
|
|
836
|
+
const captureEngine = engineModule.runningEngine(d.udid) ?? bestEngine;
|
|
825
837
|
const daemon = captureEngine === 'simframed';
|
|
826
|
-
const why = captureEngine ===
|
|
827
|
-
add(`capture engine (${d.name})`, captureEngine ===
|
|
828
|
-
captureEngine ===
|
|
829
|
-
?
|
|
830
|
-
:
|
|
838
|
+
const why = captureEngine === bestEngine ? null : api.fallbackReason(d.udid);
|
|
839
|
+
add(`capture engine (${d.name})`, captureEngine === bestEngine ? 'ok' : 'warn',
|
|
840
|
+
captureEngine === bestEngine
|
|
841
|
+
? captureEngine
|
|
842
|
+
: `${captureEngine} — roughly 30x slower per frame than ${bestEngine}${why ? `; ${bestEngine} unavailable: ${why}` : '. Run simframe start to see why'}`,
|
|
831
843
|
{ key: 'capture.engine', value: captureEngine });
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
844
|
+
// A layer this platform does not have yet is `optional`, the level that
|
|
845
|
+
// means "documented as absent" rather than "this machine is degraded".
|
|
846
|
+
if (!caps.input.supported) {
|
|
847
|
+
add(`input driver (${d.name})`, 'optional', caps.input.note, { key: 'input.driver', value: null });
|
|
848
|
+
} else {
|
|
849
|
+
// `warn` means this machine could be doing better and silently is not —
|
|
850
|
+
// which is true of idb on a simulator and false of the platform's own
|
|
851
|
+
// driver. The console is not a downgrade on Android; it is the only
|
|
852
|
+
// input path there is, and grading it a downgrade made `--strict` fail
|
|
853
|
+
// on a device that was working perfectly.
|
|
854
|
+
const best = driver.name === 'simframed' || driver.name === caps.input.via;
|
|
855
|
+
add(`input driver (${d.name})`, driver.available ? (best ? 'ok' : 'warn') : 'warn',
|
|
856
|
+
driver.available ? `${driver.name}: ${driver.version}` : driver.reason,
|
|
857
|
+
{ key: 'input.driver', value: driver.available ? driver.name : null });
|
|
858
|
+
}
|
|
835
859
|
add(`text recognition (${d.name})`, 'ok',
|
|
836
860
|
daemon ? 'simframed (in-process, off the framebuffer)' : 'sips + helper binary');
|
|
861
|
+
if (!caps.ax.supported) {
|
|
862
|
+
add(`accessibility tree (${d.name})`, 'optional', caps.ax.note, { key: 'ax.driver', value: null });
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
837
865
|
const ax = await input.axDriverFor(d.udid);
|
|
838
866
|
// idb here is a downgrade unless it was asked for. `warn` means this
|
|
839
867
|
// machine could be doing better and silently is not; a driver someone
|
|
@@ -849,6 +877,14 @@ async function doctor({ json = false, strict = false, device } = {}) {
|
|
|
849
877
|
add('capture', 'ok',
|
|
850
878
|
`frame #${res.state.seq} ${res.width}x${res.height} in ${Date.now() - t0}ms (age ${res.ageMs}ms)`,
|
|
851
879
|
{ key: 'capture.frames', value: res.state.seq });
|
|
880
|
+
// A wedged device produces the same nothing as a quiet one, so doctor has
|
|
881
|
+
// to ask the capture loop rather than look at the frames. `fail`, not
|
|
882
|
+
// `warn`: nothing here is degraded-but-working, and the cure is a device
|
|
883
|
+
// restart that simframe deliberately does not perform.
|
|
884
|
+
for (const d of booted) {
|
|
885
|
+
const live = api.liveness(d.udid, (await api.getState(d.udid)).state);
|
|
886
|
+
if (live.stalled) add(`capture health (${d.name})`, 'fail', live.note, { key: 'capture.stalled', value: true });
|
|
887
|
+
}
|
|
852
888
|
}
|
|
853
889
|
} catch (err) {
|
|
854
890
|
add('capture', 'fail', err.message);
|
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,6 +154,10 @@ 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;
|
|
145
162
|
|
|
146
163
|
const hash = frameHash(bmp);
|
|
@@ -199,7 +216,25 @@ export async function runDaemon(device, options = {}) {
|
|
|
199
216
|
} catch (err) {
|
|
200
217
|
consecutiveErrors++;
|
|
201
218
|
log(`capture error (${consecutiveErrors}): ${err.message}`);
|
|
219
|
+
// Say that capture is wedged rather than merely slow, and do nothing
|
|
220
|
+
// about it: the cure is a device restart, and that is the user's to make.
|
|
221
|
+
// Published rather than only logged, because a reader of `state` sees the
|
|
222
|
+
// last healthy frame with nothing in it to say the device stopped
|
|
223
|
+
// answering — the same frames a merely idle screen produces.
|
|
224
|
+
if (consecutiveErrors >= STALLED_AFTER_ERRORS) {
|
|
225
|
+
stalledSince ??= Date.now();
|
|
226
|
+
store.writeCaptureHealth(udid, {
|
|
227
|
+
stalled: true,
|
|
228
|
+
since: stalledSince,
|
|
229
|
+
at: Date.now(),
|
|
230
|
+
consecutiveFailures: consecutiveErrors,
|
|
231
|
+
reattaches: 0,
|
|
232
|
+
reason: err.message,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
202
235
|
if (consecutiveErrors >= 10) {
|
|
236
|
+
// Left published on purpose. The file is how a reader learns why this
|
|
237
|
+
// loop is not running any more.
|
|
203
238
|
log('exit: too many consecutive capture errors');
|
|
204
239
|
break;
|
|
205
240
|
}
|
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
|
@@ -11,6 +11,22 @@
|
|
|
11
11
|
import crypto from 'node:crypto';
|
|
12
12
|
import * as regions from './regions.js';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Bumped whenever the token rules change, and read by `graph.FINGERPRINT_VERSION`
|
|
16
|
+
* and `screenmap.MAP_VERSION` so stored hashes are discarded rather than
|
|
17
|
+
* compared against hashes computed by different rules. An old hash is a
|
|
18
|
+
* perfectly well-formed hash that never matches anything, which is the quietest
|
|
19
|
+
* kind of wrong.
|
|
20
|
+
*
|
|
21
|
+
* 2 — elements with no visible footprint, and containers holding two or more
|
|
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.
|
|
27
|
+
*/
|
|
28
|
+
export const TOKEN_RULES_VERSION = 3;
|
|
29
|
+
|
|
14
30
|
/** Frames are quantised to this, so sub-pixel drift and a nudged row do not matter. */
|
|
15
31
|
export const GRID = 24;
|
|
16
32
|
|
|
@@ -85,12 +101,36 @@ const MONTHS = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
|
|
|
85
101
|
const WEEKDAYS = /\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*day?\b/i;
|
|
86
102
|
const DATE_LIKE = /\d{1,4}[/.-]\d{1,2}([/.-]\d{1,4})?|\b\d{1,2}:\d{2}\b/;
|
|
87
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
|
+
|
|
88
121
|
export function isVolatileLabel(label) {
|
|
89
122
|
const text = String(label ?? '').trim();
|
|
90
123
|
if (!text) return true;
|
|
91
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;
|
|
92
129
|
const letters = (text.match(/\p{L}/gu) ?? []).length;
|
|
93
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;
|
|
94
134
|
// Mostly digits: a count, a price, a phone number, an ID. "1020" and
|
|
95
135
|
// "+1 (111) 111-1111" are both this; "Assets" is not.
|
|
96
136
|
return digits > 0 && digits >= letters;
|
|
@@ -108,13 +148,31 @@ export function tokens(targets, screen) {
|
|
|
108
148
|
const keyboardTop = regions.detectKeyboardTop(targets, screen);
|
|
109
149
|
const groups = new Map();
|
|
110
150
|
|
|
151
|
+
// Identity is what the screen *is*, not which sensor happened to see it, so
|
|
152
|
+
// two things that only one sensor can produce must not enter it: an element
|
|
153
|
+
// with no visible footprint, and a container that exists to hold others.
|
|
154
|
+
// Pixels cannot see either, and the accessibility tree reports both.
|
|
155
|
+
const encloses = (frame) => targets.filter((o) => {
|
|
156
|
+
const f = o.frame;
|
|
157
|
+
if (!f || f === frame) return false;
|
|
158
|
+
const cx = f.x + (f.width ?? 0) / 2;
|
|
159
|
+
const cy = f.y + (f.height ?? 0) / 2;
|
|
160
|
+
return cx > frame.x && cx < frame.x + (frame.width ?? 0)
|
|
161
|
+
&& cy > frame.y && cy < frame.y + (frame.height ?? 0);
|
|
162
|
+
}).length;
|
|
163
|
+
|
|
111
164
|
for (const t of targets) {
|
|
112
165
|
const frame = t.frame ?? { x: t.x, y: t.y, width: 0, height: 0 };
|
|
113
166
|
// Off-screen elements are not part of what this screen looks like.
|
|
114
167
|
if (frame.y + (frame.height ?? 0) <= 0 || frame.y >= screen.height) continue;
|
|
168
|
+
// Nor is anything with no footprint to be seen.
|
|
169
|
+
if (!(frame.width > 0) || !(frame.height > 0)) continue;
|
|
115
170
|
const region = t.region ?? regions.regionFor(frame, screen, { keyboardTop });
|
|
116
171
|
if (region === 'status-bar') continue;
|
|
117
172
|
if (keyboardTop != null && frame.y >= keyboardTop) continue;
|
|
173
|
+
// A thing that holds two or more other things is scenery, and only the
|
|
174
|
+
// tree can see it. Its children are already in the fingerprint.
|
|
175
|
+
if (/group|other|generic/i.test(String(t.type ?? '')) && encloses(frame) >= 2) continue;
|
|
118
176
|
|
|
119
177
|
const role = roleOf(t);
|
|
120
178
|
// Group by what a thing IS and how big it is, not where it is. Repeated
|
package/src/graph.js
CHANGED
|
@@ -7,11 +7,28 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { hashDistance } from './analyze.js';
|
|
10
|
+
import { informative } from './refs.js';
|
|
10
11
|
import * as fingerprint from './fingerprint.js';
|
|
11
12
|
import * as matching from './matching.js';
|
|
12
13
|
import * as store from './store.js';
|
|
13
14
|
|
|
14
|
-
const GRAPH_VERSION =
|
|
15
|
+
const GRAPH_VERSION = 3;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Which fingerprint produced the hashes in these files.
|
|
19
|
+
*
|
|
20
|
+
* Separate from `GRAPH_VERSION` because it answers a different question: not
|
|
21
|
+
* "is this file shaped the way I expect" but "were these hashes computed by the
|
|
22
|
+
* same rules I am about to compare them with". A stored graph whose hashes came
|
|
23
|
+
* from an older fingerprint is not stale, it is *incomparable* — and the failure
|
|
24
|
+
* is silent, because an old hash is a perfectly well-formed hash that simply
|
|
25
|
+
* never matches anything.
|
|
26
|
+
*
|
|
27
|
+
* On a mismatch the graph is discarded and rebuilt, never translated. A rebuild
|
|
28
|
+
* costs a few hundred milliseconds per screen and happens once. A mis-merged
|
|
29
|
+
* graph costs a wrong tap, and costs it for as long as the file survives.
|
|
30
|
+
*/
|
|
31
|
+
export const FINGERPRINT_VERSION = fingerprint.TOKEN_RULES_VERSION;
|
|
15
32
|
/**
|
|
16
33
|
* Screens are matched by structural hash, exactly, and then by how alike their
|
|
17
34
|
* token sets are — which tolerates one optional element appearing (a badge, a
|
|
@@ -38,6 +55,14 @@ const GRAPH_VERSION = 2;
|
|
|
38
55
|
* Most revisits match on a hash outright and never reach this at all.
|
|
39
56
|
*/
|
|
40
57
|
export const SIMILARITY_THRESHOLD = 0.36;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How many of the 288 layout bits may differ and still be the same arrangement
|
|
61
|
+
* of light. The same number `screenmap` recalls maps by, and for the same
|
|
62
|
+
* reason: measured, a revisit is usually identical and different screens sit at
|
|
63
|
+
* 74 and above, so 20 is well inside the gap.
|
|
64
|
+
*/
|
|
65
|
+
export const SAME_SCREEN_LAYOUT_BITS = 20;
|
|
41
66
|
/**
|
|
42
67
|
* A screen with three async sections has a few settled structures, not endless
|
|
43
68
|
* ones. Capping this keeps a genuinely wrong merge bounded: if a node starts
|
|
@@ -105,11 +130,28 @@ function fingerprintsOf(node) {
|
|
|
105
130
|
function load(udid, screen) {
|
|
106
131
|
const key = typeof screen === 'string' ? { hash: screen, tokens: [] } : screen;
|
|
107
132
|
const entry = store.readJson(path.join(graphDir(udid), `${key.hash}.json`));
|
|
108
|
-
if (entry?.version === GRAPH_VERSION) return entry;
|
|
133
|
+
if (entry?.version === GRAPH_VERSION && entry?.fingerprintVersion === FINGERPRINT_VERSION) return entry;
|
|
109
134
|
// The hash may be a variant of a node filed under a different name.
|
|
110
135
|
const byVariant = allNodes(udid).find((n) => (n.variants ?? []).some((v) => v.hash === key.hash));
|
|
111
136
|
if (byVariant) return byVariant;
|
|
112
|
-
return {
|
|
137
|
+
return {
|
|
138
|
+
version: GRAPH_VERSION,
|
|
139
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
140
|
+
hash: key.hash,
|
|
141
|
+
tokens: key.tokens ?? [],
|
|
142
|
+
// What the pixels looked like here. Kept because it is the evidence that
|
|
143
|
+
// two structurally different readings are the same screen — see `record`.
|
|
144
|
+
layoutHash: key.layoutHash ?? null,
|
|
145
|
+
variants: [],
|
|
146
|
+
edges: [],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Keep the pixel baseline current for a screen we are standing on. */
|
|
151
|
+
function noteLayout(node, reading) {
|
|
152
|
+
const now = typeof reading === 'string' ? null : reading?.layoutHash;
|
|
153
|
+
if (now && informative(now)) node.layoutHash = now;
|
|
154
|
+
return node;
|
|
113
155
|
}
|
|
114
156
|
|
|
115
157
|
function save(udid, node) {
|
|
@@ -124,7 +166,7 @@ export function allNodes(udid) {
|
|
|
124
166
|
.readdirSync(graphDir(udid))
|
|
125
167
|
.filter((f) => f.endsWith('.json'))
|
|
126
168
|
.map((f) => store.readJson(path.join(graphDir(udid), f)))
|
|
127
|
-
.filter((n) => n?.version === GRAPH_VERSION);
|
|
169
|
+
.filter((n) => n?.version === GRAPH_VERSION && n?.fingerprintVersion === FINGERPRINT_VERSION);
|
|
128
170
|
} catch {
|
|
129
171
|
return [];
|
|
130
172
|
}
|
|
@@ -243,6 +285,10 @@ export function record(udid, { from, action, to, kind }) {
|
|
|
243
285
|
// arrive as — that is what variants are for, and rewriting it here would let
|
|
244
286
|
// a node drift screen by screen into something it never was.
|
|
245
287
|
if (!node.tokens?.length && fromKey.tokens?.length) node.tokens = fromKey.tokens;
|
|
288
|
+
// The pixel baseline, on the other hand, *should* track: it is the evidence
|
|
289
|
+
// for "same arrangement of light as last time I stood here", and a stale one
|
|
290
|
+
// answers a question about a screen as it was weeks ago.
|
|
291
|
+
noteLayout(node, fromKey);
|
|
246
292
|
const to_ = toHash;
|
|
247
293
|
const signature = actionSignature(action);
|
|
248
294
|
const existing = node.edges.find((e) => e.action === signature);
|
|
@@ -272,9 +318,28 @@ export function record(udid, { from, action, to, kind }) {
|
|
|
272
318
|
// So the reading has to positively look like the target before it is
|
|
273
319
|
// called a face of it. Unclaimed is a necessary condition, not a
|
|
274
320
|
// sufficient one.
|
|
275
|
-
|
|
321
|
+
// Two kinds of positive evidence that this reading is a face of the
|
|
322
|
+
// target, and either will do. What will not do is "nothing else claims
|
|
323
|
+
// it", which is an absence of evidence and used to be the whole test.
|
|
324
|
+
//
|
|
325
|
+
// * It looks like the target — the tokens overlap enough to be the same
|
|
326
|
+
// screen by the same measure used everywhere else.
|
|
327
|
+
// * It *looks like* the target on screen. The pixels are within the
|
|
328
|
+
// same-screen band of what was seen here before, and we arrived
|
|
329
|
+
// through an edge that has led here. Identity belongs to the graph as
|
|
330
|
+
// much as to the hash: the transition is evidence the fingerprint
|
|
331
|
+
// cannot supply, and it is exactly the evidence needed when one
|
|
332
|
+
// perception layer answered this time and not last time.
|
|
333
|
+
//
|
|
334
|
+
// That second route is what carries a screen whose structure genuinely
|
|
335
|
+
// differs between reads. Measured on four device-native screens, the
|
|
336
|
+
// accessibility tree and OCR agree on only 0.33–0.47 of a screen's
|
|
337
|
+
// tokens and never on its hash, and coarsening the vocabulary barely
|
|
338
|
+
// moved it — so this is not a residual case, it is the common one.
|
|
339
|
+
const looksLikeTarget = resembles(target, reading) || pixelsAgree(target, reading);
|
|
276
340
|
if (unclaimed && looksLikeTarget && target.hash !== to_ && reading.tokens?.length) {
|
|
277
341
|
addVariant(target, reading);
|
|
342
|
+
noteLayout(target, reading);
|
|
278
343
|
save(udid, target);
|
|
279
344
|
existing.count += 1;
|
|
280
345
|
existing.lastSeen = Date.now();
|
|
@@ -306,6 +371,25 @@ export function record(udid, { from, action, to, kind }) {
|
|
|
306
371
|
}
|
|
307
372
|
|
|
308
373
|
/** What this action did last time, if we have ever seen it here. */
|
|
374
|
+
/**
|
|
375
|
+
* Do the pixels say this is the same screen we have stood on here before?
|
|
376
|
+
*
|
|
377
|
+
* The layout hash is a poor answer to "which screen is this" on its own — that
|
|
378
|
+
* is why identity is structural — but it is a good answer to "is this the same
|
|
379
|
+
* arrangement of light", and combined with having arrived through a known edge
|
|
380
|
+
* it is the evidence that two structurally different readings are one screen.
|
|
381
|
+
*
|
|
382
|
+
* Guarded by `informative`, because a dark or uniform screen hashes to almost
|
|
383
|
+
* nothing and two of those are within any tolerance of each other while being
|
|
384
|
+
* evidence of nothing at all.
|
|
385
|
+
*/
|
|
386
|
+
function pixelsAgree(node, reading) {
|
|
387
|
+
const before = node?.layoutHash;
|
|
388
|
+
const now = reading?.layoutHash;
|
|
389
|
+
if (!before || !now || !informative(before) || !informative(now)) return false;
|
|
390
|
+
return hashDistance(before, now) <= SAME_SCREEN_LAYOUT_BITS;
|
|
391
|
+
}
|
|
392
|
+
|
|
309
393
|
/**
|
|
310
394
|
* Does this reading look like a face of this screen, rather than a different
|
|
311
395
|
* screen we happen not to have stored yet?
|