simframe 0.5.0 → 0.6.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/README.md +128 -53
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +78 -4
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +32 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +24 -1
- package/native/simframed/Sources/SimframeCore/Element.swift +29 -1
- package/native/simframed/Sources/simframed/main.swift +137 -9
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +30 -0
- package/package.json +4 -2
- package/scripts/check-package.mjs +8 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +189 -20
- package/src/cli.js +272 -116
- package/src/control.js +1 -0
- package/src/fingerprint.js +33 -0
- package/src/graph.js +3 -1
- package/src/index.js +62 -4
- package/src/input.js +99 -0
- package/src/matching.js +72 -1
- package/src/mcp.js +384 -115
- package/src/refs.js +141 -0
- package/src/regions.js +203 -26
- package/src/screenmap.js +57 -16
- package/src/simctl.js +55 -2
- package/src/view.js +342 -0
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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
|
261
|
+
const band = bands(targets, screen);
|
|
85
262
|
for (const t of targets) {
|
|
86
|
-
t.region = regionFor(t.frame, screen,
|
|
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 =
|
|
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
|
-
//
|
|
123
|
-
//
|
|
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
|
|
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
|
-
:
|
|
132
|
-
?
|
|
133
|
-
:
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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], {
|
|
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 });
|