simframe 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 what the simulator screen is doing: a stable screen hash, how long it has been still, how much changed since the last frame, and an ASCII map of which regions moved. Costs a tiny fraction of an image. Use this to poll ("has it finished loading?", "did my tap do anything?") and only call sim_look when you actually need to see pixels.',
49
- inputSchema: { type: 'object', properties: { ...deviceProp } },
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 simulator screen settles (mode "stable") or until it changes away from what it shows now (mode "change"), then return the frame. Use this after a tap, launch or navigation instead of screenshotting repeatedly and hoping the animation finished.',
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: ['stable', 'change'],
62
- description: 'stable: wait for the screen to stop moving. change: wait for it to differ from now.',
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
- return {
167
- content: [text(header(res.device, res.state, res.ageMs)), image(res.png)],
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 res = await api.getState(target, { options });
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 body = [
292
+ const lines = [
175
293
  header(res.device, s, res.ageMs),
176
- `screen hash: ${s.hash} change since previous frame: ${(s.diff * 100).toFixed(1)}%`,
177
- s.stableForMs > 1200 ? 'screen is idle' : 'screen is currently changing',
178
- `region change map (${REGION_COLS}x${REGION_ROWS}, top-left to bottom-right; "." to "#" = more movement):`,
179
- regionMap(s.regions || []),
180
- ].join('\n');
181
- return { content: [text(body)] };
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 ?? 'stable',
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
- const note = res.satisfied
192
- ? `${res.mode === 'change' ? 'screen changed' : 'screen settled'} after ${res.waitedMs}ms`
193
- : `TIMED OUT after ${res.waitedMs}ms — screen never ${res.mode === 'change' ? 'changed' : 'settled'}`;
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
+ }