simframe 0.4.2 → 0.5.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.
Files changed (40) hide show
  1. package/README.md +227 -53
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -0
  4. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  5. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  6. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +117 -0
  7. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -0
  8. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  9. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  10. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  11. package/native/simframed/Sources/SimframeCore/Element.swift +120 -0
  12. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  13. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  14. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  15. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  16. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  17. package/native/simframed/Sources/simframed/main.swift +357 -0
  18. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
  19. package/package.json +10 -4
  20. package/scripts/bench-flow.mjs +54 -0
  21. package/scripts/bench.sh +98 -0
  22. package/scripts/check-package.mjs +91 -0
  23. package/scripts/smoke.mjs +76 -0
  24. package/scripts/verify-baseline.mjs +65 -0
  25. package/src/actions.js +82 -5
  26. package/src/cli.js +347 -31
  27. package/src/control.js +76 -0
  28. package/src/daemon.js +8 -1
  29. package/src/engine.js +99 -0
  30. package/src/fingerprint.js +150 -0
  31. package/src/graph.js +409 -0
  32. package/src/index.js +292 -23
  33. package/src/input.js +80 -2
  34. package/src/matching.js +194 -0
  35. package/src/mcp.js +45 -1
  36. package/src/navigate.js +120 -0
  37. package/src/regions.js +90 -0
  38. package/src/screenmap.js +77 -21
  39. package/src/simctl.js +20 -4
  40. package/src/store.js +8 -0
package/README.md CHANGED
@@ -32,13 +32,25 @@ Same four-tab navigation flow, on a real production app:
32
32
  | --- | --- | --- |
33
33
  | Look at the screen | ~130–400 ms, blocking | **~20 ms**, already captured |
34
34
  | "Did anything change?" | a full image | **~2 ms**, text only |
35
- | A 5-step flow | 5+ model round trips | **1 call**, ~7 s |
36
35
  | Finding a control | read tree (~570 ms) + reason | **~1 ms** from memory |
37
- | Same flow, 3rd run | no improvement — every run is the first | **3304 ms, 4/4 from memory** |
36
+ | A 4-step flow, verified | 4+ model round trips | **1 call**, 3.6 s |
37
+ | Same flow, 3rd run | no improvement — every run is the first | **3.7 s, 4/4 from memory, 4/4 verified** |
38
38
 
39
- The same four-tab tour, run three times back to back: **7370 ms → 5160 ms →
40
- 3304 ms**, with 1, then 3, then 4 of the four controls resolved from memory and
41
- no mis-taps. What is left is mostly the app's own animation and data load.
39
+ The four-tab tour, three times back to back from a cleared memory:
40
+
41
+ | Pass | Wall clock | Steps verified | Controls from memory |
42
+ | --- | --- | --- | --- |
43
+ | 1 | 10.2 s | 0/4 — nothing is known yet | **4/4** |
44
+ | 2 | **3.6 s** | **4/4** | **4/4** |
45
+ | 3 | **3.7 s** | **4/4** | **4/4** |
46
+
47
+ Every step is checked against what the same action did last time, and the run
48
+ records its own preconditions — which input path, which daemon, whether the
49
+ daemon was replaced mid-run — so a regression shows up in the measurement rather
50
+ than hiding inside it. Earlier versions of this table quoted 7.4 s → 3.3 s with
51
+ verification switched off; those numbers were measured while input was silently
52
+ falling back to a slower path and the capture daemon was being replaced by every
53
+ command, so they measured two bugs rather than the tool.
42
54
 
43
55
  ## Install
44
56
 
@@ -47,6 +59,11 @@ npm install -g simframe
47
59
  simframe doctor
48
60
  ```
49
61
 
62
+ The first `simframe start` builds a small Swift daemon from source — a few
63
+ seconds, once. It needs the Xcode command line tools, which you already have if
64
+ you have a simulator. Without them simframe falls back to the original
65
+ `simctl` loop and says so.
66
+
50
67
  `doctor` checks each capability separately and tells you what you have:
51
68
 
52
69
  ```
@@ -86,7 +103,12 @@ have. **Observation needs nothing but Xcode.**
86
103
  | --- | --- | --- |
87
104
  | Watch the screen, wait, recall | nothing extra | — |
88
105
  | Read labels + coordinates from pixels | `swiftc` (Xcode CLT) | falls back to the accessibility tree alone |
