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/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
|
-
|
|
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
|
+
}
|