simframe 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +227 -53
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -0
  4. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  5. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  6. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +117 -0
  7. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -0
  8. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  9. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  10. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  11. package/native/simframed/Sources/SimframeCore/Element.swift +120 -0
  12. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  13. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  14. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  15. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  16. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  17. package/native/simframed/Sources/simframed/main.swift +357 -0
  18. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
  19. package/package.json +10 -4
  20. package/scripts/bench-flow.mjs +54 -0
  21. package/scripts/bench.sh +98 -0
  22. package/scripts/check-package.mjs +91 -0
  23. package/scripts/smoke.mjs +76 -0
  24. package/scripts/verify-baseline.mjs +65 -0
  25. package/src/actions.js +82 -5
  26. package/src/cli.js +347 -31
  27. package/src/control.js +76 -0
  28. package/src/daemon.js +8 -1
  29. package/src/engine.js +99 -0
  30. package/src/fingerprint.js +150 -0
  31. package/src/graph.js +409 -0
  32. package/src/index.js +292 -23
  33. package/src/input.js +80 -2
  34. package/src/matching.js +194 -0
  35. package/src/mcp.js +45 -1
  36. package/src/navigate.js +120 -0
  37. package/src/regions.js +90 -0
  38. package/src/screenmap.js +77 -21
  39. package/src/simctl.js +20 -4
  40. package/src/store.js +8 -0