89
- | Tap, type, swipe | [`idb`](https://fbidb.io) | simframe observes but cannot touch |
106
+ | Tap, type, swipe | nothing extra, or [`idb`](https://fbidb.io) as a fallback | simframe observes but cannot touch |
107
+ | Accessibility tree | [`idb`](https://fbidb.io) | OCR alone still yields labels and coordinates |
108
+
109
+ `simframe doctor` names which engine is carrying each capability, per device.
110
+ **idb is now required only for the accessibility tree** — capture, input and text
111
+ recognition all run in-process.
90
112
 
91
113
  ```bash
92
114
  # input, optional
@@ -144,7 +166,8 @@ So simframe reads the screen two ways and remembers the result:
144
166
  - **Accessibility** gives real hit targets, types and enabled state.
145
167
  - **On-device OCR** (Apple's Vision, ~290 ms, no model round trip) gives every
146
168
  label a person can actually see, with coordinates.
147
- - The merge is keyed by a **layout hash**, so the next visit is a file read.
169
+ - The merge is keyed by a **structural fingerprint**, so the next visit is a
170
+ file read. What that fingerprint is, and why it is not a pixel hash, is below.
148
171
 
149
172
  ```
150
173
  first visit to a screen ~1000 ms read tree + OCR, store the map
@@ -164,34 +187,92 @@ Two details that matter:
164
187
  a tab returns an error listing both with coordinates, because silently tapping
165
188
  the title looks exactly like nothing happening.
166
189
 
167
- ### Why a layout hash, not a frame hash
190
+ ### Two hashes, because there are two questions
168
191
 
192
+ "Did this move?" and "is this the same screen?" look like one question and are
193
+ not. simframe answers them separately, and getting that wrong was the single
194
+ most expensive mistake in its development.
195
+
196
+ **Change and settle** are questions about pixels, so a pixel hash answers them.
169
197
  The frame hash changes whenever any pixel group changes — a clock digit, one new
170
- row of data — which makes it useless as a key for "have I seen this screen
171
- before?". The layout hash crops the status bar and takes a difference hash over a
172
- 12×24 grid.
198
+ row — which is exactly right for "did anything happen?" and useless as a key for
199
+ "have I been here before?". For change detection there is a layout hash: status
200
+ bar cropped, difference hash over a 12×24 grid.
173
201
 
174
202
  A mean-threshold hash was tried first and was actively dangerous: low-contrast
175
- app screens collapsed onto identical values, so unrelated screens matched at
176
- distance 0 and taps landed on the wrong control. Measured on a real app:
177
-
178
- | | Hamming distance |
203
+ screens collapsed onto identical values, so unrelated screens matched at distance
204
+ 0 and taps landed on the wrong control. The difference hash fixed that.
205
+
206
+ **Identity is not a question about pixels**, and this is the part that took three
207
+ attempts. Content *is* pixels: a list whose rows changed drifts as far as a
208
+ different screen does. Measured on a real app, same-screen revisits reached 62
209
+ bits against a different-screen floor of 74 — overlapping, with no threshold
210
+ available to choose. An earlier calibration had suggested a comfortable margin
211
+ (0–4 against 77–113), but it was measured on screens whose content happened to be
212
+ stable and did not survive contact with a real list.
213
+
214
+ So identity is **structural**. The fingerprint is built from element roles,
215
+ frames quantised to a 24 px grid, the region each element sits in, and repeated
216
+ siblings bucketed as "one" or "many" rather than counted. Deliberately included:
217
+ the labels of chrome elements only — nav title, tab labels, toolbar buttons —
218
+ because two list screens with identical structure are told apart by their title
219
+ and nothing else. Deliberately excluded: all content text and values, the status
220
+ bar, and the keyboard region when a keyboard is up.
221
+
222
+ It does not depend on the accessibility tree. Fingerprinting from OCR boxes
223
+ alone, with the tree discarded entirely, still separates screens — different
224
+ screens ceiling 0.35 against the same threshold.
225
+
226
+ | | Jaccard similarity |
179
227
  | --- | --- |
180
- | Same screen, revisited while settled | **0–4** |
181
- | Same screen, but loading vs loaded | 48–98 |
182
- | **Different screens** | **77–113** |
183
-
184
- Only one of those errors is dangerous. Matching the *wrong* screen would tap the
185
- wrong control, and that needs two different screens to land within 12 bits of
186
- each other — the closest pair ever measured was 77. Failing to recognise a screen
187
- you have seen is harmless: it rebuilds the map, costs ~600 ms, and taps correctly.
188
- So the tolerance is deliberately far below the collision floor rather than tuned
189
- to maximise hits.
190
-
191
- That middle row is worth knowing about: a screen mid-load genuinely does not look
192
- like the same screen loaded, so the first visit after a cold launch usually
193
- rebuilds. Hit rates climb as an app warms up, which is exactly what the three-pass
194
- numbers above show.
228
+ | Same screen, revisited | 0.41–1.00 |
229
+ | **Different screens** | **0.00–0.31** |
230
+
231
+ The threshold sits in that gap, but the gap is narrower than anyone would want,
232
+ and one screen causes it: a screen whose sections load from different sources has
233
+ more than one genuine settled structure, and two structures of one screen are as
234
+ far apart as two different screens.
235
+
236
+ No threshold can express that, so a screen may hold **several** accepted
237
+ fingerprints instead. A new one is admitted only when a known edge lands
238
+ somewhere its target does not recognise — the edge is the evidence that it is the
239
+ same place — and only if no other stored screen claims that reading. Identity
240
+ stays exact rather than being loosened, and the count is capped, so a
241
+ non-deterministic action shows up as a node collecting variants rather than as
242
+ screens silently merging.
243
+
244
+ It earns its place on real apps: an app reconnecting to its bundler put an alert
245
+ over one screen, and that screen gained a variant instead of a duplicate
246
+ appearing.
247
+
248
+ Failing to recognise a screen you have seen is harmless — it rebuilds the map and
249
+ taps correctly. Matching the *wrong* screen taps the wrong control. The threshold
250
+ is set to err toward the first.
251
+
252
+ ## Navigating by memory
253
+
254
+ Once simframe knows which screens exist and which action leads from one to the
255
+ next, getting somewhere is a search over known edges rather than a question for a
256
+ model:
257
+
258
+ ```bash
259
+ simframe screens # what this device has learned
260
+ simframe goto invoices # walk there, verifying every step
261
+ ```
262
+
263
+ Measured on a four-tab tour, `goto` plans and walks three-step routes with every
264
+ step verified and no model call. It fails rather than guesses: an unknown
265
+ destination, a query matching two screens equally, or no path of known edges all
266
+ report themselves instead of tapping hopefully.
267
+
268
+ Flows work the same way and refuse to save if any step went unverified —
269
+ replaying a recording of something that may not have worked just reproduces the
270
+ doubt.
271
+
272
+ ```bash
273
+ simframe flow save checkout ./checkout.json
274
+ simframe flow run checkout
275
+ ```
195
276
 
196
277
  ## Does this work on *your* app?
197
278
 
@@ -219,37 +300,65 @@ iPhone 17 Pro, iOS 26.5, Apple Silicon, default settings.
219
300
 
220
301
  | | |
221
302
  | --- | --- |
303
+ | Frame capture, whole pipeline | **6.6 ms** |
304
+ | Frame grab alone | **0.13 ms** |
305
+ | The `simctl` + `sips` path it replaces | ~210 ms |
222
306
  | Warm frame read (`sim_look`) | ~20 ms |
223
307
  | State check (`sim_state`) | ~2 ms |
224
- | Contact sheet (`sim_strip`, 5 frames) | ~30 ms |
225
- | Accessibility tree read | ~570 ms |
226
- | On-device OCR of a full frame | ~290 ms |
227
- | Screen map: first visit / remembered | ~600 ms / **~1 ms** |
228
- | Raw `simctl io screenshot`, for comparison | ~130 ms, every look |
229
- | Cold start, first frame | ~400 ms, once |
308
+ | Input round trip (`ping`) | **0 ms** |
309
+ | Tap (70 ms hold / 10 ms hold) | 76 ms / 13 ms |
310
+ | Text recognition, in-process | **~174 ms** |
311
+ | Text recognition, via PNG + helper (fallback) | ~555 ms |
312
+ | Accessibility tree read (idb) | ~570 ms |
313
+ | Screen map: first visit / remembered | ~305 ms / **~1 ms** |
230
314
  | CPU | 1.1 % idle · 3.1 % active |
231
315
  | Frame memory | ~60 s of screen, ~2.7 MB |
232
316
 
317
+ Reproduce all of it with `npm run bench`, which prints the same table against
318
+ your machine. Full detail, including the measurement traps, is in
319
+ [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md).
320
+
233
321
  ## How it works
234
322
 
235
323
  ```
236
- ┌──────────────────── background, one loop per simulator ─────────────────────┐
237
- │ simctl screenshot ──► sips ──► decode ──► hash + diff ──► rename into │
238
- │ ~130 ms ~30 ms ~6 ms ~/.simframe/<udid>/ │
324
+ ┌─── simframed — one Swift daemon per simulator ──────────────────────────────┐
325
+ │ │
326
+ │ display damage callback ──► read IOSurface ──► scale ──► hash │
327
+ │ the screen tells us 0.13 ms 6.6 ms total │
328
+ │ │ │
329
+ │ Vision OCR reads the same surface ─────┤ no PNG, no file, no spawn │
330
+ │ │ │
331
+ │ Indigo HID ◄── control socket ◄────────┤ 0600, one JSON object per line │
332
+ │ taps, swipes, text │ │
333
+ │ ▼ │
334
+ │ ~/.simframe/<udid>/ frames · state.json · meta.json │
239
335
  └─────────────────────────────────────────────────────────────────────────────┘
240
336
  │ a rename is atomic
241
337
  ┌────────────────────────────────────▼────────────────────────────────────────┐
242
- │ MCP server / CLI: stat + read. No simctl anywhere in the request path. │
243
- │ Screen memory: layout hash ──► label → point, built once per screen. │
338
+ │ MCP server / CLI: stat + read, or one socket round trip for input. │
339
+ │ Screen memory: layout hash ──► label → point, built once per screen. │
244
340
  └─────────────────────────────────────────────────────────────────────────────┘
245
341
  ```
246
342
 
247
- - **Files are the IPC.** The loop renames completed frames into place; readers
248
- just read them. A rename is atomic, so a reader can never see a half-written
249
- frame, and there is no socket or protocol to get wrong.
250
- - **Almost no dependencies.** Resizing uses `sips`; PNG codec, hashing and frame
251
- comparison are plain JavaScript over `node:zlib`. The only runtime dependency
252
- is the MCP SDK. OCR is a ~60-line Swift file compiled on first use.
343
+ The daemon links CoreSimulator and SimulatorKit, which are private frameworks
344
+ with no documentation and no stability promise. Everything it calls is recorded
345
+ in [`docs/PRIVATE_API.md`](docs/PRIVATE_API.md) with the evidence behind it, so
346
+ an Xcode upgrade that moves something is a bounded fix rather than an
347
+ archaeology project. If a layer breaks, simframe degrades to the layer below
348
+ and `doctor` says which.
349
+
350
+ Run `simframe start --engine=simctl` to use the original loop instead.
351
+
352
+ - **Files are the IPC for reads.** The daemon renames completed frames into
353
+ place; readers just read them. A rename is atomic, so a reader can never see a
354
+ half-written frame. Input is the one thing that needs a reply, and it goes
355
+ over a `0600` Unix socket — the file system is the whole permission model.
356
+ - **Almost no dependencies.** The only runtime npm dependency is the MCP SDK.
357
+ The daemon is Swift built from source against frameworks already on the
358
+ machine.
359
+ - **The screen says when it changed.** The capture loop is driven by the
360
+ display's damage callback rather than a timer, so an idle screen costs
361
+ nothing and a moving one is picked up at once.
253
362
  - **It backs off when nothing happens.** 4 fps while the screen moves, 1.5 fps
254
363
  once still, snapping back instantly on change.
255
364
  - **One writer per device.** Ownership lives in `meta.json`; `stop` refuses to
@@ -273,14 +382,72 @@ simframe ui # labels + tap points (ax and ocr)
273
382
  simframe recall # what happened in the last minute
274
383
  simframe recall --ago=15000 # the frame from 15s ago
275
384
  simframe strip --count=6 # contact sheet
385
+ simframe screens # screens this device has learned
386
+ simframe goto invoices # walk to a known screen over known steps
387
+ simframe flow save|run|list # record a verified flow, replay it
388
+ simframe doctor --json machine-readable; --strict fails on any downgrade
276
389
  simframe status / stop [--force] / devices / doctor
277
390
  ```
278
391
 
392
+ ## Degrading is allowed. Degrading quietly is not
393
+
394
+ simframe is built to degrade rather than fail: no Swift toolchain still gives
395
+ you frames through `simctl`, no accessibility tree still gives you OCR. That
396
+ policy is right, and it nearly sank the tool twice — because a downgrade looked
397
+ exactly like everything working.
398
+
399
+ Once, one file was missing from the published package, so `Package.swift`
400
+ declared a test target with no directory, SwiftPM reported overlapping sources,
401
+ and **every install silently fell back to the slow engine**. Another time OCR
402
+ shipped disabled the same way. Both passed the tests. Both printed nothing. The
403
+ bug was never the missing file; it was the silence.
404
+
405
+ So every downgrade now announces itself:
406
+
407
+ - `simframe start` prints the engine it chose, and if it is the slow one, why —
408
+ build error, missing sources, or "reason unrecorded" if the daemon was started
409
+ by an earlier process.
410
+ - `simframe doctor` marks a degraded layer `WARN`, not `ok`, and summarises what
411
+ is degraded and what that costs.
412
+ - A dependency that is simply not installed is `--`, not `WARN`. The distinction
413
+ is deliberate: `WARN` means this machine could be doing better and silently is
414
+ not, which is the failure worth shouting about. idb missing on a fresh machine
415
+ has not degraded from anything, and `--strict` ignores it.
416
+ - `--strict`, or `SIMFRAME_STRICT=1`, turns any downgrade into a non-zero exit.
417
+ CI runs strict, so a release cannot ship in the state that shipped twice.
418
+
419
+ ```
420
+ $ simframe doctor
421
+ ok capture engine simframed
422
+ WARN input driver idb — the daemon's control socket is not up
423
+ -- accessibility tree not installed: idb is not installed
424
+ ```
425
+
426
+ Two checks enforce it. A packaging check derives the required file list from the
427
+ build's own inputs — a hand-written list is what rotted last time — and runs in
428
+ seconds without a simulator. An integration job installs the packed tarball on a
429
+ real simulator and asserts `capture.engine`, `input.driver` and `ocr.available`
430
+ are all the good values, under `--strict`.
431
+
432
+ That last check found a real bug the day it was written: a daemon shutting down
433
+ unlinked the control socket unconditionally, so restarting deleted the *new*
434
+ daemon's socket. Capture kept working, input quietly dropped to idb, and nothing
435
+ said a word — the exact failure shape, found by the thing built to catch it.
436
+
279
437
  ## Limitations
280
438
 
281
- - Simulators only — `simctl` cannot capture a physical device.
282
- - Capture tops out near 6 fps, because `simctl io screenshot` costs ~130 ms. Fast
283
- animations are sampled, not recorded.
439
+ - Simulators only. Neither the framebuffer nor `simctl` can reach a physical
440
+ device.
441
+ - The daemon depends on private frameworks. They are stable enough to build on —
442
+ capture and accessibility survived the iOS 26 transition — but an Xcode
443
+ upgrade can move a symbol. `doctor` reports each layer separately so a break
444
+ is visible rather than mysterious, and `--engine=simctl` still works.
445
+ - Hardware buttons: only `home` is implemented. The other Indigo codes are
446
+ unverified, and a wrong one can crash `backboardd` or lock the device, so they
447
+ return an error rather than a guess.
448
+ - Typing sends key positions, which iOS maps through the device's active
449
+ keyboard layout. Text that must be exact goes through the pasteboard, which
450
+ `sim_do` does by default.
284
451
  - Region maps need a baseline inside the ~90 s history window. Older baselines
285
452
  still get a reliable changed / did-not-change, without a map of what moved.
286
453
  - Screen memory assumes a screen's layout is stable. A screen that reflows
@@ -290,9 +457,16 @@ simframe status / stop [--force] / devices / doctor
290
457
 
291
458
  ## Roadmap
292
459
 
293
- - **Verify-after-tap.** A tap can move the screen without doing what you meant —
294
- a swipe that animates but does not navigate still reports `changed`. Comparing
295
- against the expected destination would catch it.
460
+ - **The accessibility tree without idb.** `AXPTranslator` would remove the last
461
+ heavyweight install. Capture, input and geometry already come from the daemon;
462
+ the tree is all that is left.
463
+ - **A compact state for the agent.** The element list, regions, what changed and
464
+ the last verdict, shaped so a model spends tokens on deciding rather than on
465
+ reading. This is where the token savings actually land.
466
+ - **Region bands from where elements cluster**, rather than fractions of screen
467
+ height. A date banner sitting above the tab bar was classified as a tab label
468
+ and its text entered a screen's identity, which would have expired at
469
+ midnight. Fixed for that case by a width rule; the underlying cause remains.
296
470
  - **Reduce the input dependency.** idb is the one heavyweight requirement. Its
297
471
  simulator input is a reimplementation of the Indigo HID transport rather than a
298
472
  public API, so replacing it is real work, not a wrapper — but it is the last
@@ -0,0 +1,16 @@
1
+ // swift-tools-version:5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "simframed",
6
+ platforms: [.macOS(.v13)],
7
+ targets: [
8
+ // Every private-framework call lives here, behind a protocol, so a
9
+ // signature change is a one-file fix and tests can run without a device.
10
+ .target(name: "PrivateAPI"),
11
+ // Platform-independent: hashing, downscaling, the on-disk frame layout.
12
+ .target(name: "SimframeCore", dependencies: ["PrivateAPI"]),
13
+ .executableTarget(name: "simframed", dependencies: ["SimframeCore", "PrivateAPI"]),
14
+ .testTarget(name: "SimframeCoreTests", dependencies: ["SimframeCore"]),
15
+ ]
16
+ )