simframe 0.5.0 → 0.6.0-rc.1

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/refs.js ADDED
@@ -0,0 +1,141 @@
1
+ // Element refs and selectors.
2
+ //
3
+ // A number in front of every element is what lets the next call name one
4
+ // without describing it: `#3` instead of "the second Save button, the one in
5
+ // the nav bar". The table lives on disk because the call that numbered the
6
+ // elements and the call that acts on one are separate MCP round trips.
7
+ //
8
+ // Kept apart from view.js so that index.js can resolve a selector without
9
+ // importing the renderer that in turn imports index.js.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { hashDistance } from './analyze.js';
13
+ import * as store from './store.js';
14
+
15
+ /**
16
+ * How far the pixel layout may drift before a ref is no longer trustworthy.
17
+ *
18
+ * The same number screen memory uses to decide two frames are the same screen,
19
+ * and for the same reason: a list that gained a row is still the screen the
20
+ * elements were numbered on, but a different screen is not.
21
+ */
22
+ export const REF_TOLERANCE = 20;
23
+
24
+ /**
25
+ * Does this layout hash carry enough signal to compare?
26
+ *
27
+ * A blank, dark or near-uniform screen hashes to almost all zeros, and the
28
+ * Hamming distance between two such hashes is tiny however different the
29
+ * screens are. Below this many set bits the hash is not evidence.
30
+ */
31
+ const MIN_SET_BITS = 16;
32
+
33
+ export function informative(hex) {
34
+ let bits = 0;
35
+ for (const ch of String(hex ?? '')) {
36
+ const v = parseInt(ch, 16);
37
+ if (Number.isNaN(v)) continue;
38
+ bits += (v & 1) + ((v >> 1) & 1) + ((v >> 2) & 1) + ((v >> 3) & 1);
39
+ if (bits >= MIN_SET_BITS) return true;
40
+ }
41
+ return false;
42
+ }
43
+
44
+ const refsFile = (udid) => path.join(store.deviceDir(udid), 'refs.json');
45
+
46
+ /**
47
+ * Number the elements and write the table down.
48
+ *
49
+ * A ref is only meaningful while the screen it was numbered on is still
50
+ * showing, so the table records the screen's structural hash and resolving a
51
+ * ref against a different screen is an error rather than a tap somewhere
52
+ * unintended.
53
+ */
54
+ export function writeRefs(udid, { structuralHash, layoutHash, rows }) {
55
+ const body = {
56
+ structuralHash: structuralHash ?? null,
57
+ layoutHash: layoutHash ?? null,
58
+ at: Date.now(),
59
+ refs: rows.map((r) => ({
60
+ ref: r.ref,
61
+ label: r.label ?? null,
62
+ x: r.x,
63
+ y: r.y,
64
+ type: r.type ?? null,
65
+ region: r.region ?? 'content',
66
+ source: r.source ?? null,
67
+ })),
68
+ };
69
+ try {
70
+ fs.mkdirSync(path.dirname(refsFile(udid)), { recursive: true });
71
+ store.writeAtomic(refsFile(udid), JSON.stringify(body));
72
+ } catch {
73
+ /* refs are a convenience; failing to cache them must not fail the call */
74
+ }
75
+ return body;
76
+ }
77
+
78
+ export function readRefs(udid) {
79
+ return store.readJson(refsFile(udid));
80
+ }
81
+
82
+ /**
83
+ * `#3` | `@120,400` | anything else.
84
+ *
85
+ * Parsing is separate from resolving so a caller can tell a selector from a
86
+ * label without touching the device.
87
+ */
88
+ export function parseSelector(query) {
89
+ const raw = String(query ?? '').trim();
90
+ const ref = /^#(\d+)$/.exec(raw);
91
+ if (ref) return { kind: 'ref', ref: Number(ref[1]) };
92
+ const at = /^@\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/.exec(raw);
93
+ if (at) return { kind: 'point', x: Math.round(Number(at[1])), y: Math.round(Number(at[2])) };
94
+ // A quoted label is an explicit "this exact text", not an intent.
95
+ const quoted = /^"(.*)"$/.exec(raw) || /^'(.*)'$/.exec(raw);
96
+ if (quoted) return { kind: 'label', label: quoted[1], exact: true };
97
+ return { kind: 'label', label: raw, exact: false };
98
+ }
99
+
100
+ /**
101
+ * Turn a `#n` back into a point.
102
+ *
103
+ * Refuses when the screen has moved on. A stale ref is the one failure mode
104
+ * numbering introduces that labels do not have, and a ref resolved against the
105
+ * wrong screen taps whatever now happens to sit at those coordinates.
106
+ */
107
+ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, tolerance = REF_TOLERANCE } = {}) {
108
+ const table = readRefs(udid);
109
+ if (!table) throw new Error(`#${n} means nothing yet — read the screen first (sim_ui, or simframe ui)`);
110
+ const stale = (was, now) =>
111
+ `#${n} was numbered on a different screen (${was} → ${now}) — read the screen again before using refs`;
112
+
113
+ // Structural identity first, because it is the question actually being asked:
114
+ // is this the screen those numbers were assigned on? The caller gets it
115
+ // cheaply — screen memory is a file read, not a perception pass.
116
+ if (table.structuralHash && structuralHash && table.structuralHash !== structuralHash) {
117
+ throw new Error(stale(table.structuralHash.slice(0, 8), structuralHash.slice(0, 8)));
118
+ }
119
+ // Nothing recognises the screen we are on, so nothing can vouch for the
120
+ // numbers. Refusing costs a re-read; guessing taps whatever is at those
121
+ // coordinates now.
122
+ if (screenKnown === false) {
123
+ throw new Error(`#${n} cannot be trusted here — simframe does not recognise this screen. Read it again (sim_ui) to renumber.`);
124
+ }
125
+ // The pixel check stays, but only as a backstop, and only where it means
126
+ // something. A dark or near-uniform screen produces a layout hash of almost
127
+ // all zeros, and two such screens sit within any sane tolerance of each
128
+ // other — measured: refs numbered on the springboard resolved happily on a
129
+ // different screen because both hashes were degenerate. A hash with almost
130
+ // no bits set is not evidence of anything.
131
+ if (layoutHash && table.layoutHash && informative(table.layoutHash) && informative(layoutHash)
132
+ && hashDistance(table.layoutHash, layoutHash) > tolerance) {
133
+ throw new Error(stale(table.layoutHash.slice(0, 8), layoutHash.slice(0, 8)));
134
+ }
135
+ const hit = table.refs.find((r) => r.ref === n);
136
+ if (!hit) {
137
+ const available = table.refs.length ? `#1-#${table.refs.length}` : 'none';
138
+ throw new Error(`#${n} is not on this screen (numbered: ${available})`);
139
+ }
140
+ return hit;
141
+ }
package/src/regions.js CHANGED
@@ -1,10 +1,21 @@
1
1
  // Where on the screen something is, in the terms iOS itself uses.
