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/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import * as input from './input.js';
|
|
|
17
17
|
import * as fingerprint from './fingerprint.js';
|
|
18
18
|
import * as graph from './graph.js';
|
|
19
19
|
import * as matching from './matching.js';
|
|
20
|
+
import * as refs from './refs.js';
|
|
20
21
|
import * as screenmap from './screenmap.js';
|
|
21
22
|
import { resolveDevice, resize, screenshot } from './simctl.js';
|
|
22
23
|
import * as store from './store.js';
|
|
@@ -25,7 +26,24 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
|
25
26
|
const CLI = path.join(HERE, 'cli.js');
|
|
26
27
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
28
|
|
|
28
|
-
export const DETAIL_LEVELS = { low: 420, normal: 700, high:
|
|
29
|
+
export const DETAIL_LEVELS = { low: 420, normal: 700, high: 1024, full: 0 };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The ceiling on any image handed to a model.
|
|
33
|
+
*
|
|
34
|
+
* An image costs ~1,600 tokens when Claude Code handles it as a native image
|
|
35
|
+
* block, and 15,000–25,000 when the base64 is treated as text (claude-code
|
|
36
|
+
* issue #31208) — enough to trip the 25,000-token tool-result limit on its own.
|
|
37
|
+
* A native-resolution frame buys nothing at either price: 1024 px on the long
|
|
38
|
+
* edge is already more than a 393-point screen has to say. Only the CLI, which
|
|
39
|
+
* writes to a file rather than into a context window, may exceed it.
|
|
40
|
+
*/
|
|
41
|
+
export const MODEL_MAX_IMAGE_DIM = 1024;
|
|
42
|
+
|
|
43
|
+
export function modelDetail(detail) {
|
|
44
|
+
const dim = resolveMaxDim(detail);
|
|
45
|
+
return dim === 0 || dim > MODEL_MAX_IMAGE_DIM ? MODEL_MAX_IMAGE_DIM : dim;
|
|
46
|
+
}
|
|
29
47
|
|
|
30
48
|
export function resolveMaxDim(detail) {
|
|
31
49
|
if (typeof detail === 'number') return detail;
|
|
@@ -713,6 +731,40 @@ export async function locate(
|
|
|
713
731
|
) {
|
|
714
732
|
const { device, state: firstState } = await ensureDaemon(deviceQuery, options);
|
|
715
733
|
const udid = device.udid;
|
|
734
|
+
|
|
735
|
+
// Selectors resolve before any perception happens: `#3` is already an answer
|
|
736
|
+
// somebody numbered, and `@x,y` was never a question about the screen.
|
|
737
|
+
const selector = refs.parseSelector(query);
|
|
738
|
+
if (selector.kind === 'point') {
|
|
739
|
+
return {
|
|
740
|
+
device,
|
|
741
|
+
state: firstState,
|
|
742
|
+
target: { label: `(${selector.x},${selector.y})`, x: selector.x, y: selector.y, source: 'coordinates' },
|
|
743
|
+
from: 'selector',
|
|
744
|
+
distance: 0,
|
|
745
|
+
settled: true,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (selector.kind === 'ref') {
|
|
749
|
+
// Screen memory answers "which screen is this?" from a file, so a ref can
|
|
750
|
+
// be checked against structural identity without paying for a perception
|
|
751
|
+
// pass — which is the whole reason a ref exists.
|
|
752
|
+
const near = screenmap.recallNearest(udid, firstState.layoutHash);
|
|
753
|
+
const hit = refs.resolveRef(udid, selector.ref, {
|
|
754
|
+
layoutHash: firstState.layoutHash,
|
|
755
|
+
structuralHash: near?.entry?.structuralHash ?? null,
|
|
756
|
+
screenKnown: Boolean(near),
|
|
757
|
+
});
|
|
758
|
+
return {
|
|
759
|
+
device,
|
|
760
|
+
state: firstState,
|
|
761
|
+
target: { ...hit, label: hit.label ?? `#${hit.ref}`, source: hit.source ?? 'ref' },
|
|
762
|
+
from: 'ref',
|
|
763
|
+
distance: 0,
|
|
764
|
+
settled: true,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
if (selector.exact) query = selector.label;
|
|
716
768
|
// Key memory off a settled frame, never off whichever frame happened to be
|
|
717
769
|
// newest, so the capture rate cannot change what gets remembered.
|
|
718
770
|
const { state, settled } = await settledState(udid, { settleMs });
|
|
@@ -839,7 +891,7 @@ const STRUCTURAL_SETTLE_SAMPLES = 3;
|
|
|
839
891
|
* to. Novel fingerprints, and only those, are re-sampled until two consecutive
|
|
840
892
|
* readings agree.
|
|
841
893
|
*/
|
|
842
|
-
export async function screenIdentity(deviceQuery, { options, confirmNovel = true, settleMs, timeoutMs } = {}) {
|
|
894
|
+
export async function screenIdentity(deviceQuery, { options, confirmNovel = true, settleMs, timeoutMs, fresh = false } = {}) {
|
|
843
895
|
const { device, state } = await ensureDaemon(deviceQuery, options);
|
|
844
896
|
const udid = device.udid;
|
|
845
897
|
|
|
@@ -855,8 +907,8 @@ export async function screenIdentity(deviceQuery, { options, confirmNovel = true
|
|
|
855
907
|
});
|
|
856
908
|
const current = settledFrame ?? state;
|
|
857
909
|
let entry = fresh ? null : screenmap.recallNearest(udid, current.layoutHash)?.entry;
|
|
910
|
+
const geo = await deviceGeometry(udid, current);
|
|
858
911
|
if (!entry) {
|
|
859
|
-
const geo = await deviceGeometry(udid, current);
|
|
860
912
|
entry = await screenmap.build(udid, {
|
|
861
913
|
hash: current.hash,
|
|
862
914
|
layoutHash: current.layoutHash,
|
|
@@ -872,10 +924,16 @@ export async function screenIdentity(deviceQuery, { options, confirmNovel = true
|
|
|
872
924
|
keyboard: Boolean(entry.keyboard),
|
|
873
925
|
layoutHash: current.layoutHash,
|
|
874
926
|
settled,
|
|
927
|
+
// Carried out so callers that want the elements as well as the identity
|
|
928
|
+
// do not pay for a second perception pass to get them. The compact
|
|
929
|
+
// screen map needs both, and reading twice was the whole cost of it.
|
|
930
|
+
entry,
|
|
931
|
+
state: current,
|
|
932
|
+
points: { width: geo.pointWidth, height: geo.pointHeight },
|
|
875
933
|
};
|
|
876
934
|
};
|
|
877
935
|
|
|
878
|
-
let identity = await read();
|
|
936
|
+
let identity = await read({ fresh });
|
|
879
937
|
if (!confirmNovel) return { ...identity, confirmed: identity.settled };
|
|
880
938
|
// Being recognised is stronger evidence than the pixel settle flag: a
|
|
881
939
|
// fingerprint that matches a screen already trusted has nothing left to
|
package/src/input.js
CHANGED
|
@@ -34,6 +34,55 @@ export async function driverFor(udid) {
|
|
|
34
34
|
return detectDriver();
|
|
35
35
|
}
|
|
36
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
|
+
|
|
37
86
|
/** @returns {Promise<{name: string, available: boolean, version: string|null, reason: string|null}>} */
|
|
38
87
|
export async function detectDriver({ refresh = false } = {}) {
|
|
39
88
|
if (driverCache && !refresh) return driverCache;
|
|
@@ -128,6 +177,20 @@ async function readScreenInfo(udid) {
|
|
|
128
177
|
|
|
129
178
|
/** The accessibility tree, flattened. This is what makes tap-by-label possible. */
|
|
130
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
|
+
}
|
|
131
194
|
// Passing --json here yields empty output; the default already emits JSON.
|
|
132
195
|
const out = await idb(['ui', 'describe-all', '--udid', udid]);
|
|
133
196
|
const nodes = [];
|
|
@@ -149,6 +212,20 @@ export async function describeAll(udid) {
|
|
|
149
212
|
return nodes.filter((n) => n.frame);
|
|
150
213
|
}
|
|
151
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
|
+
|
|
152
229
|
// Icon fonts put glyphs in the Unicode private use areas, so a label arrives as
|
|
153
230
|
// "<glyph>, My Tools". Matching has to see through that to the readable text.
|
|
154
231
|
const PRIVATE_USE = /[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/gu;
|
|
@@ -263,6 +340,28 @@ export async function pressKey(udid, keycode) {
|
|
|
263
340
|
await idb(['ui', 'key', '--udid', udid, String(keycode)]);
|
|
264
341
|
}
|
|
265
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
|
+
|
|
266
365
|
export async function pressButton(udid, name) {
|
|
267
366
|
if (control.available(udid)) {
|
|
268
367
|
try {
|
package/src/matching.js
CHANGED
|
@@ -160,12 +160,83 @@ export const AMBIGUITY_MARGIN = 0.08;
|
|
|
160
160
|
/** Below this, no candidate is worth acting on. */
|
|
161
161
|
export const MINIMUM_SCORE = 0.45;
|
|
162
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
|
+
|
|
163
234
|
/**
|
|
164
235
|
* Resolve an intent to one element, or say why not.
|
|
165
236
|
* @returns {{status: 'ok'|'ambiguous'|'none', target?, score?, reasons?, alternatives?}}
|
|
166
237
|
*/
|
|
167
238
|
export function resolve(targets, intent, options = {}) {
|
|
168
|
-
const ranked = rank(targets, intent, options).filter((c) => c.score >= MINIMUM_SCORE);
|
|
239
|
+
const ranked = collapseSamePlace(rank(targets, intent, options).filter((c) => c.score >= MINIMUM_SCORE));
|
|
169
240
|
if (!ranked.length) return { status: 'none', alternatives: [] };
|
|
170
241
|
const [best, second] = ranked;
|
|
171
242
|
if (second && best.score - second.score < AMBIGUITY_MARGIN) {
|