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.
Files changed (47) hide show
  1. package/README.md +334 -85
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
  4. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
  5. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  6. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  7. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
  8. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
  9. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  10. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  11. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  12. package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
  13. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  14. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  15. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  16. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  17. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  18. package/native/simframed/Sources/simframed/main.swift +485 -0
  19. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
  20. package/package.json +12 -4
  21. package/scripts/bench-flow.mjs +54 -0
  22. package/scripts/bench.sh +98 -0
  23. package/scripts/check-package.mjs +99 -0
  24. package/scripts/ci-memory.mjs +416 -0
  25. package/scripts/eval-fingerprint.mjs +192 -0
  26. package/scripts/smoke.mjs +76 -0
  27. package/scripts/sync-server-version.mjs +39 -0
  28. package/scripts/verify-baseline.mjs +65 -0
  29. package/skills/simframe/SKILL.md +173 -0
  30. package/src/actions.js +264 -18
  31. package/src/cli.js +561 -89
  32. package/src/control.js +77 -0
  33. package/src/daemon.js +8 -1
  34. package/src/engine.js +99 -0
  35. package/src/fingerprint.js +183 -0
  36. package/src/graph.js +411 -0
  37. package/src/index.js +351 -24
  38. package/src/input.js +179 -2
  39. package/src/matching.js +265 -0
  40. package/src/mcp.js +425 -112
  41. package/src/navigate.js +120 -0
  42. package/src/refs.js +141 -0
  43. package/src/regions.js +267 -0
  44. package/src/screenmap.js +119 -22
  45. package/src/simctl.js +74 -5
  46. package/src/store.js +8 -0
  47. package/src/view.js +342 -0