2
2
  //
3
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.
4
+ // a label plus a region rarely is. And the region matters more than it looks:
5
+ // chrome labels are the only text that enters a screen's structural
6
+ // fingerprint, so anything misfiled as chrome lands directly in that screen's
7
+ // identity.
8
+ //
9
+ // This used to be fractions of screen height, and it cost three bugs in three
10
+ // phases: a nav button read as a title, an identity containing "sep 08, 2026"
11
+ // that would have expired at midnight, and a springboard whose identity was the
12
+ // name of the city in its weather widget. Each was patched with another rule.
13
+ //
14
+ // The bands are now derived from where the elements themselves sit, because the
15
+ // thing that actually distinguishes chrome from content is not height on the
16
+ // screen — it is separation. A nav bar sits above a gap; a tab bar sits below
17
+ // one; and a springboard, whose icon rows are evenly spaced from top to bottom,
18
+ // has neither and should be told it has neither.
8
19
  export const REGIONS = [
9
20
  'status-bar',
10
21
  'nav-bar',
@@ -14,34 +25,199 @@ export const REGIONS = [
14
25
  ];
15
26
 
16
27
  /**
17
- * Fractions of screen height. Deliberately conservative: a band that is too
18
- * greedy mislabels content as chrome, and content is the common case.
28
+ * The status bar stays positional, and deliberately.
29
+ *
30
+ * It is a device inset — the notch or dynamic island — not app layout, so its
31
+ * position is a property of the hardware rather than of the screen. It has
32
+ * never been the source of a misclassification, and clustering it would mean
33
+ * inferring a constant from noise.
19
34
  */
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
- };
35
+ const STATUS_BAR_FRACTION = 0.065;
25
36
 
