simframe 0.12.1 → 0.13.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/ollama.js ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * A second supervisor arm, for measuring whether capacity matters.
3
+ *
4
+ * The owner's call, 2026-09-11: *"we can of course do our own test with a
5
+ * chosen model… and decide based on the numbers rather than speculations."*
6
+ * This exists to answer that and nothing else. It is **not** a recommendation,
7
+ * it is off unless asked for by name, and a model used to measure whether
8
+ * capacity matters is not a model we ship — conflating those is how a non-goal
9
+ * erodes.
10
+ *
11
+ * No dependency is added: Node has had a global `fetch` since 18, and Ollama
12
+ * is a process already on the machine, reached over the loopback interface.
13
+ *
14
+ * **The fairness condition, which is the whole reason this file is shaped the
15
+ * way it is.** Most small-model errors are invalid-output faults, and Apple's
16
+ * guided generation eliminates those at the sampling layer — constraints are
17
+ * enforced by logit masking, so a fourth word is unrepresentable rather than
18
+ * rejected afterwards (WWDC25 301; Tech Report §7). An unconstrained challenger
19
+ * would lose on formatting and we would read it as losing on judgement. So this
20
+ * arm passes a JSON schema whose `decision` is an enum of the same three words,
21
+ * and Ollama constrains sampling to it the same way.
22
+ *
23
+ * And the briefing is not a second copy. It is **read out of
24
+ * `native/supervise.swift`**, because two hand-maintained copies of a prompt is
25
+ * two arms answering different questions, and the difference would be invisible
26
+ * in the numbers. See `readBrief`.
27
+ */
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
33
+ const SWIFT = path.join(HERE, '..', 'native', 'supervise.swift');
34
+
35
+ export const DEFAULT_MODEL = 'qwen3:8b';
36
+ export const DEFAULT_HOST = 'http://127.0.0.1:11434';
37
+
38
+ /**
39
+ * The Apple arm's own instructions, read from its source.
40
+ *
41
+ * Not duplicated. Two arms of a comparison that are briefed differently are
42
+ * measuring two different things, and nothing in the output would say so — the
43
+ * numbers would simply be wrong and look fine. The Swift file is the canonical
44
+ * copy because it is the shipped one; this parses the `let instructions = """
45
+ * … """` block out of it and un-escapes Swift's trailing-backslash line
46
+ * continuations, which is how that literal is wrapped.
47
+ *
48
+ * Throws rather than falling back to a built-in string. A silent fallback here
49
+ * would produce exactly the invisible unfairness this function exists to
50
+ * prevent.
51
+ */
52
+ export function readBrief(file = SWIFT, { mayAbstain = false } = {}) {
53
+ const src = fs.readFileSync(file, 'utf8');
54
+ const base = block(src, 'let instructions = """', file);
55
+ if (!mayAbstain) return base;
56
+ // The addendum, from the same file and joined the way the Swift joins it.
57
+ // The fourth word has to reach both arms identically or the comparison is
58
+ // measuring two different briefs, which is the failure this whole function
59
+ // exists to prevent.
60
+ return `${base}\n\n${block(src, 'let abstainInstructions = instructions + "\\n\\n" + """', file)}`;
61
+ }
62
+
63
+ function block(src, opener, file) {
64
+ const open = src.indexOf(opener);
65
+ if (open === -1) throw new Error(`no ${JSON.stringify(opener)} block in ${file}`);
66
+ const bodyStart = src.indexOf('\n', open) + 1;
67
+ const close = src.indexOf('"""', bodyStart);
68
+ if (close === -1) throw new Error(`unterminated block in ${file}`);
69
+ return src
70
+ .slice(bodyStart, close)
71
+ .split('\n')
72
+ // A Swift multi-line literal strips the closing delimiter's indentation
73
+ // from every line; here that is four spaces.
74
+ .map((l) => l.replace(/^ {4}/, ''))
75
+ .join('\n')
76
+ // Swift's line continuation: a trailing backslash removes the newline and
77
+ // nothing else. Joining with a space instead would insert one after the
78
+ // space the line already ends with, and the two arms would be reading
79
+ // briefs that differ — invisibly, and in the one place this whole file
80
+ // exists to keep identical.
81
+ .replace(/\\\n/g, '')
82
+ .trim();
83
+ }
84
+
85
+ const clip = (t, n) => {
86
+ const s = String(t ?? '');
87
+ return s.length > n ? `${s.slice(0, n)}…` : s;
88
+ };
89
+
90
+ /**
91
+ * The situation, in the words the other arm gets.
92
+ *
93
+ * A line-for-line mirror of `main.swift`'s prompt assembly, including the clip
94
+ * lengths and the 14-label cap on the screen list. The field order matters as
95
+ * much as the content: the brief tells the model to weigh the plan's guidance
96
+ * first, and a prompt that presented it last would be testing a different
97
+ * instruction.
98
+ */
99
+ export function promptFor(s = {}) {
100
+ let prompt = `Step: ${clip(s.step, 120)}\nIt failed with: ${clip(s.failure, 220)}`;
101
+ if (s.goal) prompt += `\nThe plan's guidance about this app: ${clip(s.goal, 300)}`;
102
+ if (s.expected) prompt += `\nExpected: ${clip(s.expected, 200)}`;
103
+ if (Number.isFinite(s.stillMs)) prompt += `\nThe screen has been still for ${s.stillMs}ms`;
104
+ if (s.note) prompt += `\nPerception note: ${clip(s.note, 160)}`;
105
+ if (s.screen?.length) {
106
+ prompt += `\nOn screen now: ${s.screen.slice(0, 14).map((l) => clip(l, 28)).join(', ')}`;
107
+ }
108
+ return prompt;
109
+ }
110
+
111
+ /**
112
+ * The schema. Three words and nothing else, enforced at sampling.
113
+ *
114
+ * Deliberately decision-only, matching `Judgement` in the Swift file: that
115
+ * struct has one field. Asking this arm for a rationale it would then be scored
116
+ * against would be a second difference between the arms, and the rationale is
117
+ * the one thing a supervisor is explicitly not trusted for.
118
+ */
119
+ export const DECISION_WORDS = ['wait', 'retry', 'stop'];
120
+
121
+ /**
122
+ * The fourth word, available on request.
123
+ *
124
+ * Not a new capability and it cannot become one: `abstain` means "behave as if
125
+ * there is no supervisor", which is the `null` every caller already handles on
126
+ * every failure path. It strictly *shrinks* what the model can cause to happen,
127
+ * which is why a component whose answer space is its safety property can afford
128
+ * to grow one.
129
+ */
130
+ export const ABSTAIN = 'abstain';
131
+
132
+ export const schemaFor = ({ mayAbstain = false } = {}) => ({
133
+ type: 'object',
134
+ properties: {
135
+ decision: { type: 'string', enum: mayAbstain ? [...DECISION_WORDS, ABSTAIN] : DECISION_WORDS },
136
+ },
137
+ required: ['decision'],
138
+ });
139
+
140
+ export const SCHEMA = schemaFor();
141
+
142
+ /** Parse `ollama`, `ollama:qwen3:14b`, `ollama:qwen3:8b@http://host:port`. */
143
+ export function parseTarget(raw) {
144
+ const rest = String(raw ?? '').replace(/^ollama:?/, '');
145
+ const [model, host] = rest.split('@');
146
+ return { model: model || DEFAULT_MODEL, host: host || DEFAULT_HOST };
147
+ }
148
+
149
+ async function post(host, route, body, timeoutMs) {
150
+ const control = new AbortController();
151
+ const timer = setTimeout(() => control.abort(), timeoutMs);
152
+ try {
153
+ const res = await fetch(`${host}${route}`, {
154
+ method: 'POST',
155
+ headers: { 'content-type': 'application/json' },
156
+ body: JSON.stringify(body),
157
+ signal: control.signal,
158
+ });
159
+ if (!res.ok) return { kind: 'http', error: `${res.status} ${await res.text().catch(() => '')}`.slice(0, 200) };
160
+ return { json: await res.json() };
161
+ } catch (err) {
162
+ return { kind: err.name === 'AbortError' ? 'timeout' : 'unreachable', error: String(err.message).slice(0, 200) };
163
+ } finally {
164
+ clearTimeout(timer);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * One judgement. Same shape as the Apple helper's answer, so `judge` in
170
+ * `supervisor.js` cannot tell which arm it asked — including the failure
171
+ * shapes, because "the supervisor did not answer" covering a timeout, a
172
+ * refusal and a model that was never installed is a bug this project has
173
+ * already paid for once.
174
+ */
175
+ export async function ask(target, situation, timeoutMs = 2500) {
176
+ const { model, host } = target;
177
+ const mayAbstain = Boolean(situation?.mayAbstain);
178
+ const started = Date.now();
179
+ const out = await post(host, '/api/chat', {
180
+ model,
181
+ messages: [
182
+ { role: 'system', content: readBrief(SWIFT, { mayAbstain }) },
183
+ { role: 'user', content: promptFor(situation) },
184
+ ],
185
+ stream: false,
186
+ format: schemaFor({ mayAbstain }),
187
+ // Qwen3 reasons out loud by default. Turned off for two reasons and both
188
+ // are about fairness rather than speed: the Apple arm does not deliberate
189
+ // either, and a supervisor that takes twenty seconds to answer has already
190
+ // lost the argument it is here to have — it sits in front of a 1.2s budget.
191
+ think: false,
192
+ options: { temperature: 0, num_predict: 32 },
193
+ }, timeoutMs);
194
+ if (!out.json) return { kind: out.kind, error: out.error };
195
+ const ms = Date.now() - started;
196
+ try {
197
+ return { ...JSON.parse(out.json.message?.content ?? ''), ms };
198
+ } catch {
199
+ return { kind: 'unparseable', error: clip(out.json.message?.content, 200), ms };
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Load the weights before timing anything.
205
+ *
206
+ * The Apple arm calls `prewarm()` for exactly this reason, and it was worth
207
+ * ~280ms there. Here it is worth minutes: an 8B at 4-bit is 5.2 GB off disk,
208
+ * and the first `doctor` probe aborted at 20s and reported a working model as
209
+ * one that "did not answer" — a cold load reported as a fault. Ollama's
210
+ * documented preload is a chat with no messages.
211
+ */
212
+ export async function preload(target, timeoutMs = 120_000) {
213
+ const { model, host } = target;
214
+ const started = Date.now();
215
+ const out = await post(host, '/api/chat', { model, messages: [], keep_alive: '10m' }, timeoutMs);
216
+ return out.json ? { ok: true, ms: Date.now() - started } : { ok: false, ...out };
217
+ }
218
+
219
+ /** For `doctor`: is this arm actually there, and does it answer? */
220
+ export async function status(target, timeoutMs = 20_000) {
221
+ const { model, host } = target;
222
+ const tags = await post(host, '/api/show', { model }, 4000);
223
+ if (!tags.json) {
224
+ return {
225
+ ok: false,
226
+ reason: tags.kind === 'unreachable'
227
+ ? `no Ollama server at ${host} (start it, or pass a host with ollama:<model>@<url>)`
228
+ : `Ollama has no model "${model}" (${tags.error})`,
229
+ };
230
+ }
231
+ return { ok: true, model, host, timeoutMs };
232
+ }
@@ -178,17 +178,42 @@ function isBootedSync(udid) {
178
178
  return false;
179
179
  }
180
180
 
181
+ /**
182
+ * What went wrong with a `simctl io screenshot`, in one sentence.
183
+ *
184
+ * Extracted so it can be *tested* rather than reasoned about, for the same
185
+ * reason `pickDevice` and `decisionOf` were: this is the line where a wrong
186
+ * answer was expensive, and it was wrong for a day.
187
+ */
188
+ export function screenshotFailure(err) {
189
+ const killed = err.killed || err.signal === 'SIGTERM';
190
+ // `simctl` opens with `Note: No display specified …` on every run, success or
191
+ // failure. When the display surface is dead the command does not fail, it
192
+ // *hangs* — so at kill time that Note is the only thing on stderr, and the
193
+ // tool reported a benign informational line as the reason a capture failed.
194
+ // That is how this wedge stayed nameless through five CI failures.
195
+ const lines = String(err.stderr || '').trim().split('\n').map((l) => l.trim()).filter(Boolean);
196
+ const real = lines.filter((l) => !/^Note:/i.test(l)).pop();
197
+ if (killed && !real) {
198
+ // Run to completion the device names it exactly:
199
+ // NSPOSIXErrorDomain code 60 — Timeout waiting for screen surfaces
200
+ // which is CoreSimulator saying the surface is gone, and the closest thing
201
+ // to a positive test for the wedge that exists.
202
+ return 'simctl screenshot did not return within 10s. The display surface is not answering'
203
+ + ' — run to completion it reports "Timeout waiting for screen surfaces" (NSPOSIXErrorDomain 60).'
204
+ + ' This is the device, not the capture loop: `simframe revive` restarts it.';
205
+ }
206
+ const detail = real ?? lines.pop();
207
+ return detail ? `simctl screenshot failed: ${detail}` : `simctl screenshot failed: ${err.message}`;
208
+ }
209
+
181
210
  async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
182
211
  try {
183
212
  await run('xcrun', ['simctl', 'io', udid, 'screenshot', '--type=png', `--mask=${mask}`, outFile], {
184
213
  timeout: 10_000,
185
214
  });
186
215
  } catch (err) {
187
- // Same reason as launchApp: execFile's message is "Command failed: <the
188
- // whole command>" and simctl's actual complaint is in stderr. A CI failure
189
- // here reported the command and nothing about why it did not work.
190
- const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
191
- throw new Error(detail ? `simctl screenshot failed: ${detail}` : `simctl screenshot failed: ${err.message}`);
216
+ throw new Error(screenshotFailure(err));
192
217
  }
193
218
  }
194
219
 
package/src/refs.js CHANGED
@@ -149,7 +149,18 @@ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, s
149
149
  // numbers. Refusing costs a re-read; guessing taps whatever is at those
150
150
  // coordinates now.
151
151
  if (screenKnown === false) {
152
- throw new Error(`#${n} cannot be trusted here — simframe does not recognise this screen. Read it again (sim_ui) to renumber.`);
152
+ // Flagged, like every other refusal in this function, and it was the one
153
+ // that was not.
154
+ //
155
+ // We tell callers to read `staleRef` rather than the sentence — the CI check
156
+ // for this very guard carries a comment saying it matched on prose twice and
157
+ // went red twice, so it reads the contract now. Then it went red a third
158
+ // time, on a refusal that was correct, well worded, and carried no field at
159
+ // all: `#1 cannot be trusted here — simframe does not recognise this
160
+ // screen`, reported by the harness as `no reason`. A refusal a human can
161
+ // read and a program cannot is the same defect as a failure that reads like
162
+ // a success, one level down.
163
+ throw staleError('simframe does not recognise this screen', 'unknown-screen');
153
164
  }
154
165
  // The pixel check stays, but only as a backstop, and only where it means
155
166
  // something. A dark or near-uniform screen produces a layout hash of almost
package/src/regions.js CHANGED
@@ -470,6 +470,60 @@ export function offViewport(t, screen) {
470
470
  return false;
471
471
  }
472
472
 
473
+ /**
474
+ * The smallest sliver of an element that is worth offering as a tap target.
475
+ *
476
+ * Apple's own minimum touch target is 44pt; this is deliberately smaller,
477
+ * because the question here is not "is this comfortable to tap" but "is this a
478
+ * real control a person can see and reach". A filter chip showing 29pt of
479
+ * itself at the edge of a horizontal strip is both. Below this, what is on
480
+ * screen is bleed rather than a control.
481
+ */
482
+ export const MIN_VISIBLE_PT = 24;
483
+
484
+ /**
485
+ * How much of an element is actually on screen, and where to tap what is.
486
+ *
487
+ * `offViewport` answers a yes/no question about the element's *centre*, which
488
+ * is right for "should I scroll to reach this" and wrong for "is this here at
489
+ * all". A chip at the end of a horizontal strip showed **29pt of itself** on a
490
+ * 402pt screen — real, visible, tappable — while its centre sat at x=416, so it
491
+ * was dropped from the map entirely. What the caller got in its place was OCR's
492
+ * reading of the visible sliver: a `text` element labelled **"Flc"** at x=392,
493
+ * which passes every filter because its own box is inside the viewport.
494
+ *
495
+ * So the map did not merely omit a control. It offered a different, meaningless
496
+ * name for it, at a coordinate that looks perfectly ordinary. That is the shape
497
+ * of item 121 — a caller who cannot trust what the map says about the edge of
498
+ * the screen — and the item's own words are "say so or clamp". This does both.
499
+ */
500
+ export function clipping(t, screen) {
501
+ const f = t?.frame;
502
+ const w = screen?.width;
503
+ const h = screen?.height;
504
+ if (!f || !Number.isFinite(w) || !Number.isFinite(h)) return null;
505
+ const left = Math.max(0, f.x);
506
+ const right = Math.min(w, f.x + f.width);
507
+ const top = Math.max(0, f.y);
508
+ const bottom = Math.min(h, f.y + f.height);
509
+ const visibleWidth = right - left;
510
+ const visibleHeight = bottom - top;
511
+ if (visibleWidth <= 0 || visibleHeight <= 0) {
512
+ return { visibleWidth: 0, visibleHeight: 0, clipped: true, usable: false, point: null };
513
+ }
514
+ const clipped = f.x < 0 || f.y < 0 || f.x + f.width > w || f.y + f.height > h;
515
+ return {
516
+ visibleWidth,
517
+ visibleHeight,
518
+ clipped,
519
+ usable: visibleWidth >= MIN_VISIBLE_PT && visibleHeight >= MIN_VISIBLE_PT,
520
+ // The centre of what can be seen, not the centre of the element. Tapping
521
+ // the latter would aim off the screen, which is the clamp the item asked
522
+ // for and the reason a caller could not simply be handed the real centre.
523
+ point: { x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) },
524
+ };
525
+ }
526
+
473
527
  /** Annotate a target list with region and nav slot. Mutates and returns it. */
474
528
  export function annotate(targets, screen) {
475
529
  const band = bands(targets, screen);
package/src/screenmap.js CHANGED
@@ -151,6 +151,82 @@ const inside = (point, frame) =>
151
151
  point.y >= frame.y &&
152
152
  point.y <= frame.y + frame.height;
153
153
 
154
+ const frameArea = (f) => (f ? Math.max(1, f.width) * Math.max(1, f.height) : Infinity);
155
+
156
+ /**
157
+ * What the map says is at a point — everything containing it, smallest first.
158
+ *
159
+ * Item 120. Six consecutive `swipe [201,750] -> [201,250]` reported
160
+ * `no visible change` while a support banner sat at y≈753 swallowing every
161
+ * gesture. The banner was *in the element list the same call printed*; nothing
162
+ * connected "your swipe started at y=750" to "there is an element at y=753".
163
+ * The geometry was already in hand, so this is arithmetic over data we hold,
164
+ * not a new perception pass.
165
+ *
166
+ * Smallest first is a deliberately weaker claim than z-order. We do not know
167
+ * what is on top — the accessibility tree's order is not a paint order and OCR
168
+ * has none at all — and the honest statement is "these are the elements that
169
+ * cover that point", innermost first because the innermost is the one a
170
+ * gesture most often goes to. Saying "overlay" would be a guess wearing the
171
+ * clothes of a measurement.
172
+ */
173
+ export function hitTest(entry, point) {
174
+ if (!entry?.targets || !Number.isFinite(point?.x) || !Number.isFinite(point?.y)) return [];
175
+ return entry.targets
176
+ .filter((t) => t.frame && inside(point, t.frame))
177
+ .sort((a, b) => frameArea(a.frame) - frameArea(b.frame));
178
+ }
179
+
180
+ const describeTarget = (t) => {
181
+ const name = t.label || t.text || (t.type ? `(unlabelled ${t.type})` : '(unlabelled)');
182
+ return t.region ? `"${name}" (${t.region})` : `"${name}"`;
183
+ };
184
+
185
+ /**
186
+ * One line saying what a coordinate resolved to, for a gesture that did
187
+ * nothing visible.
188
+ *
189
+ * Returns `null` when there is no map for the screen, because "we did not
190
+ * look" and "we looked and found nothing" are different answers and only one
191
+ * of them is worth printing.
192
+ */
193
+ export function describePoint(entry, point, { what = 'the point' } = {}) {
194
+ if (!entry?.targets?.length) return null;
195
+ const at = `${Math.round(point.x)},${Math.round(point.y)}`;
196
+ const hits = hitTest(entry, point);
197
+ if (!hits.length) {
198
+ return `${what} ${at} is not inside any element on the map`
199
+ + ' — empty space, or a view with no label (nothing can be said about what caught it)';
200
+ }
201
+ // The contention clause only where there is contention. On one hit the
202
+ // element's name *is* the diagnosis and anything after it is noise — and a
203
+ // note that pads every case is how a real one stops being read.
204
+ const others = hits.length > 1
205
+ ? `, the smallest of ${hits.length} elements covering it — a gesture goes to whatever is on top there`
206
+ : '';
207
+ return `${what} ${at} is inside ${describeTarget(hits[0])}${others}`;
208
+ }
209
+
210
+ /**
211
+ * The point a step is aimed at, when it is aimed at a coordinate at all.
212
+ *
213
+ * A swipe is captured by whatever sits under where the finger goes *down*, so
214
+ * the start point is the one worth diagnosing; the end point never decides who
215
+ * receives the gesture.
216
+ */
217
+ export function aimedAt(step) {
218
+ if (!step) return null;
219
+ if (step.action === 'tapAt' && Number.isFinite(step.x) && Number.isFinite(step.y)) {
220
+ return { point: { x: step.x, y: step.y }, what: 'the tap point' };
221
+ }
222
+ if (step.action === 'swipe') {
223
+ const x = step.from?.[0] ?? step.from?.x;
224
+ const y = step.from?.[1] ?? step.from?.y;
225
+ if (Number.isFinite(x) && Number.isFinite(y)) return { point: { x, y }, what: 'the swipe start point' };
226
+ }
227
+ return null;
228
+ }
229
+
154
230
  /**
155
231
  * Build the map for the screen currently showing. Accessibility elements are the
156
232
  * real hit targets, so they win where they exist; OCR fills in everything the
@@ -220,15 +296,64 @@ export async function build(udid, {
220
296
  try {
221
297
  // The tree is already in hand when the daemon answered; describeAll would
222
298
  // only ask for it a second time.
223
- const nodes = daemonScreen?.sources?.includes('ax')
224
- ? daemonScreen.elements.filter((e) => e.source?.includes('ax')).map(input.elementToNode)
225
- : await input.describeAll(udid);
299
+ //
300
+ // And when the daemon answered *without* a tree, asking again is asking
301
+ // the thing that just failed. That path cost a red CI run and a wrong
302
+ // diagnosis: the daemon's ax read timed out, `describeAll` re-asked it
303
+ // (another 20s), got the same nothing, fell through to idb, and the map
304
+ // reported **"idb is not installed, so simframe can observe the screen but
305
+ // cannot touch it"**. A sentence about a tool this project deliberately
306
+ // does not use on CI, printed because a different tool had a bad read —
307
+ // and it points whoever reads it at installing idb, which would change
308
+ // nothing. The same read worked a minute later.
309
+ //
310
+ // The daemon has sent `axError` since it learned to time its own reads,
311
+ // and nothing here had ever read it. The OCR branch below reads
312
+ // `ocrError`, which is what makes the asymmetry visible: one sensor could
313
+ // say why it failed and the other borrowed a different tool's excuse.
314
+ let nodes;
315
+ if (daemonScreen?.sources?.includes('ax')) {
316
+ nodes = daemonScreen.elements.filter((e) => e.source?.includes('ax')).map(input.elementToNode);
317
+ } else if (daemonScreen) {
318
+ throw new Error(daemonScreen.axError ?? 'the daemon read the screen and the accessibility tree did not answer');
319
+ } else {
320
+ nodes = await input.describeAll(udid);
321
+ }
226
322
 
227
323
  sources.push('ax');
324
+ // A control the tree gives no name to is still a control — item 122.
325
+ //
326
+ // This line used to read `!n.label`, on the reasoning that a row nobody
327
+ // can name is a row nobody can tap. The whole of item 122 is that the
328
+ // opposite is true, and three field reports in a row said so
329
+ // independently: icon-only overflow menus on every card, a bottom-sheet
330
+ // drag handle, a back chevron. Real, tappable, on screen, and absent from
331
+ // the map — so every one of them needed a raw `@x,y` read off a
332
+ // screenshot, which is the exact round trip the text map exists to
333
+ // remove. Knowing that *a hit target is there* is most of the value even
334
+ // with no semantics attached to it.
335
+ //
336
+ // Measured on the testbed before it was written, because the premise
337
+ // could have been false: on the list screen, of 39 accessibility nodes
338
+ // exactly **one** is nameless — and it is the one control on that screen
339
+ // no caller could reach. This does not flood the map.
340
+ //
341
+ // It also settles a thing the reports could not: those controls were
342
+ // called "absent from the tree", and AXPTranslator had them all along.
343
+ // What is genuinely absent is the drag handle, a plain view holding a
344
+ // responder that UIKit is never told is accessible. No filter here can
345
+ // recover that one; see 122's second half.
228
346
  for (const n of nodes) {
229
- if (!n.frame || !n.label || isContainer(n)) continue;
347
+ if (!n.frame || isContainer(n)) continue;
348
+ if (nameless(n) && !namelessHitTarget(n, nodes)) continue;
230
349
  targets.push({
231
- label: n.label,
350
+ label: n.label ?? undefined,
351
+ // A `testID` arrives as AXIdentifier, and `tap` has matched on it
352
+ // since long before this — but only down the tree path, because the
353
+ // map never carried it. So the one name an unlabelled React Native
354
+ // control usually does have was invisible in the map and worked if
355
+ // you guessed it.
356
+ identifier: n.identifier ?? undefined,
232
357
  // What the control *contains*, whether it is on, and whether it has
233
358
  // focus. All three come off the accessibility tree, the daemon has
234
359
  // asked for all three since 0.6.0, and all three were dropped before
@@ -430,6 +555,36 @@ export function isInteractive(target) {
430
555
  return INTERACTIVE.test(target.type || '');
431
556
  }
432
557
 
558
+ /** No name from any source: not a label, not an identifier. */
559
+ export const nameless = (n) => !n?.label && !n?.identifier;
560
+
561
+ /**
562
+ * Whether a nameless accessibility node is worth printing as a hit target.
563
+ *
564
+ * Two guards, because "has no name" is not the same as "is a control".
565
+ *
566
+ * The role has to be one the tree calls interactive. A nameless `Other` is a
567
+ * layout view and a real screen has hundreds of them; emitting those would bury
568
+ * the one node that matters under the scenery it sits in.
569
+ *
570
+ * And it must not enclose two or more other elements. That is the same test the
571
+ * fingerprint uses for scenery, reused deliberately rather than invented here:
572
+ * a table cell holding its own labels is not the target, its labels are.
573
+ */
574
+ export function namelessHitTarget(node, nodes) {
575
+ const f = node?.frame;
576
+ if (!f || !(f.width > 0) || !(f.height > 0)) return false;
577
+ if (!INTERACTIVE.test(node.type || '')) return false;
578
+ const encloses = nodes.filter((o) => {
579
+ const g = o.frame;
580
+ if (!g || o === node) return false;
581
+ const cx = g.x + (g.width ?? 0) / 2;
582
+ const cy = g.y + (g.height ?? 0) / 2;
583
+ return cx > f.x && cx < f.x + f.width && cy > f.y && cy < f.y + f.height;
584
+ }).length;
585
+ return encloses < 2;
586
+ }
587
+
433
588
  /**
434
589
  * Rank candidates for a label. Exact beats substring, and a real control beats
435
590
  * a caption that happens to read the same — a screen title and a tab are often
@@ -438,7 +593,7 @@ export function isInteractive(target) {
438
593
  export function rank(entry, query) {
439
594
  if (!entry) return [];
440
595
  const q = norm(query);
441
- const names = (t) => [t.label, ...(t.aliases || [])].map(norm);
596
+ const names = (t) => [t.label, t.identifier, ...(t.aliases || [])].map(norm);
442
597
  const exact = entry.targets.filter((t) => names(t).includes(q));
443
598
  const pool = exact.length
444
599
  ? exact
package/src/store.js CHANGED
@@ -34,10 +34,63 @@ export function paths(udid) {
34
34
  // wedged. It cannot ride in state.json: that is written when a frame is
35
35
  // recorded, and a stall is the absence of frames.
36
36
  captureHealth: path.join(dir, 'capture-health.json'),
37
+ lastInput: path.join(dir, 'last-input'),
37
38
  };
38
39
  }
39
40
 
40
41
  /** What the capture loop last said about its own health, or null if it has no complaint. */
42
+ /**
43
+ * When input was last delivered to this device.
44
+ *
45
+ * Written by every input path and read by `liveness`, which needs it to catch
46
+ * the one wedge shape nothing else can see: a capture loop that is alive,
47
+ * incrementing its frame counter, and re-reading a **dead surface**. Field
48
+ * report, 0.12.2: `age=268ms` next to `stable=159753ms` while three screen
49
+ * transitions had just happened, and no warning fired because every signal we
50
+ * had was green. A screen that has not moved since before we last touched it,
51
+ * over and over, is a contradiction the daemon can notice locally.
52
+ */
53
+ const INPUT_MEMORY = 8;
54
+
55
+ export function noteInput(udid, at = Date.now()) {
56
+ try {
57
+ writeAtomic(paths(udid).lastInput, [...inputTimes(udid), at].slice(-INPUT_MEMORY).join(','));
58
+ } catch {
59
+ /* a timestamp nothing depends on for correctness must not fail an action */
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The last few input timestamps, oldest first.
65
+ *
66
+ * A list rather than a single timestamp, and that is the whole correction.
67
+ * The first version kept only the latest, so the only question it could ask was
68
+ * "has the screen been still for a long time?" — which needed a duration
69
+ * threshold, and the threshold is what defeated it. A field report caught a
70
+ * three-hour-stale frame on a screen that had been still for **8.2 seconds**,
71
+ * under a 20-second gate, so the check could not fire on the case it was
72
+ * written for.
73
+ *
74
+ * What actually says "dead surface" is not duration. It is **several gestures
75
+ * delivered with no pixel moving at all** — one tap that changes nothing is
76
+ * ordinary, and three in a row are not.
77
+ */
78
+ export function inputTimes(udid) {
79
+ try {
80
+ return fs.readFileSync(paths(udid).lastInput, 'utf8')
81
+ .split(',')
82
+ .map(Number)
83
+ .filter(Number.isFinite);
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ export function lastInputAt(udid) {
90
+ const times = inputTimes(udid);
91
+ return times.length ? times[times.length - 1] : null;
92
+ }
93
+
41
94
  export function captureHealth(udid) {
42
95
  return readJson(paths(udid).captureHealth);
43
96
  }