@@ -0,0 +1,194 @@
1
+ // Resolving "what did they mean" against what is on screen.
2
+ //
3
+ // The rule throughout: never guess between two plausible answers. A wrong tap
4
+ // is worse than a question, because a wrong tap can do something and the caller
5
+ // will believe it did the right thing.
6
+
7
+ /** Names for controls that carry an icon and no readable label. */
8
+ const SYNONYMS = {
9
+ back: ['back', 'chevron.left', 'navigate back', 'previous', 'return'],
10
+ close: ['close', 'dismiss', 'xmark', 'cancel', 'done'],
11
+ search: ['search', 'find', 'magnifyingglass'],
12
+ add: ['add', 'new', 'create', 'plus', 'compose'],
13
+ more: ['more', 'options', 'ellipsis', 'overflow', 'menu'],
14
+ settings: ['settings', 'preferences', 'gear', 'configure'],
15
+ share: ['share', 'export', 'send'],
16
+ delete: ['delete', 'remove', 'trash', 'bin'],
17
+ edit: ['edit', 'modify', 'change'],
18
+ save: ['save', 'apply', 'confirm', 'ok', 'submit'],
19
+ };
20
+
21
+ /** Words that say what kind of control the caller means. */
22
+ const ROLE_HINTS = [
23
+ { pattern: /\b(type|enter|fill|input)\b/i, roles: /field|textfield|textview|searchfield/i },
24
+ { pattern: /\b(tap|press|click|hit)\b/i, roles: /button|link|cell|tab/i },
25
+ { pattern: /\b(toggle|switch|enable|disable|turn)\b/i, roles: /switch|toggle|checkbox/i },
26
+ { pattern: /\b(tab)\b/i, roles: /tab/i },
27
+ ];
28
+
29
+ /** Words that say where on screen the caller means. */
30
+ const REGION_HINTS = [
31
+ { pattern: /\btab\b/i, region: 'tab-bar' },
32
+ { pattern: /\b(back|nav|title|toolbar)\b/i, region: 'nav-bar' },
33
+ ];
34
+
35
+ const norm = (s) => String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
36
+
37
+ /** Levenshtein distance, capped: beyond the cap the exact value is irrelevant. */
38
+ export function editDistance(a, b, cap = 8) {
39
+ if (a === b) return 0;
40
+ if (Math.abs(a.length - b.length) > cap) return cap + 1;
41
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
42
+ for (let i = 1; i <= a.length; i++) {
43
+ const row = [i];
44
+ let best = i;
45
+ for (let j = 1; j <= b.length; j++) {
46
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
47
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost);
48
+ if (row[j] < best) best = row[j];
49
+ }
50
+ if (best > cap) return cap + 1;
51
+ prev = row;
52
+ }
53
+ return prev[b.length];
54
+ }
55
+
56
+ /** How well one name answers to a query, 0 to 1. */
57
+ export function nameScore(name, query) {
58
+ const n = norm(name);
59
+ const q = norm(query);
60
+ if (!n || !q) return 0;
61
+ if (n === q) return 1;
62
+ if (n.startsWith(q) || q.startsWith(n)) return 0.86;
63
+ // A substring match is only as good as the share of the name it covers.
64
+ // Without this, "back" scores 0.78 against a two-hundred-character list row
65
+ // that happens to contain "Back of House", and beats the actual back button.
66
+ if (n.includes(q)) return 0.78 * Math.max(0.15, q.length / n.length);
67
+ if (q.includes(n)) return 0.7 * Math.max(0.15, n.length / q.length);
68
+ // Fuzzy, so a typo or a stray plural still resolves.
69
+ //
70
+ // editDistance returns cap+1 as a sentinel when it gives up early. Treating
71
+ // that as a measurement made every long string score well: 1 - 9/200 is
72
+ // 0.955, so a two-hundred-character list row scored 0.687 against any query
73
+ // at all. A bail-out is "no answer", not "nearly identical".
74
+ const cap = 8;
75
+ const distance = editDistance(n, q, cap);
76
+ if (distance > cap) return 0;
77
+ const longest = Math.max(n.length, q.length);
78
+ const similarity = 1 - distance / longest;
79
+ return similarity >= 0.7 ? similarity * 0.72 : 0;
80
+ }
81
+
82
+ function synonymGroup(query) {
83
+ const q = norm(query);
84
+ for (const [key, words] of Object.entries(SYNONYMS)) {
85
+ if (words.some((w) => w === q || q.includes(w))) return { key, words };
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Rank what is on screen against an intent.
92
+ *
93
+ * Returns candidates sorted best first, each with the reasons behind its score
94
+ * so a caller — or a person reading a failure — can see why.
95
+ */
96
+ export function rank(targets, intent, { screen } = {}) {
97
+ // Never offer something that is not on screen. A scrolled-away row still sits
98
+ // in the map with a negative y, and tapping it lands somewhere else entirely.
99
+ const visible = screen?.width && screen?.height
100
+ ? targets.filter((t) => {
101
+ const f = t.frame;
102
+ if (!f) return t.y >= 0 && t.y <= screen.height && t.x >= 0 && t.x <= screen.width;
103
+ return f.y + (f.height ?? 0) > 0 && f.y < screen.height
104
+ && f.x + (f.width ?? 0) > 0 && f.x < screen.width;
105
+ })
106
+ : targets;
107
+ const group = synonymGroup(intent);
108
+ const roleHint = ROLE_HINTS.find((h) => h.pattern.test(intent));
109
+ const regionHint = REGION_HINTS.find((h) => h.pattern.test(intent));
110
+ // Strip the verb: "tap the Save button" should match a control called "Save".
111
+ const bare = norm(intent)
112
+ .replace(/^(please\s+)?(tap|press|click|hit|type|enter|fill|open|select|choose|toggle|switch)\s+/i, '')
113
+ .replace(/^(the|a|an)\s+/i, '')
114
+ .replace(/\s+(button|tab|field|cell|link|icon)$/i, '');
115
+
116
+ const scored = [];
117
+ for (const t of visible) {
118
+ const names = [t.label, ...(t.aliases ?? [])].filter(Boolean);
119
+ let base = 0;
120
+ let matched = null;
121
+ for (const name of names) {
122
+ const s = Math.max(nameScore(name, intent), nameScore(name, bare));
123
+ if (s > base) {
124
+ base = s;
125
+ matched = name;
126
+ }
127
+ }
128
+ // An icon-only control has no readable name, so a synonym is the only way
129
+ // to reach it — this is how "back" finds a bare chevron.
130
+ if (group && base < 0.5 && !t.label && t.rawLabel) base = 0.55;
131
+ if (group && base < 0.5 && names.some((n) => group.words.includes(norm(n)))) base = 0.9;
132
+ if (base <= 0) continue;
133
+
134
+ const reasons = [matched ? `label "${matched}"` : 'icon-only'];
135
+ let score = base;
136
+ if (roleHint && roleHint.roles.test(t.type ?? '')) {
137
+ score += 0.12;
138
+ reasons.push(`role ${t.type}`);
139
+ }
140
+ if (regionHint && t.region === regionHint.region) {
141
+ score += 0.15;
142
+ reasons.push(`region ${t.region}`);
143
+ }
144
+ // A caption is not a control. Prefer something tappable when the names tie.
145
+ if (/button|link|cell|field|switch|tab/i.test(t.type ?? '')) {
146
+ score += 0.05;
147
+ reasons.push('interactive');
148
+ }
149
+ // Not capped here: clamping to 1 before comparing throws away exactly the
150
+ // signal the bonuses exist to provide. Two elements sharing a label both
151
+ // reach 1.0 on the name alone, and the region bonus that should separate
152
+ // them disappears into the ceiling.
153
+ scored.push({ target: t, score, reasons });
154
+ }
155
+ return scored.sort((a, b) => b.score - a.score);
156
+ }
157
+
158
+ /** How close two candidates may be before the answer counts as ambiguous. */
159
+ export const AMBIGUITY_MARGIN = 0.08;
160
+ /** Below this, no candidate is worth acting on. */
161
+ export const MINIMUM_SCORE = 0.45;
162
+
163
+ /**
164
+ * Resolve an intent to one element, or say why not.
165
+ * @returns {{status: 'ok'|'ambiguous'|'none', target?, score?, reasons?, alternatives?}}
166
+ */
167
+ export function resolve(targets, intent, options = {}) {
168
+ const ranked = rank(targets, intent, options).filter((c) => c.score >= MINIMUM_SCORE);
169
+ if (!ranked.length) return { status: 'none', alternatives: [] };
170
+ const [best, second] = ranked;
171
+ if (second && best.score - second.score < AMBIGUITY_MARGIN) {
172
+ return {
173
+ status: 'ambiguous',
174
+ alternatives: ranked.slice(0, 5).map((c) => ({
175
+ label: c.target.label ?? '(icon-only)',
176
+ x: c.target.x,
177
+ y: c.target.y,
178
+ region: c.target.region,
179
+ score: Math.round(Math.min(1, c.score) * 100) / 100,
180
+ reasons: c.reasons,
181
+ })),
182
+ };
183
+ }
184
+ return {
185
+ status: 'ok',
186
+ target: best.target,
187
+ score: Math.round(Math.min(1, best.score) * 100) / 100,
188
+ reasons: best.reasons,
189
+ alternatives: ranked.slice(1, 4).map((c) => ({
190
+ label: c.target.label ?? '(icon-only)',
191
+ score: Math.round(Math.min(1, c.score) * 100) / 100,
192
+ })),
193
+ };
194
+ }
package/src/mcp.js CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  ListToolsRequestSchema,
8
8
  } from '@modelcontextprotocol/sdk/types.js';
9
9
  import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
10
12
  import { REGION_COLS, REGION_ROWS, regionMap } from './analyze.js';
11
13
  import * as actions from './actions.js';
12
14
  import * as api from './index.js';
@@ -153,6 +155,19 @@ const TOOLS = [
153
155
  },
154
156
  },