26
37
  /** Keyboards occupy the bottom of the screen and are unusually tall. */
27
38
  const KEYBOARD_MIN_FRACTION = 0.28;
28
39
 
40
+ /** Chrome is short. A 90pt list cell is not a tab item however low it sits. */
41
+ const CHROME_MAX_HEIGHT_FRACTION = 0.075;
42
+
43
+ /**
44
+ * How far into the screen chrome may reach.
45
+ *
46
+ * Not the decision — the gap is the decision — but a precondition, because a
47
+ * gap in the middle of a screen separates two pieces of content and nothing
48
+ * else. Generous on both ends so that a tall nav bar with a search field in it,
49
+ * or a tab bar above a home indicator, still qualifies.
50
+ */
51
+ const TOP_CHROME_LIMIT = 0.28;
52
+ const BOTTOM_CHROME_LIMIT = 0.82;
53
+
29
54
  /**
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.
55
+ * What makes a gap a boundary rather than spacing.
56
+ *
57
+ * Both conditions, because either alone is wrong. An absolute floor, since two
58
+ * rows 4pt apart are one visual group whatever the rest of the screen does; and
59
+ * a multiple of the screen's own median row gap, since 16pt is a boundary on a
60
+ * dense list and ordinary spacing on a sparse one. This is the whole idea: the
61
+ * screen sets its own scale.
33
62
  */
