simframe 0.4.2 → 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/README.md +334 -85
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
- package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
- package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
- package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
- package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
- package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
- package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
- package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
- package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
- package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
- package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
- package/native/simframed/Sources/simframed/main.swift +485 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
- package/package.json +12 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +99 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +264 -18
- package/src/cli.js +561 -89
- package/src/control.js +77 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +183 -0
- package/src/graph.js +411 -0
- package/src/index.js +351 -24
- package/src/input.js +179 -2
- package/src/matching.js +265 -0
- package/src/mcp.js +425 -112
- package/src/navigate.js +120 -0
- package/src/refs.js +141 -0
- package/src/regions.js +267 -0
- package/src/screenmap.js +119 -22
- package/src/simctl.js +74 -5
- package/src/store.js +8 -0
- package/src/view.js +342 -0
package/src/input.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// capability layered on top, so every entry point here has to answer "is this
|
|
3
3
|
// even available?" before it answers anything else.
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
|
+
import * as control from './control.js';
|
|
5
6
|
import { promisify } from 'node:util';
|
|
6
7
|
|
|
7
8
|
const run = promisify(execFile);
|
|
@@ -11,6 +12,77 @@ const IDB_HINT =
|
|
|
11
12
|
|
|
12
13
|
let driverCache = null;
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Which input driver to use for a device.
|
|
17
|
+
*
|
|
18
|
+
* simframed is preferred when its control socket is live: it needs no install,
|
|
19
|
+
* speaks points natively, and is the path that survives idb breaking on a new
|
|
20
|
+
* iOS. idb remains the fallback so a machine without the daemon still works.
|
|
21
|
+
*/
|
|
22
|
+
export async function driverFor(udid) {
|
|
23
|
+
if (udid && control.available(udid)) {
|
|
24
|
+
try {
|
|
25
|
+
const status = await control.status(udid);
|
|
26
|
+
if (status.input?.available) {
|
|
27
|
+
return { name: 'simframed', available: true, version: status.input.detail, reason: null, viaSocket: true };
|
|
28
|
+
}
|
|
29
|
+
return { name: 'simframed', available: false, version: null, reason: status.input?.detail ?? 'input unavailable', viaSocket: true };
|
|
30
|
+
} catch {
|
|
31
|
+
/* daemon went away mid-call; fall through to idb */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return detectDriver();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* An escape hatch back to idb for the tree.
|
|
39
|
+
*
|
|
40
|
+
* Every private-framework path here is version-coupled, and the host-side
|
|
41
|
+
* translator is no exception: an Xcode upgrade could break it on a machine
|
|
42
|
+
* where work still has to happen that day. Reading it per call rather than
|
|
43
|
+
* caching means the switch takes effect without restarting anything.
|
|
44
|
+
*/
|
|
45
|
+
function preferIdbTree() {
|
|
46
|
+
return process.env.SIMFRAME_AX_DRIVER === 'idb';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Which driver reads the accessibility tree for a device.
|
|
51
|
+
*
|
|
52
|
+
* Separate from `driverFor` because these are separate capabilities: a device
|
|
53
|
+
* can be perfectly touchable by the daemon while the translation framework is
|
|
54
|
+
* missing, and reporting one number for both hides which layer is down.
|
|
55
|
+
*/
|
|
56
|
+
export async function axDriverFor(udid) {
|
|
57
|
+
if (udid && control.available(udid) && !preferIdbTree()) {
|
|
58
|
+
try {
|
|
59
|
+
const status = await control.status(udid);
|
|
60
|
+
if (status.accessibility?.available) {
|
|
61
|
+
return { name: 'simframed', available: true, chosen: false, version: status.accessibility.detail, reason: null };
|
|
62
|
+
}
|
|
63
|
+
// The daemon is up and says it cannot read the tree. idb might still,
|
|
64
|
+
// so this is a reason to fall through rather than an answer.
|
|
65
|
+
} catch {
|
|
66
|
+
/* daemon went away mid-call; fall through to idb */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const idbDriver = await detectDriver();
|
|
70
|
+
// Asked for, or fallen back to? A driver someone chose is not a degradation,
|
|
71
|
+
// and grading it as one turns the documented escape hatch into a red CI run
|
|
72
|
+
// on exactly the day an Xcode upgrade makes you reach for it.
|
|
73
|
+
const chosen = preferIdbTree();
|
|
74
|
+
if (idbDriver.available) {
|
|
75
|
+
return {
|
|
76
|
+
name: 'idb',
|
|
77
|
+
available: true,
|
|
78
|
+
chosen,
|
|
79
|
+
version: chosen ? `${idbDriver.version} — selected by SIMFRAME_AX_DRIVER` : idbDriver.version,
|
|
80
|
+
reason: null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { name: null, available: false, chosen, version: null, reason: idbDriver.reason };
|
|
84
|
+
}
|
|
85
|
+
|
|
14
86
|
/** @returns {Promise<{name: string, available: boolean, version: string|null, reason: string|null}>} */
|
|
15
87
|
export async function detectDriver({ refresh = false } = {}) {
|
|
16
88
|
if (driverCache && !refresh) return driverCache;
|
|
@@ -68,6 +140,28 @@ export async function screenInfo(udid, { refresh = false } = {}) {
|
|
|
68
140
|
}
|
|
69
141
|
|
|
70
142
|
async function readScreenInfo(udid) {
|
|
143
|
+
// Ask the daemon first. It holds the device's own point size and scale, which
|
|
144
|
+
// makes it both authoritative and free — and it means geometry no longer
|
|
145
|
+
// needs idb at all. Going to idb first meant a machine without idb could
|
|
146
|
+
// capture and tap perfectly well but could not run a verified flow, because
|
|
147
|
+
// building a screen map needs the point size.
|
|
148
|
+
if (control.available(udid)) {
|
|
149
|
+
try {
|
|
150
|
+
const { device } = await control.status(udid);
|
|
151
|
+
if (device?.pointWidth && device?.pointHeight) {
|
|
152
|
+
const density = device.scale ?? 1;
|
|
153
|
+
return {
|
|
154
|
+
pixelWidth: Math.round(device.pointWidth * density),
|
|
155
|
+
pixelHeight: Math.round(device.pointHeight * density),
|
|
156
|
+
density,
|
|
157
|
+
pointWidth: device.pointWidth,
|
|
158
|
+
pointHeight: device.pointHeight,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
/* daemon went away mid-call; fall through to idb */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
71
165
|
const out = await idb(['describe', '--json', '--udid', udid]);
|
|
72
166
|
const info = JSON.parse(out.trim().split('\n').filter(Boolean).pop());
|
|
73
167
|
const dims = info.screen_dimensions || {};
|
|
@@ -83,6 +177,20 @@ async function readScreenInfo(udid) {
|
|
|
83
177
|
|
|
84
178
|
/** The accessibility tree, flattened. This is what makes tap-by-label possible. */
|
|
85
179
|
export async function describeAll(udid) {
|
|
180
|
+
// The daemon reads the tree host-side through AXPTranslator: no install, and
|
|
181
|
+
// measured at 45ms against idb's 203ms on the same screen. idb stays as the
|
|
182
|
+
// fallback, so a machine without the daemon still reads.
|
|
183
|
+
if (control.available(udid) && !preferIdbTree()) {
|
|
184
|
+
try {
|
|
185
|
+
const { screen } = await control.request(udid, { action: 'ui', ocr: false });
|
|
186
|
+
// An app mid-launch genuinely has no tree yet. Falling through to idb
|
|
187
|
+
// here would just ask a second time and report the same emptiness more
|
|
188
|
+
// slowly, so the honest answer is the empty one.
|
|
189
|
+
if (screen?.sources?.includes('ax')) return (screen.elements ?? []).map(elementToNode);
|
|
190
|
+
} catch {
|
|
191
|
+
/* daemon went away mid-call; fall through to idb */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
86
194
|
// Passing --json here yields empty output; the default already emits JSON.
|
|
87
195
|
const out = await idb(['ui', 'describe-all', '--udid', udid]);
|
|
88
196
|
const nodes = [];
|
|
@@ -104,6 +212,20 @@ export async function describeAll(udid) {
|
|
|
104
212
|
return nodes.filter((n) => n.frame);
|
|
105
213
|
}
|
|
106
214
|
|
|
215
|
+
/** A daemon element back into the node shape every caller here expects. */
|
|
216
|
+
export function elementToNode(e) {
|
|
217
|
+
return {
|
|
218
|
+
label: cleanLabel(e.label),
|
|
219
|
+
rawLabel: e.label ?? null,
|
|
220
|
+
value: e.value ?? null,
|
|
221
|
+
type: e.role ?? null,
|
|
222
|
+
identifier: e.identifier ?? null,
|
|
223
|
+
enabled: e.state?.enabled ?? null,
|
|
224
|
+
frame: e.frame ?? null,
|
|
225
|
+
raw: e,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
107
229
|
// Icon fonts put glyphs in the Unicode private use areas, so a label arrives as
|
|
108
230
|
// "<glyph>, My Tools". Matching has to see through that to the readable text.
|
|
109
231
|
const PRIVATE_USE = /[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/gu;
|
|
@@ -177,10 +299,15 @@ export function centerOf(node) {
|
|
|
177
299
|
}
|
|
178
300
|
|
|
179
301
|
export async function tapPoint(udid, x, y, { durationMs } = {}) {
|
|
180
|
-
const
|
|
302
|
+
const point = { x: Math.round(x), y: Math.round(y) };
|
|
303
|
+
if (control.available(udid)) {
|
|
304
|
+
await control.tap(udid, point.x, point.y, durationMs ? { durationMs } : {});
|
|
305
|
+
return point;
|
|
306
|
+
}
|
|
307
|
+
const args = ['ui', 'tap', '--udid', udid, String(point.x), String(point.y)];
|
|
181
308
|
if (durationMs) args.push('--duration', String(durationMs / 1000));
|
|
182
309
|
await idb(args);
|
|
183
|
-
return
|
|
310
|
+
return point;
|
|
184
311
|
}
|
|
185
312
|
|
|
186
313
|
export async function tapLabel(udid, query, { index, durationMs } = {}) {
|
|
@@ -191,6 +318,21 @@ export async function tapLabel(udid, query, { index, durationMs } = {}) {
|
|
|
191
318
|
}
|
|
192
319
|
|
|
193
320
|
export async function typeText(udid, value) {
|
|
321
|
+
if (control.available(udid)) {
|
|
322
|
+
// The daemon's paste path carries characters rather than key positions, so
|
|
323
|
+
// it is not reinterpreted by the device's keyboard layout.
|
|
324
|
+
await control.paste(udid, String(value));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
await idb(['ui', 'text', '--udid', udid, String(value)]);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Key events rather than text: for shortcuts and search-as-you-type. */
|
|
331
|
+
export async function typeKeys(udid, value) {
|
|
332
|
+
if (control.available(udid)) {
|
|
333
|
+
await control.type(udid, String(value));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
194
336
|
await idb(['ui', 'text', '--udid', udid, String(value)]);
|
|
195
337
|
}
|
|
196
338
|
|
|
@@ -198,11 +340,46 @@ export async function pressKey(udid, keycode) {
|
|
|
198
340
|
await idb(['ui', 'key', '--udid', udid, String(keycode)]);
|
|
199
341
|
}
|
|
200
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Rebuild the daemon's HID session.
|
|
345
|
+
*
|
|
346
|
+
* Input is the one path with no feedback: a dispatched Indigo message reports
|
|
347
|
+
* success when the send succeeds, and nothing asks the device whether anything
|
|
348
|
+
* happened. Measured on a long-running daemon, a HOME press returned in 66ms
|
|
349
|
+
* and the screen did not move; the same press on a freshly started daemon
|
|
350
|
+
* worked. Whoever holds the frames is the only one who can notice, which is why
|
|
351
|
+
* this is something callers invoke rather than something input does for itself.
|
|
352
|
+
*
|
|
353
|
+
* @returns {Promise<boolean>} whether a session was actually reset.
|
|
354
|
+
*/
|
|
355
|
+
export async function resetSession(udid) {
|
|
356
|
+
if (!control.available(udid)) return false;
|
|
357
|
+
try {
|
|
358
|
+
await control.resetInput(udid);
|
|
359
|
+
return true;
|
|
360
|
+
} catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
201
365
|
export async function pressButton(udid, name) {
|
|
366
|
+
if (control.available(udid)) {
|
|
367
|
+
try {
|
|
368
|
+
await control.press(udid, String(name).toLowerCase());
|
|
369
|
+
return;
|
|
370
|
+
} catch (err) {
|
|
371
|
+
// Only home is verified through Indigo; anything else falls back.
|
|
372
|
+
if (!(await detectDriver()).available) throw err;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
202
375
|
await idb(['ui', 'button', '--udid', udid, String(name).toUpperCase()]);
|
|
203
376
|
}
|
|
204
377
|
|
|
205
378
|
export async function swipe(udid, from, to, { durationMs = 300 } = {}) {
|
|
379
|
+
if (control.available(udid)) {
|
|
380
|
+
await control.swipe(udid, from, to, { durationMs });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
206
383
|
await idb([
|
|
207
384
|
'ui', 'swipe', '--udid', udid,
|
|
208
385
|
String(Math.round(from.x)), String(Math.round(from.y)),
|
package/src/matching.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
* How close two tap points have to be to mean the same control.
|
|
165
|
+
*
|
|
166
|
+
* Deliberately small. Two genuinely different controls are not twelve points
|
|
167
|
+
* apart centre to centre on any screen iOS lays out; two *readings* of one
|
|
168
|
+
* control are one or two points apart, because the accessibility tree and OCR
|
|
169
|
+
* are describing the same rectangle. Measured on a real filter row: the tree
|
|
170
|
+
* published "Location (All)" at (201,181) and OCR read "Location (AII)" at
|
|
171
|
+
* (200,182), and the caller was asked which of the two it meant.
|
|
172
|
+
*/
|
|
173
|
+
export const SAME_CONTROL_POINTS = 12;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Two candidates in the same place are one control read twice.
|
|
177
|
+
*
|
|
178
|
+
* Asking which one was meant is not caution here, it is a question with no
|
|
179
|
+
* answer — either tap lands on the same pixel. So the readings are collapsed,
|
|
180
|
+
* and the accessibility one wins, because it is the actual hit target and its
|
|
181
|
+
* label has not been through OCR.
|
|
182
|
+
*/
|
|
183
|
+
const INTERACTIVE_ROLE = /button|field|cell|row|link|switch|slider|tab|menu|segment|checkbox/i;
|
|
184
|
+
|
|
185
|
+
const contains = (frame, target) =>
|
|
186
|
+
Boolean(frame)
|
|
187
|
+
&& target.x >= frame.x && target.x <= frame.x + (frame.width ?? 0)
|
|
188
|
+
&& target.y >= frame.y && target.y <= frame.y + (frame.height ?? 0);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Are these two candidates the same control?
|
|
192
|
+
*
|
|
193
|
+
* Two ways, and the second one cost a measurement. Centres a couple of points
|
|
194
|
+
* apart are one rectangle read twice. But a full-width list cell and the
|
|
195
|
+
* left-aligned text printed inside it have centres a hundred points apart and
|
|
196
|
+
* are still one tap target — measured on a Settings list, where "General" came
|
|
197
|
+
* back as the cell at (201,326) and the OCR text at (102,327) and the caller
|
|
198
|
+
* was asked which of the two it meant. The screen map already folds that pair
|
|
199
|
+
* into one row; this is `locate` catching up with it.
|
|
200
|
+
*/
|
|
201
|
+
function sameControl(a, b) {
|
|
202
|
+
if (Math.abs(a.x - b.x) <= SAME_CONTROL_POINTS && Math.abs(a.y - b.y) <= SAME_CONTROL_POINTS) return true;
|
|
203
|
+
// Containment only counts when the container is a hit target. A group that
|
|
204
|
+
// merely encloses things is not the thing inside it, which is what stops a
|
|
205
|
+
// tab bar from absorbing its own tabs.
|
|
206
|
+
if (INTERACTIVE_ROLE.test(a.type ?? '') && contains(a.frame, b)) return true;
|
|
207
|
+
if (INTERACTIVE_ROLE.test(b.type ?? '') && contains(b.frame, a)) return true;
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function collapseSamePlace(ranked) {
|
|
212
|
+
const kept = [];
|
|
213
|
+
for (const c of ranked) {
|
|
214
|
+
const twin = kept.find((k) => sameControl(k.target, c.target));
|
|
215
|
+
if (!twin) {
|
|
216
|
+
kept.push(c);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// Prefer the real hit target: an accessibility element over OCR's reading of
|
|
220
|
+
// it, and an interactive role over a caption sitting inside it.
|
|
221
|
+
const better = (candidate, incumbent) => {
|
|
222
|
+
if (candidate.target.source === 'ax' && incumbent.target.source !== 'ax') return true;
|
|
223
|
+
if (candidate.target.source !== 'ax' && incumbent.target.source === 'ax') return false;
|
|
224
|
+
return INTERACTIVE_ROLE.test(candidate.target.type ?? '')
|
|
225
|
+
&& !INTERACTIVE_ROLE.test(incumbent.target.type ?? '');
|
|
226
|
+
};
|
|
227
|
+
if (better(c, twin)) {
|
|
228
|
+
kept[kept.indexOf(twin)] = { ...c, reasons: [...c.reasons, 'the hit target, not the text printed on it'] };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return kept;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Resolve an intent to one element, or say why not.
|
|
236
|
+
* @returns {{status: 'ok'|'ambiguous'|'none', target?, score?, reasons?, alternatives?}}
|
|
237
|
+
*/
|
|
238
|
+
export function resolve(targets, intent, options = {}) {
|
|
239
|
+
const ranked = collapseSamePlace(rank(targets, intent, options).filter((c) => c.score >= MINIMUM_SCORE));
|
|
240
|
+
if (!ranked.length) return { status: 'none', alternatives: [] };
|
|
241
|
+
const [best, second] = ranked;
|
|
242
|
+
if (second && best.score - second.score < AMBIGUITY_MARGIN) {
|
|
243
|
+
return {
|
|
244
|
+
status: 'ambiguous',
|
|
245
|
+
alternatives: ranked.slice(0, 5).map((c) => ({
|
|
246
|
+
label: c.target.label ?? '(icon-only)',
|
|
247
|
+
x: c.target.x,
|
|
248
|
+
y: c.target.y,
|
|
249
|
+
region: c.target.region,
|
|
250
|
+
score: Math.round(Math.min(1, c.score) * 100) / 100,
|
|
251
|
+
reasons: c.reasons,
|
|
252
|
+
})),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
status: 'ok',
|
|
257
|
+
target: best.target,
|
|
258
|
+
score: Math.round(Math.min(1, best.score) * 100) / 100,
|
|
259
|
+
reasons: best.reasons,
|
|
260
|
+
alternatives: ranked.slice(1, 4).map((c) => ({
|
|
261
|
+
label: c.target.label ?? '(icon-only)',
|
|
262
|
+
score: Math.round(Math.min(1, c.score) * 100) / 100,
|
|
263
|
+
})),
|
|
264
|
+
};
|
|
265
|
+
}
|