155
157
  },
158
+ {
159
+ name: 'sim_find',
160
+ description:
161
+ 'Resolve an intent to one control on screen: "tap Save", "the Assets tab", "back". Understands verbs, typos, where on screen you meant, and icon-only controls by their common name. Returns the element with its tap point and the reasons it was chosen. When two things answer equally well it says so and lists them rather than guessing — a wrong tap is worse than a question, because it can do something and leave you believing it did the right thing. Cheaper and more reliable than reading a screenshot to find a control.',
162
+ inputSchema: {
163
+ type: 'object',
164
+ properties: {
165
+ ...deviceProp,
166
+ intent: { type: 'string', description: 'What you want to act on, in your own words.' },
167
+ },
168
+ required: ['intent'],
169
+ },
170
+ },
156
171
  {
157
172
  name: 'sim_capture',
158
173
  description:
@@ -180,6 +195,16 @@ const TOOLS = [
180
195
  // frame-to-frame delta look broken in practice.
181
196
  const lastSeen = new Map();
182
197
 
198
+ /** Report the real version: a hardcoded one silently drifts every release. */
199
+ function packageVersion() {
200
+ try {
201
+ const here = path.dirname(fileURLToPath(import.meta.url));
202
+ return JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version;
203
+ } catch {
204
+ return '0.0.0';
205
+ }
206
+ }
207
+
183
208
  function remember(udid, state) {
184
209
  lastSeen.set(udid, { hash: state.hash, seq: state.seq, at: state.capturedAt });
185
210
  }
@@ -221,7 +246,7 @@ function sinceLine(since) {
221
246
 
222
247
  export async function serve({ device: defaultDevice, options = {} } = {}) {
223
248
  const server = new Server(
224
- { name: 'simframe', version: '0.1.0' },
249
+ { name: 'simframe', version: packageVersion() },
225
250
  { capabilities: { tools: {} } },
226
251
  );
227
252
 
@@ -242,6 +267,8 @@ export async function serve({ device: defaultDevice, options = {} } = {}) {
242
267
  return await strip(target, args, options);
243
268
  case 'sim_recall':
244
269
  return await recall(target, args, options);
270
+ case 'sim_find':
271
+ return await find(target, args, options);
245
272
  case 'sim_do':
246
273
  return await doScript(target, args, options);
247
274
  case 'sim_ui':
@@ -479,6 +506,23 @@ async function ui(target, args, options) {
479
506
  return { content: [text(`${head}\n${rows.join('\n')}${tail}`)] };
480
507
  }
481
508
 
509
+ async function find(target, args, options) {
510
+ try {
511
+ const r = await api.locate(target, String(args.intent ?? ''), { options });
512
+ const lines = [
513
+ `${r.target.label ?? '(icon-only)'} — tap at (${r.target.x}, ${r.target.y})`,
514
+ `${r.target.region ?? 'content'} · ${r.target.type ?? '?'} · seen by ${r.target.source} · score ${r.score ?? '-'}`,
515
+ ];
516
+ if (r.reasons?.length) lines.push(`chosen because: ${r.reasons.join(', ')}`);
517
+ if (r.alternatives?.length) {
518
+ lines.push(`also considered: ${r.alternatives.map((a) => `"${a.label}" (${a.score})`).join(', ')}`);
519
+ }
520
+ return { content: [text(lines.join('\n'))] };
521
+ } catch (err) {
522
+ return { isError: true, content: [text(err.message)] };
523
+ }
524
+ }
525
+
482
526
  async function capture(target, args, options) {
483
527
  if (args.action === 'start') {
484
528
  const res = await api.ensureDaemon(target, { ...options, fps: args.fps ?? options.fps });
@@ -0,0 +1,120 @@
1
+ // Navigating by memory.
2
+ //
3
+ // Once the graph knows which screens exist and which action leads from one to
4
+ // the next, getting somewhere is a search over known edges rather than a
5
+ // question for a model. `goto` plans a path and walks it; a flow is a path
6
+ // somebody already walked, saved so it can be walked again.
7
+ import { runScript } from './actions.js';
8
+ import * as graph from './graph.js';
9
+ import * as api from './index.js';
10
+ import * as store from './store.js';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ const flowDir = (udid) => path.join(store.deviceDir(udid), 'flows');
15
+
16
+ /** The signature is lossy; older edges predate `step` and have to be reconstructed. */
17
+ export function stepFor(edge) {
18
+ if (edge.step) return edge.step;
19
+ const [kind, rest] = [edge.action.slice(0, edge.action.indexOf(':')), edge.action.slice(edge.action.indexOf(':') + 1)];
20
+ if (kind === 'tap') return { tap: rest };
21
+ if (kind === 'scroll') return { scroll: rest };
22
+ if (kind === 'button') return { button: rest };
23
+ if (kind === 'tapAt') {
24
+ const [x, y] = rest.split(',').map(Number);
25
+ return Number.isFinite(x) && Number.isFinite(y) ? { tapAt: { x, y } } : null;
26
+ }
27
+ if (kind === 'swipe') {
28
+ const [from, to] = rest.split('->').map((p) => p.split(',').map(Number));
29
+ return from?.length === 2 && to?.length === 2 ? { swipe: { from, to } } : null;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Walk to a known screen.
36
+ *
37
+ * Fails rather than guesses: if the destination is not in the graph, or the
38
+ * query fits two screens equally, or no path of known edges reaches it, that is
39
+ * reported. A wrong route is worse than no route, because it taps things.
40
+ */
41
+ export async function goto(deviceQuery, target, { options, ...runOptions } = {}) {
42
+ const { device } = await api.ensureDaemon(deviceQuery, options);
43
+ const udid = device.udid;
44
+
45
+ const found = graph.findScreen(udid, target);
46
+ if (!found) return { ok: false, reason: 'unknown-screen', known: knownScreens(udid) };
47
+ if (found.ambiguous) return { ok: false, reason: 'ambiguous', candidates: found.ambiguous };
48
+
49
+ const here = await api.screenIdentity(udid, {});
50
+ if (here.hash === found.node.hash) {
51
+ return { ok: true, already: true, screen: found.name, steps: [] };
52
+ }
53
+
54
+ const path_ = graph.route(udid, { hash: here.hash, tokens: here.tokens }, found.node.hash);
55
+ if (!path_) return { ok: false, reason: 'no-route', from: here.hash.slice(0, 8), to: found.name };
56
+
57
+ const steps = path_.map(stepFor);
58
+ if (steps.some((s) => !s)) return { ok: false, reason: 'unreplayable-edge', to: found.name };
59
+
60
+ const result = await runScript(udid, { steps, stopOnUnexpected: true, ...runOptions });
61
+ const arrived = await api.screenIdentity(udid, {});
62
+ return {
63
+ ok: arrived.hash === found.node.hash,
64
+ screen: found.name,
65
+ steps,
66
+ ranSteps: result.ranSteps,
67
+ results: result.results,
68
+ arrived: arrived.hash.slice(0, 8),
69
+ };
70
+ }
71
+
72
+ export function knownScreens(udid) {
73
+ return graph.allNodes(udid).map((n) => ({ name: graph.describe(n), hash: n.hash.slice(0, 8), edges: n.edges.length }));
74
+ }
75
+
76
+ /**
77
+ * Save a flow.
78
+ *
79
+ * Only flows that verified end to end are worth saving: a flow with an
80
+ * unverified step in it is a recording of something that may not have worked,
81
+ * and replaying it faithfully reproduces the doubt.
82
+ */
83
+ export function saveFlow(udid, name, script, { force = false } = {}) {
84
+ const verdicts = (script.results ?? []).map((r) => r.verification?.verdict);
85
+ if (!force && verdicts.some((v) => v && v !== 'ok')) {
86
+ return { ok: false, reason: 'unverified-steps', verdicts };
87
+ }
88
+ const dir = flowDir(udid);
89
+ fs.mkdirSync(dir, { recursive: true });
90
+ const body = {
91
+ name,
92
+ savedAt: Date.now(),
93
+ steps: script.steps ?? (script.results ?? []).map((r) => r.step).filter(Boolean),
94
+ startScreen: script.startScreen ?? null,
95
+ };
96
+ store.writeAtomic(path.join(dir, `${encodeURIComponent(name)}.json`), JSON.stringify(body, null, 2));
97
+ return { ok: true, name, steps: body.steps.length };
98
+ }
99
+
100
+ export function loadFlow(udid, name) {
101
+ return store.readJson(path.join(flowDir(udid), `${encodeURIComponent(name)}.json`));
102
+ }
103
+
104
+ export function listFlows(udid) {
105
+ const dir = flowDir(udid);
106
+ if (!fs.existsSync(dir)) return [];
107
+ return fs.readdirSync(dir)
108
+ .filter((f) => f.endsWith('.json'))
109
+ .map((f) => store.readJson(path.join(dir, f)))
110
+ .filter(Boolean)
111
+ .map((f) => ({ name: f.name, steps: f.steps?.length ?? 0, savedAt: f.savedAt }));
112
+ }
113
+
114
+ export async function runFlow(deviceQuery, name, { options, ...runOptions } = {}) {
115
+ const { device } = await api.ensureDaemon(deviceQuery, options);
116
+ const flow = loadFlow(device.udid, name);
117
+ if (!flow) return { ok: false, reason: 'unknown-flow', known: listFlows(device.udid).map((f) => f.name) };
118
+ const result = await runScript(device.udid, { steps: flow.steps, stopOnUnexpected: true, ...runOptions });
119
+ return { ok: result.ranSteps === flow.steps.length, name, ...result };
120
+ }
package/src/regions.js ADDED
@@ -0,0 +1,90 @@
1
+ // Where on the screen something is, in the terms iOS itself uses.
2
+ //
3
+ // A label alone is ambiguous — "Assets" is both a screen title and a tab — but
4
+ // a label plus a region rarely is. These are geometric priors from the Human
5
+ // Interface Guidelines rather than anything read from the app, so they are
6
+ // cheap, always available, and occasionally wrong in the same ways the
7
+ // guidelines are.
8
+ export const REGIONS = [
9
+ 'status-bar',
10
+ 'nav-bar',
11
+ 'tab-bar',
12
+ 'keyboard',
13
+ 'content',
14
+ ];
15
+
16
+ /**
17
+ * Fractions of screen height. Deliberately conservative: a band that is too
18
+ * greedy mislabels content as chrome, and content is the common case.
19
+ */
20
+ const BANDS = {
21
+ statusBar: 0.065, // through the notch / dynamic island
22
+ navBar: 0.14, // title and its leading/trailing controls
23
+ tabBar: 0.92, // home indicator sits below this
24
+ };
25
+
26
+ /** Keyboards occupy the bottom of the screen and are unusually tall. */
27
+ const KEYBOARD_MIN_FRACTION = 0.28;
28
+
29
+ /**
30
+ * Chrome is short. Position alone is not enough: the last row of a long list
31
+ * reaches into the tab-bar band, and calling a 90pt cell a tab item makes a
32
+ * seven-row list a different screen from a three-row one.
33
+ */
34
+ const CHROME_MAX_HEIGHT_FRACTION = 0.075;
35
+
36
+ export function regionFor(frame, screen, { keyboardTop } = {}) {
37
+ if (!frame || !screen?.height) return 'content';
38
+ const top = frame.y / screen.height;
39
+ const bottom = (frame.y + (frame.height ?? 0)) / screen.height;
40
+ if (keyboardTop != null && frame.y >= keyboardTop) return 'keyboard';
41
+ const short = (frame.height ?? 0) <= screen.height * CHROME_MAX_HEIGHT_FRACTION;
42
+ if (bottom <= BANDS.statusBar) return 'status-bar';
43
+ if (short && top < BANDS.navBar && bottom < BANDS.navBar * 1.6) return 'nav-bar';
44
+ if (short && top >= BANDS.tabBar - 0.06) return 'tab-bar';
45
+ return 'content';
46
+ }
47
+
48
+ /**
49
+ * Where a nav-bar control sits across the bar: leading, title or trailing.
50
+ * A back button is leading; an edit or done button is trailing. Callers use it
51
+ * to disambiguate two controls that share a label.
52
+ */
53
+ export function navSlot(frame, screen) {
54
+ if (!frame || !screen?.width) return null;
55
+ const centre = frame.x + (frame.width ?? 0) / 2;
56
+ const third = screen.width / 3;
57
+ if (centre < third) return 'leading';
58
+ if (centre > third * 2) return 'trailing';
59
+ return 'title';
60
+ }
61
+
62
+ /**
63
+ * The top of the keyboard, if one appears to be up.
64
+ *
65
+ * Inferred from a dense band of similar-height elements filling the bottom of
66
+ * the screen — keys. Returns null when nothing looks like one, which is the
67
+ * common case and must stay cheap.
68
+ */
69
+ export function detectKeyboardTop(elements, screen) {
70
+ if (!screen?.height || elements.length < 12) return null;
71
+ const threshold = screen.height * (1 - KEYBOARD_MIN_FRACTION);
72
+ const low = elements.filter((e) => e.frame && e.frame.y > threshold);
73
+ if (low.length < 12) return null;
74
+ const heights = low.map((e) => e.frame.height ?? 0).sort((a, b) => a - b);
75
+ const median = heights[heights.length >> 1];
76
+ // Keys are small and uniform; a list of cells down there is not.
77
+ const uniform = heights.filter((h) => Math.abs(h - median) <= Math.max(3, median * 0.4)).length;
78
+ if (uniform / low.length < 0.7 || median > screen.height * 0.07) return null;
79
+ return Math.min(...low.map((e) => e.frame.y));
80
+ }
81
+
82
+ /** Annotate a target list with region and nav slot. Mutates and returns it. */
83
+ export function annotate(targets, screen) {
84
+ const keyboardTop = detectKeyboardTop(targets, screen);
85
+ for (const t of targets) {
86
+ t.region = regionFor(t.frame, screen, { keyboardTop });
87
+ if (t.region === 'nav-bar') t.navSlot = navSlot(t.frame, screen);
88
+ }
89
+ return targets;
90
+ }
package/src/screenmap.js CHANGED
@@ -8,11 +8,14 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { hashDistance } from './analyze.js';
11
+ import * as control from './control.js';
12
+ import * as fingerprint from './fingerprint.js';
11
13
  import * as input from './input.js';
12
14
  import * as ocr from './ocr.js';
15
+ import * as regions from './regions.js';
13
16
  import * as store from './store.js';
14
17
 
15
- const MAP_VERSION = 2; // layout hash crop changed; old maps no longer comparable
18
+ const MAP_VERSION = 5; // tab-band content no longer contributes labels
16
19
 
17
20
  function mapDir(udid) {
18
21
  return path.join(store.deviceDir(udid), 'screens');
@@ -20,11 +23,18 @@ function mapDir(udid) {
20
23
 
21
24
  /**
22
25
  * How many of the 288 layout bits may differ and still count as the same screen.
23
- * Measured on a real app: revisiting a screen (with different list rows and a
24
- * different clock) moved 0-3 bits; different screens were 77-96 apart. 12 sits
25
- * well clear of both.
26
+ *
27
+ * Re-measured across four visits to each of five screens: a revisit is usually
28
+ * identical (median 0) but the tail reaches 62 when list content has changed,
29
+ * while different screens sit at 74 and above. That margin is much narrower
30
+ * than the first calibration suggested, and it is the reason this number stays
31
+ * conservative rather than being raised to cover the tail.
32
+ *
33
+ * The consequence is deliberate: a heavily changed screen is rebuilt rather
34
+ * than recognised. A rebuild costs ~300ms; a false match taps the wrong
35
+ * control. See docs/DEFERRED.md on fingerprinting structure instead of pixels.
26
36
  */
27
- export const DEFAULT_TOLERANCE = 12;
37
+ export const DEFAULT_TOLERANCE = 20;
28
38
 
29
39
  export function recall(udid, hash) {
30
40
  if (!hash) return null;
@@ -104,16 +114,25 @@ export async function build(udid, {
104
114
  density = 3,
105
115
  useAx = true,
106
116
  useOcr = true,
117
+ persist = true,
107
118
  screen,
108
119
  } = {}) {
109
120
  const targets = [];
110
121
  const sources = [];
111
- // Kick OCR off before reading the tree: they are independent, and the OCR
112
- // pass is pure computation on a file that already exists.
113
- const ocrPromise =
114
- useOcr && fullFrame && fs.existsSync(fullFrame)
115
- ? ocr.readText(fullFrame, { density }).catch((err) => err)
116
- : null;
122
+ // OCR starts before the tree read: they are independent, and running them in
123
+ // series costs the whole recognition pass.
124
+ //
125
+ // The daemon reads text straight off the framebuffer. The fallback encodes a
126
+ // PNG, writes it, spawns a helper and decodes it again — measured at 555ms
127
+ // against 174ms — so it is only used when no daemon is listening.
128
+ const viaDaemon = useOcr && control.available(udid);
129
+ const ocrPromise = !useOcr
130
+ ? null
131
+ : viaDaemon
132
+ ? control.request(udid, { action: 'ui' }).catch((err) => err)
133
+ : fullFrame && fs.existsSync(fullFrame)
134
+ ? ocr.readText(fullFrame, { density }).catch((err) => err)
135
+ : null;
117
136
  // With no geometry, treat every element as a potential control rather than
118
137
  // guessing a screen size and mis-classifying containers.
119
138
  const screenArea = screen?.width && screen?.height ? screen.width * screen.height : Infinity;
@@ -146,8 +165,24 @@ export async function build(udid, {
146
165
 
147
166
  if (ocrPromise) {
148
167
  try {
149
- const words = await ocrPromise;
150
- if (words instanceof Error) throw words;
168
+ const result = await ocrPromise;
169
+ if (result instanceof Error) throw result;
170
+ // The daemon answers in points; readText answers in points too, having
171
+ // divided by density. Normalise the daemon's element shape to match.
172
+ const words = viaDaemon
173
+ ? (result.screen?.elements ?? [])
174
+ .filter((e) => e.label?.trim())
175
+ .map((e) => ({
176
+ text: e.label,
177
+ confidence: e.confidence,
178
+ x: e.frame.x,
179
+ y: e.frame.y,
180
+ width: e.frame.width,
181
+ height: e.frame.height,
182
+ centerX: e.center.x,
183
+ centerY: e.center.y,
184
+ }))
185
+ : result;
151
186
  sources.push('ocr');
152
187
  for (const w of words) {
153
188
  if (!w.text.trim()) continue;
@@ -187,14 +222,35 @@ export async function build(udid, {
187
222
  }
188
223
  }
189
224
 
190
- return remember(udid, {
191
- version: MAP_VERSION,
192
- hash,
193
- layoutHash,
194
- at: Date.now(),
195
- sources,
196
- targets,
197
- });
225
+ return finish();
226
+
227
+ function finish() {
228
+ // Region priors are geometry, so they cost nothing and disambiguate a great
229
+ // deal: "Assets" the nav title and "Assets" the tab differ only by where
230
+ // they are.
231
+ if (screen?.width && screen?.height) regions.annotate(targets, screen);
232
+ // Two hashes, two jobs. The pixel layout hash indexes this entry, because
233
+ // it can be computed from a frame alone and so can find a map without
234
+ // building one. The structural hash identifies the screen, because content
235
+ // is pixels and a list with new rows is not a new screen.
236
+ const structure = screen?.width && screen?.height
237
+ ? fingerprint.fingerprint(targets, screen)
238
+ : { hash: null, tokens: [], keyboard: false };
239
+ const entry = {
240
+ version: MAP_VERSION,
241
+ hash,
242
+ layoutHash,
243
+ structuralHash: structure.hash,
244
+ structuralTokens: structure.tokens,
245
+ keyboard: structure.keyboard,
246
+ at: Date.now(),
247
+ sources,
248
+ targets,
249
+ };
250
+ // Only a map of a settled screen is worth keeping; remembering a transition
251
+ // fills the store with layouts that will never be seen again.
252
+ return persist ? remember(udid, entry) : entry;
253
+ }
198
254
  }
199
255
 
200
256
  const norm = (s) => String(s ?? '').toLowerCase().trim();
package/src/simctl.js CHANGED
@@ -96,9 +96,17 @@ export function isBootedSync(udid) {
96
96
  }
97
97
 
98
98
  export async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
99
- await run('xcrun', ['simctl', 'io', udid, 'screenshot', '--type=png', `--mask=${mask}`, outFile], {
100
- timeout: 10_000,
101
- });
99
+ try {
100
+ await run('xcrun', ['simctl', 'io', udid, 'screenshot', '--type=png', `--mask=${mask}`, outFile], {
101
+ timeout: 10_000,
102
+ });
103
+ } catch (err) {
104
+ // Same reason as launchApp: execFile's message is "Command failed: <the
105
+ // whole command>" and simctl's actual complaint is in stderr. A CI failure
106
+ // here reported the command and nothing about why it did not work.
107
+ const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
108
+ throw new Error(detail ? `simctl screenshot failed: ${detail}` : `simctl screenshot failed: ${err.message}`);
109
+ }
102
110
  }
103
111
 
104
112
  /** Resample with sips, which ships with macOS, so simframe needs no image deps. */
@@ -107,7 +115,15 @@ export async function resize(inFile, outFile, maxDim) {
107
115
  }
108
116
 
109
117
  export async function launchApp(udid, bundleId) {
110
- await run('xcrun', ['simctl', 'launch', udid, bundleId], { timeout: 20_000 });
118
+ try {
119
+ await run('xcrun', ['simctl', 'launch', udid, bundleId], { timeout: 20_000 });
120
+ } catch (err) {
121
+ // execFile's message is just "Command failed: ..." with simctl's actual
122
+ // complaint left in stderr. A CI run failed here and said nothing about
123
+ // why, which is the same sin as a silent fallback.
124
+ const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
125
+ throw new Error(detail ? `could not launch ${bundleId}: ${detail}` : `could not launch ${bundleId}: ${err.message}`);
126
+ }
111
127
  }
112
128
 
113
129
  export async function terminateApp(udid, bundleId) {