34
- const CHROME_MAX_HEIGHT_FRACTION = 0.075;
63
+ const MIN_BOUNDARY_GAP_PT = 10;
64
+ const BOUNDARY_GAP_FACTOR = 1.9;
65
+
66
+ /** A tab bar is several things spread across the width, not one thing at the bottom. */
67
+ const TAB_BAR_MIN_ITEMS = 2;
68
+ const TAB_BAR_MIN_SPREAD = 0.4;
69
+
70
+ /** Below this many elements there is no distribution to cluster; fall back. */
71
+ const MIN_ELEMENTS_TO_CLUSTER = 6;
72
+
73
+ /**
74
+ * Fractions of screen height, used only when clustering has nothing to work
75
+ * with. These are the old rules, kept because a screen with four elements on it
76
+ * still needs an answer and the HIG is a better guess than none.
77
+ */
78
+ const FALLBACK = { navBar: 0.14, tabBar: 0.86 };
79
+
80
+ const heightOf = (frame) => frame?.height ?? 0;
81
+ const midY = (frame) => frame.y + heightOf(frame) / 2;
82
+
83
+ /** Group elements into horizontal rows: a sweep down the screen, joining anything that overlaps. */
84
+ export function rowsOf(elements) {
85
+ const items = elements
86
+ .filter((e) => e.frame && Number.isFinite(e.frame.y))
87
+ .map((e) => ({ e, top: e.frame.y, bottom: e.frame.y + heightOf(e.frame) }))
88
+ .sort((a, b) => midY(a.e.frame) - midY(b.e.frame));
89
+ const rows = [];
90
+ for (const it of items) {
91
+ const row = rows[rows.length - 1];
92
+ // Overlapping vertically means side by side, which means one row.
93
+ if (row && it.top < row.bottom) {
94
+ row.items.push(it.e);
95
+ row.top = Math.min(row.top, it.top);
96
+ row.bottom = Math.max(row.bottom, it.bottom);
97
+ } else {
98
+ rows.push({ items: [it.e], top: it.top, bottom: it.bottom });
99
+ }
100
+ }
101
+ return rows;
102
+ }
103
+
104
+ function medianOf(values) {
105
+ if (!values.length) return 0;
106
+ const v = [...values].sort((a, b) => a - b);
107
+ return v[v.length >> 1];
108
+ }
109
+
110
+ /**
111
+ * The horizontal reach of a row, as a fraction of screen width.
112
+ *
113
+ * A tab bar spans the screen; a single centred label does not. Measured between
114
+ * the outermost centres rather than the outermost edges, so one wide element
115
+ * cannot fake a spread on its own.
116
+ */
117
+ function spreadOf(row, screen) {
118
+ if (!screen?.width || row.items.length < 2) return 0;
119
+ const centres = row.items.map((e) => e.frame.x + (e.frame.width ?? 0) / 2);
120
+ return (Math.max(...centres) - Math.min(...centres)) / screen.width;
121
+ }
122
+
123
+ const allShort = (row, screen) =>
124
+ row.items.every((e) => heightOf(e.frame) <= screen.height * CHROME_MAX_HEIGHT_FRACTION);
125
+
126
+ /**
127
+ * Where this screen's bands actually are.
128
+ *
129
+ * Returns boundaries in points, so `regionFor` stays a cheap comparison and the
130
+ * clustering is paid for once per screen rather than once per element.
131
+ */
132
+ export function bands(elements, screen) {
133
+ const keyboardTop = detectKeyboardTop(elements, screen);
134
+ const statusBarBottom = screen?.height ? screen.height * STATUS_BAR_FRACTION : 0;
135
+ if (!screen?.width || !screen?.height) {
136
+ return { statusBarBottom: 0, navBarBottom: 0, tabBarTop: Infinity, keyboardTop, clustered: false };
137
+ }
138
+
139
+ // Everything the bands are inferred from: on-screen, below the status bar,
140
+ // and above the keyboard if one is up. The status bar is a clock and a battery
141
+ // icon, and letting them vote on where the nav bar ends is how a nav bar came
142
+ // to include the clock.
143
+ const considered = elements.filter((e) => {
144
+ if (!e.frame || !Number.isFinite(e.frame.y)) return false;
145
+ if (e.frame.y + heightOf(e.frame) <= statusBarBottom) return false;
146
+ if (keyboardTop != null && e.frame.y >= keyboardTop) return false;
147
+ return e.frame.y < screen.height && e.frame.y + heightOf(e.frame) > 0;
148
+ });
149
+
150
+ const rows = rowsOf(considered);
151
+ if (rows.length < 3 || considered.length < MIN_ELEMENTS_TO_CLUSTER) {
152
+ return {
153
+ statusBarBottom,
154
+ navBarBottom: screen.height * FALLBACK.navBar,
155
+ tabBarTop: screen.height * FALLBACK.tabBar,
156
+ keyboardTop,
157
+ clustered: false,
158
+ };
159
+ }
35
160
 
36
- export function regionFor(frame, screen, { keyboardTop } = {}) {
161
+ const gaps = rows.slice(1).map((row, i) => row.top - rows[i].bottom);
162
+ const typical = medianOf(gaps.filter((g) => g > 0));
163
+ const isBoundary = (gap) => gap >= Math.max(MIN_BOUNDARY_GAP_PT, typical * BOUNDARY_GAP_FACTOR);
164
+
165
+ // --- top chrome. Up to two rows, because a nav bar can be a title above a
166
+ // search field, and no more, because three rows of anything is content.
167
+ let navBarBottom = 0;
168
+ for (let i = 0; i < Math.min(2, rows.length - 1); i += 1) {
169
+ const gap = rows[i + 1].top - rows[i].bottom;
170
+ const withinReach = rows[i].bottom <= screen.height * TOP_CHROME_LIMIT;
171
+ if (!withinReach) break;
172
+ if (isBoundary(gap) && rows.slice(0, i + 1).every((r) => allShort(r, screen))) {
173
+ navBarBottom = rows[i].bottom;
174
+ break;
175
+ }
176
+ }
177
+
178
+ // --- bottom chrome. One row: a tab bar is one row by construction, and the
179
+ // gap above it is what separates it from the list it floats over.
180
+ let tabBarTop = Infinity;
181
+ const last = rows[rows.length - 1];
182
+ const gapAbove = last.top - rows[rows.length - 2].bottom;
183
+ if (
184
+ last.top >= screen.height * BOTTOM_CHROME_LIMIT
185
+ && allShort(last, screen)
186
+ && last.items.length >= TAB_BAR_MIN_ITEMS
187
+ && spreadOf(last, screen) >= TAB_BAR_MIN_SPREAD
188
+ && isBoundary(gapAbove)
189
+ ) {
190
+ tabBarTop = last.top;
191
+ }
192
+
193
+ return { statusBarBottom, navBarBottom, tabBarTop, keyboardTop, clustered: true, typicalGap: typical };
194
+ }
195
+
196
+ /**
197
+ * Which band this frame falls in.
198
+ *
199
+ * `band` comes from `bands()`. Passing only `{keyboardTop}` still works and
200
+ * falls back to the HIG fractions, which is what callers that have one element
201
+ * and no screen context get.
202
+ */
203
+ export function regionFor(frame, screen, band = {}) {
37
204
  if (!frame || !screen?.height) return 'content';
38
- const top = frame.y / screen.height;
39
- const bottom = (frame.y + (frame.height ?? 0)) / screen.height;
205
+ const { keyboardTop } = band;
40
206
  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';
207
+
208
+ const top = frame.y;
209
+ const bottom = frame.y + heightOf(frame);
210
+ const statusBarBottom = band.statusBarBottom ?? screen.height * STATUS_BAR_FRACTION;
211
+ if (bottom <= statusBarBottom) return 'status-bar';
212
+
213
+ const short = heightOf(frame) <= screen.height * CHROME_MAX_HEIGHT_FRACTION;
214
+ const navBarBottom = band.navBarBottom ?? screen.height * FALLBACK.navBar;
215
+ const tabBarTop = band.tabBarTop ?? screen.height * FALLBACK.tabBar;
216
+ // A screen with no top chrome has navBarBottom 0, so nothing is a nav bar —
217
+ // which is the correct answer for a springboard, and the answer the old
218
+ // positional rule could not give.
219
+ if (short && navBarBottom > 0 && bottom <= navBarBottom + 1) return 'nav-bar';
220
+ if (short && top >= tabBarTop - 1) return 'tab-bar';
45
221
  return 'content';
46
222
  }
47
223
 
@@ -64,14 +240,15 @@ export function navSlot(frame, screen) {
64
240
  *
65
241
  * Inferred from a dense band of similar-height elements filling the bottom of
66
242
  * the screen — keys. Returns null when nothing looks like one, which is the
67
- * common case and must stay cheap.
243
+ * common case and must stay cheap. This was the first band derived from the
244
+ * elements rather than from a fraction, and it is the model the rest now follow.
68
245
  */
69
246
  export function detectKeyboardTop(elements, screen) {
70
247
  if (!screen?.height || elements.length < 12) return null;
71
248
  const threshold = screen.height * (1 - KEYBOARD_MIN_FRACTION);
72
249
  const low = elements.filter((e) => e.frame && e.frame.y > threshold);
73
250
  if (low.length < 12) return null;
74
- const heights = low.map((e) => e.frame.height ?? 0).sort((a, b) => a - b);
251
+ const heights = low.map((e) => heightOf(e.frame)).sort((a, b) => a - b);
75
252
  const median = heights[heights.length >> 1];
76
253
  // Keys are small and uniform; a list of cells down there is not.
77
254
  const uniform = heights.filter((h) => Math.abs(h - median) <= Math.max(3, median * 0.4)).length;
@@ -81,9 +258,9 @@ export function detectKeyboardTop(elements, screen) {
81
258
 
82
259
  /** Annotate a target list with region and nav slot. Mutates and returns it. */
83
260
  export function annotate(targets, screen) {
84
- const keyboardTop = detectKeyboardTop(targets, screen);
261
+ const band = bands(targets, screen);
85
262
  for (const t of targets) {
86
- t.region = regionFor(t.frame, screen, { keyboardTop });
263
+ t.region = regionFor(t.frame, screen, band);
87
264
  if (t.region === 'nav-bar') t.navSlot = navSlot(t.frame, screen);
88
265
  }
89
266
  return targets;
package/src/screenmap.js CHANGED
@@ -15,7 +15,7 @@ import * as ocr from './ocr.js';
15
15
  import * as regions from './regions.js';
16
16
  import * as store from './store.js';
17
17
 
18
- const MAP_VERSION = 5; // tab-band content no longer contributes labels
18
+ const MAP_VERSION = 6; // dates, prices and phone numbers no longer contribute labels
19
19
 
20
20
  function mapDir(udid) {
21
21
  return path.join(store.deviceDir(udid), 'screens');
@@ -119,20 +119,42 @@ export async function build(udid, {
119
119
  } = {}) {
120
120
  const targets = [];
121
121
  const sources = [];
122
- // OCR starts before the tree read: they are independent, and running them in
123
- // series costs the whole recognition pass.
122
+ // Why a layer is missing, kept rather than swallowed.
123
+ //
124
+ // A partial map used to be indistinguishable from a whole one: `sources` said
125
+ // ["ax"] and nothing said why OCR was not there. On CI this produced a map
126
+ // with zero elements reported as a successful read, and the only way to find
127
+ // out what had happened was to guess. Before the tree came in-process the
128
+ // same failure was loud, because with no idb the ax layer failed too and an
129
+ // empty `sources` rethrew — so making a layer work turned a loud failure into
130
+ // a quiet one.
131
+ const degraded = [];
132
+ // One round trip for both, because the daemon runs the tree read and the
133
+ // recognition pass concurrently against the same instant of the screen. Asked
134
+ // separately they would queue: the control socket serves one request at a
135
+ // time, so a second call pays the first one's latency before it starts.
124
136
  //
125
137
  // The daemon reads text straight off the framebuffer. The fallback encodes a
126
138
  // PNG, writes it, spawns a helper and decodes it again — measured at 555ms
127
139
  // against 174ms — so it is only used when no daemon is listening.
128
- const viaDaemon = useOcr && control.available(udid);
129
- const ocrPromise = !useOcr
140
+ const viaDaemon = (useOcr || useAx) && control.available(udid);
141
+ const axViaDaemon = useAx && process.env.SIMFRAME_AX_DRIVER !== 'idb';
142
+ const daemonPromise = viaDaemon
143
+ ? control.request(udid, { action: 'ui', ax: axViaDaemon, ocr: useOcr }).catch((err) => err)
144
+ : null;
145
+ const ocrPromise = !useOcr || viaDaemon
130
146
  ? 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;
147
+ : fullFrame && fs.existsSync(fullFrame)
148
+ ? ocr.readText(fullFrame, { density }).catch((err) => err)
149
+ : null;
150
+ const daemonAnswer = daemonPromise ? await daemonPromise : null;
151
+ // Keep the failure rather than flattening it to null. A daemon that was
152
+ // listening and then did not answer is a loud failure, and the version of
153
+ // this that dropped it returned an empty map with no error — which `persist`
154
+ // then wrote into screen memory, so a later warm visit read the emptiness
155
+ // back instead of perceiving the screen again.
156
+ const daemonError = daemonAnswer instanceof Error ? daemonAnswer : null;
157
+ const daemonScreen = daemonError ? null : daemonAnswer?.screen ?? null;
136
158
  // With no geometry, treat every element as a potential control rather than
137
159
  // guessing a screen size and mis-classifying containers.
138
160
  const screenArea = screen?.width && screen?.height ? screen.width * screen.height : Infinity;
@@ -144,7 +166,12 @@ export async function build(udid, {
144
166
 
145
167
  if (useAx) {
146
168
  try {
147
- const nodes = await input.describeAll(udid);
169
+ // The tree is already in hand when the daemon answered; describeAll would
170
+ // only ask for it a second time.
171
+ const nodes = daemonScreen?.sources?.includes('ax')
172
+ ? daemonScreen.elements.filter((e) => e.source?.includes('ax')).map(input.elementToNode)
173
+ : await input.describeAll(udid);
174
+
148
175
  sources.push('ax');
149
176
  for (const n of nodes) {
150
177
  if (!n.frame || !n.label || isContainer(n)) continue;
@@ -158,20 +185,29 @@ export async function build(udid, {
158
185
  source: 'ax',
159
186
  });
160
187
  }
161
- } catch {
188
+ } catch (err) {
162
189
  /* no idb, or the tree read failed; OCR alone is still useful */
190
+ degraded.push(`accessibility: ${err.message}`);
163
191
  }
164
192
  }
165
193
 
166
- if (ocrPromise) {
194
+ // Neither layer was even attempted: nothing below can report the failure, so
195
+ // it has to be reported here rather than returned as an empty screen.
196
+ if (daemonError && !useOcr) throw daemonError;
197
+
198
+ if (ocrPromise || (useOcr && viaDaemon)) {
167
199
  try {
168
- const result = await ocrPromise;
200
+ const result = ocrPromise ? await ocrPromise : (daemonError ?? daemonScreen);
169
201
  if (result instanceof Error) throw result;
202
+ if (!result) throw new Error('the daemon did not answer');
203
+ // A daemon that answered without reading text is not an OCR source, and
204
+ // saying it was would claim the screen had been read when it had not.
205
+ if (viaDaemon && !result.sources?.includes('ocr')) throw new Error(result.ocrError ?? 'no text was read');
170
206
  // The daemon answers in points; readText answers in points too, having
171
207
  // divided by density. Normalise the daemon's element shape to match.
172
208
  const words = viaDaemon
173
- ? (result.screen?.elements ?? [])
174
- .filter((e) => e.label?.trim())
209
+ ? (result.elements ?? [])
210
+ .filter((e) => e.source?.includes('ocr') && e.label?.trim())
175
211
  .map((e) => ({
176
212
  text: e.label,
177
213
  confidence: e.confidence,
@@ -218,6 +254,7 @@ export async function build(udid, {
218
254
  });
219
255
  }
220
256
  } catch (err) {
257
+ degraded.push(`text recognition: ${err.message}`);
221
258
  if (!sources.length) throw err;
222
259
  }
223
260
  }
@@ -247,6 +284,10 @@ export async function build(udid, {
247
284
  sources,
248
285
  targets,
249
286
  };
287
+ // A truncated tree is nodes without authority; the daemon says so and the
288
+ // map has to carry it, because this is what gets written into memory.
289
+ if (daemonScreen?.axTruncated) degraded.push(`accessibility tree cut short: ${daemonScreen.axTruncated}`);
290
+ if (degraded.length) entry.degraded = degraded;
250
291
  // Only a map of a settled screen is worth keeping; remembering a transition
251
292
  // fills the store with layouts that will never be seen again.
252
293
  return persist ? remember(udid, entry) : entry;
package/src/simctl.js CHANGED
@@ -114,9 +114,31 @@ export async function resize(inFile, outFile, maxDim) {
114
114
  await run('sips', ['-Z', String(maxDim), inFile, '--out', outFile], { timeout: 10_000 });
115
115
  }
116
116
 
117
- export async function launchApp(udid, bundleId) {
117
+ /**
118
+ * Launch, optionally with arguments and environment.
119
+ *
120
+ * simctl passes launch arguments after the bundle id and environment through
121
+ * `SIMCTL_CHILD_`-prefixed variables of its own process — which is why env has
122
+ * to be set on the child rather than passed as flags.
123
+ */
124
+ export async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst = false } = {}) {
125
+ if (terminateFirst) {
126
+ // A launch against an already-running app is a no-op that reports success,
127
+ // which is how a flow "relaunched" an app and tested the screen it was
128
+ // already on.
129
+ try {
130
+ await terminateApp(udid, bundleId);
131
+ } catch {
132
+ /* not running; that is the state we wanted */
133
+ }
134
+ }
135
+ const childEnv = { ...process.env };
136
+ for (const [k, v] of Object.entries(env)) childEnv[`SIMCTL_CHILD_${k}`] = String(v);
118
137
  try {
119
- await run('xcrun', ['simctl', 'launch', udid, bundleId], { timeout: 20_000 });
138
+ await run('xcrun', ['simctl', 'launch', udid, bundleId, ...args.map(String)], {
139
+ timeout: 20_000,
140
+ env: childEnv,
141
+ });
120
142
  } catch (err) {
121
143
  // execFile's message is just "Command failed: ..." with simctl's actual
122
144
  // complaint left in stderr. A CI run failed here and said nothing about
@@ -134,6 +156,37 @@ export async function openUrl(udid, url) {
134
156
  await run('xcrun', ['simctl', 'openurl', udid, url], { timeout: 20_000 });
135
157
  }
136
158
 
159
+ export const PERMISSION_SERVICES = [
160
+ 'all', 'calendar', 'contacts-limited', 'contacts', 'location', 'location-always',
161
+ 'photos-add', 'photos', 'media-library', 'microphone', 'motion', 'reminders', 'siri',
162
+ ];
163
+
164
+ /**
165
+ * Grant, revoke or reset a privacy permission.
166
+ *
167
+ * The point of doing this from a test harness is that the alternative is
168
+ * tapping a system alert, and a system alert is not part of the app under test:
169
+ * its buttons move between iOS versions and its appearance is a race.
170
+ */
171
+ export async function setPermission(udid, action, service, bundleId) {
172
+ const verb = String(action).toLowerCase();
173
+ if (!['grant', 'revoke', 'reset'].includes(verb)) {
174
+ throw new Error(`permission action must be grant, revoke or reset (got "${action}")`);
175
+ }
176
+ if (!PERMISSION_SERVICES.includes(service)) {
177
+ throw new Error(`unknown permission "${service}" — one of: ${PERMISSION_SERVICES.join(', ')}`);
178
+ }
179
+ const args = ['simctl', 'privacy', udid, verb, service];
180
+ if (bundleId) args.push(bundleId);
181
+ try {
182
+ await run('xcrun', args, { timeout: 20_000 });
183
+ } catch (err) {
184
+ const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
185
+ throw new Error(`could not ${verb} ${service}: ${detail || err.message}`);
186
+ }
187
+ return `${verb === 'reset' ? 'reset' : verb + 'ed'} ${service}${bundleId ? ` for ${bundleId}` : ''}`;
188
+ }
189
+
137
190
  /** Put text on the device pasteboard — far faster than typing a long string. */
138
191
  export async function setPasteboard(udid, value) {
139
192
  const child = execFile('xcrun', ['simctl', 'pbcopy', udid], { timeout: 10_000 });