simframe 0.4.2 → 0.6.0-rc.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 +334 -85
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
- package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
- package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
- package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
- package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
- package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
- package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
- package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
- package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
- package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
- package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
- package/native/simframed/Sources/simframed/main.swift +485 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
- package/package.json +12 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +99 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +264 -18
- package/src/cli.js +561 -89
- package/src/control.js +77 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +183 -0
- package/src/graph.js +411 -0
- package/src/index.js +351 -24
- package/src/input.js +179 -2
- package/src/matching.js +265 -0
- package/src/mcp.js +425 -112
- package/src/navigate.js +120 -0
- package/src/refs.js +141 -0
- package/src/regions.js +267 -0
- package/src/screenmap.js +119 -22
- package/src/simctl.js +74 -5
- package/src/store.js +8 -0
- package/src/view.js +342 -0
package/src/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { DEFAULTS, STATE_VERSION } from './daemon.js';
|
|
7
|
+
import * as engine from './engine.js';
|
|
7
8
|
import { decodePng, encodePng, scaleBitmap } from './png.js';
|
|
8
9
|
import {
|
|
9
10
|
REGION_COLS,
|
|
@@ -13,6 +14,10 @@ import {
|
|
|
13
14
|
signatureDiff,
|
|
14
15
|
} from './analyze.js';
|
|
15
16
|
import * as input from './input.js';
|
|
17
|
+
import * as fingerprint from './fingerprint.js';
|
|
18
|
+
import * as graph from './graph.js';
|
|
19
|
+
import * as matching from './matching.js';
|
|
20
|
+
import * as refs from './refs.js';
|
|
16
21
|
import * as screenmap from './screenmap.js';
|
|
17
22
|
import { resolveDevice, resize, screenshot } from './simctl.js';
|
|
18
23
|
import * as store from './store.js';
|
|
@@ -21,7 +26,24 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
|
21
26
|
const CLI = path.join(HERE, 'cli.js');
|
|
22
27
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
28
|
|
|
24
|
-
export const DETAIL_LEVELS = { low: 420, normal: 700, high:
|
|
29
|
+
export const DETAIL_LEVELS = { low: 420, normal: 700, high: 1024, full: 0 };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The ceiling on any image handed to a model.
|
|
33
|
+
*
|
|
34
|
+
* An image costs ~1,600 tokens when Claude Code handles it as a native image
|
|
35
|
+
* block, and 15,000–25,000 when the base64 is treated as text (claude-code
|
|
36
|
+
* issue #31208) — enough to trip the 25,000-token tool-result limit on its own.
|
|
37
|
+
* A native-resolution frame buys nothing at either price: 1024 px on the long
|
|
38
|
+
* edge is already more than a 393-point screen has to say. Only the CLI, which
|
|
39
|
+
* writes to a file rather than into a context window, may exceed it.
|
|
40
|
+
*/
|
|
41
|
+
export const MODEL_MAX_IMAGE_DIM = 1024;
|
|
42
|
+
|
|
43
|
+
export function modelDetail(detail) {
|
|
44
|
+
const dim = resolveMaxDim(detail);
|
|
45
|
+
return dim === 0 || dim > MODEL_MAX_IMAGE_DIM ? MODEL_MAX_IMAGE_DIM : dim;
|
|
46
|
+
}
|
|
25
47
|
|
|
26
48
|
export function resolveMaxDim(detail) {
|
|
27
49
|
if (typeof detail === 'number') return detail;
|
|
@@ -37,6 +59,22 @@ export function resolveMaxDim(detail) {
|
|
|
37
59
|
async function fullFrameFor(udid, state) {
|
|
38
60
|
if (state.fullFile && fs.existsSync(state.fullFile)) return state.fullFile;
|
|
39
61
|
const p = store.paths(udid);
|
|
62
|
+
// The pointer in state.json can name a frame retention has already thinned
|
|
63
|
+
// away, and it does so routinely — state named full/2475.png while the
|
|
64
|
+
// directory held 2470, 2869 and 2870. Falling straight through to simctl
|
|
65
|
+
// meant OCR quietly shelled out for a screenshot on a machine whose daemon
|
|
66
|
+
// was capturing full frames the whole time: slower, and it made the whole
|
|
67
|
+
// step fail on a runner where that shell-out did not work.
|
|
68
|
+
try {
|
|
69
|
+
const newest = fs.readdirSync(p.full)
|
|
70
|
+
.filter((f) => f.endsWith('.png'))
|
|
71
|
+
.map((f) => ({ f, seq: Number.parseInt(f, 10) }))
|
|
72
|
+
.filter((x) => Number.isFinite(x.seq))
|
|
73
|
+
.sort((a, b) => b.seq - a.seq)[0];
|
|
74
|
+
if (newest) return path.join(p.full, newest.f);
|
|
75
|
+
} catch {
|
|
76
|
+
/* no full directory yet; fall through */
|
|
77
|
+
}
|
|
40
78
|
const file = path.join(p.dir, 'ocr-source.png');
|
|
41
79
|
await screenshot(udid, file, { mask: 'ignored' });
|
|
42
80
|
return file;
|
|
@@ -54,11 +92,16 @@ async function deviceGeometry(udid, state) {
|
|
|
54
92
|
} catch {
|
|
55
93
|
/* idb absent: fall through */
|
|
56
94
|
}
|
|
95
|
+
// Last resort only. These numbers are an iPhone 17 Pro, so on anything else —
|
|
96
|
+
// an iPad especially — they are silently wrong, and every tap point derived
|
|
97
|
+
// from them lands in the wrong place. screenInfo asks the daemon first now,
|
|
98
|
+
// so reaching here means neither the daemon nor idb could answer.
|
|
57
99
|
const density = 3;
|
|
58
100
|
return {
|
|
59
101
|
density,
|
|
60
102
|
pointWidth: Math.round((state.width * (state.nativeScale ?? 1)) / 1) || 402,
|
|
61
103
|
pointHeight: Math.round((state.height * (state.nativeScale ?? 1)) / 1) || 874,
|
|
104
|
+
guessed: true,
|
|
62
105
|
};
|
|
63
106
|
}
|
|
64
107
|
|
|
@@ -89,7 +132,7 @@ export async function ensureDaemon(deviceQuery, options = {}) {
|
|
|
89
132
|
if (!existing.alive) {
|
|
90
133
|
if (acquireSpawnLock(p.lock)) {
|
|
91
134
|
try {
|
|
92
|
-
|
|
135
|
+
await startEngine(device.udid, options);
|
|
93
136
|
} finally {
|
|
94
137
|
// Hold the lock briefly so a burst of callers does not double-spawn.
|
|
95
138
|
setTimeout(() => releaseSpawnLock(p.lock), 1500).unref?.();
|
|
@@ -97,7 +140,8 @@ export async function ensureDaemon(deviceQuery, options = {}) {
|
|
|
97
140
|
}
|
|
98
141
|
}
|
|
99
142
|
|
|
100
|
-
|
|
143
|
+
// The daemon may need a first build, which is slower than a spawn.
|
|
144
|
+
const deadline = Date.now() + (options.readyTimeoutMs ?? 20_000);
|
|
101
145
|
while (Date.now() < deadline) {
|
|
102
146
|
const state = store.readJson(p.state);
|
|
103
147
|
if (state && state.capturedAt >= minCapturedAt && Date.now() - state.capturedAt < 30_000) {
|
|
@@ -109,7 +153,63 @@ export async function ensureDaemon(deviceQuery, options = {}) {
|
|
|
109
153
|
throw new Error(`simframe daemon did not produce a frame for ${device.name}${tail ? `\n${tail}` : ''}`);
|
|
110
154
|
}
|
|
111
155
|
|
|
112
|
-
|
|
156
|
+
/** Why the daemon was not used, when it was not. Surfaced by doctor. */
|
|
157
|
+
export let engineFallbackReason = null;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Degrading has to announce itself.
|
|
161
|
+
*
|
|
162
|
+
* "Degrade rather than fail" is the right policy and it nearly sank the tool
|
|
163
|
+
* twice: a file missing from the published package made every install fall back
|
|
164
|
+
* to the simctl engine, and OCR ship disabled, both **silently**. Tests passed,
|
|
165
|
+
* CI passed, nothing printed. The failure was not the missing file; it was that
|
|
166
|
+
* the degradation was invisible.
|
|
167
|
+
*
|
|
168
|
+
* So the reason is written next to the device's state, not just held in this
|
|
169
|
+
* process's memory — otherwise a later `doctor` or `start` sees `engine=simctl`
|
|
170
|
+
* with no explanation, because the process that chose it has exited.
|
|
171
|
+
*/
|
|
172
|
+
function recordFallback(udid, reason) {
|
|
173
|
+
const file = path.join(store.deviceDir(udid), 'engine-fallback.json');
|
|
174
|
+
try {
|
|
175
|
+
if (reason) store.writeAtomic(file, JSON.stringify({ reason, at: Date.now() }));
|
|
176
|
+
else fs.rmSync(file, { force: true });
|
|
177
|
+
} catch {
|
|
178
|
+
// Never let bookkeeping stop capture from starting.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Why the running engine is not simframed, if it is not. Survives the process that chose it. */
|
|
183
|
+
export function fallbackReason(udid) {
|
|
184
|
+
if (engineFallbackReason) return engineFallbackReason;
|
|
185
|
+
return store.readJson(path.join(store.deviceDir(udid), 'engine-fallback.json'))?.reason ?? null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Start whichever engine was asked for.
|
|
190
|
+
*
|
|
191
|
+
* simframed unless told otherwise: it reads the framebuffer directly and is
|
|
192
|
+
* roughly thirty times faster per frame. The simctl loop stays reachable with
|
|
193
|
+
* `engine: 'simctl'`, and is used automatically when the daemon cannot be
|
|
194
|
+
* built — a machine with no Swift toolchain still has to work.
|
|
195
|
+
*/
|
|
196
|
+
async function startEngine(udid, options) {
|
|
197
|
+
if ((options.engine ?? 'simframed') === 'simframed') {
|
|
198
|
+
const built = await engine.ensureBuilt();
|
|
199
|
+
if (built.ok) {
|
|
200
|
+
engineFallbackReason = null;
|
|
201
|
+
recordFallback(udid, null);
|
|
202
|
+
engine.spawnDaemon(udid, options);
|
|
203
|
+
return 'simframed';
|
|
204
|
+
}
|
|
205
|
+
engineFallbackReason = built.reason ?? 'simframed unavailable';
|
|
206
|
+
recordFallback(udid, engineFallbackReason);
|
|
207
|
+
}
|
|
208
|
+
spawnNodeDaemon(udid, options);
|
|
209
|
+
return 'simctl';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function spawnNodeDaemon(udid, options) {
|
|
113
213
|
const args = [CLI, 'daemon', udid];
|
|
114
214
|
for (const key of ['fps', 'maxDim', 'ringSize', 'idleExitMs']) {
|
|
115
215
|
if (options[key] != null) args.push(`--${key}=${options[key]}`);
|
|
@@ -164,6 +264,21 @@ function readLogTail(file, lines = 6) {
|
|
|
164
264
|
/** A frame this old means the capture loop is wedged, not that the screen is calm. */
|
|
165
265
|
export const STALE_FRAME_MS = 2500;
|
|
166
266
|
|
|
267
|
+
/**
|
|
268
|
+
* How still the screen must be before a frame may be used to key screen memory.
|
|
269
|
+
*
|
|
270
|
+
* A screen map describes a screen, so it must be built from a frame that shows
|
|
271
|
+
* one — not from the middle of a transition, where the layout belongs to
|
|
272
|
+
* neither the screen you left nor the one you are arriving at. Without this,
|
|
273
|
+
* the capture rate leaks into the hit rate: a faster loop samples more
|
|
274
|
+
* transitional frames and remembers more layouts that will never recur.
|
|
275
|
+
*
|
|
276
|
+
* Phase 4 replaces this with the real settle detector, which can tell a
|
|
277
|
+
* spinner from a still screen. Until then, "nothing moved for a while" is
|
|
278
|
+
* enough to decouple memory from frame rate.
|
|
279
|
+
*/
|
|
280
|
+
export const MEMORY_SETTLE_MS = 250;
|
|
281
|
+
|
|
167
282
|
/** Below this a "change" is a clock digit or a caret, not a new screen. */
|
|
168
283
|
export const MINOR_CHANGE = 0.004;
|
|
169
284
|
export const MAJOR_CHANGE = 0.03;
|
|
@@ -180,7 +295,17 @@ export function liveness(udid, state) {
|
|
|
180
295
|
if (!running) {
|
|
181
296
|
return { ok: false, ageMs, note: 'the capture loop has died; the frame you are looking at is the last one it wrote' };
|
|
182
297
|
}
|
|
183
|
-
|
|
298
|
+
// Frame age means "stalled" only for a fixed-rate loop.
|
|
299
|
+
//
|
|
300
|
+
// simframed captures on damage, so a screen that is genuinely still produces
|
|
301
|
+
// no frames at all — which is precisely the state `settle` exists to detect.
|
|
302
|
+
// Treating that as a stall made a flow fail on a static page with "capture
|
|
303
|
+
// loop is stalled: newest frame is 2984ms old" immediately after a step had
|
|
304
|
+
// succeeded. The pid check above is the honest liveness signal for this
|
|
305
|
+
// engine; the heartbeat file cannot help, because clients write it, not the
|
|
306
|
+
// daemon.
|
|
307
|
+
const damageDriven = engine.runningEngine(udid) === 'simframed';
|
|
308
|
+
if (!damageDriven && ageMs > STALE_FRAME_MS) {
|
|
184
309
|
return { ok: false, ageMs, note: `capture loop is stalled: newest frame is ${ageMs}ms old` };
|
|
185
310
|
}
|
|
186
311
|
return { ok: true, ageMs, note: null };
|
|
@@ -577,15 +702,79 @@ export async function getFrameAt(deviceQuery, { msAgo = 0, options } = {}) {
|
|
|
577
702
|
* A screen seen for the first time pays once to build its map, and every later
|
|
578
703
|
* visit is a file read.
|
|
579
704
|
*/
|
|
580
|
-
|
|
581
|
-
|
|
705
|
+
/**
|
|
706
|
+
* Wait, briefly, for a frame that is holding still. Returns whatever the newest
|
|
707
|
+
* frame is once the screen settles or the budget runs out, saying which.
|
|
708
|
+
*/
|
|
709
|
+
export async function settledState(udid, { settleMs = MEMORY_SETTLE_MS, timeoutMs = 1500 } = {}) {
|
|
710
|
+
const p = store.paths(udid);
|
|
711
|
+
const deadline = Date.now() + timeoutMs;
|
|
712
|
+
let state = store.readJson(p.state);
|
|
713
|
+
while (Date.now() < deadline) {
|
|
714
|
+
state = store.readJson(p.state) ?? state;
|
|
715
|
+
// The daemon runs a real settle detector that can tell a spinner from a
|
|
716
|
+
// still screen. Prefer it; the duration check is the fallback for the
|
|
717
|
+
// simctl engine, which has no such thing.
|
|
718
|
+
if (state?.settled === true) return { state, settled: true };
|
|
719
|
+
if (state && state.settled === undefined && state.stableForMs >= settleMs) {
|
|
720
|
+
return { state, settled: true };
|
|
721
|
+
}
|
|
722
|
+
await sleep(40);
|
|
723
|
+
}
|
|
724
|
+
return { state, settled: false };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export async function locate(
|
|
728
|
+
deviceQuery,
|
|
729
|
+
query,
|
|
730
|
+
{ index, refresh = false, useAx = true, useOcr = true, settleMs = MEMORY_SETTLE_MS, options } = {},
|
|
731
|
+
) {
|
|
732
|
+
const { device, state: firstState } = await ensureDaemon(deviceQuery, options);
|
|
582
733
|
const udid = device.udid;
|
|
734
|
+
|
|
735
|
+
// Selectors resolve before any perception happens: `#3` is already an answer
|
|
736
|
+
// somebody numbered, and `@x,y` was never a question about the screen.
|
|
737
|
+
const selector = refs.parseSelector(query);
|
|
738
|
+
if (selector.kind === 'point') {
|
|
739
|
+
return {
|
|
740
|
+
device,
|
|
741
|
+
state: firstState,
|
|
742
|
+
target: { label: `(${selector.x},${selector.y})`, x: selector.x, y: selector.y, source: 'coordinates' },
|
|
743
|
+
from: 'selector',
|
|
744
|
+
distance: 0,
|
|
745
|
+
settled: true,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (selector.kind === 'ref') {
|
|
749
|
+
// Screen memory answers "which screen is this?" from a file, so a ref can
|
|
750
|
+
// be checked against structural identity without paying for a perception
|
|
751
|
+
// pass — which is the whole reason a ref exists.
|
|
752
|
+
const near = screenmap.recallNearest(udid, firstState.layoutHash);
|
|
753
|
+
const hit = refs.resolveRef(udid, selector.ref, {
|
|
754
|
+
layoutHash: firstState.layoutHash,
|
|
755
|
+
structuralHash: near?.entry?.structuralHash ?? null,
|
|
756
|
+
screenKnown: Boolean(near),
|
|
757
|
+
});
|
|
758
|
+
return {
|
|
759
|
+
device,
|
|
760
|
+
state: firstState,
|
|
761
|
+
target: { ...hit, label: hit.label ?? `#${hit.ref}`, source: hit.source ?? 'ref' },
|
|
762
|
+
from: 'ref',
|
|
763
|
+
distance: 0,
|
|
764
|
+
settled: true,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
if (selector.exact) query = selector.label;
|
|
768
|
+
// Key memory off a settled frame, never off whichever frame happened to be
|
|
769
|
+
// newest, so the capture rate cannot change what gets remembered.
|
|
770
|
+
const { state, settled } = await settledState(udid, { settleMs });
|
|
771
|
+
const current = state ?? firstState;
|
|
583
772
|
let entry = null;
|
|
584
773
|
let from = 'memory';
|
|
585
774
|
let distance = 0;
|
|
586
775
|
|
|
587
776
|
if (!refresh) {
|
|
588
|
-
const near = screenmap.recallNearest(udid,
|
|
777
|
+
const near = screenmap.recallNearest(udid, current.layoutHash);
|
|
589
778
|
if (near) {
|
|
590
779
|
entry = near.entry;
|
|
591
780
|
distance = near.distance;
|
|
@@ -593,34 +782,59 @@ export async function locate(deviceQuery, query, { index, refresh = false, useAx
|
|
|
593
782
|
}
|
|
594
783
|
|
|
595
784
|
if (!entry) {
|
|
596
|
-
const geo = await deviceGeometry(udid,
|
|
785
|
+
const geo = await deviceGeometry(udid, current);
|
|
597
786
|
entry = await screenmap.build(udid, {
|
|
598
|
-
hash:
|
|
599
|
-
layoutHash:
|
|
600
|
-
fullFrame: await fullFrameFor(udid,
|
|
787
|
+
hash: current.hash,
|
|
788
|
+
layoutHash: current.layoutHash,
|
|
789
|
+
fullFrame: await fullFrameFor(udid, current),
|
|
601
790
|
density: geo.density,
|
|
602
791
|
screen: { width: geo.pointWidth, height: geo.pointHeight },
|
|
603
792
|
useAx,
|
|
604
793
|
useOcr,
|
|
794
|
+
// A map built while the screen was moving describes nothing that will
|
|
795
|
+
// recur, so it is used for this call and then thrown away.
|
|
796
|
+
persist: settled,
|
|
605
797
|
});
|
|
606
|
-
from = 'built';
|
|
798
|
+
from = settled ? 'built' : 'built-unsettled';
|
|
607
799
|
}
|
|
608
800
|
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
801
|
+
const screenSize = { width: current.width, height: current.height };
|
|
802
|
+
const geo = await deviceGeometry(udid, current);
|
|
803
|
+
const points = { width: geo.pointWidth, height: geo.pointHeight };
|
|
804
|
+
|
|
805
|
+
// Intent resolution rather than string matching: it understands verbs
|
|
806
|
+
// ("tap Save"), typos, icon-only controls by synonym ("back"), and where on
|
|
807
|
+
// screen the caller meant ("Assets tab").
|
|
808
|
+
if (index == null) {
|
|
809
|
+
const outcome = matching.resolve(entry.targets, query, { screen: points });
|
|
810
|
+
if (outcome.status === 'ambiguous') {
|
|
811
|
+
const list = outcome.alternatives
|
|
812
|
+
.map((a, i) => `[${i}] "${a.label}" (${a.x},${a.y}) ${a.region ?? 'content'} ${a.score}`)
|
|
618
813
|
.join(', ');
|
|
619
814
|
throw new Error(
|
|
620
|
-
`"${query}" matches ${
|
|
815
|
+
`"${query}" matches ${outcome.alternatives.length} things on this screen — say which, or pass index: ${list}`,
|
|
621
816
|
);
|
|
622
817
|
}
|
|
818
|
+
if (outcome.status === 'ok') {
|
|
819
|
+
return {
|
|
820
|
+
device, state: current, entry, target: outcome.target, from, distance, settled,
|
|
821
|
+
score: outcome.score, reasons: outcome.reasons, alternatives: outcome.alternatives,
|
|
822
|
+
screens: screenmap.stats(udid).screens,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
// Nothing scored well enough. Falling through to plain substring matching
|
|
826
|
+
// here undoes every guard above — it has no off-screen filter and no
|
|
827
|
+
// coverage weighting, and it is what returned a scrolled-away list row for
|
|
828
|
+
// "back". "Not found" is the correct answer.
|
|
829
|
+
const sample = entry.targets
|
|
830
|
+
.filter((t) => t.label && t.y >= 0 && t.y <= points.height)
|
|
831
|
+
.slice(0, 12)
|
|
832
|
+
.map((t) => t.label.slice(0, 24))
|
|
833
|
+
.join(', ');
|
|
834
|
+
throw new Error(`"${query}" is not on this screen. Visible: ${sample || '(nothing readable)'}`);
|
|
623
835
|
}
|
|
836
|
+
|
|
837
|
+
const candidates = screenmap.rank(entry, query);
|
|
624
838
|
const target = index != null ? candidates[index] : candidates[0];
|
|
625
839
|
if (!target) {
|
|
626
840
|
const sample = entry.targets
|
|
@@ -632,7 +846,120 @@ export async function locate(deviceQuery, query, { index, refresh = false, useAx
|
|
|
632
846
|
`"${query}" is not on this screen. Visible: ${sample || '(nothing readable)'}`,
|
|
633
847
|
);
|
|
634
848
|
}
|
|
635
|
-
return { device, state, entry, target, from, distance, screens: screenmap.stats(udid).screens };
|
|
849
|
+
return { device, state: current, entry, target, from, distance, settled, screens: screenmap.stats(udid).screens };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* What screen this is, structurally.
|
|
854
|
+
*
|
|
855
|
+
* Uses the screen map — recalled when the pixel index finds one, built when it
|
|
856
|
+
* does not. The pixel hash is what makes the lookup cheap; the structural hash
|
|
857
|
+
* is what makes the answer right.
|
|
858
|
+
*/
|
|
859
|
+
/**
|
|
860
|
+
* Structural settle: how long to let a screen finish arriving before believing
|
|
861
|
+
* a fingerprint that nothing recognises.
|
|
862
|
+
*/
|
|
863
|
+
export const STRUCTURAL_SETTLE_MS = 300;
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* How long identity will wait for pixels to go quiet.
|
|
867
|
+
*
|
|
868
|
+
* Not the caller's timeout. A flow allows twelve seconds for a screen to
|
|
869
|
+
* arrive, but some screens never report settled at all — live content, a
|
|
870
|
+
* looping animation — and spending the flow's whole budget waiting for a flag
|
|
871
|
+
* that will not come cost 53s on a tour that had taken 7s. Long enough to
|
|
872
|
+
* outlast a normal transition, short enough that a screen which never settles
|
|
873
|
+
* is cheap to give up on.
|
|
874
|
+
*/
|
|
875
|
+
export const IDENTITY_SETTLE_TIMEOUT_MS = 2500;
|
|
876
|
+
const STRUCTURAL_SETTLE_SAMPLES = 3;
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* What screen is this?
|
|
880
|
+
*
|
|
881
|
+
* `settled` is a question about pixels, and it is answered before a screen has
|
|
882
|
+
* necessarily finished arriving: a list whose spinner has gone but whose rows
|
|
883
|
+
* have not landed is perfectly still and structurally wrong. Measured, that put
|
|
884
|
+
* one screen at 17 tokens on one visit and 7 on the next, which is the whole
|
|
885
|
+
* reason the same-screen floor sits at 0.41 instead of somewhere comfortable.
|
|
886
|
+
*
|
|
887
|
+
* So structural identity gets a structural settle of its own — but only where
|
|
888
|
+
* it costs something worth paying for. A fingerprint that matches a screen we
|
|
889
|
+
* already know is taken at face value; the risk is not that we mislabel a known
|
|
890
|
+
* screen, it is that a half-drawn one becomes a new node nobody can navigate
|
|
891
|
+
* to. Novel fingerprints, and only those, are re-sampled until two consecutive
|
|
892
|
+
* readings agree.
|
|
893
|
+
*/
|
|
894
|
+
export async function screenIdentity(deviceQuery, { options, confirmNovel = true, settleMs, timeoutMs, fresh = false } = {}) {
|
|
895
|
+
const { device, state } = await ensureDaemon(deviceQuery, options);
|
|
896
|
+
const udid = device.udid;
|
|
897
|
+
|
|
898
|
+
const read = async ({ fresh = false } = {}) => {
|
|
899
|
+
// Settle with the caller's patience, not a default. settledState times out
|
|
900
|
+
// at 1.5s on its own, so inside a flow that allows twelve seconds this was
|
|
901
|
+
// calling a screen unsettled while the flow was still happily waiting for
|
|
902
|
+
// it — and an unsettled screen records no edge, so the graph learned
|
|
903
|
+
// nothing and every later step read `unverified`.
|
|
904
|
+
const { state: settledFrame, settled } = await settledState(udid, {
|
|
905
|
+
settleMs,
|
|
906
|
+
timeoutMs: Math.min(timeoutMs ?? IDENTITY_SETTLE_TIMEOUT_MS, IDENTITY_SETTLE_TIMEOUT_MS),
|
|
907
|
+
});
|
|
908
|
+
const current = settledFrame ?? state;
|
|
909
|
+
let entry = fresh ? null : screenmap.recallNearest(udid, current.layoutHash)?.entry;
|
|
910
|
+
const geo = await deviceGeometry(udid, current);
|
|
911
|
+
if (!entry) {
|
|
912
|
+
entry = await screenmap.build(udid, {
|
|
913
|
+
hash: current.hash,
|
|
914
|
+
layoutHash: current.layoutHash,
|
|
915
|
+
fullFrame: await fullFrameFor(udid, current),
|
|
916
|
+
density: geo.density,
|
|
917
|
+
screen: { width: geo.pointWidth, height: geo.pointHeight },
|
|
918
|
+
persist: settled,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
return {
|
|
922
|
+
hash: entry.structuralHash,
|
|
923
|
+
tokens: entry.structuralTokens ?? [],
|
|
924
|
+
keyboard: Boolean(entry.keyboard),
|
|
925
|
+
layoutHash: current.layoutHash,
|
|
926
|
+
settled,
|
|
927
|
+
// Carried out so callers that want the elements as well as the identity
|
|
928
|
+
// do not pay for a second perception pass to get them. The compact
|
|
929
|
+
// screen map needs both, and reading twice was the whole cost of it.
|
|
930
|
+
entry,
|
|
931
|
+
state: current,
|
|
932
|
+
points: { width: geo.pointWidth, height: geo.pointHeight },
|
|
933
|
+
};
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
let identity = await read({ fresh });
|
|
937
|
+
if (!confirmNovel) return { ...identity, confirmed: identity.settled };
|
|
938
|
+
// Being recognised is stronger evidence than the pixel settle flag: a
|
|
939
|
+
// fingerprint that matches a screen already trusted has nothing left to
|
|
940
|
+
// prove, and some screens (live content, a looping animation) never report
|
|
941
|
+
// settled at all. The gate exists to stop a half-drawn screen becoming a new
|
|
942
|
+
// node — not to re-interrogate a known one.
|
|
943
|
+
if (graph.nearestScreen(udid, identity)) return { ...identity, confirmed: true, known: true };
|
|
944
|
+
|
|
945
|
+
// Nothing recognises this, or the pixels have not gone quiet. Either way, make
|
|
946
|
+
// it prove it is the same screen twice running before it becomes a node.
|
|
947
|
+
for (let i = 1; i < STRUCTURAL_SETTLE_SAMPLES; i += 1) {
|
|
948
|
+
await sleep(STRUCTURAL_SETTLE_MS);
|
|
949
|
+
const again = await read({ fresh: true });
|
|
950
|
+
// Two readings agree if they are the same screen — the same test identity
|
|
951
|
+
// itself uses. Demanding an identical hash is a stricter question than the
|
|
952
|
+
// one being asked, and a row a grid-unit wider fails it.
|
|
953
|
+
const agrees = again.hash === identity.hash
|
|
954
|
+
|| fingerprint.similarity(again.tokens, identity.tokens) >= graph.SIMILARITY_THRESHOLD;
|
|
955
|
+
if (agrees) {
|
|
956
|
+
return { ...again, confirmed: true, known: Boolean(graph.nearestScreen(udid, again)) };
|
|
957
|
+
}
|
|
958
|
+
identity = again;
|
|
959
|
+
}
|
|
960
|
+
// Still moving structurally. Report the latest reading and say it is unproven,
|
|
961
|
+
// so callers can decline to record an edge to a screen that never held still.
|
|
962
|
+
return { ...identity, confirmed: false, known: false };
|
|
636
963
|
}
|
|
637
964
|
|
|
638
965
|
export { DEFAULTS, screenmap, store };
|