simframe 0.1.0 → 0.4.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 +237 -155
- package/native/ocr.swift +29 -0
- package/package.json +2 -1
- package/src/actions.js +244 -0
- package/src/analyze.js +59 -0
- package/src/cli.js +198 -15
- package/src/daemon.js +139 -9
- package/src/index.js +382 -15
- package/src/input.js +221 -0
- package/src/intent.js +100 -0
- package/src/mcp.js +284 -22
- package/src/ocr.js +71 -0
- package/src/screenmap.js +229 -0
- package/src/simctl.js +26 -1
package/src/mcp.js
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import { REGION_COLS, REGION_ROWS, regionMap } from './analyze.js';
|
|
11
|
+
import * as actions from './actions.js';
|
|
11
12
|
import * as api from './index.js';
|
|
13
|
+
import * as input from './input.js';
|
|
12
14
|
import { bootedDevices } from './simctl.js';
|
|
13
15
|
import * as store from './store.js';
|
|
14
16
|
|
|
@@ -45,21 +47,37 @@ const TOOLS = [
|
|
|
45
47
|
{
|
|
46
48
|
name: 'sim_state',
|
|
47
49
|
description:
|
|
48
|
-
'Cheap TEXT-ONLY check of
|
|
49
|
-
inputSchema: {
|
|
50
|
+
'Cheap TEXT-ONLY check of the simulator screen: a stable screen hash, whether anything has changed SINCE YOUR LAST LOOK in this session, how long the screen has been still, and an ASCII map of which regions moved. Costs a tiny fraction of an image. Use it to poll ("has it finished loading?", "did my tap register?") and call sim_look only when you need to see pixels. The comparison is against the last frame you observed through any simframe tool, so calling this before and after an action is the reliable way to tell whether the action did anything.',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
...deviceProp,
|
|
55
|
+
since: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description:
|
|
58
|
+
'Compare against this specific frame hash instead of your last look. Defaults to your previous observation in this session.',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
50
62
|
},
|
|
51
63
|
{
|
|
52
64
|
name: 'sim_wait',
|
|
53
65
|
description:
|
|
54
|
-
'Block until the
|
|
66
|
+
'Block until the screen finishes reacting, then return the frame. Use after a tap, launch or navigation instead of sleeping and screenshotting. Default mode "settle" waits for the screen to CHANGE and then hold still, which is what you want after acting — plain "stable" can return instantly if you call it in the moment before an animation starts. The baseline is whatever you last observed in this session, so the normal pattern is: call sim_state or sim_look, act, then call sim_wait. If the change already completed before you call, that is detected rather than waited out.',
|
|
55
67
|
inputSchema: {
|
|
56
68
|
type: 'object',
|
|
57
69
|
properties: {
|
|
58
70
|
...deviceProp,
|
|
59
71
|
mode: {
|
|
60
72
|
type: 'string',
|
|
61
|
-
enum: ['
|
|
62
|
-
description:
|
|
73
|
+
enum: ['settle', 'change', 'stable'],
|
|
74
|
+
description:
|
|
75
|
+
'settle (default): wait for a change, then for it to hold still. change: return as soon as it differs from the baseline. stable: return once it is still, even if nothing ever changed.',
|
|
76
|
+
},
|
|
77
|
+
since: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
description:
|
|
80
|
+
'Frame hash to treat as the "before" state. Defaults to your last observation in this session. Pass this when you captured a hash before acting.',
|
|
63
81
|
},
|
|
64
82
|
stableMs: { type: 'number', description: 'How long the screen must hold still for mode "stable" (default 600).' },
|
|
65
83
|
timeoutMs: { type: 'number', description: 'Give up after this long (default 8000).' },
|
|
@@ -82,6 +100,59 @@ const TOOLS = [
|
|
|
82
100
|
},
|
|
83
101
|
},
|
|
84
102
|
},
|
|
103
|
+
{
|
|
104
|
+
name: 'sim_recall',
|
|
105
|
+
description:
|
|
106
|
+
'Look BACKWARDS in time. simframe remembers roughly the last 60 seconds of the screen — every frame for the last 10s, thinned to about 2fps before that. action "timeline" (default) returns a TEXT-ONLY summary of what happened and when: each change, how long ago it started, how long it took, how much of the screen it moved. action "at" returns the buffered frame from a moment in the past. Use this when you look up and find the screen already different, or when something flashed by and you need to know what it was — instead of guessing or re-running the action.',
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: {
|
|
110
|
+
...deviceProp,
|
|
111
|
+
action: { type: 'string', enum: ['timeline', 'at'], description: 'timeline (default) or at.' },
|
|
112
|
+
spanMs: { type: 'number', description: 'For timeline: how far back to summarise (default 60000).' },
|
|
113
|
+
msAgo: { type: 'number', description: 'For at: how long ago the moment of interest was, in milliseconds.' },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'sim_do',
|
|
119
|
+
description:
|
|
120
|
+
'Run a whole flow in ONE call: tap, type, scroll, wait and assert, in order. Each action automatically waits for the screen to settle before the next step, using a baseline captured before that action, so steps do not race the UI. This is the fastest way to drive the simulator — a twelve-step flow costs one round trip instead of twelve. Prefer it over single taps whenever you know more than one step ahead. Steps stop at the first failure and the result says exactly which step failed and why. Requires idb for input; observation-only steps work without it.',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
...deviceProp,
|
|
125
|
+
steps: {
|
|
126
|
+
type: 'array',
|
|
127
|
+
description:
|
|
128
|
+
'Ordered steps. Shorthand forms: {"tap":"Save"} (by accessibility label; add "index" if ambiguous), {"tapAt":{"x":100,"y":200,"space":"points"|"image"}}, {"type":{"into":"Name","text":"Fryer 3"}}, {"paste":{"into":"Notes","text":"long text"}}, {"scroll":"down"}, {"swipe":{"from":[x,y],"to":[x,y]}}, {"button":"HOME"}, {"launch":"com.example.app"}, {"openUrl":"myapp://x"}, {"waitText":"Saved","timeoutMs":5000}, {"assertText":"Saved"}, {"assertGone":"Spinner"}, {"settle":{"stableMs":600}}, {"look":{"detail":"low"}}, {"pause":300}.',
|
|
129
|
+
items: { type: 'object' },
|
|
130
|
+
},
|
|
131
|
+
autoSettle: {
|
|
132
|
+
type: 'boolean',
|
|
133
|
+
description: 'Wait for the screen to settle after each action (default true). Turn off only for deliberate rapid input.',
|
|
134
|
+
},
|
|
135
|
+
stableMs: { type: 'number', description: 'How still the screen must be to count as settled (default 500).' },
|
|
136
|
+
timeoutMs: { type: 'number', description: 'Per-step settle timeout (default 8000).' },
|
|
137
|
+
continueOnError: { type: 'boolean', description: 'Keep going after a failed step (default false).' },
|
|
138
|
+
finalLook: { type: 'boolean', description: 'Attach a frame of the end state (default true).' },
|
|
139
|
+
},
|
|
140
|
+
required: ['steps'],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'sim_ui',
|
|
145
|
+
description:
|
|
146
|
+
'Read the screen as an accessibility tree instead of an image: every element with its label, value, type and position in points. Often cheaper AND more useful than a screenshot, because it tells you what is actually tappable and gives exact coordinates — no measuring pixels by eye. Use it before tapping something you are unsure about. Requires idb.',
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
...deviceProp,
|
|
151
|
+
filter: { type: 'string', description: 'Only elements whose label, value or identifier contains this text.' },
|
|
152
|
+
interactive: { type: 'boolean', description: 'Only elements that look tappable (buttons, fields, cells).' },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
85
156
|
{
|
|
86
157
|
name: 'sim_capture',
|
|
87
158
|
description:
|
|
@@ -103,6 +174,22 @@ const TOOLS = [
|
|
|
103
174
|
},
|
|
104
175
|
];
|
|
105
176
|
|
|
177
|
+
// What this MCP session last observed, per device. This is the baseline that
|
|
178
|
+
// makes "what changed since I last looked?" answerable without the caller
|
|
179
|
+
// having to thread a hash through every call — the mistake that made the
|
|
180
|
+
// frame-to-frame delta look broken in practice.
|
|
181
|
+
const lastSeen = new Map();
|
|
182
|
+
|
|
183
|
+
function remember(udid, state) {
|
|
184
|
+
lastSeen.set(udid, { hash: state.hash, seq: state.seq, at: state.capturedAt });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function baselineFor(udid, explicit) {
|
|
188
|
+
if (explicit != null) return explicit;
|
|
189
|
+
const seen = lastSeen.get(udid);
|
|
190
|
+
return seen ? seen.hash : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
106
193
|
const text = (s) => ({ type: 'text', text: s });
|
|
107
194
|
const image = (png) => ({ type: 'image', data: png.toString('base64'), mimeType: 'image/png' });
|
|
108
195
|
|
|
@@ -113,6 +200,25 @@ function header(device, state, ageMs, extra = '') {
|
|
|
113
200
|
);
|
|
114
201
|
}
|
|
115
202
|
|
|
203
|
+
function livenessLine(live) {
|
|
204
|
+
return live?.ok ? null : `WARNING: ${live.note}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function sinceLine(since) {
|
|
208
|
+
if (!since) return 'no previous look in this session to compare against';
|
|
209
|
+
if (since.kind === 'unmatched') {
|
|
210
|
+
return `baseline ${since.requested} is not in the buffered history — cannot compare`;
|
|
211
|
+
}
|
|
212
|
+
if (since.kind === 'coarse') {
|
|
213
|
+
return since.changed
|
|
214
|
+
? `the screen HAS changed since your baseline ${Math.round(since.ageMs / 1000)}s ago (too old for a detailed diff)`
|
|
215
|
+
: `the screen has NOT changed since your baseline ${Math.round(since.ageMs / 1000)}s ago`;
|
|
216
|
+
}
|
|
217
|
+
return since.changed
|
|
218
|
+
? `CHANGED since your last look ${since.ageMs}ms ago (${(since.diff * 100).toFixed(1)}% of the screen)`
|
|
219
|
+
: `unchanged since your last look ${since.ageMs}ms ago`;
|
|
220
|
+
}
|
|
221
|
+
|
|
116
222
|
export async function serve({ device: defaultDevice, options = {} } = {}) {
|
|
117
223
|
const server = new Server(
|
|
118
224
|
{ name: 'simframe', version: '0.1.0' },
|
|
@@ -129,11 +235,17 @@ export async function serve({ device: defaultDevice, options = {} } = {}) {
|
|
|
129
235
|
case 'sim_look':
|
|
130
236
|
return await look(target, args, options);
|
|
131
237
|
case 'sim_state':
|
|
132
|
-
return await state(target, options);
|
|
238
|
+
return await state(target, args, options);
|
|
133
239
|
case 'sim_wait':
|
|
134
240
|
return await wait(target, args, options);
|
|
135
241
|
case 'sim_strip':
|
|
136
242
|
return await strip(target, args, options);
|
|
243
|
+
case 'sim_recall':
|
|
244
|
+
return await recall(target, args, options);
|
|
245
|
+
case 'sim_do':
|
|
246
|
+
return await doScript(target, args, options);
|
|
247
|
+
case 'sim_ui':
|
|
248
|
+
return await ui(target, args, options);
|
|
137
249
|
case 'sim_capture':
|
|
138
250
|
return await capture(target, args, options);
|
|
139
251
|
case 'sim_devices':
|
|
@@ -163,34 +275,71 @@ async function look(target, args, options) {
|
|
|
163
275
|
}
|
|
164
276
|
res = await api.getFrame(target, { detail: args.detail ?? 'normal', options });
|
|
165
277
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
278
|
+
const prior = baselineFor(res.device.udid, undefined);
|
|
279
|
+
const st = await api.getState(target, { since: prior, options });
|
|
280
|
+
remember(res.device.udid, res.state);
|
|
281
|
+
const lines = [header(res.device, res.state, res.ageMs), sinceLine(st.since)];
|
|
282
|
+
const warn = livenessLine(st.live);
|
|
283
|
+
if (warn) lines.unshift(warn);
|
|
284
|
+
return { content: [text(lines.filter(Boolean).join('\n')), image(res.png)] };
|
|
169
285
|
}
|
|
170
286
|
|
|
171
|
-
async function state(target, options) {
|
|
172
|
-
const
|
|
287
|
+
async function state(target, args, options) {
|
|
288
|
+
const { device } = await api.ensureDaemon(target, options);
|
|
289
|
+
const requested = baselineFor(device.udid, args.since);
|
|
290
|
+
const res = await api.getState(target, { since: requested, options });
|
|
173
291
|
const s = res.state;
|
|
174
|
-
const
|
|
292
|
+
const lines = [
|
|
175
293
|
header(res.device, s, res.ageMs),
|
|
176
|
-
`screen hash: ${s.hash}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
294
|
+
`screen hash: ${s.hash}`,
|
|
295
|
+
sinceLine(res.since),
|
|
296
|
+
s.firstFrame
|
|
297
|
+
? 'this is the first frame of a freshly started capture loop'
|
|
298
|
+
: s.stableForMs > 1200
|
|
299
|
+
? 'screen is idle right now'
|
|
300
|
+
: 'screen is moving right now',
|
|
301
|
+
];
|
|
302
|
+
if (res.since?.kind === 'history') {
|
|
303
|
+
lines.push(
|
|
304
|
+
`what moved since your last look (${REGION_COLS}x${REGION_ROWS}, top-left to bottom-right; "." to "@" = more movement):`,
|
|
305
|
+
res.since.map,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const warn = livenessLine(res.live);
|
|
309
|
+
if (warn) lines.unshift(warn);
|
|
310
|
+
remember(device.udid, s);
|
|
311
|
+
return { content: [text(lines.filter(Boolean).join('\n'))] };
|
|
182
312
|
}
|
|
183
313
|
|
|
184
314
|
async function wait(target, args, options) {
|
|
315
|
+
const { device } = await api.ensureDaemon(target, options);
|
|
185
316
|
const res = await api.waitFor(target, {
|
|
186
|
-
mode: args.mode ?? '
|
|
317
|
+
mode: args.mode ?? 'settle',
|
|
318
|
+
since: baselineFor(device.udid, args.since),
|
|
187
319
|
stableMs: args.stableMs ?? 600,
|
|
188
320
|
timeoutMs: args.timeoutMs ?? 8000,
|
|
189
321
|
options,
|
|
190
322
|
});
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
323
|
+
let note;
|
|
324
|
+
if (res.satisfied) {
|
|
325
|
+
note = `${res.mode === 'change' ? 'screen changed' : 'screen settled'} after ${res.waitedMs}ms`;
|
|
326
|
+
if (res.changedBeforeWait) note += ' (the change had already happened before this call)';
|
|
327
|
+
} else if (res.noVisibleChange) {
|
|
328
|
+
note =
|
|
329
|
+
`no visible change after ${res.waitedMs}ms — the screen is stable but nothing moved. ` +
|
|
330
|
+
'The action may have done nothing, or its effect may be too small to see (a checkbox, a radio, a button state). ' +
|
|
331
|
+
'Check with sim_ui rather than waiting longer.';
|
|
332
|
+
} else if (res.stalled) {
|
|
333
|
+
note = `CAPTURE STALLED after ${res.waitedMs}ms — ${res.live.note}. This is a simframe problem, not a screen that failed to change.`;
|
|
334
|
+
} else {
|
|
335
|
+
note = `TIMED OUT after ${res.waitedMs}ms — screen never ${res.mode === 'change' ? 'changed' : 'settled'}`;
|
|
336
|
+
if (!res.sawChange) {
|
|
337
|
+
note += res.baselineResolved
|
|
338
|
+
? '. No change was seen at all; if the change happened before this call, pass the hash you saw beforehand as `since`.'
|
|
339
|
+
: `. The baseline you passed was not in the buffered history, so "changed" could not be judged.`;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
remember(device.udid, res.state);
|
|
194
343
|
const content = [text(`${note}\n${header(res.device, res.state, Date.now() - res.state.capturedAt)}`)];
|
|
195
344
|
if (args.includeImage !== false) {
|
|
196
345
|
const frame = await api.getFrame(target, { detail: args.detail ?? 'normal', options });
|
|
@@ -217,6 +366,119 @@ async function strip(target, args, options) {
|
|
|
217
366
|
};
|
|
218
367
|
}
|
|
219
368
|
|
|
369
|
+
function dur(ms) {
|
|
370
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function ago(ms) {
|
|
374
|
+
return `${dur(ms)} ago`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function recall(target, args, options) {
|
|
378
|
+
if (args.action === 'at') {
|
|
379
|
+
const res = await api.getFrameAt(target, { msAgo: args.msAgo ?? 0, options });
|
|
380
|
+
return {
|
|
381
|
+
content: [
|
|
382
|
+
text(
|
|
383
|
+
`${res.device.name} · frame #${res.seq} from ${ago(res.actualMsAgo)}` +
|
|
384
|
+
(Math.abs(res.actualMsAgo - res.requestedMsAgo) > 400
|
|
385
|
+
? ` (nearest buffered frame to the ${ago(res.requestedMsAgo)} you asked for)`
|
|
386
|
+
: '') +
|
|
387
|
+
`\nbuffered memory reaches back ${ago(res.oldestMsAgo)}`,
|
|
388
|
+
),
|
|
389
|
+
image(res.png),
|
|
390
|
+
],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const res = await api.getTimeline(target, { spanMs: args.spanMs ?? 60_000, options });
|
|
395
|
+
const lines = [
|
|
396
|
+
`${res.device.name} · remembering the last ${dur(res.coveredMs)} · ${res.buffered} frames buffered`,
|
|
397
|
+
];
|
|
398
|
+
if (!res.events.length) {
|
|
399
|
+
lines.push(`nothing changed in that window; the screen has been still for ${dur(res.idleForMs)}`);
|
|
400
|
+
} else {
|
|
401
|
+
lines.push(`${res.events.length} change${res.events.length === 1 ? '' : 's'}, oldest first:`);
|
|
402
|
+
for (const e of res.events) {
|
|
403
|
+
lines.push(
|
|
404
|
+
` ${ago(e.startedMsAgo).padStart(9)} ${e.level === 'major' ? 'screen changed' : 'small change '} ` +
|
|
405
|
+
`${(e.magnitude * 100).toFixed(0)}% of the screen, over ${dur(e.durationMs)}`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
lines.push(`the screen has been still for ${dur(res.idleForMs)}`);
|
|
409
|
+
const last = res.events[res.events.length - 1];
|
|
410
|
+
if (last.map) lines.push('what moved in the most recent change:', last.map);
|
|
411
|
+
}
|
|
412
|
+
const warn = livenessLine(res.live);
|
|
413
|
+
if (warn) lines.unshift(warn);
|
|
414
|
+
return { content: [text(lines.join('\n'))] };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function doScript(target, args, options) {
|
|
418
|
+
const res = await actions.runScript(target, {
|
|
419
|
+
steps: args.steps,
|
|
420
|
+
autoSettle: args.autoSettle,
|
|
421
|
+
stableMs: args.stableMs,
|
|
422
|
+
timeoutMs: args.timeoutMs,
|
|
423
|
+
continueOnError: args.continueOnError,
|
|
424
|
+
options,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const lines = [
|
|
428
|
+
`${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps in ${res.totalMs}ms`,
|
|
429
|
+
];
|
|
430
|
+
for (const r of res.results) {
|
|
431
|
+
const settle = r.settled
|
|
432
|
+
? r.settled.ok
|
|
433
|
+
? ` · settled in ${r.settled.waitedMs}ms`
|
|
434
|
+
: ` · WARNING: ${r.settled.stalled ? 'capture stalled' : 'never settled'} after ${r.settled.waitedMs}ms`
|
|
435
|
+
: '';
|
|
436
|
+
lines.push(
|
|
437
|
+
` ${r.ok ? 'ok ' : 'FAIL'} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}${settle}`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
if (!res.ok) lines.push('later steps were not run; the screen is left wherever the failing step stopped');
|
|
441
|
+
|
|
442
|
+
const content = [text(lines.join('\n'))];
|
|
443
|
+
for (const f of res.frames) content.push(image(f.png));
|
|
444
|
+
if (args.finalLook !== false) {
|
|
445
|
+
const frame = await api.getFrame(target, { detail: args.detail ?? 'normal', options });
|
|
446
|
+
remember(res.device.udid, frame.state);
|
|
447
|
+
content.push(text(`end state — ${header(frame.device, frame.state, frame.ageMs)}`), image(frame.png));
|
|
448
|
+
}
|
|
449
|
+
return { content, isError: !res.ok };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function ui(target, args, options) {
|
|
453
|
+
const { device } = await api.ensureDaemon(target, options);
|
|
454
|
+
const driver = await input.detectDriver();
|
|
455
|
+
if (!driver.available) return { isError: true, content: [text(driver.reason)] };
|
|
456
|
+
|
|
457
|
+
let nodes = await input.describeAll(device.udid);
|
|
458
|
+
if (args.filter) {
|
|
459
|
+
const q = String(args.filter).toLowerCase();
|
|
460
|
+
nodes = nodes.filter((n) =>
|
|
461
|
+
[n.label, n.value, n.identifier].filter(Boolean).join(' ').toLowerCase().includes(q),
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
if (args.interactive) {
|
|
465
|
+
nodes = nodes.filter((n) => /button|field|cell|link|switch|slider|tab|menu/i.test(n.type || ''));
|
|
466
|
+
}
|
|
467
|
+
if (!nodes.length) return { content: [text('no matching elements on screen')] };
|
|
468
|
+
|
|
469
|
+
const rows = nodes.slice(0, 200).map((n) => {
|
|
470
|
+
const c = input.centerOf(n);
|
|
471
|
+
const name = [n.label, n.value && `= ${n.value}`, n.identifier && `#${n.identifier}`]
|
|
472
|
+
.filter(Boolean)
|
|
473
|
+
.join(' ');
|
|
474
|
+
const fallback = n.rawLabel ? '(icon-only — tap by these coordinates)' : '(unlabelled)';
|
|
475
|
+
return ` ${(n.type || '?').padEnd(14)} ${String(`${c.x},${c.y}`).padEnd(10)} ${name || fallback}`;
|
|
476
|
+
});
|
|
477
|
+
const head = `${device.name} — ${nodes.length} element${nodes.length === 1 ? '' : 's'} (type, tap point in points, label)`;
|
|
478
|
+
const tail = nodes.length > 200 ? `\n ... ${nodes.length - 200} more; use filter to narrow` : '';
|
|
479
|
+
return { content: [text(`${head}\n${rows.join('\n')}${tail}`)] };
|
|
480
|
+
}
|
|
481
|
+
|
|
220
482
|
async function capture(target, args, options) {
|
|
221
483
|
if (args.action === 'start') {
|
|
222
484
|
const res = await api.ensureDaemon(target, { ...options, fps: args.fps ?? options.fps });
|
package/src/ocr.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// On-device OCR via Apple's Vision framework.
|
|
2
|
+
//
|
|
3
|
+
// This exists because an accessibility tree is a promise the app has to keep,
|
|
4
|
+
// and plenty of apps do not: custom tab bars publish no children, icon buttons
|
|
5
|
+
// carry unreadable glyphs, React Native inputs are invisible. Pixels never lie
|
|
6
|
+
// about what a person can see, and Vision turns them into text plus coordinates
|
|
7
|
+
// locally in ~300ms with no model round trip.
|
|
8
|
+
import { execFile } 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 SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'native', 'ocr.swift');
|
|
17
|
+
const BIN_DIR = path.join(store.ROOT, 'bin');
|
|
18
|
+
const BIN = path.join(BIN_DIR, 'ocr');
|
|
19
|
+
|
|
20
|
+
let ready = null;
|
|
21
|
+
|
|
22
|
+
/** Compile once, then reuse. Recompiles only if the source is newer than the binary. */
|
|
23
|
+
export async function ensureBinary() {
|
|
24
|
+
if (ready) return ready;
|
|
25
|
+
ready = (async () => {
|
|
26
|
+
try {
|
|
27
|
+
const src = fs.statSync(SOURCE).mtimeMs;
|
|
28
|
+
const bin = fs.existsSync(BIN) ? fs.statSync(BIN).mtimeMs : 0;
|
|
29
|
+
if (bin > src) return { available: true, binary: BIN, compiled: false };
|
|
30
|
+
} catch {
|
|
31
|
+
return { available: false, reason: 'the OCR source is missing from this install' };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
35
|
+
await run('swiftc', ['-O', SOURCE, '-o', BIN], { timeout: 120_000 });
|
|
36
|
+
return { available: true, binary: BIN, compiled: true };
|
|
37
|
+
} catch (err) {
|
|
38
|
+
ready = null; // let a later call retry once the toolchain is present
|
|
39
|
+
return {
|
|
40
|
+
available: false,
|
|
41
|
+
reason:
|
|
42
|
+
err.code === 'ENOENT'
|
|
43
|
+
? 'swiftc is not installed, so on-device OCR is unavailable (install Xcode command line tools)'
|
|
44
|
+
: `could not build the OCR helper: ${err.message.split('\n')[0]}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
return ready;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Recognised text with point coordinates.
|
|
53
|
+
* @returns {Promise<Array<{text,x,y,width,height,centerX,centerY,confidence}>>}
|
|
54
|
+
*/
|
|
55
|
+
export async function readText(pngFile, { density = 3 } = {}) {
|
|
56
|
+
const built = await ensureBinary();
|
|
57
|
+
if (!built.available) throw new Error(built.reason);
|
|
58
|
+
const { stdout } = await run(built.binary, [pngFile], { timeout: 30_000, maxBuffer: 16 << 20 });
|
|
59
|
+
const raw = JSON.parse(stdout || '[]');
|
|
60
|
+
return raw.map((r) => ({
|
|
61
|
+
text: r.text,
|
|
62
|
+
confidence: r.confidence,
|
|
63
|
+
// Vision reports pixels; everything that drives input speaks points.
|
|
64
|
+
x: r.x / density,
|
|
65
|
+
y: r.y / density,
|
|
66
|
+
width: r.width / density,
|
|
67
|
+
height: r.height / density,
|
|
68
|
+
centerX: Math.round((r.x + r.width / 2) / density),
|
|
69
|
+
centerY: Math.round((r.y + r.height / 2) / density),
|
|
70
|
+
}));
|
|
71
|
+
}
|