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/screenmap.js
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { hashDistance } from './analyze.js';
|
|
11
|
+
import * as control from './control.js';
|
|
12
|
+
import * as fingerprint from './fingerprint.js';
|
|
11
13
|
import * as input from './input.js';
|
|
12
14
|
import * as ocr from './ocr.js';
|
|
15
|
+
import * as regions from './regions.js';
|
|
13
16
|
import * as store from './store.js';
|
|
14
17
|
|
|
15
|
-
const MAP_VERSION =
|
|
18
|
+
const MAP_VERSION = 6; // dates, prices and phone numbers no longer contribute labels
|
|
16
19
|
|
|
17
20
|
function mapDir(udid) {
|
|
18
21
|
return path.join(store.deviceDir(udid), 'screens');
|
|
@@ -20,11 +23,18 @@ function mapDir(udid) {
|
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* How many of the 288 layout bits may differ and still count as the same screen.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
+
*
|
|
27
|
+
* Re-measured across four visits to each of five screens: a revisit is usually
|
|
28
|
+
* identical (median 0) but the tail reaches 62 when list content has changed,
|
|
29
|
+
* while different screens sit at 74 and above. That margin is much narrower
|
|
30
|
+
* than the first calibration suggested, and it is the reason this number stays
|
|
31
|
+
* conservative rather than being raised to cover the tail.
|
|
32
|
+
*
|
|
33
|
+
* The consequence is deliberate: a heavily changed screen is rebuilt rather
|
|
34
|
+
* than recognised. A rebuild costs ~300ms; a false match taps the wrong
|
|
35
|
+
* control. See docs/DEFERRED.md on fingerprinting structure instead of pixels.
|
|
26
36
|
*/
|
|
27
|
-
export const DEFAULT_TOLERANCE =
|
|
37
|
+
export const DEFAULT_TOLERANCE = 20;
|
|
28
38
|
|
|
29
39
|
export function recall(udid, hash) {
|
|
30
40
|
if (!hash) return null;
|
|
@@ -104,16 +114,47 @@ export async function build(udid, {
|
|
|
104
114
|
density = 3,
|
|
105
115
|
useAx = true,
|
|
106
116
|
useOcr = true,
|
|
117
|
+
persist = true,
|
|
107
118
|
screen,
|
|
108
119
|
} = {}) {
|
|
109
120
|
const targets = [];
|
|
110
121
|
const sources = [];
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
// Why a layer is missing, kept rather than swallowed.
|
|
123
|
+
//
|
|
124
|
+
// A partial map used to be indistinguishable from a whole one: `sources` said
|
|
125
|
+
// ["ax"] and nothing said why OCR was not there. On CI this produced a map
|
|
126
|
+
// with zero elements reported as a successful read, and the only way to find
|
|
127
|
+
// out what had happened was to guess. Before the tree came in-process the
|
|
128
|
+
// same failure was loud, because with no idb the ax layer failed too and an
|
|
129
|
+
// empty `sources` rethrew — so making a layer work turned a loud failure into
|
|
130
|
+
// a quiet one.
|
|
131
|
+
const degraded = [];
|
|
132
|
+
// One round trip for both, because the daemon runs the tree read and the
|
|
133
|
+
// recognition pass concurrently against the same instant of the screen. Asked
|
|
134
|
+
// separately they would queue: the control socket serves one request at a
|
|
135
|
+
// time, so a second call pays the first one's latency before it starts.
|
|
136
|
+
//
|
|
137
|
+
// The daemon reads text straight off the framebuffer. The fallback encodes a
|
|
138
|
+
// PNG, writes it, spawns a helper and decodes it again — measured at 555ms
|
|
139
|
+
// against 174ms — so it is only used when no daemon is listening.
|
|
140
|
+
const viaDaemon = (useOcr || useAx) && control.available(udid);
|
|
141
|
+
const axViaDaemon = useAx && process.env.SIMFRAME_AX_DRIVER !== 'idb';
|
|
142
|
+
const daemonPromise = viaDaemon
|
|
143
|
+
? control.request(udid, { action: 'ui', ax: axViaDaemon, ocr: useOcr }).catch((err) => err)
|
|
144
|
+
: null;
|
|
145
|
+
const ocrPromise = !useOcr || viaDaemon
|
|
146
|
+
? null
|
|
147
|
+
: fullFrame && fs.existsSync(fullFrame)
|
|
115
148
|
? ocr.readText(fullFrame, { density }).catch((err) => err)
|
|
116
149
|
: null;
|
|
150
|
+
const daemonAnswer = daemonPromise ? await daemonPromise : null;
|
|
151
|
+
// Keep the failure rather than flattening it to null. A daemon that was
|
|
152
|
+
// listening and then did not answer is a loud failure, and the version of
|
|
153
|
+
// this that dropped it returned an empty map with no error — which `persist`
|
|
154
|
+
// then wrote into screen memory, so a later warm visit read the emptiness
|
|
155
|
+
// back instead of perceiving the screen again.
|
|
156
|
+
const daemonError = daemonAnswer instanceof Error ? daemonAnswer : null;
|
|
157
|
+
const daemonScreen = daemonError ? null : daemonAnswer?.screen ?? null;
|
|
117
158
|
// With no geometry, treat every element as a potential control rather than
|
|
118
159
|
// guessing a screen size and mis-classifying containers.
|
|
119
160
|
const screenArea = screen?.width && screen?.height ? screen.width * screen.height : Infinity;
|
|
@@ -125,7 +166,12 @@ export async function build(udid, {
|
|
|
125
166
|
|
|
126
167
|
if (useAx) {
|
|
127
168
|
try {
|
|
128
|
-
|
|
169
|
+
// The tree is already in hand when the daemon answered; describeAll would
|
|
170
|
+
// only ask for it a second time.
|
|
171
|
+
const nodes = daemonScreen?.sources?.includes('ax')
|
|
172
|
+
? daemonScreen.elements.filter((e) => e.source?.includes('ax')).map(input.elementToNode)
|
|
173
|
+
: await input.describeAll(udid);
|
|
174
|
+
|
|
129
175
|
sources.push('ax');
|
|
130
176
|
for (const n of nodes) {
|
|
131
177
|
if (!n.frame || !n.label || isContainer(n)) continue;
|
|
@@ -139,15 +185,40 @@ export async function build(udid, {
|
|
|
139
185
|
source: 'ax',
|
|
140
186
|
});
|
|
141
187
|
}
|
|
142
|
-
} catch {
|
|
188
|
+
} catch (err) {
|
|
143
189
|
/* no idb, or the tree read failed; OCR alone is still useful */
|
|
190
|
+
degraded.push(`accessibility: ${err.message}`);
|
|
144
191
|
}
|
|
145
192
|
}
|
|
146
193
|
|
|
147
|
-
|
|
194
|
+
// Neither layer was even attempted: nothing below can report the failure, so
|
|
195
|
+
// it has to be reported here rather than returned as an empty screen.
|
|
196
|
+
if (daemonError && !useOcr) throw daemonError;
|
|
197
|
+
|
|
198
|
+
if (ocrPromise || (useOcr && viaDaemon)) {
|
|
148
199
|
try {
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
200
|
+
const result = ocrPromise ? await ocrPromise : (daemonError ?? daemonScreen);
|
|
201
|
+
if (result instanceof Error) throw result;
|
|
202
|
+
if (!result) throw new Error('the daemon did not answer');
|
|
203
|
+
// A daemon that answered without reading text is not an OCR source, and
|
|
204
|
+
// saying it was would claim the screen had been read when it had not.
|
|
205
|
+
if (viaDaemon && !result.sources?.includes('ocr')) throw new Error(result.ocrError ?? 'no text was read');
|
|
206
|
+
// The daemon answers in points; readText answers in points too, having
|
|
207
|
+
// divided by density. Normalise the daemon's element shape to match.
|
|
208
|
+
const words = viaDaemon
|
|
209
|
+
? (result.elements ?? [])
|
|
210
|
+
.filter((e) => e.source?.includes('ocr') && e.label?.trim())
|
|
211
|
+
.map((e) => ({
|
|
212
|
+
text: e.label,
|
|
213
|
+
confidence: e.confidence,
|
|
214
|
+
x: e.frame.x,
|
|
215
|
+
y: e.frame.y,
|
|
216
|
+
width: e.frame.width,
|
|
217
|
+
height: e.frame.height,
|
|
218
|
+
centerX: e.center.x,
|
|
219
|
+
centerY: e.center.y,
|
|
220
|
+
}))
|
|
221
|
+
: result;
|
|
151
222
|
sources.push('ocr');
|
|
152
223
|
for (const w of words) {
|
|
153
224
|
if (!w.text.trim()) continue;
|
|
@@ -183,18 +254,44 @@ export async function build(udid, {
|
|
|
183
254
|
});
|
|
184
255
|
}
|
|
185
256
|
} catch (err) {
|
|
257
|
+
degraded.push(`text recognition: ${err.message}`);
|
|
186
258
|
if (!sources.length) throw err;
|
|
187
259
|
}
|
|
188
260
|
}
|
|
189
261
|
|
|
190
|
-
return
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
targets,
|
|
197
|
-
|
|
262
|
+
return finish();
|
|
263
|
+
|
|
264
|
+
function finish() {
|
|
265
|
+
// Region priors are geometry, so they cost nothing and disambiguate a great
|
|
266
|
+
// deal: "Assets" the nav title and "Assets" the tab differ only by where
|
|
267
|
+
// they are.
|
|
268
|
+
if (screen?.width && screen?.height) regions.annotate(targets, screen);
|
|
269
|
+
// Two hashes, two jobs. The pixel layout hash indexes this entry, because
|
|
270
|
+
// it can be computed from a frame alone and so can find a map without
|
|
271
|
+
// building one. The structural hash identifies the screen, because content
|
|
272
|
+
// is pixels and a list with new rows is not a new screen.
|
|
273
|
+
const structure = screen?.width && screen?.height
|
|
274
|
+
? fingerprint.fingerprint(targets, screen)
|
|
275
|
+
: { hash: null, tokens: [], keyboard: false };
|
|
276
|
+
const entry = {
|
|
277
|
+
version: MAP_VERSION,
|
|
278
|
+
hash,
|
|
279
|
+
layoutHash,
|
|
280
|
+
structuralHash: structure.hash,
|
|
281
|
+
structuralTokens: structure.tokens,
|
|
282
|
+
keyboard: structure.keyboard,
|
|
283
|
+
at: Date.now(),
|
|
284
|
+
sources,
|
|
285
|
+
targets,
|
|
286
|
+
};
|
|
287
|
+
// A truncated tree is nodes without authority; the daemon says so and the
|
|
288
|
+
// map has to carry it, because this is what gets written into memory.
|
|
289
|
+
if (daemonScreen?.axTruncated) degraded.push(`accessibility tree cut short: ${daemonScreen.axTruncated}`);
|
|
290
|
+
if (degraded.length) entry.degraded = degraded;
|
|
291
|
+
// Only a map of a settled screen is worth keeping; remembering a transition
|
|
292
|
+
// fills the store with layouts that will never be seen again.
|
|
293
|
+
return persist ? remember(udid, entry) : entry;
|
|
294
|
+
}
|
|
198
295
|
}
|
|
199
296
|
|
|
200
297
|
const norm = (s) => String(s ?? '').toLowerCase().trim();
|
package/src/simctl.js
CHANGED
|
@@ -96,9 +96,17 @@ export function isBootedSync(udid) {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
try {
|
|
100
|
+
await run('xcrun', ['simctl', 'io', udid, 'screenshot', '--type=png', `--mask=${mask}`, outFile], {
|
|
101
|
+
timeout: 10_000,
|
|
102
|
+
});
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Same reason as launchApp: execFile's message is "Command failed: <the
|
|
105
|
+
// whole command>" and simctl's actual complaint is in stderr. A CI failure
|
|
106
|
+
// here reported the command and nothing about why it did not work.
|
|
107
|
+
const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
|
|
108
|
+
throw new Error(detail ? `simctl screenshot failed: ${detail}` : `simctl screenshot failed: ${err.message}`);
|
|
109
|
+
}
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
/** Resample with sips, which ships with macOS, so simframe needs no image deps. */
|
|
@@ -106,8 +114,38 @@ export async function resize(inFile, outFile, maxDim) {
|
|
|
106
114
|
await run('sips', ['-Z', String(maxDim), inFile, '--out', outFile], { timeout: 10_000 });
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Launch, optionally with arguments and environment.
|
|
119
|
+
*
|
|
120
|
+
* simctl passes launch arguments after the bundle id and environment through
|
|
121
|
+
* `SIMCTL_CHILD_`-prefixed variables of its own process — which is why env has
|
|
122
|
+
* to be set on the child rather than passed as flags.
|
|
123
|
+
*/
|
|
124
|
+
export async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst = false } = {}) {
|
|
125
|
+
if (terminateFirst) {
|
|
126
|
+
// A launch against an already-running app is a no-op that reports success,
|
|
127
|
+
// which is how a flow "relaunched" an app and tested the screen it was
|
|
128
|
+
// already on.
|
|
129
|
+
try {
|
|
130
|
+
await terminateApp(udid, bundleId);
|
|
131
|
+
} catch {
|
|
132
|
+
/* not running; that is the state we wanted */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const childEnv = { ...process.env };
|
|
136
|
+
for (const [k, v] of Object.entries(env)) childEnv[`SIMCTL_CHILD_${k}`] = String(v);
|
|
137
|
+
try {
|
|
138
|
+
await run('xcrun', ['simctl', 'launch', udid, bundleId, ...args.map(String)], {
|
|
139
|
+
timeout: 20_000,
|
|
140
|
+
env: childEnv,
|
|
141
|
+
});
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// execFile's message is just "Command failed: ..." with simctl's actual
|
|
144
|
+
// complaint left in stderr. A CI run failed here and said nothing about
|
|
145
|
+
// why, which is the same sin as a silent fallback.
|
|
146
|
+
const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
|
|
147
|
+
throw new Error(detail ? `could not launch ${bundleId}: ${detail}` : `could not launch ${bundleId}: ${err.message}`);
|
|
148
|
+
}
|
|
111
149
|
}
|
|
112
150
|
|
|
113
151
|
export async function terminateApp(udid, bundleId) {
|
|
@@ -118,6 +156,37 @@ export async function openUrl(udid, url) {
|
|
|
118
156
|
await run('xcrun', ['simctl', 'openurl', udid, url], { timeout: 20_000 });
|
|
119
157
|
}
|
|
120
158
|
|
|
159
|
+
export const PERMISSION_SERVICES = [
|
|
160
|
+
'all', 'calendar', 'contacts-limited', 'contacts', 'location', 'location-always',
|
|
161
|
+
'photos-add', 'photos', 'media-library', 'microphone', 'motion', 'reminders', 'siri',
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Grant, revoke or reset a privacy permission.
|
|
166
|
+
*
|
|
167
|
+
* The point of doing this from a test harness is that the alternative is
|
|
168
|
+
* tapping a system alert, and a system alert is not part of the app under test:
|
|
169
|
+
* its buttons move between iOS versions and its appearance is a race.
|
|
170
|
+
*/
|
|
171
|
+
export async function setPermission(udid, action, service, bundleId) {
|
|
172
|
+
const verb = String(action).toLowerCase();
|
|
173
|
+
if (!['grant', 'revoke', 'reset'].includes(verb)) {
|
|
174
|
+
throw new Error(`permission action must be grant, revoke or reset (got "${action}")`);
|
|
175
|
+
}
|
|
176
|
+
if (!PERMISSION_SERVICES.includes(service)) {
|
|
177
|
+
throw new Error(`unknown permission "${service}" — one of: ${PERMISSION_SERVICES.join(', ')}`);
|
|
178
|
+
}
|
|
179
|
+
const args = ['simctl', 'privacy', udid, verb, service];
|
|
180
|
+
if (bundleId) args.push(bundleId);
|
|
181
|
+
try {
|
|
182
|
+
await run('xcrun', args, { timeout: 20_000 });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
|
|
185
|
+
throw new Error(`could not ${verb} ${service}: ${detail || err.message}`);
|
|
186
|
+
}
|
|
187
|
+
return `${verb === 'reset' ? 'reset' : verb + 'ed'} ${service}${bundleId ? ` for ${bundleId}` : ''}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
121
190
|
/** Put text on the device pasteboard — far faster than typing a long string. */
|
|
122
191
|
export async function setPasteboard(udid, value) {
|
|
123
192
|
const child = execFile('xcrun', ['simctl', 'pbcopy', udid], { timeout: 10_000 });
|
package/src/store.js
CHANGED
|
@@ -5,6 +5,14 @@ import fs from 'node:fs';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* A directory under ROOT is a device only if it is named like a UDID.
|
|
10
|
+
*
|
|
11
|
+
* `simframe status` listed five phantom `? ` rows once, which were test
|
|
12
|
+
* fixtures. Anything that is not a UDID is not a device, whoever wrote it.
|
|
13
|
+
*/
|
|
14
|
+
export const isUdid = (name) => /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(name);
|
|
15
|
+
|
|
8
16
|
export const ROOT = process.env.SIMFRAME_HOME || path.join(os.homedir(), '.simframe');
|
|
9
17
|
|
|
10
18
|
export function deviceDir(udid) {
|
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
|
+
}
|