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/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/view.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// What Claude sees.
|
|
2
|
+
//
|
|
3
|
+
// Every action used to answer with an image. An image costs 1,600 tokens when
|
|
4
|
+
// Claude Code handles it natively and 15,000–25,000 when it does not, and it
|
|
5
|
+
// answers the one question the tool already knew: what is on the screen and
|
|
6
|
+
// what can be tapped. This module answers that in text, with a number in front
|
|
7
|
+
// of every element so the next call can name one without describing it.
|
|
8
|
+
//
|
|
9
|
+
// The format is deliberately dense. Region first, because "Assets" the nav
|
|
10
|
+
// title and "Assets" the tab differ only by where they are; a tap point,
|
|
11
|
+
// because that is what an action needs; and the source, because an element the
|
|
12
|
+
// app published and one OCR read off the pixels deserve different amounts of
|
|
13
|
+
// trust.
|
|
14
|
+
import * as api from './index.js';
|
|
15
|
+
import * as graph from './graph.js';
|
|
16
|
+
import { writeRefs } from './refs.js';
|
|
17
|
+
|
|
18
|
+
/** Reading order. Chrome frames the screen, so it reads first and last. */
|
|
19
|
+
const REGION_ORDER = ['nav-bar', 'content', 'tab-bar', 'keyboard', 'status-bar'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The status bar says the time and the battery level. It is on every screen,
|
|
23
|
+
* it is never what anybody wants to tap, and it costs a row every time.
|
|
24
|
+
*/
|
|
25
|
+
const HIDDEN_REGIONS = new Set(['status-bar']);
|
|
26
|
+
|
|
27
|
+
/** A keyboard is 30-odd keys nobody refers to by name. One line says it. */
|
|
28
|
+
const COLLAPSE_REGIONS = new Set(['keyboard']);
|
|
29
|
+
|
|
30
|
+
/** Past this many rows the map stops being cheaper than looking. */
|
|
31
|
+
export const DEFAULT_LIMIT = 60;
|
|
32
|
+
|
|
33
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
34
|
+
|
|
35
|
+
/** Types that are hit targets rather than description. */
|
|
36
|
+
const INTERACTIVE = /button|field|cell|link|switch|slider|tab|menu|segment|checkbox/i;
|
|
37
|
+
|
|
38
|
+
/** A label past this length is a paragraph, and no selector needs a paragraph. */
|
|
39
|
+
const MAX_LABEL = 64;
|
|
40
|
+
|
|
41
|
+
/** Anything covering more of the screen than this is scenery, not a control. */
|
|
42
|
+
const CONTAINER_AREA_FRACTION = 0.35;
|
|
43
|
+
|
|
44
|
+
const area = (f) => (f ? Math.max(1, f.width) * Math.max(1, f.height) : 0);
|
|
45
|
+
|
|
46
|
+
const centerInside = (t, f) =>
|
|
47
|
+
Boolean(f) && t.x >= f.x && t.x <= f.x + f.width && t.y >= f.y && t.y <= f.y + f.height;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fold read text into the control it is printed on.
|
|
51
|
+
*
|
|
52
|
+
* The screen map keeps an accessibility element and the text OCR read off it as
|
|
53
|
+
* separate targets, deliberately: identity is computed from that list and
|
|
54
|
+
* throwing away a target would change what a screen is. But as something to
|
|
55
|
+
* show a model it is nearly twice as long as it needs to be — "TRACK TIME" the
|
|
56
|
+
* button and "TRACK TIME" the pixels are one thing to tap.
|
|
57
|
+
*
|
|
58
|
+
* So the folding happens here, in the presentation, and the fingerprint never
|
|
59
|
+
* sees it. The rule is containment plus interactivity: text sitting inside a
|
|
60
|
+
* button belongs to that button. Size is not part of it — that check exists in
|
|
61
|
+
* screenmap.build to stop a tab bar swallowing its five tabs, and a tab bar is
|
|
62
|
+
* not interactive.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* How many pieces of text one control may absorb.
|
|
66
|
+
*
|
|
67
|
+
* A control's visible text is a fragment or three: a title, a count, a unit. A
|
|
68
|
+
* container swallows five, and a tab bar that absorbed its own tabs would leave
|
|
69
|
+
* nothing to tap. This is what separates the two, because size does not: a
|
|
70
|
+
* dashboard tile and a tab bar are the same few thousand square points.
|
|
71
|
+
*/
|
|
72
|
+
const MAX_ABSORBED = 3;
|
|
73
|
+
|
|
74
|
+
/** Could this be the thing the text is printed on? */
|
|
75
|
+
function isHost(t) {
|
|
76
|
+
if (!t.frame) return false;
|
|
77
|
+
if (INTERACTIVE.test(t.type || '')) return true;
|
|
78
|
+
// An accessibility element the app gave a label to is a unit the app itself
|
|
79
|
+
// considers one thing — a dashboard tile reading "WOs past ETA, 1910" is one
|
|
80
|
+
// tap target whose parts OCR happens to read separately.
|
|
81
|
+
return t.source === 'ax' && Boolean(t.label);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fold read text into the control it is printed on.
|
|
86
|
+
*
|
|
87
|
+
* The screen map keeps an accessibility element and the text OCR read off it as
|
|
88
|
+
* separate targets, deliberately: identity is computed from that list and
|
|
89
|
+
* dropping a target would change what a screen is. But as something to show a
|
|
90
|
+
* model it is nearly twice as long as it needs to be — "TRACK TIME" the button
|
|
91
|
+
* and "TRACK TIME" the pixels are one thing to tap.
|
|
92
|
+
*
|
|
93
|
+
* So the folding happens here, in the presentation, and the fingerprint never
|
|
94
|
+
* sees it.
|
|
95
|
+
*/
|
|
96
|
+
function foldText(targets) {
|
|
97
|
+
const hosts = targets.filter(isHost);
|
|
98
|
+
// Who would absorb what, before absorbing anything: a host that turns out to
|
|
99
|
+
// be a container must not have already eaten two of its children.
|
|
100
|
+
const claims = new Map(hosts.map((h) => [h, []]));
|
|
101
|
+
for (const t of targets) {
|
|
102
|
+
if (isHost(t)) continue;
|
|
103
|
+
const host = hosts
|
|
104
|
+
.filter((h) => centerInside(t, h.frame))
|
|
105
|
+
.sort((a, b) => area(a.frame) - area(b.frame))[0];
|
|
106
|
+
if (host) claims.get(host).push(t);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const absorbed = new Set();
|
|
110
|
+
for (const [host, texts] of claims) {
|
|
111
|
+
if (!texts.length || texts.length > MAX_ABSORBED) continue;
|
|
112
|
+
for (const t of texts) {
|
|
113
|
+
absorbed.add(t);
|
|
114
|
+
const text = String(t.label ?? '').trim();
|
|
115
|
+
if (text && !saysTheSame(host, text)) host.aliases = [...(host.aliases ?? []), text];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return targets.filter((t) => !absorbed.has(t));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Comparison that ignores what OCR adds: a stray bullet, a mangled glyph. */
|
|
122
|
+
const alnum = (s_) => String(s_ ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
123
|
+
|
|
124
|
+
function saysTheSame(host, text) {
|
|
125
|
+
const t = alnum(text);
|
|
126
|
+
if (!t) return true;
|
|
127
|
+
return [host.label, ...(host.aliases ?? [])]
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.some((known) => alnum(known).includes(t));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Drop the scenery.
|
|
134
|
+
*
|
|
135
|
+
* A group that encloses several other elements is the thing they are arranged
|
|
136
|
+
* in, not a thing anybody means to tap — and it is exactly what "tap the tab
|
|
137
|
+
* bar" would resolve to if it were listed.
|
|
138
|
+
*/
|
|
139
|
+
function dropContainers(targets, screen) {
|
|
140
|
+
const screenArea = screen?.width && screen?.height ? screen.width * screen.height : Infinity;
|
|
141
|
+
return targets.filter((t) => {
|
|
142
|
+
if (!t.frame) return true;
|
|
143
|
+
if (INTERACTIVE.test(t.type || '')) return true;
|
|
144
|
+
if (area(t.frame) > screenArea * CONTAINER_AREA_FRACTION) return false;
|
|
145
|
+
const encloses = targets.filter((o) => o !== t && centerInside(o, t.frame)).length;
|
|
146
|
+
return encloses < 2;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Text with no letters or digits in it is OCR reading the furniture: a divider,
|
|
152
|
+
* an ellipsis menu, a chevron it decided was a period. Nothing can be tapped by
|
|
153
|
+
* that name, so listing it is pure cost.
|
|
154
|
+
*/
|
|
155
|
+
const isNoise = (t) => t.source === 'ocr' && !alnum(t.label);
|
|
156
|
+
|
|
157
|
+
const trim = (text) => {
|
|
158
|
+
const one = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
159
|
+
return one.length > MAX_LABEL ? `${one.slice(0, MAX_LABEL - 1)}…` : one;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Rank and number what is on screen. */
|
|
163
|
+
export function rowsFor(entry, { screen, filter, interactive, all = false, limit = DEFAULT_LIMIT } = {}) {
|
|
164
|
+
let kept = (entry?.targets ?? []).map((t) => ({ ...t })).filter((t) => {
|
|
165
|
+
if (!isNum(t.x) || !isNum(t.y)) return false;
|
|
166
|
+
// Off-screen elements are real in the tree and untappable in fact.
|
|
167
|
+
if (screen?.height && (t.y < 0 || t.y > screen.height)) return false;
|
|
168
|
+
if (!all && HIDDEN_REGIONS.has(t.region)) return false;
|
|
169
|
+
if (!all && isNoise(t)) return false;
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!all) {
|
|
174
|
+
kept = foldText(kept);
|
|
175
|
+
kept = dropContainers(kept, screen);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (filter) {
|
|
179
|
+
const q = String(filter).toLowerCase();
|
|
180
|
+
kept = kept.filter((t) =>
|
|
181
|
+
[t.label, ...(t.aliases ?? [])].filter(Boolean).join(' ').toLowerCase().includes(q));
|
|
182
|
+
}
|
|
183
|
+
if (interactive) kept = kept.filter((t) => INTERACTIVE.test(t.type || ''));
|
|
184
|
+
|
|
185
|
+
const order = (t) => {
|
|
186
|
+
const i = REGION_ORDER.indexOf(t.region ?? 'content');
|
|
187
|
+
return i === -1 ? REGION_ORDER.indexOf('content') : i;
|
|
188
|
+
};
|
|
189
|
+
kept.sort((a, b) => order(a) - order(b) || a.y - b.y || a.x - b.x);
|
|
190
|
+
|
|
191
|
+
const rows = [];
|
|
192
|
+
const collapsed = new Map();
|
|
193
|
+
for (const t of kept) {
|
|
194
|
+
const region = t.region ?? 'content';
|
|
195
|
+
if (COLLAPSE_REGIONS.has(region)) {
|
|
196
|
+
collapsed.set(region, (collapsed.get(region) ?? 0) + 1);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
rows.push({ ...t, region, ref: rows.length + 1 });
|
|
200
|
+
}
|
|
201
|
+
return { rows: rows.slice(0, limit), truncated: Math.max(0, rows.length - limit), collapsed };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderRow(r) {
|
|
205
|
+
const name = [
|
|
206
|
+
trim(r.label) || (r.source === 'ax' ? '(unlabelled)' : '(no text)'),
|
|
207
|
+
aliasNote(r),
|
|
208
|
+
].filter(Boolean).join(' ');
|
|
209
|
+
const state = [
|
|
210
|
+
r.enabled === false ? 'disabled' : null,
|
|
211
|
+
r.selected ? 'selected' : null,
|
|
212
|
+
].filter(Boolean).join(',');
|
|
213
|
+
return [
|
|
214
|
+
`#${r.ref}`.padStart(4),
|
|
215
|
+
shortType(r.type).padEnd(9),
|
|
216
|
+
`${r.x},${r.y}`.padEnd(9),
|
|
217
|
+
state ? `${state} ` : '',
|
|
218
|
+
name,
|
|
219
|
+
].join(' ');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Only aliases that say something the label does not.
|
|
224
|
+
*
|
|
225
|
+
* The screen map records OCR's reading of an element it already had a label
|
|
226
|
+
* for, which is useful when they disagree and pure cost when they agree —
|
|
227
|
+
* "WELCOME ~ WELCOME" was a third of some rows.
|
|
228
|
+
*/
|
|
229
|
+
function aliasNote(r) {
|
|
230
|
+
const extra = (r.aliases ?? [])
|
|
231
|
+
.filter((a) => {
|
|
232
|
+
const t = alnum(a);
|
|
233
|
+
return t && !alnum(r.label).includes(t);
|
|
234
|
+
})
|
|
235
|
+
.slice(0, 2);
|
|
236
|
+
return extra.length ? `~ ${trim(extra.join(' '))}` : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Element types, in as few characters as carry the meaning. iOS calls things
|
|
241
|
+
* `GenericElement` and `StaticText`; nothing is lost by calling them `element`
|
|
242
|
+
* and `text`, and a column of them costs a third as much.
|
|
243
|
+
*/
|
|
244
|
+
const TYPE_NAMES = [
|
|
245
|
+
[/textfield|textview|searchfield|field/i, 'field'],
|
|
246
|
+
[/button/i, 'button'],
|
|
247
|
+
[/statictext|^text$/i, 'text'],
|
|
248
|
+
[/cell|row/i, 'cell'],
|
|
249
|
+
[/^link$/i, 'link'],
|
|
250
|
+
[/switch|toggle/i, 'switch'],
|
|
251
|
+
[/tab/i, 'tab'],
|
|
252
|
+
[/image|icon/i, 'image'],
|
|
253
|
+
[/generic|other|group|^any$/i, 'element'],
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
function shortType(type) {
|
|
257
|
+
const t = String(type ?? '').trim();
|
|
258
|
+
if (!t) return '?';
|
|
259
|
+
for (const [pattern, name] of TYPE_NAMES) if (pattern.test(t)) return name;
|
|
260
|
+
return t.slice(0, 9).toLowerCase();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The whole map as text.
|
|
265
|
+
*
|
|
266
|
+
* One read of the screen produces the identity, the elements and the verdict,
|
|
267
|
+
* so this is the same cost as the `screenIdentity` call an action already makes
|
|
268
|
+
* to verify itself.
|
|
269
|
+
*/
|
|
270
|
+
export async function screenMap(deviceQuery, {
|
|
271
|
+
options,
|
|
272
|
+
filter,
|
|
273
|
+
interactive,
|
|
274
|
+
all = false,
|
|
275
|
+
limit = DEFAULT_LIMIT,
|
|
276
|
+
refresh = false,
|
|
277
|
+
identity: given,
|
|
278
|
+
} = {}) {
|
|
279
|
+
// `refresh` rebuilds this screen's map; it does not wipe the device's memory.
|
|
280
|
+
// Forgetting everything to re-read one screen would throw away every other
|
|
281
|
+
// screen's muscle memory to answer a question about this one.
|
|
282
|
+
const identity = given ?? await api.screenIdentity(deviceQuery, { options, confirmNovel: false, fresh: refresh });
|
|
283
|
+
const { device } = await api.ensureDaemon(deviceQuery, options);
|
|
284
|
+
const udid = device.udid;
|
|
285
|
+
const entry = identity.entry;
|
|
286
|
+
const screen = identity.points;
|
|
287
|
+
|
|
288
|
+
const { rows, truncated, collapsed } = rowsFor(entry, { screen, filter, interactive, all, limit });
|
|
289
|
+
writeRefs(udid, { structuralHash: identity.hash, layoutHash: identity.layoutHash, rows });
|
|
290
|
+
|
|
291
|
+
const found = identity.hash ? graph.nearestScreen(udid, identity) : null;
|
|
292
|
+
const node = found?.node ?? null;
|
|
293
|
+
const name = node ? graph.describe(node) : null;
|
|
294
|
+
// Not a visit count — nothing stores one. The number of edges out of this
|
|
295
|
+
// screen is what the agent can actually use: it says how much of this screen
|
|
296
|
+
// the graph can navigate from without being told.
|
|
297
|
+
const exits = node ? node.edges.length : null;
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
device,
|
|
301
|
+
identity,
|
|
302
|
+
rows,
|
|
303
|
+
truncated,
|
|
304
|
+
collapsed,
|
|
305
|
+
screen,
|
|
306
|
+
name,
|
|
307
|
+
exits,
|
|
308
|
+
text: render({ device, identity, rows, truncated, collapsed, screen, name, exits }),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function render({ device, identity, rows, truncated, collapsed, screen, name, exits, verdictLine, ambiguities }) {
|
|
313
|
+
const head = [
|
|
314
|
+
device?.name,
|
|
315
|
+
screen?.width ? `${screen.width}x${screen.height}pt` : null,
|
|
316
|
+
identity?.hash
|
|
317
|
+
? `screen ${identity.hash.slice(0, 8)}${name ? ` "${name}"` : ''}` +
|
|
318
|
+
(exits == null ? ' (new to simframe)' : ` (known, ${exits} known exit${exits === 1 ? '' : 's'})`)
|
|
319
|
+
: 'screen unidentified',
|
|
320
|
+
identity?.keyboard ? 'keyboard up' : null,
|
|
321
|
+
identity?.settled === false ? 'STILL MOVING' : null,
|
|
322
|
+
].filter(Boolean).join(' · ');
|
|
323
|
+
|
|
324
|
+
const lines = [head];
|
|
325
|
+
if (verdictLine) lines.push(verdictLine);
|
|
326
|
+
|
|
327
|
+
let region = null;
|
|
328
|
+
for (const r of rows) {
|
|
329
|
+
if (r.region !== region) {
|
|
330
|
+
region = r.region;
|
|
331
|
+
lines.push(`${region}:`);
|
|
332
|
+
}
|
|
333
|
+
lines.push(renderRow(r));
|
|
334
|
+
}
|
|
335
|
+
for (const [name_, count] of collapsed ?? []) lines.push(`${name_}: ${count} keys (tap by label or type directly)`);
|
|
336
|
+
if (!rows.length) lines.push('no elements read on this screen — try sim_look, or the app may still be drawing');
|
|
337
|
+
if (truncated) lines.push(`... ${truncated} more; pass filter to narrow`);
|
|
338
|
+
if (ambiguities?.length) {
|
|
339
|
+
for (const a of ambiguities) lines.push(`ambiguous "${a.query}": ${a.options.join(', ')}`);
|
|
340
|
+
}
|
|
341
|
+
return lines.join('\n');
|
|
342
|
+
}
|