simframe 0.7.0 → 0.7.2

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 CHANGED
@@ -4,11 +4,11 @@
4
4
  [![npm](https://img.shields.io/npm/v/simframe.svg)](https://www.npmjs.com/package/simframe)
5
5
  [![license](https://img.shields.io/npm/l/simframe.svg)](./LICENSE)
6
6
 
7
- **Eyes, hands and memory for an agent driving the iOS Simulator.**
7
+ **Eyes, hands and memory for an agent driving the iOS Simulator or an Android emulator.**
8
8
 
9
9
  [Website](https://lvlrsajjad.github.io/simframe/) · [npm](https://www.npmjs.com/package/simframe)
10
10
 
11
- An agent driving the iOS Simulator is slow for three reasons, and only the first
11
+ An agent driving a simulator is slow for three reasons, and only the first
12
12
  one is obvious:
13
13
 
14
14
  1. **Every look is a wait.** `simctl io screenshot` costs ~130 ms of blocking
@@ -143,7 +143,7 @@ boundary hands it frames and nothing above it knows what a simulator is.
143
143
  | Read labels + coordinates from pixels | yes | the same Vision OCR + CV, off the same PNG |
144
144
  | Screen map, refs, screen memory, the graph | yes | unchanged above the boundary |
145
145
  | Tap, type, swipe, keys | yes | the console's `event mouse` as a real down/move/up, `event text` for characters, `input keyevent` for keys |
146
- | Clipboard | yes | the emulator's gRPC `setClipboard`, over `node:http2`, no dependency |
146
+ | Clipboard, and `paste` into a field | yes | the emulator's gRPC `setClipboard`, over `node:http2`, no dependency, then `KEYCODE_PASTE` to deliver it |
147
147
  | List/resolve devices, launch, terminate, open a URL, permissions | yes | `adb`, with the permission state read back off the device |
148
148
  | Accessibility tree | **not available (OCR + CV only)** | `uiautomator dump` costs **2,012 ms** a read, against 45 ms for the iOS tree. See [`docs/DEFERRED.md`](docs/DEFERRED.md) |
149
149
 
@@ -154,6 +154,17 @@ simframe ui --device=emulator-5554
154
154
  simframe do --device=emulator-5554 flow.json
155
155
  ```
156
156
 
157
+ A host with a booted simulator **and** a booted emulator has no default, and
158
+ simframe will not pick one for you: preferring iOS because it came first would
159
+ tap a simulator while you were driving an emulator, and acting on the wrong
160
+ device is worse than refusing. So a command with no device names both and stops.
161
+ `--device` answers it per command; `SIMFRAME_DEVICE` answers it per shell:
162
+
163
+ ```bash
164
+ export SIMFRAME_DEVICE=emulator-5554
165
+ simframe ui # the emulator, without saying so every time
166
+ ```
167
+
157
168
  The tree is a deliberate omission, not an oversight. Making it fast needs a
158
169
  resident instrumentation APK on the device — the shape uiautomator2, Maestro and
159
170
  Appium all converged on — and that would be simframe's first runtime artifact
@@ -517,8 +528,14 @@ simframe frame --out=now.png # newest frame, native resolution, to a file
517
528
  simframe strip --count=6 # contact sheet, for an animation
518
529
  simframe doctor --strict # any degraded layer is a non-zero exit
519
530
  simframe start / status / stop [--force] / devices
531
+ simframe ui --device=emulator-5554 # or export SIMFRAME_DEVICE once
520
532
  ```
521
533
 
534
+ `--device` takes `--device=X` and `--device X` alike. It used to take only the
535
+ first: the space form set the flag to `true` and then resolved a device named
536
+ "true", which is a poor answer to a flag `doctor`'s own advice tells you to
537
+ type.
538
+
522
539
  ### The Claude Code skill
523
540
 
524
541
  [`skills/simframe/SKILL.md`](skills/simframe/SKILL.md) teaches the CLI path
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "simframe",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "mcpName": "io.github.lvlrSajjad/simframe",
5
- "description": "Always-warm iOS Simulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
5
+ "description": "Always-warm iOS Simulator and Android emulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
6
6
  "keywords": [
7
7
  "ios",
8
8
  "simulator",
9
+ "android",
10
+ "emulator",
9
11
  "mcp",
10
12
  "model-context-protocol",
11
13
  "claude",
@@ -123,4 +123,19 @@ if (missing.length) {
123
123
  );
124
124
  process.exit(1);
125
125
  }
126
+ // The MCP Registry rejects a description over 100 characters, and it does so at
127
+ // `mcp-publisher validate` — after the tag is pushed and the release has begun.
128
+ // A 0.7.1 release found that out the hard way with a 113-character one. The
129
+ // constraint is the registry's; discovering it locally is this script's job.
130
+ const DESCRIPTION_MAX = 100;
131
+ const server = JSON.parse(fs.readFileSync(path.join(ROOT, 'server.json'), 'utf8'));
132
+ if (server.description.length > DESCRIPTION_MAX) {
133
+ console.error(
134
+ `server.json description is ${server.description.length} characters; the MCP Registry accepts ` +
135
+ `${DESCRIPTION_MAX}. It would fail at validate, with the tag already pushed.`,
136
+ );
137
+ process.exit(1);
138
+ }
139
+ console.log(`server.json description ${server.description.length}/${DESCRIPTION_MAX} chars`);
140
+
126
141
  console.log('ok — every build input ships');
package/src/actions.js CHANGED
@@ -6,7 +6,7 @@ import * as api from './index.js';
6
6
  import * as graph from './graph.js';
7
7
  import * as input from './input.js';
8
8
  import * as intent from './intent.js';
9
- import { launchApp, openUrl, setPasteboard, setPermission, terminateApp } from './platform/index.js';
9
+ import { launchApp, openUrl, setPermission, terminateApp } from './platform/index.js';
10
10
 
11
11
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
12
  const MAX_PAUSE_MS = 5000;
@@ -272,6 +272,33 @@ export async function runScript(
272
272
  };
273
273
  }
274
274
 
275
+ /**
276
+ * Tap a field and wait for it to take focus, for the steps that then put text
277
+ * in it.
278
+ *
279
+ * `locate`, not `tapLabel`: tapLabel asks the accessibility tree directly, so a
280
+ * field only OCR can see was untypeable and a selector (`#4`, `@x,y`) meant
281
+ * nothing here. The returned `quiet` says when the field never visibly took
282
+ * focus — usually fine, since a field that already had focus does not move, but
283
+ * also exactly what a tap that missed looks like, so the caller should be told.
284
+ */
285
+ async function focusField(deviceQuery, udid, step, ctx) {
286
+ const found = await api.locate(deviceQuery, step.into, { index: step.index, refresh: step.refresh });
287
+ await input.tapPoint(udid, found.target.x, found.target.y);
288
+ const focused = await api.waitFor(deviceQuery, {
289
+ mode: 'settle',
290
+ stableMs: FOCUS_STABLE_MS,
291
+ reactionMs: FOCUS_REACTION_MS,
292
+ timeoutMs: FOCUS_TIMEOUT_MS,
293
+ options: ctx.options,
294
+ });
295
+ return {
296
+ found,
297
+ where: `"${found.target.label}" at ${found.target.x},${found.target.y}`,
298
+ quiet: focused.satisfied ? '' : ' [the field did not visibly take focus]',
299
+ };
300
+ }
301
+
275
302
  async function runStep(deviceQuery, udid, step, ctx) {
276
303
  switch (step.action) {
277
304
  case 'tap': {
@@ -298,33 +325,25 @@ async function runStep(deviceQuery, udid, step, ctx) {
298
325
  }
299
326
  case 'type': {
300
327
  if (step.into) {
301
- // locate, not tapLabel: tapLabel asks the accessibility tree directly,
302
- // so a field that only OCR can see was untypeable, and a selector
303
- // (`#4`, `@x,y`) meant nothing here.
304
- const found = await api.locate(deviceQuery, step.into, { index: step.index, refresh: step.refresh });
305
- await input.tapPoint(udid, found.target.x, found.target.y);
306
- const focused = await api.waitFor(deviceQuery, {
307
- mode: 'settle',
308
- stableMs: FOCUS_STABLE_MS,
309
- reactionMs: FOCUS_REACTION_MS,
310
- timeoutMs: FOCUS_TIMEOUT_MS,
311
- options: ctx.options,
312
- });
328
+ const field = await focusField(deviceQuery, udid, step, ctx);
313
329
  await input.typeText(udid, step.text ?? step.value);
314
- // Say when the field never visibly took focus. It is usually fine — a
315
- // field that was already focused does not move — but it is also what a
316
- // tap that missed looks like, and the caller should be able to tell.
317
- const quiet = focused.satisfied ? '' : ' [the field did not visibly take focus]';
318
- return `typed into "${found.target.label}" at ${found.target.x},${found.target.y}${quiet}`;
330
+ return `typed into ${field.where}${field.quiet}`;
319
331
  }
320
332
  await input.typeText(udid, step.text ?? step.value);
321
333
  return 'typed text';
322
334
  }
323
335
  case 'paste': {
324
- // Long strings are much faster on the pasteboard than through the keyboard.
325
- await setPasteboard(udid, step.text ?? step.value);
326
- if (step.into) await input.tapLabel(udid, step.into, { index: step.index, durationMs: 900 });
327
- return 'placed text on the pasteboard';
336
+ // Long strings are much faster on the pasteboard than through the
337
+ // keyboard. `pasteText` delivers the keystroke as well as setting the
338
+ // pasteboard, and throws if it cannot — this step used to do neither and
339
+ // report success anyway.
340
+ if (step.into) {
341
+ const field = await focusField(deviceQuery, udid, step, ctx);
342
+ await input.pasteText(udid, step.text ?? step.value);
343
+ return `pasted into ${field.where}${field.quiet}`;
344
+ }
345
+ await input.pasteText(udid, step.text ?? step.value);
346
+ return 'pasted into the focused field';
328
347
  }
329
348
  case 'swipe': {
330
349
  const from = { x: step.from?.[0] ?? step.from?.x, y: step.from?.[1] ?? step.from?.y };
package/src/cli.js CHANGED
@@ -86,14 +86,39 @@ The reliable pattern around an action is:
86
86
  simframe state --since=$H
87
87
  `;
88
88
 
89
+ /**
90
+ * Flags that take a value, so `--flag value` can mean what it looks like.
91
+ *
92
+ * Deliberately not every flag: `--json`, `--refresh` and friends have a bare
93
+ * form, and letting those swallow the next argument would turn
94
+ * `simframe tap --refresh Save` into a tap on nothing.
95
+ */
96
+ const VALUE_FLAGS = new Set([
97
+ 'ago', 'count', 'detail', 'device', 'durationMs', 'engine', 'filter', 'fps', 'index', 'maxDim',
98
+ 'mode', 'out', 'ringSize', 'since', 'spanMs', 'stableMs', 'timeoutMs',
99
+ ]);
100
+
89
101
  function parseArgs(argv) {
90
102
  const flags = {};
91
103
  const positional = [];
92
- for (const arg of argv) {
104
+ for (let i = 0; i < argv.length; i += 1) {
105
+ const arg = argv[i];
93
106
  if (arg.startsWith('--')) {
94
107
  const [key, value] = arg.slice(2).split('=');
95
108
  const camel = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
96
- flags[camel] = value === undefined ? true : value;
109
+ if (value !== undefined) {
110
+ flags[camel] = value;
111
+ } else if (VALUE_FLAGS.has(camel) && argv[i + 1] != null && !argv[i + 1].startsWith('--')) {
112
+ // `--device X` as well as `--device=X`. Only for flags whose bare form
113
+ // means nothing: `simframe doctor --device B55AB0AE` used to set
114
+ // `device` to `true`, push the udid to positional, and resolve the
115
+ // literal string "true" — and doctor's own advice is to name a device
116
+ // with --device, which is exactly how someone would write it.
117
+ flags[camel] = argv[i + 1];
118
+ i += 1;
119
+ } else {
120
+ flags[camel] = true;
121
+ }
97
122
  } else {
98
123
  positional.push(arg);
99
124
  }
@@ -800,6 +825,25 @@ async function doctor({ json = false, strict = false, device } = {}) {
800
825
  const wanted = await resolveDevice(device);
801
826
  booted = booted.filter((d) => d.udid === wanted.udid);
802
827
  }
828
+ // Which devices get *probed*, as opposed to listed. The probes below start
829
+ // a capture loop and read frames, and doctor used to do that to every
830
+ // booted device on the host. On a shared machine that means starting a
831
+ // daemon on a colleague's simulator and capturing their screen to answer a
832
+ // question about this one. Listing is free and stays; probing is not, so
833
+ // without --device it goes to a device already running its own capture loop
834
+ // (nothing new is started), or to the only booted device, and otherwise to
835
+ // none, with a line saying which flag would pick one.
836
+ let probed = booted;
837
+ if (!device && booted.length > 1) {
838
+ probed = booted.filter((d) => engineModule.runningEngine(d.udid));
839
+ if (probed.length !== 1) {
840
+ probed = [];
841
+ add('device probes', 'warn',
842
+ `${booted.length} devices are booted and none is clearly yours — name one with --device ` +
843
+ 'to check its capture, input and accessibility layers',
844
+ { key: 'probes.skipped', value: booted.length });
845
+ }
846
+ }
803
847
  // `deviceNoun` earns its place here: one platform's devices are called by
804
848
  // its own word, and a mixed set by the neutral one. An emulator reported as
805
849
  // a "booted simulator" is the same small lie as an emulator reported as
@@ -807,7 +851,7 @@ async function doctor({ json = false, strict = false, device } = {}) {
807
851
  const nouns = [...new Set(booted.map((d) => capabilitiesFor(d.udid) && PLATFORMS[d.platform].deviceNoun))];
808
852
  add(`booted ${nouns.length === 1 ? nouns[0] : 'device'}`, booted.length ? 'ok' : 'warn',
809
853
  booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
810
- for (const d of booted) {
854
+ for (const d of probed) {
811
855
  const input = await import('./input.js');
812
856
  const control = await import('./control.js');
813
857
  // What this device's platform can do at all. Without asking, doctor
@@ -871,9 +915,9 @@ async function doctor({ json = false, strict = false, device } = {}) {
871
915
  ax.available ? `${ax.name}: ${ax.version}` : `unavailable: ${ax.reason}`,
872
916
  { key: 'ax.driver', value: ax.name });
873
917
  }
874
- if (booted.length) {
918
+ if (probed.length) {
875
919
  const t0 = Date.now();
876
- const res = await api.getFrame(booted[0].udid);
920
+ const res = await api.getFrame(probed[0].udid);
877
921
  add('capture', 'ok',
878
922
  `frame #${res.state.seq} ${res.width}x${res.height} in ${Date.now() - t0}ms (age ${res.ageMs}ms)`,
879
923
  { key: 'capture.frames', value: res.state.seq });
@@ -881,7 +925,7 @@ async function doctor({ json = false, strict = false, device } = {}) {
881
925
  // to ask the capture loop rather than look at the frames. `fail`, not
882
926
  // `warn`: nothing here is degraded-but-working, and the cure is a device
883
927
  // restart that simframe deliberately does not perform.
884
- for (const d of booted) {
928
+ for (const d of probed) {
885
929
  const live = api.liveness(d.udid, (await api.getState(d.udid)).state);
886
930
  if (live.stalled) add(`capture health (${d.name})`, 'fail', live.note, { key: 'capture.stalled', value: true });
887
931
  }
@@ -893,8 +937,21 @@ async function doctor({ json = false, strict = false, device } = {}) {
893
937
  // doctor is a diagnostic, not a way to start things. If it had to start a
894
938
  // daemon to answer "which engine is in use", it stops it again rather than
895
939
  // leaving a detached process behind.
940
+ //
941
+ // And it says when it could not. This was `catch { /* best effort */ }`, and
942
+ // best effort silently failed: a stop refused because another client holds
943
+ // the device left a capture loop running on a machine somebody else was
944
+ // using, with doctor reporting a clean bill of health. A diagnostic that
945
+ // leaves something behind has to name it.
896
946
  for (const udid of startedHere) {
897
- try { await api.stopDaemon(udid); } catch { /* best effort */ }
947
+ try {
948
+ await api.stopDaemon(udid);
949
+ } catch (err) {
950
+ add('cleanup', 'warn',
951
+ `started a capture loop on ${udid} to answer a question and could not stop it again ` +
952
+ `(${err.message}) — stop it with: simframe stop --device=${udid}`,
953
+ { key: 'cleanup.left', value: udid });
954
+ }
898
955
  }
899
956
 
900
957
  const failed = checks.filter((c) => c.level === 'fail');
package/src/daemon.js CHANGED
@@ -159,6 +159,12 @@ export async function runDaemon(device, options = {}) {
159
159
  store.writeCaptureHealth(udid, null);
160
160
  }
161
161
  consecutiveErrors = 0;
162
+ // And the clock, which it did not. A second stall then kept the first
163
+ // one's timestamp — `stalledSince ??=` only fills a null — and reported
164
+ // "unreadable for 4 hours" about a wedge four seconds old. The Swift loop
165
+ // resets it (main.swift), which is what made this a missed line rather
166
+ // than a difference of opinion between the two engines.
167
+ stalledSince = null;
162
168
 
163
169
  const hash = frameHash(bmp);
164
170
  const layout = layoutHash(bmp);
package/src/index.js CHANGED
@@ -223,9 +223,19 @@ function spawnNodeDaemon(udid, options) {
223
223
  for (const key of ['fps', 'maxDim', 'ringSize', 'idleExitMs']) {
224
224
  if (options[key] != null) args.push(`--${key}=${options[key]}`);
225
225
  }
226
+ // stderr goes to the device's own log, the way the Swift daemon's does
227
+ // (engine.js). It was `stdio: 'ignore'`, so anything this loop said about
228
+ // itself went to /dev/null — including the Android backend's notice that the
229
+ // console capture path had failed and it had fallen back to adb at five times
230
+ // the latency. A silent fallback is the failure mode this project has been
231
+ // bitten by twice; it does not get a third time for want of a file handle.
232
+ // The directory may not exist yet on a first start, and `writeCaptureHealth`
233
+ // already taught this project that an unwritable path here fails silently.
234
+ fs.mkdirSync(store.paths(udid).dir, { recursive: true });
235
+ const log = fs.openSync(store.paths(udid).log, 'a');
226
236
  const child = spawn(process.execPath, args, {
227
237
  detached: true,
228
- stdio: 'ignore',
238
+ stdio: ['ignore', log, log],
229
239
  env: process.env,
230
240
  });
231
241
  child.unref();
package/src/input.js CHANGED
@@ -3,7 +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
+ import { capabilitiesFor, geometryFor, inputDriverFor, setPasteboard } from './platform/index.js';
7
7
  import { promisify } from 'node:util';
8
8
 
9
9
  const run = promisify(execFile);
@@ -207,6 +207,14 @@ export async function describeAll(udid) {
207
207
  /* daemon went away mid-call; fall through to idb */
208
208
  }
209
209
  }
210
+ // A platform with no accessibility tree is not a machine missing idb.
211
+ // `doctor` learned that when it reported "input driver: idb" for an emulator;
212
+ // this path had not, so every Android screen map carried a note telling the
213
+ // reader to brew-install a tool that has never spoken to an Android device —
214
+ // and on a machine where idb *is* installed, spawned it against an emulator
215
+ // serial on every map build.
216
+ const ax = capabilitiesFor(udid).ax;
217
+ if (!ax.supported) throw new Error(`no accessibility tree on this device — ${ax.note}`);
210
218
  // Passing --json here yields empty output; the default already emits JSON.
211
219
  const out = await idb(['ui', 'describe-all', '--udid', udid]);
212
220
  const nodes = [];
@@ -357,6 +365,39 @@ export async function typeText(udid, value) {
357
365
  await idb(['ui', 'text', '--udid', udid, String(value)]);
358
366
  }
359
367
 
368
+ /**
369
+ * Put text on the pasteboard **and deliver it** into the focused field.
370
+ *
371
+ * The delivery is the whole point, and it is what was missing: the `paste` step
372
+ * used to set the pasteboard, long-press the field, and report success while
373
+ * the field stayed empty — on both platforms, not just the one it was filed
374
+ * against. iOS had the mechanism and did not use it (the daemon's `paste` is
375
+ * pbcopy *plus* Cmd-V) and Android had the keycode sitting unused in `KEYS`.
376
+ *
377
+ * When nothing can deliver the keystroke this throws rather than returning a
378
+ * cheerful description of half the job. A step that cannot do what it says has
379
+ * to say so; `type` still works on every device.
380
+ */
381
+ export async function pasteText(udid, value) {
382
+ const own = inputDriverFor(udid);
383
+ if (own?.key) {
384
+ // The clipboard goes over gRPC; KEYCODE_PASTE is what puts it in the field.
385
+ await setPasteboard(udid, String(value));
386
+ await own.key(udid, 'paste');
387
+ return;
388
+ }
389
+ if (control.available(udid)) {
390
+ // One round trip: the daemon copies and presses Cmd-V.
391
+ await control.paste(udid, String(value));
392
+ return;
393
+ }
394
+ await setPasteboard(udid, String(value));
395
+ throw new Error(
396
+ 'the text is on the pasteboard but nothing here can paste it: the keystroke needs the simframe ' +
397
+ 'daemon (start it with `simframe start`), or use `type`, which carries the characters itself',
398
+ );
399
+ }
400
+
360
401
  /** Key events rather than text: for shortcuts and search-as-you-type. */
361
402
  export async function typeKeys(udid, value) {
362
403
  const own = inputDriverFor(udid);
package/src/navigate.js CHANGED
@@ -51,6 +51,13 @@ export async function goto(deviceQuery, target, { options, ...runOptions } = {})
51
51
  return { ok: true, already: true, screen: found.name, steps: [] };
52
52
  }
53
53
 
54
+ // `hashTokens` returns null for an empty token set on purpose — a constant
55
+ // hash for "I could read nothing" is the self-confirming-emptiness bug. So a
56
+ // screen with no identity has to be reported, not sliced: this threw
57
+ // `Cannot read properties of null (reading 'slice')` instead of answering.
58
+ // Not hypothetical on Android, where README's own table puts the launcher at
59
+ // one token.
60
+ if (!here.hash) return { ok: false, reason: 'no-identity', to: found.name };
54
61
  const path_ = graph.route(udid, { hash: here.hash, tokens: here.tokens }, found.node.hash);
55
62
  if (!path_) return { ok: false, reason: 'no-route', from: here.hash.slice(0, 8), to: found.name };
56
63
 
@@ -65,7 +72,7 @@ export async function goto(deviceQuery, target, { options, ...runOptions } = {})
65
72
  steps,
66
73
  ranSteps: result.ranSteps,
67
74
  results: result.results,
68
- arrived: arrived.hash.slice(0, 8),
75
+ arrived: arrived.hash ? arrived.hash.slice(0, 8) : null,
69
76
  };
70
77
  }
71
78
 
@@ -10,6 +10,10 @@
10
10
  // below, and the reason two of them are not the obvious command:
11
11
  //
12
12
  // emulator console `screenrecord screenshot <dir>` 20 ms, written host-side
13
+ //
14
+ // Those are screenshot costs. A *frame* — screenshot plus the resize that makes
15
+ // it readable — measures 41 ms, which is the number README quotes; the two were
16
+ // quoted interchangeably in three files and agreed with each other in none.
13
17
  // adb exec-out screencap -p 113 ms, 9 KB over adb
14
18
  // adb exec-out screencap (raw RGBA, 3.7 MB) 218 ms — the transfer, not the encode
15
19
  // adb shell getprop x4 in one hop 28 ms
@@ -107,7 +111,14 @@ async function fetchDevices() {
107
111
  const serials = [];
108
112
  for (const line of stdout.split('\n').slice(1)) {
109
113
  const [serial, state] = line.trim().split(/\s+/);
110
- if (serial && state) serials.push({ serial, adbState: state });
114
+ // Only emulators. Physical devices are a stated non-goal, and this list is
115
+ // "devices simframe can drive" — `ownsUdid` claims emulator serials and
116
+ // nothing else, so listing a plugged-in phone here made listing and routing
117
+ // disagree: `devices` offered it, a bare `resolveDevice` could return it,
118
+ // and the tap that followed failed with a complaint about the serial rather
119
+ // than an honest "not supported". A unit test asserts the two agree; it
120
+ // passed only because nobody had a phone attached.
121
+ if (serial && state && ownsUdid(serial)) serials.push({ serial, adbState: state });
111
122
  }
112
123
  const out = [];
113
124
  for (const { serial, adbState } of serials) {
@@ -556,6 +567,12 @@ async function screenshot(udid, outFile, { mask: _mask = 'ignored' } = {}) {
556
567
  try {
557
568
  const { stdout } = await adb(udid, ['exec-out', 'screencap', '-p'], { encoding: 'buffer', timeout: 20_000 });
558
569
  fs.writeFileSync(outFile, stdout);
570
+ // stderr, not a store write: a backend below the boundary has no business
571
+ // knowing where simframe keeps its files. The capture loop is now spawned
572
+ // with its stderr pointed at `daemon.log` (src/index.js), which is what
573
+ // makes this line readable at all — it used to go to a daemon started
574
+ // `stdio: 'ignore'`, so a five-times-slower capture path could be in
575
+ // effect for a whole session with no trace of it anywhere.
559
576
  process.env.SIMFRAME_QUIET === '1' ||
560
577
  process.stderr.write(`simframe: emulator console unavailable (${consoleErr.message}); used adb screencap\n`);
561
578
  } catch (adbErr) {
@@ -796,7 +813,14 @@ async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst =
796
813
  }
797
814
 
798
815
  async function terminateApp(udid, bundleId) {
799
- await adb(udid, ['shell', 'am', 'force-stop', bundleId]);
816
+ // `am` reports failure on stdout and still exits 0 — the same trap launchApp
817
+ // and openUrl already check for. Without this, terminating a package that is
818
+ // not installed answered "terminated com.typo.app".
819
+ const { stdout, stderr } = await adb(udid, ['shell', 'am', 'force-stop', bundleId]);
820
+ const error = /^Error:.*$/m.exec(`${stdout}${stderr}`);
821
+ if (error) {
822
+ throw new Error(`could not terminate ${bundleId}: ${error[0].replace(/^Error:\s*/, '')}`);
823
+ }
800
824
  }
801
825
 
802
826
  async function openUrl(udid, url) {
@@ -928,10 +952,15 @@ function toolchain() {
928
952
  * There is no framebuffer engine, so `screenshot` is not a downgrade on this
929
953
  * platform and doctor must not report it as one.
930
954
  *
931
- * Input and the accessibility tree: not yet, and said so rather than answered
932
- * with the iOS driver's name. Both paths are measured and unwired — the console's
933
- * `event mouse` puts a real down/move/up on the touch screen in ~20 ms, and
934
- * `uiautomator dump` costs 2 s a read, which is the interesting problem.
955
+ * Input: yes, through the emulator console — `event mouse` puts a real
956
+ * down/move/up on the touch screen, and `event text` carries characters rather
957
+ * than key positions, so a non-Latin host layout cannot reinterpret them. This
958
+ * comment said "not yet" for a release after it shipped; a stale comment above
959
+ * a live value is worse than no comment, because the next reader believes it.
960
+ *
961
+ * The accessibility tree: no, and said so rather than answered with the iOS
962
+ * driver's name. `uiautomator dump` costs 2,012 ms a read, which is why it is
963
+ * not the answer; see docs/DEFERRED.md for the shape of the one that would be.
935
964
  */
936
965
  function capabilities() {
937
966
  return {
@@ -147,18 +147,30 @@ export async function bootedDevices(opts) {
147
147
  * device is the wrong-device bug wearing a different hat.
148
148
  *
149
149
  * Cross-platform ambiguity — one name matching a simulator and an emulator — is
150
- * reported rather than guessed at. Which of them a bare query should prefer is
151
- * a question for the step that adds the second backend, not one to invent an
152
- * answer to here.
150
+ * reported rather than guessed at, and so is a *bare* query on a host with a
151
+ * booted device on both platforms. That case is not exotic: it is the mixed
152
+ * setup the second backend exists for, and it used to break every command that
153
+ * did not name a device, with the word "undefined" standing in for the query.
154
+ *
155
+ * There is no defensible way to pick for you. Preferring iOS because it came
156
+ * first would silently tap a simulator while you were driving an emulator,
157
+ * which is the one failure this project will not ship. So a bare query on a
158
+ * mixed host says so and names both devices — and `SIMFRAME_DEVICE` exists so
159
+ * the answer can be given once per shell instead of on every command.
153
160
  *
154
161
  * @returns {Promise<Device>}
155
162
  */
156
163
  export async function resolveDevice(query, opts) {
157
- return resolveAcross(query, opts, backends());
164
+ return resolveAcross(query ?? process.env.SIMFRAME_DEVICE ?? undefined, opts, backends());
158
165
  }
159
166
 
160
167
  /** As above, over a given set of backends — the testable half. @returns {Promise<Device>} */
161
168
  export async function resolveAcross(query, opts, all) {
169
+ // A backend is entitled to assume a query is a string. `--device X` used to
170
+ // parse as `device: true`, which reached ios.js as `query.toLowerCase` and
171
+ // crashed with a TypeError where an unmatched device should have been a
172
+ // sentence. The parser no longer produces that, and this makes it unable to.
173
+ if (query != null && typeof query !== 'string') query = String(query);
162
174
  const hits = [];
163
175
  const misses = [];
164
176
  for (const backend of all) {
@@ -171,9 +183,12 @@ export async function resolveAcross(query, opts, all) {
171
183
  }
172
184
  if (hits.length === 1) return hits[0];
173
185
  if (hits.length > 1) {
186
+ const named = hits.map((d) => `${d.name} (${d.platform}, ${d.udid})`).join(', ');
174
187
  throw new Error(
175
- `"${query}" matches a device on more than one platform: ` +
176
- `${hits.map((d) => `${d.name} (${d.platform})`).join(', ')} — name one by its id`,
188
+ (query == null || query === ''
189
+ ? 'no device named, and there is a booted device on more than one platform'
190
+ : `"${query}" matches a device on more than one platform`) +
191
+ `: ${named} — name one by its id, or set SIMFRAME_DEVICE to pick a default`,
177
192
  );
178
193
  }
179
194
  // One backend: its own message, unchanged. It is the better message, because