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.
Files changed (40) hide show
  1. package/README.md +227 -53
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -0
  4. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  5. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  6. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +117 -0
  7. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -0
  8. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  9. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  10. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  11. package/native/simframed/Sources/SimframeCore/Element.swift +120 -0
  12. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  13. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  14. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  15. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  16. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  17. package/native/simframed/Sources/simframed/main.swift +357 -0
  18. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
  19. package/package.json +10 -4
  20. package/scripts/bench-flow.mjs +54 -0
  21. package/scripts/bench.sh +98 -0
  22. package/scripts/check-package.mjs +91 -0
  23. package/scripts/smoke.mjs +76 -0
  24. package/scripts/verify-baseline.mjs +65 -0
  25. package/src/actions.js +82 -5
  26. package/src/cli.js +347 -31
  27. package/src/control.js +76 -0
  28. package/src/daemon.js +8 -1
  29. package/src/engine.js +99 -0
  30. package/src/fingerprint.js +150 -0
  31. package/src/graph.js +409 -0
  32. package/src/index.js +292 -23
  33. package/src/input.js +80 -2
  34. package/src/matching.js +194 -0
  35. package/src/mcp.js +45 -1
  36. package/src/navigate.js +120 -0
  37. package/src/regions.js +90 -0
  38. package/src/screenmap.js +77 -21
  39. package/src/simctl.js +20 -4
  40. 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
- spawnDaemon(device.udid, options);
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
- const deadline = Date.now() + (options.readyTimeoutMs ?? 8000);
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
- function spawnDaemon(udid, options) {
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
- if (ageMs > STALE_FRAME_MS) {
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
- export async function locate(deviceQuery, query, { index, refresh = false, useAx = true, useOcr = true, options } = {}) {
581
- const { device, state } = await ensureDaemon(deviceQuery, options);
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, state.layoutHash);
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, state);
733
+ const geo = await deviceGeometry(udid, current);
597
734
  entry = await screenmap.build(udid, {
598
- hash: state.hash,
599
- layoutHash: state.layoutHash,
600
- fullFrame: await fullFrameFor(udid, state),
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 candidates = screenmap.rank(entry, query);
610
- if (candidates.length > 1 && index == null) {
611
- const top = candidates[0];
612
- const second = candidates[1];
613
- const decisive = screenmap.isInteractive(top) && !screenmap.isInteractive(second);
614
- if (!decisive) {
615
- const list = candidates
616
- .slice(0, 6)
617
- .map((t, i) => `[${i}] "${t.label}" (${t.x},${t.y}) ${t.type}/${t.source}`)
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 ${candidates.length} things on this screen — pass index to choose: ${list}`,
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 args = ['ui', 'tap', '--udid', udid, String(Math.round(x)), String(Math.round(y))];
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 { x: Math.round(x), y: Math.round(y) };
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)),