simframe 0.1.0 → 0.4.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 +237 -155
- package/native/ocr.swift +29 -0
- package/package.json +2 -1
- package/src/actions.js +244 -0
- package/src/analyze.js +59 -0
- package/src/cli.js +198 -15
- package/src/daemon.js +139 -9
- package/src/index.js +382 -15
- package/src/input.js +221 -0
- package/src/intent.js +100 -0
- package/src/mcp.js +284 -22
- package/src/ocr.js +71 -0
- package/src/screenmap.js +229 -0
- package/src/simctl.js +26 -1
package/src/input.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Input driver. simframe observes without any of this; input is an optional
|
|
2
|
+
// capability layered on top, so every entry point here has to answer "is this
|
|
3
|
+
// even available?" before it answers anything else.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const run = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
const IDB_HINT =
|
|
10
|
+
'install it with: brew tap facebook/fb && brew install idb-companion && pipx install fb-idb';
|
|
11
|
+
|
|
12
|
+
let driverCache = null;
|
|
13
|
+
|
|
14
|
+
/** @returns {Promise<{name: string, available: boolean, version: string|null, reason: string|null}>} */
|
|
15
|
+
export async function detectDriver({ refresh = false } = {}) {
|
|
16
|
+
if (driverCache && !refresh) return driverCache;
|
|
17
|
+
try {
|
|
18
|
+
// idb has no --version; `--help` is the cheapest proof the client runs.
|
|
19
|
+
await run('idb', ['--help'], { timeout: 8000 });
|
|
20
|
+
let version = 'installed';
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await run('idb_companion', ['--version'], { timeout: 5000 });
|
|
23
|
+
const info = JSON.parse(stdout.trim());
|
|
24
|
+
version = `companion built ${info.build_date}`;
|
|
25
|
+
} catch {
|
|
26
|
+
/* the companion is spawned on demand; its absence surfaces at first use */
|
|
27
|
+
}
|
|
28
|
+
driverCache = { name: 'idb', available: true, version, reason: null };
|
|
29
|
+
} catch (err) {
|
|
30
|
+
driverCache = {
|
|
31
|
+
name: 'idb',
|
|
32
|
+
available: false,
|
|
33
|
+
version: null,
|
|
34
|
+
reason:
|
|
35
|
+
err.code === 'ENOENT'
|
|
36
|
+
? `idb is not installed, so simframe can observe the screen but cannot touch it — ${IDB_HINT}`
|
|
37
|
+
: `idb is present but did not run: ${err.message}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return driverCache;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function requireDriver() {
|
|
44
|
+
const driver = await detectDriver();
|
|
45
|
+
if (!driver.available) throw new Error(driver.reason);
|
|
46
|
+
return driver;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function idb(args, { timeout = 20_000 } = {}) {
|
|
50
|
+
await requireDriver();
|
|
51
|
+
const { stdout } = await run('idb', args, { timeout, maxBuffer: 32 << 20 });
|
|
52
|
+
return stdout;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Screen geometry, needed because the accessibility tree speaks in points while
|
|
57
|
+
* a simframe frame is a scaled bitmap. Without this mapping, a coordinate read
|
|
58
|
+
* off an image lands in the wrong place.
|
|
59
|
+
*/
|
|
60
|
+
const geometryCache = new Map();
|
|
61
|
+
|
|
62
|
+
/** Cached: geometry costs an idb round trip and never changes while booted. */
|
|
63
|
+
export async function screenInfo(udid, { refresh = false } = {}) {
|
|
64
|
+
if (!refresh && geometryCache.has(udid)) return geometryCache.get(udid);
|
|
65
|
+
const info = await readScreenInfo(udid);
|
|
66
|
+
geometryCache.set(udid, info);
|
|
67
|
+
return info;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readScreenInfo(udid) {
|
|
71
|
+
const out = await idb(['describe', '--json', '--udid', udid]);
|
|
72
|
+
const info = JSON.parse(out.trim().split('\n').filter(Boolean).pop());
|
|
73
|
+
const dims = info.screen_dimensions || {};
|
|
74
|
+
const density = dims.density || 1;
|
|
75
|
+
return {
|
|
76
|
+
pixelWidth: dims.width ?? null,
|
|
77
|
+
pixelHeight: dims.height ?? null,
|
|
78
|
+
density,
|
|
79
|
+
pointWidth: dims.width_points ?? (dims.width ? Math.round(dims.width / density) : null),
|
|
80
|
+
pointHeight: dims.height_points ?? (dims.height ? Math.round(dims.height / density) : null),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The accessibility tree, flattened. This is what makes tap-by-label possible. */
|
|
85
|
+
export async function describeAll(udid) {
|
|
86
|
+
// Passing --json here yields empty output; the default already emits JSON.
|
|
87
|
+
const out = await idb(['ui', 'describe-all', '--udid', udid]);
|
|
88
|
+
const nodes = [];
|
|
89
|
+
for (const line of out.split('\n')) {
|
|
90
|
+
const trimmed = line.trim();
|
|
91
|
+
if (!trimmed) continue;
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(trimmed);
|
|
94
|
+
const stack = Array.isArray(parsed) ? [...parsed] : [parsed];
|
|
95
|
+
while (stack.length) {
|
|
96
|
+
const node = stack.shift();
|
|
97
|
+
nodes.push(normalizeNode(node));
|
|
98
|
+
if (Array.isArray(node.children)) stack.unshift(...node.children);
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
/* idb interleaves non-JSON status lines; skip them */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return nodes.filter((n) => n.frame);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Icon fonts put glyphs in the Unicode private use areas, so a label arrives as
|
|
108
|
+
// "<glyph>, My Tools". Matching has to see through that to the readable text.
|
|
109
|
+
const PRIVATE_USE = /[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/gu;
|
|
110
|
+
|
|
111
|
+
export function cleanLabel(label) {
|
|
112
|
+
if (!label) return label ?? null;
|
|
113
|
+
const stripped = label.replace(PRIVATE_USE, '');
|
|
114
|
+
return stripped.replace(/\s*,\s*/g, ', ').replace(/^[,\s]+|[,\s]+$/g, '').trim() || null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeNode(node) {
|
|
118
|
+
const frame = node.frame || node.AXFrame || null;
|
|
119
|
+
const rawLabel = node.AXLabel ?? node.label ?? null;
|
|
120
|
+
return {
|
|
121
|
+
label: cleanLabel(rawLabel),
|
|
122
|
+
rawLabel,
|
|
123
|
+
value: node.AXValue ?? node.value ?? null,
|
|
124
|
+
type: node.type ?? node.AXType ?? null,
|
|
125
|
+
identifier: node.AXUniqueId ?? node.identifier ?? null,
|
|
126
|
+
enabled: node.AXEnabled ?? node.enabled ?? null,
|
|
127
|
+
frame: frame
|
|
128
|
+
? {
|
|
129
|
+
x: frame.x ?? frame.X ?? 0,
|
|
130
|
+
y: frame.y ?? frame.Y ?? 0,
|
|
131
|
+
width: frame.width ?? frame.Width ?? 0,
|
|
132
|
+
height: frame.height ?? frame.Height ?? 0,
|
|
133
|
+
}
|
|
134
|
+
: null,
|
|
135
|
+
raw: node,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const text = (n) => [n.label, n.value, n.identifier].filter(Boolean).join(' ');
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Find the element a caller means. Exact label first, then identifier, then a
|
|
143
|
+
* case-insensitive substring — and an ambiguous match is an error rather than a
|
|
144
|
+
* guess, because a wrong tap is worse than no tap.
|
|
145
|
+
*/
|
|
146
|
+
export function matchElement(nodes, query, { index } = {}) {
|
|
147
|
+
const q = String(query).toLowerCase();
|
|
148
|
+
const tiers = [
|
|
149
|
+
nodes.filter((n) => (n.label ?? '').toLowerCase() === q),
|
|
150
|
+
nodes.filter((n) => (n.identifier ?? '').toLowerCase() === q),
|
|
151
|
+
nodes.filter((n) => text(n).toLowerCase().includes(q)),
|
|
152
|
+
];
|
|
153
|
+
for (const tier of tiers) {
|
|
154
|
+
if (!tier.length) continue;
|
|
155
|
+
if (index != null) {
|
|
156
|
+
if (index >= tier.length) {
|
|
157
|
+
throw new Error(`"${query}" matched ${tier.length} elements; index ${index} is out of range`);
|
|
158
|
+
}
|
|
159
|
+
return tier[index];
|
|
160
|
+
}
|
|
161
|
+
if (tier.length > 1) {
|
|
162
|
+
const shown = tier.slice(0, 6).map((n, i) => `[${i}] ${text(n) || n.type}`).join(', ');
|
|
163
|
+
throw new Error(
|
|
164
|
+
`"${query}" matched ${tier.length} elements — pass index to choose: ${shown}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return tier[0];
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`no element matching "${query}" is on screen`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function centerOf(node) {
|
|
173
|
+
return {
|
|
174
|
+
x: Math.round(node.frame.x + node.frame.width / 2),
|
|
175
|
+
y: Math.round(node.frame.y + node.frame.height / 2),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function tapPoint(udid, x, y, { durationMs } = {}) {
|
|
180
|
+
const args = ['ui', 'tap', '--udid', udid, String(Math.round(x)), String(Math.round(y))];
|
|
181
|
+
if (durationMs) args.push('--duration', String(durationMs / 1000));
|
|
182
|
+
await idb(args);
|
|
183
|
+
return { x: Math.round(x), y: Math.round(y) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function tapLabel(udid, query, { index, durationMs } = {}) {
|
|
187
|
+
const node = matchElement(await describeAll(udid), query, { index });
|
|
188
|
+
const point = centerOf(node);
|
|
189
|
+
await tapPoint(udid, point.x, point.y, { durationMs });
|
|
190
|
+
return { node, point };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function typeText(udid, value) {
|
|
194
|
+
await idb(['ui', 'text', '--udid', udid, String(value)]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function pressKey(udid, keycode) {
|
|
198
|
+
await idb(['ui', 'key', '--udid', udid, String(keycode)]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function pressButton(udid, name) {
|
|
202
|
+
await idb(['ui', 'button', '--udid', udid, String(name).toUpperCase()]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function swipe(udid, from, to, { durationMs = 300 } = {}) {
|
|
206
|
+
await idb([
|
|
207
|
+
'ui', 'swipe', '--udid', udid,
|
|
208
|
+
String(Math.round(from.x)), String(Math.round(from.y)),
|
|
209
|
+
String(Math.round(to.x)), String(Math.round(to.y)),
|
|
210
|
+
'--duration', String(durationMs / 1000),
|
|
211
|
+
]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Map a coordinate read off a simframe image into the points idb expects. */
|
|
215
|
+
export function imageToPoints({ x, y }, { imageWidth, imageHeight, pointWidth, pointHeight }) {
|
|
216
|
+
if (!pointWidth || !pointHeight) throw new Error('screen geometry is unknown');
|
|
217
|
+
return {
|
|
218
|
+
x: Math.round((x / imageWidth) * pointWidth),
|
|
219
|
+
y: Math.round((y / imageHeight) * pointHeight),
|
|
220
|
+
};
|
|
221
|
+
}
|
package/src/intent.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Human-level steps.
|
|
2
|
+
//
|
|
3
|
+
// A person filling an unfamiliar form does not study it. They pick something in
|
|
4
|
+
// the picker and hit whatever confirming button is on screen. Reproducing that
|
|
5
|
+
// instinct removes the expensive part of driving a UI with an agent: the model
|
|
6
|
+
// round trip spent reasoning about controls it has never seen.
|
|
7
|
+
import * as input from './input.js';
|
|
8
|
+
|
|
9
|
+
/** Confirming words, most specific first. The first tier with a hit wins. */
|
|
10
|
+
const CONFIRM_TIERS = [
|
|
11
|
+
[/^apply$/i, /^confirm$/i, /^ok$/i],
|
|
12
|
+
[/^done$/i, /^submit$/i, /^save$/i],
|
|
13
|
+
[/^continue$/i, /^next$/i, /^yes$/i],
|
|
14
|
+
[/^select$/i, /^choose$/i, /^add$/i],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const DISMISS = /^(cancel|reset|close|back|no|clear|dismiss)$/i;
|
|
18
|
+
|
|
19
|
+
export function onScreen(node, geo) {
|
|
20
|
+
const f = node.frame;
|
|
21
|
+
return f && f.y >= 0 && f.y < (geo?.pointHeight ?? 874) && f.x >= 0 && f.x < (geo?.pointWidth ?? 402);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The control a person would press to commit what is on screen. Prefers enabled
|
|
26
|
+
* controls, then the lowest one, then the rightmost — confirm sits bottom-right
|
|
27
|
+
* of cancel by near-universal convention.
|
|
28
|
+
*/
|
|
29
|
+
export function findConfirm(nodes, geo) {
|
|
30
|
+
const usable = nodes.filter(
|
|
31
|
+
(n) => n.label && onScreen(n, geo) && !DISMISS.test(n.label.trim()),
|
|
32
|
+
);
|
|
33
|
+
for (const tier of CONFIRM_TIERS) {
|
|
34
|
+
const hits = usable.filter((n) => tier.some((re) => re.test(n.label.trim())));
|
|
35
|
+
if (!hits.length) continue;
|
|
36
|
+
return hits.sort(
|
|
37
|
+
(a, b) =>
|
|
38
|
+
(b.enabled !== false) - (a.enabled !== false) ||
|
|
39
|
+
b.frame.y - a.frame.y ||
|
|
40
|
+
b.frame.x - a.frame.x,
|
|
41
|
+
)[0];
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Selectable options in an open picker: labelled rows that are neither the
|
|
48
|
+
* confirm/dismiss row nor the search box, sharing a left edge and a row height.
|
|
49
|
+
*/
|
|
50
|
+
export function findOptions(nodes, geo) {
|
|
51
|
+
const confirm = findConfirm(nodes, geo);
|
|
52
|
+
const floor = confirm ? confirm.frame.y - 8 : (geo?.pointHeight ?? 874);
|
|
53
|
+
const rows = nodes.filter(
|
|
54
|
+
(n) =>
|
|
55
|
+
n.label &&
|
|
56
|
+
onScreen(n, geo) &&
|
|
57
|
+
n.frame.y > 120 &&
|
|
58
|
+
n.frame.y < floor &&
|
|
59
|
+
n.frame.height >= 12 &&
|
|
60
|
+
n.frame.height <= 70 &&
|
|
61
|
+
!DISMISS.test(n.label.trim()) &&
|
|
62
|
+
!CONFIRM_TIERS.flat().some((re) => re.test(n.label.trim())) &&
|
|
63
|
+
!/^search$/i.test(n.label.trim()) &&
|
|
64
|
+
!/required|please select/i.test(n.label),
|
|
65
|
+
);
|
|
66
|
+
// Picker rows repeat a left edge; that is what separates them from headings.
|
|
67
|
+
const byX = new Map();
|
|
68
|
+
for (const r of rows) {
|
|
69
|
+
const key = Math.round(r.frame.x / 8) * 8;
|
|
70
|
+
byX.set(key, [...(byX.get(key) || []), r]);
|
|
71
|
+
}
|
|
72
|
+
const biggest = [...byX.values()].sort((a, b) => b.length - a.length)[0] || [];
|
|
73
|
+
return biggest.length >= 2 ? biggest.sort((a, b) => a.frame.y - b.frame.y) : rows;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Pick an option without caring which — the human move in an unfamiliar picker. */
|
|
77
|
+
export async function chooseAny(udid, { prefer, geo } = {}) {
|
|
78
|
+
const nodes = await input.describeAll(udid);
|
|
79
|
+
const options = findOptions(nodes, geo);
|
|
80
|
+
if (!options.length) throw new Error('no selectable options found on screen');
|
|
81
|
+
const chosen =
|
|
82
|
+
(prefer && options.find((o) => o.label.toLowerCase().includes(String(prefer).toLowerCase()))) ||
|
|
83
|
+
options[0];
|
|
84
|
+
const point = input.centerOf(chosen);
|
|
85
|
+
await input.tapPoint(udid, point.x, point.y);
|
|
86
|
+
return { label: chosen.label, point, optionCount: options.length };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function confirm(udid, { geo } = {}) {
|
|
90
|
+
const nodes = await input.describeAll(udid);
|
|
91
|
+
const target = findConfirm(nodes, geo);
|
|
92
|
+
if (!target) throw new Error('no confirming control on screen');
|
|
93
|
+
const point = input.centerOf(target);
|
|
94
|
+
await input.tapPoint(udid, point.x, point.y);
|
|
95
|
+
return { label: target.label, point, enabled: target.enabled };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isTextInput(node) {
|
|
99
|
+
return /TextField|TextView|SearchField/i.test(node.type || '');
|
|
100
|
+
}
|