package/src/control.js ADDED
@@ -0,0 +1,77 @@
1
+ // Client for the simframed control socket.
2
+ //
3
+ // One JSON object per line over a per-device Unix socket. The socket is mode
4
+ // 0600, so reachability is the whole authorisation model: if you can open it,
5
+ // you are the user who owns the simulator.
6
+ import fs from 'node:fs';
7
+ import net from 'node:net';
8
+ import path from 'node:path';
9
+ import * as store from './store.js';
10
+
11
+ export function socketPath(udid) {
12
+ return path.join(store.deviceDir(udid), 'control.sock');
13
+ }
14
+
15
+ /** Whether a daemon is listening for this device. */
16
+ export function available(udid) {
17
+ const meta = store.readJson(path.join(store.deviceDir(udid), 'meta.json'));
18
+ if (!meta || !store.isProcessAlive(meta.pid)) return false;
19
+ try {
20
+ return fs.statSync(socketPath(udid)).isSocket();
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export function request(udid, payload, { timeoutMs = 30_000 } = {}) {
27
+ return new Promise((resolve, reject) => {
28
+ const socket = net.createConnection(socketPath(udid));
29
+ let buffer = '';
30
+ let settled = false;
31
+ const finish = (fn, value) => {
32
+ if (settled) return;
33
+ settled = true;
34
+ socket.destroy();
35
+ fn(value);
36
+ };
37
+ socket.setTimeout(timeoutMs, () => finish(reject, new Error('control socket timed out')));
38
+ socket.on('connect', () => socket.write(`${JSON.stringify(payload)}\n`));
39
+ socket.on('data', (chunk) => {
40
+ buffer += chunk;
41
+ const line = buffer.indexOf('\n');
42
+ if (line < 0) return;
43
+ try {
44
+ const response = JSON.parse(buffer.slice(0, line));
45
+ if (response.ok === false) finish(reject, new Error(response.error || 'request failed'));
46
+ else finish(resolve, response);
47
+ } catch (err) {
48
+ finish(reject, err);
49
+ }
50
+ });
51
+ socket.on('error', (err) => {
52
+ finish(
53
+ reject,
54
+ err.code === 'ENOENT' || err.code === 'ECONNREFUSED'
55
+ ? new Error('no simframed daemon is listening for this device')
56
+ : err,
57
+ );
58
+ });
59
+ });
60
+ }
61
+
62
+ export const tap = (udid, x, y, opts = {}) => request(udid, { action: 'tap', x, y, ...opts });
63
+ export const swipe = (udid, from, to, opts = {}) =>
64
+ request(udid, { action: 'swipe', x1: from.x, y1: from.y, x2: to.x, y2: to.y, ...opts });
65
+ export const type = (udid, text) => request(udid, { action: 'type', text });
66
+ export const paste = (udid, text) => request(udid, { action: 'paste', text });
67
+ export const press = (udid, button) => request(udid, { action: 'press', button });
68
+ export const status = (udid) => request(udid, { action: 'status' });
69
+ export const resetInput = (udid) => request(udid, { action: 'resetInput' });
70
+ export const longPress = (udid, x, y, opts = {}) => request(udid, { action: 'longPress', x, y, ...opts });
71
+ export const drag = (udid, from, to, opts = {}) =>
72
+ request(udid, { action: 'drag', x1: from.x, y1: from.y, x2: to.x, y2: to.y, ...opts });
73
+ export const launch = (udid, bundleId, opts = {}) => request(udid, { action: 'launch', bundleId, ...opts });
74
+ export const terminate = (udid, bundleId) => request(udid, { action: 'terminate', bundleId });
75
+ export const openUrl = (udid, url) => request(udid, { action: 'openUrl', url });
76
+ export const permission = (udid, permissionAction, service, bundleId) =>
77
+ request(udid, { action: 'permission', permissionAction, service, bundleId });
package/src/daemon.js CHANGED
@@ -16,7 +16,14 @@ import { isBootedSync, resize, screenshot } from './simctl.js';
16
16
 
17
17
  // Bump whenever the shape of state.json changes, so an upgraded client retires
18
18
  // a capture loop left running by an older install instead of misreading it.
19
- export const STATE_VERSION = 5;
19
+ // MUST equal SimframeCore.FrameStore.stateVersion in the Swift daemon. When
20
+ // these drifted — Node on 5, Swift writing 6 — every single CLI command judged
21
+ // the live daemon stale and spawned a replacement: 993 "superseded by another
22
+ // capture loop" lines in one log. Capture still worked, so nothing looked
23
+ // wrong, but each command lost the previous daemon's history, which silently
24
+ // broke `recall`, `state --since`, `wait --since` and every timing measured
25
+ // through a flow. A unit test asserts these two constants match.
26
+ export const STATE_VERSION = 6;
20
27
 
21
28
  export const DEFAULTS = {
22
29
  fps: 4,
package/src/engine.js ADDED
@@ -0,0 +1,99 @@
1
+ // Chooses and starts the capture engine.
2
+ //
3
+ // Two exist: `simframed`, a Swift daemon that reads the framebuffer directly,
4
+ // and `simctl`, the original loop that shells out for each screenshot. The
5
+ // daemon is the default because it is roughly thirty times faster, but the old
6
+ // loop stays reachable — a machine without a Swift toolchain, or an Xcode
7
+ // version where a private symbol has moved, still needs to work.
8
+ import { execFile, spawn } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { promisify } from 'node:util';
13
+ import * as store from './store.js';
14
+
15
+ const run = promisify(execFile);
16
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
17
+ const PACKAGE = path.join(HERE, '..', 'native', 'simframed');
18
+ const BINARY = path.join(PACKAGE, '.build', 'release', 'simframed');
19
+
20
+ export const ENGINES = ['simframed', 'simctl'];
21
+
22
+ export function binaryPath() {
23
+ return BINARY;
24
+ }
25
+
26
+ /** Newest mtime across the Swift sources, so a stale binary is rebuilt. */
27
+ function sourceMtime() {
28
+ const roots = [path.join(PACKAGE, 'Sources'), path.join(PACKAGE, 'Package.swift')];
29
+ let newest = 0;
30
+ const walk = (p) => {
31
+ let stat;
32
+ try {
33
+ stat = fs.statSync(p);
34
+ } catch {
35
+ return;
36
+ }
37
+ if (stat.isDirectory()) {
38
+ for (const name of fs.readdirSync(p)) walk(path.join(p, name));
39
+ return;
40
+ }
41
+ if (stat.mtimeMs > newest) newest = stat.mtimeMs;
42
+ };
43
+ for (const r of roots) walk(r);
44
+ return newest;
45
+ }
46
+
47
+ export function status() {
48
+ const haveSource = fs.existsSync(path.join(PACKAGE, 'Package.swift'));
49
+ const haveBinary = fs.existsSync(BINARY);
50
+ const stale = haveBinary && haveSource && fs.statSync(BINARY).mtimeMs < sourceMtime();
51
+ return { haveSource, haveBinary, stale, binary: BINARY };
52
+ }
53
+
54
+ /**
55
+ * Make sure the daemon binary exists and is current.
56
+ * @returns {Promise<{ok: boolean, built: boolean, reason?: string}>}
57
+ */
58
+ export async function ensureBuilt({ rebuild = false } = {}) {
59
+ const state = status();
60
+ if (!state.haveSource) {
61
+ return { ok: false, built: false, reason: 'the simframed sources are not in this install' };
62
+ }
63
+ if (state.haveBinary && !state.stale && !rebuild) return { ok: true, built: false };
64
+ try {
65
+ // Release, because a debug build is several times slower per frame and
66
+ // this binary's whole purpose is latency.
67
+ await run('swift', ['build', '-c', 'release', '--package-path', PACKAGE], { timeout: 300_000 });
68
+ return { ok: fs.existsSync(BINARY), built: true };
69
+ } catch (err) {
70
+ const detail = (err.stderr || err.message || '').split('\n').filter(Boolean).slice(-2).join(' ');
71
+ return {
72
+ ok: false,
73
+ built: false,
74
+ reason:
75
+ err.code === 'ENOENT'
76
+ ? 'swift is not installed, so the daemon cannot be built (install Xcode command line tools)'
77
+ : `building simframed failed: ${detail}`,
78
+ };
79
+ }
80
+ }
81
+
82
+ /** Start the Swift daemon detached, the way the Node loop is started. */
83
+ export function spawnDaemon(udid, { maxDim, minIntervalMs, idleExitMs } = {}) {
84
+ const args = ['run', `--udid=${udid}`];
85
+ if (maxDim != null) args.push(`--max-dim=${maxDim}`);
86
+ if (minIntervalMs != null) args.push(`--min-interval-ms=${minIntervalMs}`);
87
+ if (idleExitMs != null) args.push(`--idle-exit-ms=${idleExitMs}`);
88
+ const log = fs.openSync(path.join(store.deviceDir(udid), 'simframed.log'), 'a');
89
+ const child = spawn(BINARY, args, { detached: true, stdio: ['ignore', log, log] });
90
+ child.unref();
91
+ return child;
92
+ }
93
+
94
+ /** Which engine wrote the state we are reading, according to meta.json. */
95
+ export function runningEngine(udid) {
96
+ const meta = store.readJson(path.join(store.deviceDir(udid), 'meta.json'));
97
+ if (!meta || !store.isProcessAlive(meta.pid)) return null;
98
+ return meta.options?.engine === 'simframed' ? 'simframed' : 'simctl';
99
+ }
@@ -0,0 +1,183 @@
1
+ // Identity, as distinct from change.
2
+ //
3
+ // The pixel dHash answers "did this move?", which is a question about pixels
4
+ // and which it answers well. It is a poor answer to "is this the same screen?",
5
+ // because content is pixels: a list whose rows changed drifts as far as a
6
+ // different screen does. Measured, same-screen revisits reached 62 bits against
7
+ // a different-screen floor of 74 — no threshold separates those.
8
+ //
9
+ // This fingerprints layout instead. Two screens are the same when the same
10
+ // kinds of thing sit in the same places, whatever they currently say.
11
+ import crypto from 'node:crypto';
12
+ import * as regions from './regions.js';
13
+
14
+ /** Frames are quantised to this, so sub-pixel drift and a nudged row do not matter. */
15
+ export const GRID = 24;
16
+
17
+ /** Regions whose labels identify the screen rather than describe its contents. */
18
+ const CHROME = new Set(['nav-bar', 'tab-bar']);
19
+
20
+ /**
21
+ * How wide a tab label can be before it is not a tab label.
22
+ *
23
+ * The region bands are positional, so anything low enough on the screen lands
24
+ * in `tab-bar` — including page content sitting just above the real tabs. One
25
+ * app put a date banner there, and because chrome labels go into the
26
+ * fingerprint, that screen's identity contained "sep 08, 2026" and would have
27
+ * become a different screen at midnight. Every stored map, node and route
28
+ * touching it would have broken overnight.
29
+ *
30
+ * A tab bar divides its width between its tabs, so a tab's label is a fraction
31
+ * of the screen: measured on that app, real tab labels ran 24-72 px against the
32
+ * banner's 144 px on a 402 px screen. The element still contributes its shape
33
+ * to the fingerprint — presence is structure — it just stops contributing text.
34
+ */
35
+ const TAB_LABEL_MAX_WIDTH_FRACTION = 0.3;
36
+
37
+ const quantise = (v) => Math.round((v ?? 0) / GRID);
38
+
39
+ /** Coarse role, so "Button" and "AXButton" and an OCR-inferred button agree. */
40
+ export function roleOf(target) {
41
+ const t = String(target.type ?? '').toLowerCase();
42
+ if (/button/.test(t)) return 'button';
43
+ if (/textfield|textview|searchfield|field/.test(t)) return 'field';
44
+ if (/switch|toggle|checkbox/.test(t)) return 'switch';
45
+ if (/cell|row/.test(t)) return 'cell';
46
+ if (/link/.test(t)) return 'link';
47
+ if (/image|icon/.test(t)) return 'image';
48
+ if (/statictext|text|label/.test(t)) return 'text';
49
+ if (/group|other|generic/.test(t)) return 'group';
50
+ return t || 'unknown';
51
+ }
52
+
53
+ /**
54
+ * One, or several.
55
+ *
56
+ * Finer buckets were tried and are worse: a list that grows from three rows to
57
+ * seven crosses a 2-4 / 5+ boundary and changes identity, which is exactly the
58
+ * instability the bucketing existed to prevent. "A group of these lives here"
59
+ * is the stable fact; how many there are today is content.
60
+ */
61
+ function bucket(n) {
62
+ return n <= 1 ? '1' : 'many';
63
+ }
64
+
65
+ const normLabel = (s) => String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 40);
66
+
67
+ /**
68
+ * A value, not a name.
69
+ *
70
+ * Phase 6d removed a date banner from one screen's identity after that
71
+ * fingerprint would have expired at midnight. It came back through a different
72
+ * door: measured across twenty screens of a real app, three carried content in
73
+ * their identity because the positional region bands had called it chrome — a
74
+ * store address, a phone number, and a nav title reading "Tuesday, September 8".
75
+ * That last one is a screen whose identity has until midnight to live.
76
+ *
77
+ * The band misclassification is the root cause and is fixed by clustering, not
78
+ * by another threshold (docs/DEFERRED.md). What can be fixed here without
79
+ * guessing at geometry is the narrower question: is this text a name for the
80
+ * screen, or is it today's value? A name is words. A date, a phone number, a
81
+ * price and a bare count are not, and every one of them changes while the
82
+ * screen stays the same screen.
83
+ */
84
+ const MONTHS = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
85
+ const WEEKDAYS = /\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*day?\b/i;
86
+ const DATE_LIKE = /\d{1,4}[/.-]\d{1,2}([/.-]\d{1,4})?|\b\d{1,2}:\d{2}\b/;
87
+
88
+ export function isVolatileLabel(label) {
89
+ const text = String(label ?? '').trim();
90
+ if (!text) return true;
91
+ if (MONTHS.test(text) || WEEKDAYS.test(text) || DATE_LIKE.test(text)) return true;
92
+ const letters = (text.match(/\p{L}/gu) ?? []).length;
93
+ const digits = (text.match(/\p{N}/gu) ?? []).length;
94
+ // Mostly digits: a count, a price, a phone number, an ID. "1020" and
95
+ // "+1 (111) 111-1111" are both this; "Assets" is not.
96
+ return digits > 0 && digits >= letters;
97
+ }
98
+
99
+ /**
100
+ * The canonical tokens this screen is made of.
101
+ *
102
+ * Deliberately excluded: the status bar (a clock is not identity), everything
103
+ * inside the keyboard when one is up (it is the same keyboard on every screen),
104
+ * and the text of anything in the content region (that is the content).
105
+ */
106
+ export function tokens(targets, screen) {
107
+ if (!screen?.width || !screen?.height) return { tokens: [], keyboard: false };
108
+ const keyboardTop = regions.detectKeyboardTop(targets, screen);
109
+ const groups = new Map();
110
+
111
+ for (const t of targets) {
112
+ const frame = t.frame ?? { x: t.x, y: t.y, width: 0, height: 0 };
113
+ // Off-screen elements are not part of what this screen looks like.
114
+ if (frame.y + (frame.height ?? 0) <= 0 || frame.y >= screen.height) continue;
115
+ const region = t.region ?? regions.regionFor(frame, screen, { keyboardTop });
116
+ if (region === 'status-bar') continue;
117
+ if (keyboardTop != null && frame.y >= keyboardTop) continue;
118
+
119
+ const role = roleOf(t);
120
+ // Group by what a thing IS and how big it is, not where it is. Repeated
121
+ // siblings — the rows of a list — differ only in position, and including
122
+ // position in the key makes a four-row list a different screen from a
123
+ // three-row one.
124
+ const parts = [role, region];
125
+ // Where in the nav bar a thing sits is structure, not content — and it is
126
+ // what tells a title apart from a button that happens to be up there.
127
+ if (CHROME.has(region) && t.navSlot) parts.push(`@${t.navSlot}`);
128
+ parts.push(`w${quantise(frame.width)}`, `h${quantise(frame.height)}`);
129
+ // Chrome labels are the only text that survives: two list screens with
130
+ // identical structure differ by their title, and nothing else says so. But
131
+ // only where the element is plausibly chrome — a nav bar has slots, and a
132
+ // tab label is narrow; content that merely fell into the band is not a name.
133
+ const labelWorthKeeping = CHROME.has(region)
134
+ && t.label
135
+ && !isVolatileLabel(t.label)
136
+ && (region !== 'tab-bar' || (frame.width ?? 0) <= screen.width * TAB_LABEL_MAX_WIDTH_FRACTION);
137
+ if (labelWorthKeeping) parts.push(`"${normLabel(t.label)}"`);
138
+ const key = parts.join(':');
139
+ const group = groups.get(key) ?? { count: 0, x: quantise(frame.x), y: quantise(frame.y) };
140
+ group.count += 1;
141
+ // Anchor the group at its topmost member, which is stable as a list grows.
142
+ if (quantise(frame.y) < group.y) {
143
+ group.x = quantise(frame.x);
144
+ group.y = quantise(frame.y);
145
+ }
146
+ groups.set(key, group);
147
+ }
148
+
149
+ const out = [...groups.entries()]
150
+ .map(([key, g]) => (g.count > 1
151
+ // A repeated group is identified by its anchor and how many of it there
152
+ // roughly are, never by an exact count.
153
+ ? `${key}:x${g.x}:y${g.y}#${bucket(g.count)}`
154
+ : `${key}:x${g.x}:y${g.y}#1`))
155
+ .sort();
156
+ return { tokens: out, keyboard: keyboardTop != null };
157
+ }
158
+
159
+ export function hashTokens(list) {
160
+ return crypto.createHash('sha256').update(list.join('\n')).digest('hex').slice(0, 32);
161
+ }
162
+
163
+ /** Structural fingerprint of a screen, plus the tokens it was built from. */
164
+ export function fingerprint(targets, screen) {
165
+ const { tokens: list, keyboard } = tokens(targets, screen);
166
+ return { hash: hashTokens(list), tokens: list, keyboard, count: list.length };
167
+ }
168
+
169
+ /**
170
+ * How alike two token sets are, 0 to 1.
171
+ *
172
+ * Jaccard rather than Hamming: the sets are of different sizes when an optional
173
+ * element appears — a badge, a banner — and that should cost a little, not
174
+ * everything.
175
+ */
176
+ export function similarity(a = [], b = []) {
177
+ if (!a.length && !b.length) return 1;
178
+ const setA = new Set(a);
179
+ const setB = new Set(b);
180
+ let shared = 0;
181
+ for (const token of setA) if (setB.has(token)) shared += 1;
182
+ return shared / (setA.size + setB.size - shared);
183
+ }