simframe 0.6.2 → 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/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 './simctl.js';
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|simctl capture engine (default simframed)
55
- --fps=<n> capture rate while the screen is moving (simctl engine only)
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 running = engineModule.runningEngine(dev.udid) ?? 'simctl';
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 (running !== 'simframed') {
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=simctl — roughly 30x slower per frame. ` +
191
- (why ? `simframed unavailable: ${why}` : 'reason unrecorded; run simframe doctor'),
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 simulators (pass --all to list every device)');
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
- try {
752
- add('xcrun', 'ok', execFileSync('xcrun', ['--version'], { encoding: 'utf8' }).trim().split('\n')[0]);
753
- } catch (err) {
754
- add('xcrun', 'fail', err.message);
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
- add('booted simulator', booted.length ? 'ok' : 'warn',
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) ?? 'simctl';
836
+ const captureEngine = engineModule.runningEngine(d.udid) ?? bestEngine;
825
837
  const daemon = captureEngine === 'simframed';
826
- const why = captureEngine === 'simctl' ? api.fallbackReason(d.udid) : null;
827
- add(`capture engine (${d.name})`, captureEngine === 'simframed' ? 'ok' : 'warn',
828
- captureEngine === 'simframed'
829
- ? 'simframed'
830
- : `simctl — roughly 30x slower per frame${why ? `; simframed unavailable: ${why}` : '. Run simframe start to see why'}`,
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
- add(`input driver (${d.name})`, driver.available ? (driver.name === 'simframed' ? 'ok' : 'warn') : 'warn',
833
- driver.available ? `${driver.name}: ${driver.version}` : driver.reason,
834
- { key: 'input.driver', value: driver.available ? driver.name : null });
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 './simctl.js';
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 `simctl`, the original loop that shells out for each screenshot. The
5
- // daemon is the default because it is roughly thirty times faster, but the old
6
- // loop stays reachable — a machine without a Swift toolchain, or an Xcode
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', 'simctl'];
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' : 'simctl';
111
+ return meta.options?.engine === 'simframed' ? 'simframed' : 'screenshot';
99
112
  }
@@ -20,8 +20,12 @@ import * as regions from './regions.js';
20
20
  *
21
21
  * 2 — elements with no visible footprint, and containers holding two or more
22
22
  * others, no longer enter identity: only one sensor can see either.
23
+ * 3 — a chrome label must be a name: at least two letters, and not a URL. A
24
+ * browser's address bar put "== example.com" into a screen's identity, so
25
+ * a different page read as a different screen, and OCR's ":" and "+" read
26
+ * off icons were identities of their own.
23
27
  */
24
- export const TOKEN_RULES_VERSION = 2;
28
+ export const TOKEN_RULES_VERSION = 3;
25
29
 
26
30
  /** Frames are quantised to this, so sub-pixel drift and a nudged row do not matter. */
27
31
  export const GRID = 24;
@@ -97,12 +101,36 @@ const MONTHS = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
97
101
  const WEEKDAYS = /\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*day?\b/i;
98
102
  const DATE_LIKE = /\d{1,4}[/.-]\d{1,2}([/.-]\d{1,4})?|\b\d{1,2}:\d{2}\b/;
99
103
 
104
+ /**
105
+ * A URL is the most volatile thing a nav bar can hold.
106
+ *
107
+ * Measured on Android, where a browser's address bar is chrome by every
108
+ * structural test there is: the screen's identity contained `"== example.com"`,
109
+ * so the same browser on a different page was a different screen, and every
110
+ * route through it broke on navigation. The `==` is OCR reading the lock icon.
111
+ *
112
+ * Matched after stripping the punctuation OCR decorates it with, and only when
113
+ * the whole label is the address — a sentence that happens to mention a domain
114
+ * is still a sentence.
115
+ */
116
+ const URL_LIKE = /^(https?:\/\/|www\.)|^[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)*\.[a-z]{2,}(\/\S*)?$/i;
117
+
118
+ /** How many letters a name has to have. One is a glyph, not a name. */
119
+ const NAME_MIN_LETTERS = 2;
120
+
100
121
  export function isVolatileLabel(label) {
101
122
  const text = String(label ?? '').trim();
102
123
  if (!text) return true;
103
124
  if (MONTHS.test(text) || WEEKDAYS.test(text) || DATE_LIKE.test(text)) return true;
125
+ // Strip what OCR hangs off an icon before asking whether the rest is an
126
+ // address: the observed label was `== example.com`.
127
+ const bare = text.replace(/^[^\p{L}\p{N}]+/u, '').replace(/[^\p{L}\p{N}/]+$/u, '');
128
+ if (URL_LIKE.test(bare)) return true;
104
129
  const letters = (text.match(/\p{L}/gu) ?? []).length;
105
130
  const digits = (text.match(/\p{N}/gu) ?? []).length;
131
+ // A label with no word in it is not a name for anything. OCR reads `:`, `+`,
132
+ // `...` and `—` off icons, and each of those became an identity of its own.
133
+ if (letters < NAME_MIN_LETTERS) return true;
106
134
  // Mostly digits: a count, a price, a phone number, an ID. "1020" and
107
135
  // "+1 (111) 111-1111" are both this; "Assets" is not.
108
136
  return digits > 0 && digits >= letters;
package/src/index.js CHANGED
@@ -19,7 +19,7 @@ import * as graph from './graph.js';
19
19
  import * as matching from './matching.js';
20
20
  import * as refs from './refs.js';
21
21
  import * as screenmap from './screenmap.js';
22
- import { resolveDevice, resize, screenshot } from './simctl.js';
22
+ import { capabilitiesFor, resolveDevice, resize, screenshot } from './platform/index.js';
23
23
  import * as store from './store.js';
24
24
 
25
25
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -186,15 +186,24 @@ export function fallbackReason(udid) {
186
186
  }
187
187
 
188
188
  /**
189
- * Start whichever engine was asked for.
189
+ * Start whichever engine was asked for, out of the ones this device's platform
190
+ * has.
190
191
  *
191
- * simframed unless told otherwise: it reads the framebuffer 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.
192
+ * On iOS that is simframed unless told otherwise: it reads the framebuffer
193
+ * directly and is roughly thirty times faster per frame, and the screenshot
194
+ * loop stays reachable with `engine: 'screenshot'` for a machine with no Swift
195
+ * toolchain. On Android the loop is the only engine there is — and asking for
196
+ * simframed there is refused rather than attempted, because a Swift daemon
197
+ * built against CoreSimulator has nothing to say to an emulator, and the
198
+ * failure it produces says nothing useful about why.
195
199
  */
196
200
  async function startEngine(udid, options) {
197
- if ((options.engine ?? 'simframed') === 'simframed') {
201
+ const supported = capabilitiesFor(udid).captureEngines;
202
+ const wanted = engine.normalizeEngine(options.engine ?? supported[0]);
203
+ if (!supported.includes(wanted)) {
204
+ throw new Error(`this device cannot run the ${wanted} capture engine — it supports ${supported.join(', ')}`);
205
+ }
206
+ if (wanted === 'simframed') {
198
207
  const built = await engine.ensureBuilt();
199
208
  if (built.ok) {
200
209
  engineFallbackReason = null;
@@ -206,7 +215,7 @@ async function startEngine(udid, options) {
206
215
  recordFallback(udid, engineFallbackReason);
207
216
  }
208
217
  spawnNodeDaemon(udid, options);
209
- return 'simctl';
218
+ return 'screenshot';
210
219
  }
211
220
 
212
221
  function spawnNodeDaemon(udid, options) {
@@ -289,12 +298,38 @@ export function changeLevel(diff) {
289
298
  return 'none';
290
299
  }
291
300
 
301
+ /** How long a stall has been going on, in words an agent can act on. */
302
+ function stallNote(health) {
303
+ const forMs = Math.max(0, Date.now() - (health.since ?? Date.now()));
304
+ const parts = [`capture: stalled — the display surface has been unreadable for ${Math.round(forMs / 1000)}s`];
305
+ if (health.reattaches) parts.push(`${health.reattaches} re-attach${health.reattaches === 1 ? '' : 'es'} did not help`);
306
+ if (health.reason) parts.push(String(health.reason).slice(0, 120));
307
+ // The cure is the user's to apply. Saying so is the difference between an
308
+ // agent that reports "the simulator is wedged" and one that retries a tap
309
+ // twenty times because nothing appeared to change.
310
+ parts.push('only restarting the device is known to cure it');
311
+ return parts.join('; ');
312
+ }
313
+
292
314
  export function liveness(udid, state) {
293
315
  const ageMs = Date.now() - state.capturedAt;
294
316
  const { running } = daemonStatus(udid);
317
+ // A wedged device and a quiet one look identical from the frames alone: both
318
+ // produce nothing. The difference is that a wedged one is failing reads, and
319
+ // only the capture loop knows that, so it writes it down.
320
+ const health = store.captureHealth(udid);
321
+ const stalled = Boolean(health?.stalled);
295
322
  if (!running) {
296
- return { ok: false, ageMs, note: 'the capture loop has died; the frame you are looking at is the last one it wrote' };
323
+ return {
324
+ ok: false,
325
+ ageMs,
326
+ stalled,
327
+ note: stalled
328
+ ? `the capture loop has died, and it was stalled before it did — ${stallNote(health)}`
329
+ : 'the capture loop has died; the frame you are looking at is the last one it wrote',
330
+ };
297
331
  }
332
+ if (stalled) return { ok: false, ageMs, stalled: true, note: stallNote(health) };
298
333
  // Frame age means "stalled" only for a fixed-rate loop.
299
334
  //
300
335
  // simframed captures on damage, so a screen that is genuinely still produces
@@ -306,9 +341,9 @@ export function liveness(udid, state) {
306
341
  // daemon.
307
342
  const damageDriven = engine.runningEngine(udid) === 'simframed';
308
343
  if (!damageDriven && ageMs > STALE_FRAME_MS) {
309
- return { ok: false, ageMs, note: `capture loop is stalled: newest frame is ${ageMs}ms old` };
344
+ return { ok: false, ageMs, stalled: false, note: `capture loop is stalled: newest frame is ${ageMs}ms old` };
310
345
  }
311
- return { ok: true, ageMs, note: null };
346
+ return { ok: true, ageMs, stalled: false, note: null };
312
347
  }
313
348
 
314
349
  /**
@@ -502,7 +537,16 @@ export async function waitFor(
502
537
  // a button changing state. Waiting the full timeout for a change that
503
538
  // will never be visible turns a 100ms action into a 12s one, so give up
504
539
  // early and say so, rather than silently burning the clock.
505
- if (!sawChange && Date.now() - startedAt > reactionMs && state.stableForMs >= stableMs) {
540
+ //
541
+ // But "nothing changed" is a claim about something observed, and with no
542
+ // frame captured since this call began, nothing has been. The screenshot
543
+ // engine idles at 1.5 fps, so a 500 ms reaction window expired before the
544
+ // first new frame existed: a tap that opened a whole activity was
545
+ // reported as having no visible effect, and the text meant for the field
546
+ // it opened was typed into nothing. Damage-driven capture on iOS hid this
547
+ // by being fast.
548
+ const observedSomething = state.seq - startSeq >= 1;
549
+ if (!sawChange && observedSomething && Date.now() - startedAt > reactionMs && state.stableForMs >= stableMs) {
506
550
  return done(false, { noVisibleChange: true });
507
551
  }
508
552
 
@@ -724,6 +768,31 @@ export async function settledState(udid, { settleMs = MEMORY_SETTLE_MS, timeoutM
724
768
  return { state, settled: false };
725
769
  }
726
770
 
771
+ /**
772
+ * Read the screen with the perception layers pinned, and keep nothing.
773
+ *
774
+ * Every other path decides for itself which layers to read, which is right for
775
+ * doing work and useless for measuring: the question "what is the accessibility
776
+ * tier worth" needs the same frame read twice, once with it and once without.
777
+ * `persist: false` so measuring teaches the graph nothing.
778
+ */
779
+ export async function readScreenWith(deviceQuery, { useAx = true, useOcr = true, options } = {}) {
780
+ const { device, state } = await ensureDaemon(deviceQuery, options);
781
+ const udid = device.udid;
782
+ const geo = await deviceGeometry(udid, state);
783
+ const entry = await screenmap.build(udid, {
784
+ hash: state.hash,
785
+ layoutHash: state.layoutHash,
786
+ fullFrame: await fullFrameFor(udid, state),
787
+ density: geo.density,
788
+ screen: { width: geo.pointWidth, height: geo.pointHeight },
789
+ useAx,
790
+ useOcr,
791
+ persist: false,
792
+ });
793
+ return { device, entry, points: { width: geo.pointWidth, height: geo.pointHeight } };
794
+ }
795
+
727
796
  export async function locate(
728
797
  deviceQuery,
729
798
  query,
package/src/input.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // even available?" before it answers anything else.
4
4
  import { execFile } from 'node:child_process';
5
5
  import * as control from './control.js';
6
+ import { geometryFor, inputDriverFor } from './platform/index.js';
6
7
  import { promisify } from 'node:util';
7
8
 
8
9
  const run = promisify(execFile);
@@ -20,6 +21,12 @@ let driverCache = null;
20
21
  * iOS. idb remains the fallback so a machine without the daemon still works.
21
22
  */
22
23
  export async function driverFor(udid) {
24
+ // A platform that carries its own input path answers first: there is no
25
+ // daemon to ask and no idb to fall back to, and reporting either for an
26
+ // Android emulator is how doctor came to claim "input driver: idb" about a
27
+ // tool that has never spoken to one.
28
+ const own = udid ? inputDriverFor(udid) : null;
29
+ if (own) return { name: own.id, available: true, version: own.detail, reason: null, viaSocket: false };
23
30
  if (udid && control.available(udid)) {
24
31
  try {
25
32
  const status = await control.status(udid);
@@ -140,6 +147,15 @@ export async function screenInfo(udid, { refresh = false } = {}) {
140
147
  }
141
148
 
142
149
  async function readScreenInfo(udid) {
150
+ // The platform first, where it can answer at all: on Android it is the only
151
+ // source, and the alternative is `deviceGeometry`'s last-resort guess, which
152
+ // is an iPhone's numbers and silently wrong for everything else.
153
+ try {
154
+ const geo = await geometryFor(udid);
155
+ if (geo?.pointWidth && geo?.pointHeight) return geo;
156
+ } catch {
157
+ /* the backend could not say; the daemon or idb may still be able to */
158
+ }
143
159
  // Ask the daemon first. It holds the device's own point size and scale, which
144
160
  // makes it both authoritative and free — and it means geometry no longer
145
161
  // needs idb at all. Going to idb first meant a machine without idb could
@@ -300,6 +316,11 @@ export function centerOf(node) {
300
316
 
301
317
  export async function tapPoint(udid, x, y, { durationMs } = {}) {
302
318
  const point = { x: Math.round(x), y: Math.round(y) };
319
+ const own = inputDriverFor(udid);
320
+ if (own) {
321
+ await own.tap(udid, point.x, point.y, durationMs ? { durationMs } : {});
322
+ return point;
323
+ }
303
324
  if (control.available(udid)) {
304
325
  await control.tap(udid, point.x, point.y, durationMs ? { durationMs } : {});
305
326
  return point;
@@ -318,6 +339,15 @@ export async function tapLabel(udid, query, { index, durationMs } = {}) {
318
339
  }
319
340
 
320
341
  export async function typeText(udid, value) {
342
+ const own = inputDriverFor(udid);
343
+ if (own) {
344
+ // No pasteboard on Android (docs/DEFERRED.md), so exact text goes through
345
+ // the same keystroke path as everything else. `event text` carries
346
+ // characters rather than key positions, so a non-Latin host layout does not
347
+ // reinterpret them — which is the reason the pasteboard exists on iOS.
348
+ await own.text(udid, String(value));
349
+ return;
350
+ }
321
351
  if (control.available(udid)) {
322
352
  // The daemon's paste path carries characters rather than key positions, so
323
353
  // it is not reinterpreted by the device's keyboard layout.
@@ -329,6 +359,11 @@ export async function typeText(udid, value) {
329
359
 
330
360
  /** Key events rather than text: for shortcuts and search-as-you-type. */
331
361
  export async function typeKeys(udid, value) {
362
+ const own = inputDriverFor(udid);
363
+ if (own) {
364
+ await own.text(udid, String(value));
365
+ return;
366
+ }
332
367
  if (control.available(udid)) {
333
368
  await control.type(udid, String(value));
334
369
  return;
@@ -337,6 +372,11 @@ export async function typeKeys(udid, value) {
337
372
  }
338
373
 
339
374
  export async function pressKey(udid, keycode) {
375
+ const own = inputDriverFor(udid);
376
+ if (own) {
377
+ await own.key(udid, keycode);
378
+ return;
379
+ }
340
380
  await idb(['ui', 'key', '--udid', udid, String(keycode)]);
341
381
  }
342
382
 
@@ -363,6 +403,13 @@ export async function resetSession(udid) {
363
403
  }
364
404
 
365
405
  export async function pressButton(udid, name) {
406
+ const own = inputDriverFor(udid);
407
+ if (own) {
408
+ // Android's whole key vocabulary is safe to offer: `input keyevent` takes
409
+ // names through a public API, so unlike Indigo there is nothing to guess.
410
+ await own.key(udid, name);
411
+ return;
412
+ }
366
413
  if (control.available(udid)) {
367
414
  try {
368
415
  await control.press(udid, String(name).toLowerCase());
@@ -376,6 +423,11 @@ export async function pressButton(udid, name) {
376
423
  }
377
424
 
378
425
  export async function swipe(udid, from, to, { durationMs = 300 } = {}) {
426
+ const own = inputDriverFor(udid);
427
+ if (own) {
428
+ await own.swipe(udid, from, to, { durationMs });
429
+ return;
430
+ }
379
431
  if (control.available(udid)) {
380
432
  await control.swipe(udid, from, to, { durationMs });
381
433
  return;
package/src/mcp.js CHANGED
@@ -14,14 +14,14 @@ import * as actions from './actions.js';
14
14
  import * as api from './index.js';
15
15
  import * as input from './input.js';
16
16
  import * as navigate from './navigate.js';
17
- import { bootedDevices, PERMISSION_SERVICES } from './simctl.js';
17
+ import { bootedDevices, permissionServices } from './platform/index.js';
18
18
  import * as store from './store.js';
19
19
  import * as view from './view.js';
20
20
 
21
21
  const deviceProp = {
22
22
  device: {
23
23
  type: 'string',
24
- description: 'Simulator UDID or name substring. Defaults to the booted simulator.',
24
+ description: 'Device UDID or name substring — a simulator udid or an emulator serial. Defaults to the booted device.',
25
25
  },
26
26
  };
27
27
 
@@ -180,7 +180,7 @@ const TOOLS = [
180
180
  },
181
181
  {
182
182
  name: 'sim_open_url',
183
- description: 'Open a URL or deep link on the simulator — the fastest way to reach a screen when the app has a link for it.',
183
+ description: 'Open a URL or deep link on the device — the fastest way to reach a screen when the app has a link for it.',
184
184
  inputSchema: {
185
185
  type: 'object',
186
186
  properties: { ...deviceProp, url: { type: 'string' } },
@@ -189,7 +189,7 @@ const TOOLS = [
189
189
  },
190
190
  {
191
191
  name: 'sim_permission',
192
- description: `Grant, revoke or reset a privacy permission for an app. Do this instead of tapping the system alert: the alert is not part of the app under test, and its buttons move between iOS versions. Services: ${PERMISSION_SERVICES.join(', ')}.`,
192
+ description: `Grant, revoke or reset a privacy permission for an app. Do this instead of tapping the system alert: the alert is not part of the app under test, and its buttons move between OS versions. Not every service exists on every platform — the device's own backend refuses one it does not have. Services: ${permissionServices().join(', ')}.`,
193
193
  inputSchema: {
194
194
  type: 'object',
195
195
  properties: {
@@ -304,7 +304,7 @@ const TOOLS = [
304
304
  },
305
305
  {
306
306
  name: 'sim_devices',
307
- description: 'List booted iOS simulators that simframe can capture.',
307
+ description: 'List the booted devices simframe can drive — iOS simulators and Android emulators.',
308
308
  inputSchema: { type: 'object', properties: {} },
309
309
  },
310
310
  ];
@@ -826,7 +826,7 @@ function listStateDirs() {
826
826
 
827
827
  async function devices() {
828
828
  const booted = await bootedDevices();
829
- if (!booted.length) return { content: [text('no booted simulators')] };
829
+ if (!booted.length) return { content: [text('no booted devices')] };
830
830
  return {
831
831
  content: [text(booted.map((d) => `${d.name} · ${d.runtime} · ${d.udid}`).join('\n'))],
832
832
  };