simframe 0.4.2 → 0.5.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 +227 -53
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -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 +117 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -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 +120 -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 +357 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
- package/package.json +10 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +91 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/src/actions.js +82 -5
- package/src/cli.js +347 -31
- package/src/control.js +76 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +150 -0
- package/src/graph.js +409 -0
- package/src/index.js +292 -23
- package/src/input.js +80 -2
- package/src/matching.js +194 -0
- package/src/mcp.js +45 -1
- package/src/navigate.js +120 -0
- package/src/regions.js +90 -0
- package/src/screenmap.js +77 -21
- package/src/simctl.js +20 -4
- package/src/store.js +8 -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,9 @@ 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';
|
|
16
20
|
import * as screenmap from './screenmap.js';
|
|
17
21
|
import { resolveDevice, resize, screenshot } from './simctl.js';
|
|
18
22
|
import * as store from './store.js';
|
|
@@ -37,6 +41,22 @@ export function resolveMaxDim(detail) {
|
|
|
37
41
|
async function fullFrameFor(udid, state) {
|
|
38
42
|
if (state.fullFile && fs.existsSync(state.fullFile)) return state.fullFile;
|
|
39
43
|
const p = store.paths(udid);
|
|
44
|
+
// The pointer in state.json can name a frame retention has already thinned
|
|
45
|
+
// away, and it does so routinely — state named full/2475.png while the
|
|
46
|
+
// directory held 2470, 2869 and 2870. Falling straight through to simctl
|
|
47
|
+
// meant OCR quietly shelled out for a screenshot on a machine whose daemon
|
|
48
|
+
// was capturing full frames the whole time: slower, and it made the whole
|
|
49
|
+
// step fail on a runner where that shell-out did not work.
|
|
50
|
+
try {
|
|
51
|
+
const newest = fs.readdirSync(p.full)
|
|
52
|
+
.filter((f) => f.endsWith('.png'))
|
|
53
|
+
.map((f) => ({ f, seq: Number.parseInt(f, 10) }))
|
|
54
|
+
.filter((x) => Number.isFinite(x.seq))
|
|
55
|
+
.sort((a, b) => b.seq - a.seq)[0];
|
|
56
|
+
if (newest) return path.join(p.full, newest.f);
|
|
57
|
+
} catch {
|
|
58
|
+
/* no full directory yet; fall through */
|
|
59
|
+
}
|
|
40
60
|
const file = path.join(p.dir, 'ocr-source.png');
|
|
41
61
|
await screenshot(udid, file, { mask: 'ignored' });
|
|
42
62
|
return file;
|
|
@@ -54,11 +74,16 @@ async function deviceGeometry(udid, state) {
|
|
|
54
74
|
} catch {
|
|
55
75
|
/* idb absent: fall through */
|
|
56
76
|
}
|
|
77
|
+
// Last resort only. These numbers are an iPhone 17 Pro, so on anything else —
|
|
78
|
+
// an iPad especially — they are silently wrong, and every tap point derived
|
|
79
|
+
// from them lands in the wrong place. screenInfo asks the daemon first now,
|
|
80
|
+
// so reaching here means neither the daemon nor idb could answer.
|
|
57
81
|
const density = 3;
|
|
58
82
|
return {
|
|
59
83
|
density,
|
|
60
84
|
pointWidth: Math.round((state.width * (state.nativeScale ?? 1)) / 1) || 402,
|
|
61
85
|
pointHeight: Math.round((state.height * (state.nativeScale ?? 1)) / 1) || 874,
|
|
86
|
+
guessed: true,
|
|
62
87
|
};
|
|
63
88
|
}
|
|
64
89
|
|
|
@@ -89,7 +114,7 @@ export async function ensureDaemon(deviceQuery, options = {}) {
|
|
|
89
114
|
if (!existing.alive) {
|
|
90
115
|
if (acquireSpawnLock(p.lock)) {
|
|
91
116
|
try {
|
|
92
|
-
|
|
117
|
+
await startEngine(device.udid, options);
|
|
93
118
|
} finally {
|
|
94
119
|
// Hold the lock briefly so a burst of callers does not double-spawn.
|
|
95
120
|
setTimeout(() => releaseSpawnLock(p.lock), 1500).unref?.();
|
|
@@ -97,7 +122,8 @@ export async function ensureDaemon(deviceQuery, options = {}) {
|
|
|
97
122
|
}
|
|
98
123
|
}
|
|
99
124
|
|
|
100
|
-
|
|
125
|
+
// The daemon may need a first build, which is slower than a spawn.
|
|
126
|
+
const deadline = Date.now() + (options.readyTimeoutMs ?? 20_000);
|
|
101
127
|
while (Date.now() < deadline) {
|
|
102
128
|
const state = store.readJson(p.state);
|
|
103
129
|
if (state && state.capturedAt >= minCapturedAt && Date.now() - state.capturedAt < 30_000) {
|
|
@@ -109,7 +135,63 @@ export async function ensureDaemon(deviceQuery, options = {}) {
|
|
|
109
135
|
throw new Error(`simframe daemon did not produce a frame for ${device.name}${tail ? `\n${tail}` : ''}`);
|
|
110
136
|
}
|
|
111
137
|
|
|
112
|
-
|
|
138
|
+
/** Why the daemon was not used, when it was not. Surfaced by doctor. */
|
|
139
|
+
export let engineFallbackReason = null;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Degrading has to announce itself.
|
|
143
|
+
*
|
|
144
|
+
* "Degrade rather than fail" is the right policy and it nearly sank the tool
|
|
145
|
+
* twice: a file missing from the published package made every install fall back
|
|
146
|
+
* to the simctl engine, and OCR ship disabled, both **silently**. Tests passed,
|
|
147
|
+
* CI passed, nothing printed. The failure was not the missing file; it was that
|
|
148
|
+
* the degradation was invisible.
|
|
149
|
+
*
|
|
150
|
+
* So the reason is written next to the device's state, not just held in this
|
|
151
|
+
* process's memory — otherwise a later `doctor` or `start` sees `engine=simctl`
|
|
152
|
+
* with no explanation, because the process that chose it has exited.
|
|
153
|
+
*/
|
|
154
|
+
function recordFallback(udid, reason) {
|
|
155
|
+
const file = path.join(store.deviceDir(udid), 'engine-fallback.json');
|
|
156
|
+
try {
|
|
157
|
+
if (reason) store.writeAtomic(file, JSON.stringify({ reason, at: Date.now() }));
|
|
158
|
+
else fs.rmSync(file, { force: true });
|
|
159
|
+
} catch {
|
|
160
|
+
// Never let bookkeeping stop capture from starting.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Why the running engine is not simframed, if it is not. Survives the process that chose it. */
|
|
165
|
+
export function fallbackReason(udid) {
|
|
166
|
+
if (engineFallbackReason) return engineFallbackReason;
|
|
167
|
+
return store.readJson(path.join(store.deviceDir(udid), 'engine-fallback.json'))?.reason ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Start whichever engine was asked for.
|
|
172
|
+
*
|
|
173
|
+
* simframed unless told otherwise: it reads the framebuffer directly and is
|
|
174
|
+
* roughly thirty times faster per frame. The simctl loop stays reachable with
|
|
175
|
+
* `engine: 'simctl'`, and is used automatically when the daemon cannot be
|
|
176
|
+
* built — a machine with no Swift toolchain still has to work.
|
|
177
|
+
*/
|
|
178
|
+
async function startEngine(udid, options) {
|
|
179
|
+
if ((options.engine ?? 'simframed') === 'simframed') {
|
|
180
|
+
const built = await engine.ensureBuilt();
|
|
181
|
+
if (built.ok) {
|
|
182
|
+
engineFallbackReason = null;
|
|
183
|
+
recordFallback(udid, null);
|
|
184
|
+
engine.spawnDaemon(udid, options);
|
|
185
|
+
return 'simframed';
|
|
186
|
+
}
|
|
187
|
+
engineFallbackReason = built.reason ?? 'simframed unavailable';
|
|
188
|
+
recordFallback(udid, engineFallbackReason);
|
|
189
|
+
}
|
|
190
|
+
spawnNodeDaemon(udid, options);
|
|
191
|
+
return 'simctl';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function spawnNodeDaemon(udid, options) {
|
|
113
195
|
const args = [CLI, 'daemon', udid];
|
|
114
196
|
for (const key of ['fps', 'maxDim', 'ringSize', 'idleExitMs']) {
|
|
115
197
|
if (options[key] != null) args.push(`--${key}=${options[key]}`);
|
|
@@ -164,6 +246,21 @@ function readLogTail(file, lines = 6) {
|
|
|
164
246
|
/** A frame this old means the capture loop is wedged, not that the screen is calm. */
|
|
165
247
|
export const STALE_FRAME_MS = 2500;
|
|
166
248
|
|
|
249
|
+
/**
|
|
250
|
+
* How still the screen must be before a frame may be used to key screen memory.
|
|
251
|
+
*
|
|
252
|
+
* A screen map describes a screen, so it must be built from a frame that shows
|
|
253
|
+
* one — not from the middle of a transition, where the layout belongs to
|
|
254
|
+
* neither the screen you left nor the one you are arriving at. Without this,
|
|
255
|
+
* the capture rate leaks into the hit rate: a faster loop samples more
|
|
256
|
+
* transitional frames and remembers more layouts that will never recur.
|
|
257
|
+
*
|
|
258
|
+
* Phase 4 replaces this with the real settle detector, which can tell a
|
|
259
|
+
* spinner from a still screen. Until then, "nothing moved for a while" is
|
|
260
|
+
* enough to decouple memory from frame rate.
|
|
261
|
+
*/
|
|
262
|
+
export const MEMORY_SETTLE_MS = 250;
|
|
263
|
+
|
|
167
264
|
/** Below this a "change" is a clock digit or a caret, not a new screen. */
|
|
168
265
|
export const MINOR_CHANGE = 0.004;
|
|
169
266
|
export const MAJOR_CHANGE = 0.03;
|
|
@@ -180,7 +277,17 @@ export function liveness(udid, state) {
|
|
|
180
277
|
if (!running) {
|
|
181
278
|
return { ok: false, ageMs, note: 'the capture loop has died; the frame you are looking at is the last one it wrote' };
|
|
182
279
|
}
|
|
183
|
-
|
|
280
|
+
// Frame age means "stalled" only for a fixed-rate loop.
|
|
281
|
+
//
|
|
282
|
+
// simframed captures on damage, so a screen that is genuinely still produces
|
|
283
|
+
// no frames at all — which is precisely the state `settle` exists to detect.
|
|
284
|
+
// Treating that as a stall made a flow fail on a static page with "capture
|
|
285
|
+
// loop is stalled: newest frame is 2984ms old" immediately after a step had
|
|
286
|
+
// succeeded. The pid check above is the honest liveness signal for this
|
|
287
|
+
// engine; the heartbeat file cannot help, because clients write it, not the
|
|
288
|
+
// daemon.
|
|
289
|
+
const damageDriven = engine.runningEngine(udid) === 'simframed';
|
|
290
|
+
if (!damageDriven && ageMs > STALE_FRAME_MS) {
|
|
184
291
|
return { ok: false, ageMs, note: `capture loop is stalled: newest frame is ${ageMs}ms old` };
|
|
185
292
|
}
|
|
186
293
|
return { ok: true, ageMs, note: null };
|
|
@@ -577,15 +684,45 @@ export async function getFrameAt(deviceQuery, { msAgo = 0, options } = {}) {
|
|
|
577
684
|
* A screen seen for the first time pays once to build its map, and every later
|
|
578
685
|
* visit is a file read.
|
|
579
686
|
*/
|
|
580
|
-
|
|
581
|
-
|
|
687
|
+
/**
|
|
688
|
+
* Wait, briefly, for a frame that is holding still. Returns whatever the newest
|
|
689
|
+
* frame is once the screen settles or the budget runs out, saying which.
|
|
690
|
+
*/
|
|
691
|
+
export async function settledState(udid, { settleMs = MEMORY_SETTLE_MS, timeoutMs = 1500 } = {}) {
|
|
692
|
+
const p = store.paths(udid);
|
|
693
|
+
const deadline = Date.now() + timeoutMs;
|
|
694
|
+
let state = store.readJson(p.state);
|
|
695
|
+
while (Date.now() < deadline) {
|
|
696
|
+
state = store.readJson(p.state) ?? state;
|
|
697
|
+
// The daemon runs a real settle detector that can tell a spinner from a
|
|
698
|
+
// still screen. Prefer it; the duration check is the fallback for the
|
|
699
|
+
// simctl engine, which has no such thing.
|
|
700
|
+
if (state?.settled === true) return { state, settled: true };
|
|
701
|
+
if (state && state.settled === undefined && state.stableForMs >= settleMs) {
|
|
702
|
+
return { state, settled: true };
|
|
703
|
+
}
|
|
704
|
+
await sleep(40);
|
|
705
|
+
}
|
|
706
|
+
return { state, settled: false };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export async function locate(
|
|
710
|
+
deviceQuery,
|
|
711
|
+
query,
|
|
712
|
+
{ index, refresh = false, useAx = true, useOcr = true, settleMs = MEMORY_SETTLE_MS, options } = {},
|
|
713
|
+
) {
|
|
714
|
+
const { device, state: firstState } = await ensureDaemon(deviceQuery, options);
|
|
582
715
|
const udid = device.udid;
|
|
716
|
+
// Key memory off a settled frame, never off whichever frame happened to be
|
|
717
|
+
// newest, so the capture rate cannot change what gets remembered.
|
|
718
|
+
const { state, settled } = await settledState(udid, { settleMs });
|
|
719
|
+
const current = state ?? firstState;
|
|
583
720
|
let entry = null;
|
|
584
721
|
let from = 'memory';
|
|
585
722
|
let distance = 0;
|
|
586
723
|
|
|
587
724
|
if (!refresh) {
|
|
588
|
-
const near = screenmap.recallNearest(udid,
|
|
725
|
+
const near = screenmap.recallNearest(udid, current.layoutHash);
|
|
589
726
|
if (near) {
|
|
590
727
|
entry = near.entry;
|
|
591
728
|
distance = near.distance;
|
|
@@ -593,34 +730,59 @@ export async function locate(deviceQuery, query, { index, refresh = false, useAx
|
|
|
593
730
|
}
|
|
594
731
|
|
|
595
732
|
if (!entry) {
|
|
596
|
-
const geo = await deviceGeometry(udid,
|
|
733
|
+
const geo = await deviceGeometry(udid, current);
|
|
597
734
|
entry = await screenmap.build(udid, {
|
|
598
|
-
hash:
|
|
599
|
-
layoutHash:
|
|
600
|
-
fullFrame: await fullFrameFor(udid,
|
|
735
|
+
hash: current.hash,
|
|
736
|
+
layoutHash: current.layoutHash,
|
|
737
|
+
fullFrame: await fullFrameFor(udid, current),
|
|
601
738
|
density: geo.density,
|
|
602
739
|
screen: { width: geo.pointWidth, height: geo.pointHeight },
|
|
603
740
|
useAx,
|
|
604
741
|
useOcr,
|
|
742
|
+
// A map built while the screen was moving describes nothing that will
|
|
743
|
+
// recur, so it is used for this call and then thrown away.
|
|
744
|
+
persist: settled,
|
|
605
745
|
});
|
|
606
|
-
from = 'built';
|
|
746
|
+
from = settled ? 'built' : 'built-unsettled';
|
|
607
747
|
}
|
|
608
748
|
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
749
|
+
const screenSize = { width: current.width, height: current.height };
|
|
750
|
+
const geo = await deviceGeometry(udid, current);
|
|
751
|
+
const points = { width: geo.pointWidth, height: geo.pointHeight };
|
|
752
|
+
|
|
753
|
+
// Intent resolution rather than string matching: it understands verbs
|
|
754
|
+
// ("tap Save"), typos, icon-only controls by synonym ("back"), and where on
|
|
755
|
+
// screen the caller meant ("Assets tab").
|
|
756
|
+
if (index == null) {
|
|
757
|
+
const outcome = matching.resolve(entry.targets, query, { screen: points });
|
|
758
|
+
if (outcome.status === 'ambiguous') {
|
|
759
|
+
const list = outcome.alternatives
|
|
760
|
+
.map((a, i) => `[${i}] "${a.label}" (${a.x},${a.y}) ${a.region ?? 'content'} ${a.score}`)
|
|
618
761
|
.join(', ');
|
|
619
762
|
throw new Error(
|
|
620
|
-
`"${query}" matches ${
|
|
763
|
+
`"${query}" matches ${outcome.alternatives.length} things on this screen — say which, or pass index: ${list}`,
|
|
621
764
|
);
|
|
622
765
|
}
|
|
766
|
+
if (outcome.status === 'ok') {
|
|
767
|
+
return {
|
|
768
|
+
device, state: current, entry, target: outcome.target, from, distance, settled,
|
|
769
|
+
score: outcome.score, reasons: outcome.reasons, alternatives: outcome.alternatives,
|
|
770
|
+
screens: screenmap.stats(udid).screens,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
// Nothing scored well enough. Falling through to plain substring matching
|
|
774
|
+
// here undoes every guard above — it has no off-screen filter and no
|
|
775
|
+
// coverage weighting, and it is what returned a scrolled-away list row for
|
|
776
|
+
// "back". "Not found" is the correct answer.
|
|
777
|
+
const sample = entry.targets
|
|
778
|
+
.filter((t) => t.label && t.y >= 0 && t.y <= points.height)
|
|
779
|
+
.slice(0, 12)
|
|
780
|
+
.map((t) => t.label.slice(0, 24))
|
|
781
|
+
.join(', ');
|
|
782
|
+
throw new Error(`"${query}" is not on this screen. Visible: ${sample || '(nothing readable)'}`);
|
|
623
783
|
}
|
|
784
|
+
|
|
785
|
+
const candidates = screenmap.rank(entry, query);
|
|
624
786
|
const target = index != null ? candidates[index] : candidates[0];
|
|
625
787
|
if (!target) {
|
|
626
788
|
const sample = entry.targets
|
|
@@ -632,7 +794,114 @@ export async function locate(deviceQuery, query, { index, refresh = false, useAx
|
|
|
632
794
|
`"${query}" is not on this screen. Visible: ${sample || '(nothing readable)'}`,
|
|
633
795
|
);
|
|
634
796
|
}
|
|
635
|
-
return { device, state, entry, target, from, distance, screens: screenmap.stats(udid).screens };
|
|
797
|
+
return { device, state: current, entry, target, from, distance, settled, screens: screenmap.stats(udid).screens };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* What screen this is, structurally.
|
|
802
|
+
*
|
|
803
|
+
* Uses the screen map — recalled when the pixel index finds one, built when it
|
|
804
|
+
* does not. The pixel hash is what makes the lookup cheap; the structural hash
|
|
805
|
+
* is what makes the answer right.
|
|
806
|
+
*/
|
|
807
|
+
/**
|
|
808
|
+
* Structural settle: how long to let a screen finish arriving before believing
|
|
809
|
+
* a fingerprint that nothing recognises.
|
|
810
|
+
*/
|
|
811
|
+
export const STRUCTURAL_SETTLE_MS = 300;
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* How long identity will wait for pixels to go quiet.
|
|
815
|
+
*
|
|
816
|
+
* Not the caller's timeout. A flow allows twelve seconds for a screen to
|
|
817
|
+
* arrive, but some screens never report settled at all — live content, a
|
|
818
|
+
* looping animation — and spending the flow's whole budget waiting for a flag
|
|
819
|
+
* that will not come cost 53s on a tour that had taken 7s. Long enough to
|
|
820
|
+
* outlast a normal transition, short enough that a screen which never settles
|
|
821
|
+
* is cheap to give up on.
|
|
822
|
+
*/
|
|
823
|
+
export const IDENTITY_SETTLE_TIMEOUT_MS = 2500;
|
|
824
|
+
const STRUCTURAL_SETTLE_SAMPLES = 3;
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* What screen is this?
|
|
828
|
+
*
|
|
829
|
+
* `settled` is a question about pixels, and it is answered before a screen has
|
|
830
|
+
* necessarily finished arriving: a list whose spinner has gone but whose rows
|
|
831
|
+
* have not landed is perfectly still and structurally wrong. Measured, that put
|
|
832
|
+
* one screen at 17 tokens on one visit and 7 on the next, which is the whole
|
|
833
|
+
* reason the same-screen floor sits at 0.41 instead of somewhere comfortable.
|
|
834
|
+
*
|
|
835
|
+
* So structural identity gets a structural settle of its own — but only where
|
|
836
|
+
* it costs something worth paying for. A fingerprint that matches a screen we
|
|
837
|
+
* already know is taken at face value; the risk is not that we mislabel a known
|
|
838
|
+
* screen, it is that a half-drawn one becomes a new node nobody can navigate
|
|
839
|
+
* to. Novel fingerprints, and only those, are re-sampled until two consecutive
|
|
840
|
+
* readings agree.
|
|
841
|
+
*/
|
|
842
|
+
export async function screenIdentity(deviceQuery, { options, confirmNovel = true, settleMs, timeoutMs } = {}) {
|
|
843
|
+
const { device, state } = await ensureDaemon(deviceQuery, options);
|
|
844
|
+
const udid = device.udid;
|
|
845
|
+
|
|
846
|
+
const read = async ({ fresh = false } = {}) => {
|
|
847
|
+
// Settle with the caller's patience, not a default. settledState times out
|
|
848
|
+
// at 1.5s on its own, so inside a flow that allows twelve seconds this was
|
|
849
|
+
// calling a screen unsettled while the flow was still happily waiting for
|
|
850
|
+
// it — and an unsettled screen records no edge, so the graph learned
|
|
851
|
+
// nothing and every later step read `unverified`.
|
|
852
|
+
const { state: settledFrame, settled } = await settledState(udid, {
|
|
853
|
+
settleMs,
|
|
854
|
+
timeoutMs: Math.min(timeoutMs ?? IDENTITY_SETTLE_TIMEOUT_MS, IDENTITY_SETTLE_TIMEOUT_MS),
|
|
855
|
+
});
|
|
856
|
+
const current = settledFrame ?? state;
|
|
857
|
+
let entry = fresh ? null : screenmap.recallNearest(udid, current.layoutHash)?.entry;
|
|
858
|
+
if (!entry) {
|
|
859
|
+
const geo = await deviceGeometry(udid, current);
|
|
860
|
+
entry = await screenmap.build(udid, {
|
|
861
|
+
hash: current.hash,
|
|
862
|
+
layoutHash: current.layoutHash,
|
|
863
|
+
fullFrame: await fullFrameFor(udid, current),
|
|
864
|
+
density: geo.density,
|
|
865
|
+
screen: { width: geo.pointWidth, height: geo.pointHeight },
|
|
866
|
+
persist: settled,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
hash: entry.structuralHash,
|
|
871
|
+
tokens: entry.structuralTokens ?? [],
|
|
872
|
+
keyboard: Boolean(entry.keyboard),
|
|
873
|
+
layoutHash: current.layoutHash,
|
|
874
|
+
settled,
|
|
875
|
+
};
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
let identity = await read();
|
|
879
|
+
if (!confirmNovel) return { ...identity, confirmed: identity.settled };
|
|
880
|
+
// Being recognised is stronger evidence than the pixel settle flag: a
|
|
881
|
+
// fingerprint that matches a screen already trusted has nothing left to
|
|
882
|
+
// prove, and some screens (live content, a looping animation) never report
|
|
883
|
+
// settled at all. The gate exists to stop a half-drawn screen becoming a new
|
|
884
|
+
// node — not to re-interrogate a known one.
|
|
885
|
+
if (graph.nearestScreen(udid, identity)) return { ...identity, confirmed: true, known: true };
|
|
886
|
+
|
|
887
|
+
// Nothing recognises this, or the pixels have not gone quiet. Either way, make
|
|
888
|
+
// it prove it is the same screen twice running before it becomes a node.
|
|
889
|
+
for (let i = 1; i < STRUCTURAL_SETTLE_SAMPLES; i += 1) {
|
|
890
|
+
await sleep(STRUCTURAL_SETTLE_MS);
|
|
891
|
+
const again = await read({ fresh: true });
|
|
892
|
+
// Two readings agree if they are the same screen — the same test identity
|
|
893
|
+
// itself uses. Demanding an identical hash is a stricter question than the
|
|
894
|
+
// one being asked, and a row a grid-unit wider fails it.
|
|
895
|
+
const agrees = again.hash === identity.hash
|
|
896
|
+
|| fingerprint.similarity(again.tokens, identity.tokens) >= graph.SIMILARITY_THRESHOLD;
|
|
897
|
+
if (agrees) {
|
|
898
|
+
return { ...again, confirmed: true, known: Boolean(graph.nearestScreen(udid, again)) };
|
|
899
|
+
}
|
|
900
|
+
identity = again;
|
|
901
|
+
}
|
|
902
|
+
// Still moving structurally. Report the latest reading and say it is unproven,
|
|
903
|
+
// so callers can decline to record an edge to a screen that never held still.
|
|
904
|
+
return { ...identity, confirmed: false, known: false };
|
|
636
905
|
}
|
|
637
906
|
|
|
638
907
|
export { DEFAULTS, screenmap, store };
|
package/src/input.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// capability layered on top, so every entry point here has to answer "is this
|
|
3
3
|
// even available?" before it answers anything else.
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
|
+
import * as control from './control.js';
|
|
5
6
|
import { promisify } from 'node:util';
|
|
6
7
|
|
|
7
8
|
const run = promisify(execFile);
|
|
@@ -11,6 +12,28 @@ const IDB_HINT =
|
|
|
11
12
|
|
|
12
13
|
let driverCache = null;
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Which input driver to use for a device.
|
|
17
|
+
*
|
|
18
|
+
* simframed is preferred when its control socket is live: it needs no install,
|
|
19
|
+
* speaks points natively, and is the path that survives idb breaking on a new
|
|
20
|
+
* iOS. idb remains the fallback so a machine without the daemon still works.
|
|
21
|
+
*/
|
|
22
|
+
export async function driverFor(udid) {
|
|
23
|
+
if (udid && control.available(udid)) {
|
|
24
|
+
try {
|
|
25
|
+
const status = await control.status(udid);
|
|
26
|
+
if (status.input?.available) {
|
|
27
|
+
return { name: 'simframed', available: true, version: status.input.detail, reason: null, viaSocket: true };
|
|
28
|
+
}
|
|
29
|
+
return { name: 'simframed', available: false, version: null, reason: status.input?.detail ?? 'input unavailable', viaSocket: true };
|
|
30
|
+
} catch {
|
|
31
|
+
/* daemon went away mid-call; fall through to idb */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return detectDriver();
|
|
35
|
+
}
|
|
36
|
+
|
|
14
37
|
/** @returns {Promise<{name: string, available: boolean, version: string|null, reason: string|null}>} */
|
|
15
38
|
export async function detectDriver({ refresh = false } = {}) {
|
|
16
39
|
if (driverCache && !refresh) return driverCache;
|
|
@@ -68,6 +91,28 @@ export async function screenInfo(udid, { refresh = false } = {}) {
|
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
async function readScreenInfo(udid) {
|
|
94
|
+
// Ask the daemon first. It holds the device's own point size and scale, which
|
|
95
|
+
// makes it both authoritative and free — and it means geometry no longer
|
|
96
|
+
// needs idb at all. Going to idb first meant a machine without idb could
|
|
97
|
+
// capture and tap perfectly well but could not run a verified flow, because
|
|
98
|
+
// building a screen map needs the point size.
|
|
99
|
+
if (control.available(udid)) {
|
|
100
|
+
try {
|
|
101
|
+
const { device } = await control.status(udid);
|
|
102
|
+
if (device?.pointWidth && device?.pointHeight) {
|
|
103
|
+
const density = device.scale ?? 1;
|
|
104
|
+
return {
|
|
105
|
+
pixelWidth: Math.round(device.pointWidth * density),
|
|
106
|
+
pixelHeight: Math.round(device.pointHeight * density),
|
|
107
|
+
density,
|
|
108
|
+
pointWidth: device.pointWidth,
|
|
109
|
+
pointHeight: device.pointHeight,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
/* daemon went away mid-call; fall through to idb */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
71
116
|
const out = await idb(['describe', '--json', '--udid', udid]);
|
|
72
117
|
const info = JSON.parse(out.trim().split('\n').filter(Boolean).pop());
|
|
73
118
|
const dims = info.screen_dimensions || {};
|
|
@@ -177,10 +222,15 @@ export function centerOf(node) {
|
|
|
177
222
|
}
|
|
178
223
|
|
|
179
224
|
export async function tapPoint(udid, x, y, { durationMs } = {}) {
|
|
180
|
-
const
|
|
225
|
+
const point = { x: Math.round(x), y: Math.round(y) };
|
|
226
|
+
if (control.available(udid)) {
|
|
227
|
+
await control.tap(udid, point.x, point.y, durationMs ? { durationMs } : {});
|
|
228
|
+
return point;
|
|
229
|
+
}
|
|
230
|
+
const args = ['ui', 'tap', '--udid', udid, String(point.x), String(point.y)];
|
|
181
231
|
if (durationMs) args.push('--duration', String(durationMs / 1000));
|
|
182
232
|
await idb(args);
|
|
183
|
-
return
|
|
233
|
+
return point;
|
|
184
234
|
}
|
|
185
235
|
|
|
186
236
|
export async function tapLabel(udid, query, { index, durationMs } = {}) {
|
|
@@ -191,6 +241,21 @@ export async function tapLabel(udid, query, { index, durationMs } = {}) {
|
|
|
191
241
|
}
|
|
192
242
|
|
|
193
243
|
export async function typeText(udid, value) {
|
|
244
|
+
if (control.available(udid)) {
|
|
245
|
+
// The daemon's paste path carries characters rather than key positions, so
|
|
246
|
+
// it is not reinterpreted by the device's keyboard layout.
|
|
247
|
+
await control.paste(udid, String(value));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
await idb(['ui', 'text', '--udid', udid, String(value)]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Key events rather than text: for shortcuts and search-as-you-type. */
|
|
254
|
+
export async function typeKeys(udid, value) {
|
|
255
|
+
if (control.available(udid)) {
|
|
256
|
+
await control.type(udid, String(value));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
194
259
|
await idb(['ui', 'text', '--udid', udid, String(value)]);
|
|
195
260
|
}
|
|
196
261
|
|
|
@@ -199,10 +264,23 @@ export async function pressKey(udid, keycode) {
|
|
|
199
264
|
}
|
|
200
265
|
|
|
201
266
|
export async function pressButton(udid, name) {
|
|
267
|
+
if (control.available(udid)) {
|
|
268
|
+
try {
|
|
269
|
+
await control.press(udid, String(name).toLowerCase());
|
|
270
|
+
return;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
// Only home is verified through Indigo; anything else falls back.
|
|
273
|
+
if (!(await detectDriver()).available) throw err;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
202
276
|
await idb(['ui', 'button', '--udid', udid, String(name).toUpperCase()]);
|
|
203
277
|
}
|
|
204
278
|
|
|
205
279
|
export async function swipe(udid, from, to, { durationMs = 300 } = {}) {
|
|
280
|
+
if (control.available(udid)) {
|
|
281
|
+
await control.swipe(udid, from, to, { durationMs });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
206
284
|
await idb([
|
|
207
285
|
'ui', 'swipe', '--udid', udid,
|
|
208
286
|
String(Math.round(from.x)), String(Math.round(from.y)),
|