simframe 0.1.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/LICENSE +21 -0
- package/README.md +236 -0
- package/package.json +54 -0
- package/src/analyze.js +65 -0
- package/src/cli.js +253 -0
- package/src/daemon.js +170 -0
- package/src/index.js +271 -0
- package/src/mcp.js +258 -0
- package/src/png.js +191 -0
- package/src/simctl.js +104 -0
- package/src/store.js +94 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sadjad Asadi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# simframe
|
|
2
|
+
|
|
3
|
+
[](https://github.com/lvlrSajjad/simframe/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/simframe)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
**Always-warm iOS Simulator frames for coding agents.**
|
|
8
|
+
|
|
9
|
+
[Website](https://lvlrsajjad.github.io/simframe/) · [npm](https://www.npmjs.com/package/simframe)
|
|
10
|
+
|
|
11
|
+
An agent that drives the iOS Simulator spends most of its time waiting on
|
|
12
|
+
screenshots. Every "let me check the screen" is a fresh `simctl io screenshot`:
|
|
13
|
+
a process spawn, a framebuffer grab, a file write, an image encode. On this
|
|
14
|
+
machine that is ~130 ms of pure blocking latency, paid again on every look — and
|
|
15
|
+
the agent pays it *twice* whenever it screenshots too early, sees a
|
|
16
|
+
mid-animation frame, and has to look again.
|
|
17
|
+
|
|
18
|
+
simframe removes the wait from the request path. A tiny background loop keeps
|
|
19
|
+
the newest frame of your simulator permanently warm on disk, so when the agent
|
|
20
|
+
asks what's on screen it gets an answer in **~20 ms** instead of ~130 ms — and
|
|
21
|
+
can ask "did anything change?" for **~2 ms and no image at all**.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
without simframe with simframe
|
|
25
|
+
agent asks ──► spawn simctl ──► grab ──► encode ──► image ~130-400 ms
|
|
26
|
+
agent asks ──► read the frame that is already there ──► image ~20 ms
|
|
27
|
+
agent polls ──► read 300 bytes of JSON ──► text ~2 ms
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Why this makes an agent faster
|
|
31
|
+
|
|
32
|
+
Speed is not only latency. It is also *how many* round trips a question takes
|
|
33
|
+
and how many tokens each one costs.
|
|
34
|
+
|
|
35
|
+
| Question the agent has | Before | With simframe |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| "What's on screen?" | screenshot, ~130-400 ms, full image every time | `sim_look`, ~20 ms, warm frame |
|
|
38
|
+
| "Has it finished loading yet?" | screenshot in a loop, an image per attempt | `sim_state`, ~2 ms, **text only** |
|
|
39
|
+
| "Did my tap do anything?" | screenshot, compare by eye | `sim_state` — a stable screen hash plus an ASCII map of which regions moved |
|
|
40
|
+
| "Wait for the animation to end" | sleep, screenshot, hope, repeat | `sim_wait` — blocks until the screen actually settles, then returns the frame |
|
|
41
|
+
| "What did that transition look like?" | 5 screenshots, 5 round trips, 5 images | `sim_strip` — the last N buffered frames tiled into **one** image, looking backwards in time |
|
|
42
|
+
|
|
43
|
+
The change map is the part that pays for itself. A screen hash and a
|
|
44
|
+
4×8 movement grid cost a couple of hundred bytes, so an agent can poll freely
|
|
45
|
+
and only spend image tokens when there is genuinely something new to look at:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
$ simframe state
|
|
49
|
+
iPhone 17 Pro frame #3 age 124ms 322x700
|
|
50
|
+
hash 007cfefefefefefefefefefefefefe00 diff 0.15453 stable 0ms
|
|
51
|
+
@@@@
|
|
52
|
+
###*
|
|
53
|
+
+*#*
|
|
54
|
+
:.#*
|
|
55
|
+
..#*
|
|
56
|
+
..#*
|
|
57
|
+
..#*
|
|
58
|
+
@@@@ ← a screen sliding in from the right, mid-transition
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install -g simframe
|
|
65
|
+
simframe doctor
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`doctor` verifies Xcode's command line tools, `sips`, a booted simulator, and
|
|
69
|
+
an actual round-trip capture.
|
|
70
|
+
|
|
71
|
+
### Claude Code
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
claude mcp add simframe -- npx -y simframe mcp
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Any other MCP client
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"simframe": {
|
|
83
|
+
"command": "npx",
|
|
84
|
+
"args": ["-y", "simframe", "mcp"]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Nothing else to set up. Capture starts on the first tool call, targets the
|
|
91
|
+
booted simulator, and stops itself 15 minutes after the last request.
|
|
92
|
+
|
|
93
|
+
## MCP tools
|
|
94
|
+
|
|
95
|
+
| Tool | What it does |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `sim_look` | The newest buffered frame as an image, no capture wait. `detail`: `low` (~420 px) / `normal` (~700 px, default) / `high` (~1100 px) / `full` (native). |
|
|
98
|
+
| `sim_state` | Text only: screen hash, how long the screen has been still, change since the previous frame, and the region movement map. |
|
|
99
|
+
| `sim_wait` | Blocks until the screen settles (`mode: "stable"`) or moves away from what it shows now (`mode: "change"`), then returns the frame. |
|
|
100
|
+
| `sim_strip` | The last N buffered frames tiled into one image, oldest first, with millisecond offsets. |
|
|
101
|
+
| `sim_capture` | `status` / `start` / `stop` for the background loops. Rarely needed. |
|
|
102
|
+
| `sim_devices` | Booted simulators simframe can capture. |
|
|
103
|
+
|
|
104
|
+
Every tool takes an optional `device` (UDID or a substring of the name) and
|
|
105
|
+
defaults to the booted simulator.
|
|
106
|
+
|
|
107
|
+
## CLI
|
|
108
|
+
|
|
109
|
+
The same capabilities without an agent, useful for debugging and scripts:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
simframe start # start the capture loop
|
|
113
|
+
simframe state # metadata + change map
|
|
114
|
+
simframe frame --out=now.png # newest frame, --detail=low|normal|high|full
|
|
115
|
+
simframe wait --stable-ms=700 # block until the screen settles
|
|
116
|
+
simframe wait --change # block until the screen changes
|
|
117
|
+
simframe strip --count=6 # contact sheet of recent frames
|
|
118
|
+
simframe status # what is running, and how fresh
|
|
119
|
+
simframe stop --all
|
|
120
|
+
simframe devices --all
|
|
121
|
+
simframe doctor
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## How it works
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
┌──────────────────────────── background, one per simulator ───┐
|
|
128
|
+
│ xcrun simctl io screenshot ──► sips -Z ──► decode PNG │
|
|
129
|
+
│ ~130 ms ~30 ms ~6 ms │
|
|
130
|
+
│ │ │
|
|
131
|
+
│ rename into ~/.simframe/<udid>/ │
|
|
132
|
+
│ latest.png · ring/<seq>.png · state.json │
|
|
133
|
+
└───────────────────────────────────────────────────────────────┘
|
|
134
|
+
│ a rename is atomic
|
|
135
|
+
┌──────────────────────────▼────────────────────────────────────┐
|
|
136
|
+
│ MCP server / CLI: stat + read. No simctl in the request path.│
|
|
137
|
+
└───────────────────────────────────────────────────────────────┘
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A few decisions worth knowing about:
|
|
141
|
+
|
|
142
|
+
- **Files are the IPC.** The loop renames completed frames into place and
|
|
143
|
+
readers just read them. A rename is atomic, so a reader can never see a
|
|
144
|
+
half-written frame, and there is no socket, port or protocol to get wrong.
|
|
145
|
+
- **Zero image dependencies.** Resizing uses `sips`, which ships with macOS.
|
|
146
|
+
PNG encode/decode and all frame comparison are a few hundred lines of plain
|
|
147
|
+
JavaScript over `node:zlib`. The only runtime dependency is the MCP SDK.
|
|
148
|
+
- **It backs off when nothing is happening.** 4 fps while the screen is moving,
|
|
149
|
+
1.5 fps once it has been still for 2.5 s, snapping back instantly on change.
|
|
150
|
+
Measured on an M-series Mac: **1.1 % CPU idle, 3.1 % active.**
|
|
151
|
+
- **Comparison is done on a small grayscale grid,** which is why "did anything
|
|
152
|
+
change?" costs microseconds. The screen hash is a 128-bit mean-threshold
|
|
153
|
+
hash: the same screen always produces the same hash, even though the JPEG and
|
|
154
|
+
PNG bytes coming out of `simctl` are not stable frame to frame.
|
|
155
|
+
- **One writer per device.** Ownership is recorded in `meta.json`; a second loop
|
|
156
|
+
refuses to start, and a loop that has been superseded retires itself. Two
|
|
157
|
+
loops would otherwise overwrite and prune each other's frames.
|
|
158
|
+
- **Capture is independent of the Simulator window.** `simctl` reads the
|
|
159
|
+
framebuffer, so frames keep flowing while the window is hidden, behind other
|
|
160
|
+
windows, or on another Space.
|
|
161
|
+
|
|
162
|
+
## Measured
|
|
163
|
+
|
|
164
|
+
iPhone 17 Pro, iOS 26.5, Apple Silicon, default settings:
|
|
165
|
+
|
|
166
|
+
| | |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| Warm frame read (`sim_look`) | ~20 ms |
|
|
169
|
+
| State check (`sim_state`) | ~2 ms |
|
|
170
|
+
| Contact sheet (`sim_strip`, 5 frames) | ~30 ms |
|
|
171
|
+
| Cold start (first frame after boot) | ~400 ms, once |
|
|
172
|
+
| Frame age when read | ≤ ~250 ms active, ≤ ~670 ms idle |
|
|
173
|
+
| Raw `simctl io screenshot` for comparison | ~130 ms, on every single look |
|
|
174
|
+
| CPU | 1.1 % idle, 3.1 % active |
|
|
175
|
+
| Disk | ~2 MB per device (24-frame ring) |
|
|
176
|
+
|
|
177
|
+
Image sizes are chosen for token cost as much as legibility: at `detail: normal`
|
|
178
|
+
a frame is ~322×700, roughly a third of the pixels — and so roughly a third of
|
|
179
|
+
the image tokens — of a native-resolution screenshot, while the status bar stays
|
|
180
|
+
readable. `sim_state` sends no image at all.
|
|
181
|
+
|
|
182
|
+
## Requirements
|
|
183
|
+
|
|
184
|
+
- macOS with Xcode command line tools (`xcrun simctl`)
|
|
185
|
+
- Node.js ≥ 18.17
|
|
186
|
+
- A booted iOS Simulator
|
|
187
|
+
|
|
188
|
+
## Limitations
|
|
189
|
+
|
|
190
|
+
- Simulators only. `simctl` cannot capture a physical device.
|
|
191
|
+
- simframe **reads** the screen; it does not tap, swipe or type. It is meant to
|
|
192
|
+
sit alongside whatever already drives input, replacing only the screenshot.
|
|
193
|
+
- Capture tops out near 6 fps, because `simctl io screenshot` costs ~130 ms.
|
|
194
|
+
Fast animations are sampled, not recorded.
|
|
195
|
+
|
|
196
|
+
## Roadmap
|
|
197
|
+
|
|
198
|
+
- A higher-frame-rate backend via `simctl io recordVideo` piped through ffmpeg,
|
|
199
|
+
used automatically when ffmpeg is present.
|
|
200
|
+
- Optional accessibility-tree text alongside the frame, so an agent can read
|
|
201
|
+
labels without spending image tokens.
|
|
202
|
+
- Fusing input with settle-and-look, so tap → wait → see is one round trip
|
|
203
|
+
rather than three.
|
|
204
|
+
|
|
205
|
+
## Releasing
|
|
206
|
+
|
|
207
|
+
`npm version <patch|minor|major>` does not update `server.json`, so bump both,
|
|
208
|
+
then push the tag:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
npm version minor --no-git-tag-version # bumps package.json
|
|
212
|
+
$EDITOR server.json # match "version" and packages[0].version
|
|
213
|
+
git commit -am "Release v0.2.0" && git tag v0.2.0
|
|
214
|
+
git push && git push --tags
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The `release` workflow then verifies that the tag, `package.json` and
|
|
218
|
+
`server.json` all agree, validates `server.json` against the live registry, and
|
|
219
|
+
publishes to npm and to the MCP Registry. It needs an npm automation token in
|
|
220
|
+
the `NPM_TOKEN` repository secret; the MCP Registry needs no secret, because it
|
|
221
|
+
trusts the workflow's GitHub OIDC identity.
|
|
222
|
+
|
|
223
|
+
To publish by hand instead:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
npm publish --access public
|
|
227
|
+
|
|
228
|
+
curl -fsSL https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_darwin_arm64.tar.gz | tar -xz mcp-publisher
|
|
229
|
+
./mcp-publisher validate
|
|
230
|
+
./mcp-publisher login github
|
|
231
|
+
./mcp-publisher publish
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "simframe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"mcpName": "io.github.lvlrSajjad/simframe",
|
|
5
|
+
"description": "Always-warm iOS Simulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"ios",
|
|
8
|
+
"simulator",
|
|
9
|
+
"mcp",
|
|
10
|
+
"model-context-protocol",
|
|
11
|
+
"claude",
|
|
12
|
+
"claude-code",
|
|
13
|
+
"screenshot",
|
|
14
|
+
"xcode",
|
|
15
|
+
"simctl",
|
|
16
|
+
"agent"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/lvlrSajjad/simframe#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/lvlrSajjad/simframe/issues"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/lvlrSajjad/simframe.git"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Sadjad Asadi (https://github.com/lvlrSajjad)",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"os": [
|
|
30
|
+
"darwin"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.17"
|
|
34
|
+
},
|
|
35
|
+
"bin": {
|
|
36
|
+
"simframe": "src/cli.js"
|
|
37
|
+
},
|
|
38
|
+
"exports": {
|
|
39
|
+
".": "./src/index.js"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"src",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "node --test test/unit.test.mjs",
|
|
48
|
+
"mcp": "node src/cli.js mcp",
|
|
49
|
+
"smoke": "node scripts/smoke.mjs"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/analyze.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Frame comparison. Everything here works on a small grayscale grid, so a
|
|
2
|
+
// "did anything change?" question costs microseconds and no image tokens.
|
|
3
|
+
import { grayGrid } from './png.js';
|
|
4
|
+
|
|
5
|
+
export const HASH_COLS = 8;
|
|
6
|
+
export const HASH_ROWS = 16;
|
|
7
|
+
export const REGION_COLS = 4;
|
|
8
|
+
export const REGION_ROWS = 8;
|
|
9
|
+
|
|
10
|
+
/** A 128-bit mean-threshold hash, rendered as hex. Same screen => same hash. */
|
|
11
|
+
export function frameHash(bmp) {
|
|
12
|
+
const { gray } = grayGrid(bmp, HASH_COLS, HASH_ROWS);
|
|
13
|
+
let mean = 0;
|
|
14
|
+
for (const v of gray) mean += v;
|
|
15
|
+
mean /= gray.length;
|
|
16
|
+
let hex = '';
|
|
17
|
+
for (let i = 0; i < gray.length; i += 4) {
|
|
18
|
+
let nibble = 0;
|
|
19
|
+
for (let b = 0; b < 4; b++) if (gray[i + b] > mean) nibble |= 1 << b;
|
|
20
|
+
hex += nibble.toString(16);
|
|
21
|
+
}
|
|
22
|
+
return hex;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Coarse per-region change, 0-100, laid out row-major over REGION_COLS x REGION_ROWS. */
|
|
26
|
+
export function regionSignature(bmp) {
|
|
27
|
+
return Array.from(grayGrid(bmp, REGION_COLS, REGION_ROWS).gray);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Mean absolute difference of two region signatures, as a 0-1 fraction. */
|
|
31
|
+
export function signatureDiff(a, b) {
|
|
32
|
+
if (!a || !b || a.length !== b.length) return 1;
|
|
33
|
+
let sum = 0;
|
|
34
|
+
for (let i = 0; i < a.length; i++) sum += Math.abs(a[i] - b[i]);
|
|
35
|
+
return sum / a.length / 255;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Per-region change fractions, so callers can tell a toast from a screen push. */
|
|
39
|
+
export function regionDeltas(a, b) {
|
|
40
|
+
if (!a || !b || a.length !== b.length) return a ? a.map(() => 1) : [];
|
|
41
|
+
return a.map((v, i) => Math.abs(v - b[i]) / 255);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const RAMP = ['.', ':', '+', '*', '#', '@'];
|
|
45
|
+
// Screen changes span orders of magnitude — a moving caret is ~0.3%, a screen
|
|
46
|
+
// push is ~30% — so the ramp is bucketed logarithmically rather than linearly.
|
|
47
|
+
const RAMP_STOPS = [0.002, 0.01, 0.03, 0.08, 0.2];
|
|
48
|
+
|
|
49
|
+
export function rampLevel(delta) {
|
|
50
|
+
let level = 0;
|
|
51
|
+
for (const stop of RAMP_STOPS) if (delta >= stop) level++;
|
|
52
|
+
return level;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Render region deltas as a tiny ASCII map: readable in text, ~40 tokens. */
|
|
56
|
+
export function regionMap(deltas, cols = REGION_COLS) {
|
|
57
|
+
if (!deltas.length) return '';
|
|
58
|
+
const lines = [];
|
|
59
|
+
for (let r = 0; r < deltas.length / cols; r++) {
|
|
60
|
+
let line = '';
|
|
61
|
+
for (let c = 0; c < cols; c++) line += RAMP[rampLevel(deltas[r * cols + c] ?? 0)];
|
|
62
|
+
lines.push(line);
|
|
63
|
+
}
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { runDaemon, DEFAULTS } from './daemon.js';
|
|
5
|
+
import { bootedDevices, listDevices, resolveDevice } from './simctl.js';
|
|
6
|
+
import * as api from './index.js';
|
|
7
|
+
import * as store from './store.js';
|
|
8
|
+
|
|
9
|
+
const USAGE = `simframe — always-warm iOS Simulator frames
|
|
10
|
+
|
|
11
|
+
simframe mcp run the MCP server on stdio (for agents)
|
|
12
|
+
simframe start [device] start the capture loop in the background
|
|
13
|
+
simframe stop [device|--all] stop the capture loop
|
|
14
|
+
simframe status [device] show daemon and newest-frame status
|
|
15
|
+
simframe frame [device] write the newest frame to a file
|
|
16
|
+
simframe state [device] print frame metadata and the change map
|
|
17
|
+
simframe wait [device] wait for the screen to settle
|
|
18
|
+
simframe strip [device] write a contact sheet of recent frames
|
|
19
|
+
simframe devices list simulators
|
|
20
|
+
simframe doctor check that this machine can capture
|
|
21
|
+
|
|
22
|
+
Options
|
|
23
|
+
--device=<udid|name> simulator to target (default: the booted one)
|
|
24
|
+
--out=<file> output path for frame/strip
|
|
25
|
+
--detail=low|normal|high|full or --detail=<max pixels>
|
|
26
|
+
--fps=<n> capture rate while the screen is moving (default ${DEFAULTS.fps})
|
|
27
|
+
--count=<n> frames in a strip (default 5)
|
|
28
|
+
--stable-ms=<n> settle window for wait (default 600)
|
|
29
|
+
--timeout-ms=<n> give up after this long (default 8000)
|
|
30
|
+
--json machine-readable output
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const flags = {};
|
|
35
|
+
const positional = [];
|
|
36
|
+
for (const arg of argv) {
|
|
37
|
+
if (arg.startsWith('--')) {
|
|
38
|
+
const [key, value] = arg.slice(2).split('=');
|
|
39
|
+
const camel = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
40
|
+
flags[camel] = value === undefined ? true : value;
|
|
41
|
+
} else {
|
|
42
|
+
positional.push(arg);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { flags, positional };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const num = (v, fallback) => (v == null ? fallback : Number(v));
|
|
49
|
+
|
|
50
|
+
async function main() {
|
|
51
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
52
|
+
const { flags, positional } = parseArgs(rest);
|
|
53
|
+
const device = flags.device || positional[0];
|
|
54
|
+
const options = {};
|
|
55
|
+
if (flags.fps) options.fps = num(flags.fps);
|
|
56
|
+
if (flags.maxDim) options.maxDim = num(flags.maxDim);
|
|
57
|
+
if (flags.ringSize) options.ringSize = num(flags.ringSize);
|
|
58
|
+
|
|
59
|
+
switch (command) {
|
|
60
|
+
case undefined:
|
|
61
|
+
case '-h':
|
|
62
|
+
case '--help':
|
|
63
|
+
case 'help':
|
|
64
|
+
process.stdout.write(USAGE);
|
|
65
|
+
return;
|
|
66
|
+
|
|
67
|
+
case '--version':
|
|
68
|
+
case 'version': {
|
|
69
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
70
|
+
console.log(pkg.version);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case 'mcp': {
|
|
75
|
+
const { serve } = await import('./mcp.js');
|
|
76
|
+
await serve({ device, options });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Internal: the detached capture process.
|
|
81
|
+
case 'daemon': {
|
|
82
|
+
const resolved = await resolveDevice(device);
|
|
83
|
+
await runDaemon(resolved, options);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
case 'start': {
|
|
88
|
+
const { device: dev, state, started } = await api.ensureDaemon(device, options);
|
|
89
|
+
console.log(
|
|
90
|
+
`${started ? 'started' : 'already running'} — ${dev.name} (${dev.runtime}) frame #${state.seq} ${state.width}x${state.height}`,
|
|
91
|
+
);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case 'stop': {
|
|
96
|
+
const targets = flags.all
|
|
97
|
+
? fs.existsSync(store.ROOT)
|
|
98
|
+
? fs.readdirSync(store.ROOT)
|
|
99
|
+
: []
|
|
100
|
+
: [(await resolveDevice(device)).udid];
|
|
101
|
+
let stopped = 0;
|
|
102
|
+
for (const udid of targets) if (api.stopDaemon(udid)) stopped++;
|
|
103
|
+
console.log(`stopped ${stopped} daemon${stopped === 1 ? '' : 's'}`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'status': {
|
|
108
|
+
const udids = device
|
|
109
|
+
? [(await resolveDevice(device)).udid]
|
|
110
|
+
: fs.existsSync(store.ROOT)
|
|
111
|
+
? fs.readdirSync(store.ROOT)
|
|
112
|
+
: [];
|
|
113
|
+
const rows = udids.map((udid) => {
|
|
114
|
+
const { meta, pid, alive, stale } = api.daemonStatus(udid);
|
|
115
|
+
const state = store.readJson(store.paths(udid).state);
|
|
116
|
+
return {
|
|
117
|
+
udid,
|
|
118
|
+
device: meta?.device?.name ?? '?',
|
|
119
|
+
pid,
|
|
120
|
+
running: alive || stale,
|
|
121
|
+
stale,
|
|
122
|
+
seq: state?.seq ?? null,
|
|
123
|
+
ageMs: state ? Date.now() - state.capturedAt : null,
|
|
124
|
+
size: state ? `${state.width}x${state.height}` : null,
|
|
125
|
+
stableForMs: state?.stableForMs ?? null,
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
if (flags.json) {
|
|
129
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
130
|
+
} else if (!rows.length) {
|
|
131
|
+
console.log('no simframe daemons have run yet');
|
|
132
|
+
} else {
|
|
133
|
+
for (const r of rows) {
|
|
134
|
+
console.log(
|
|
135
|
+
`${r.running ? '●' : '○'} ${r.device.padEnd(18)} pid=${r.pid ?? '-'} frame=#${r.seq ?? '-'} age=${r.ageMs ?? '-'}ms ${r.size ?? ''} stable=${r.stableForMs ?? '-'}ms`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case 'frame': {
|
|
143
|
+
const res = await api.getFrame(device, { detail: flags.detail ?? 'normal', options });
|
|
144
|
+
const out = flags.out || path.join(process.cwd(), 'simframe.png');
|
|
145
|
+
fs.writeFileSync(out, res.png);
|
|
146
|
+
console.log(`${out} — ${res.width}x${res.height}, ${res.ageMs}ms old, frame #${res.state.seq}`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'state': {
|
|
151
|
+
const res = await api.getState(device, { options });
|
|
152
|
+
if (flags.json) {
|
|
153
|
+
console.log(JSON.stringify({ ...res.state, ageMs: res.ageMs }, null, 2));
|
|
154
|
+
} else {
|
|
155
|
+
const s = res.state;
|
|
156
|
+
console.log(
|
|
157
|
+
`${res.device.name} frame #${s.seq} age ${res.ageMs}ms ${s.width}x${s.height}\n` +
|
|
158
|
+
`hash ${s.hash} diff ${s.diff} stable ${s.stableForMs}ms\n${res.map}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case 'wait': {
|
|
165
|
+
const res = await api.waitFor(device, {
|
|
166
|
+
mode: flags.mode || (flags.change ? 'change' : 'stable'),
|
|
167
|
+
stableMs: num(flags.stableMs, 600),
|
|
168
|
+
timeoutMs: num(flags.timeoutMs, 8000),
|
|
169
|
+
options,
|
|
170
|
+
});
|
|
171
|
+
console.log(
|
|
172
|
+
`${res.satisfied ? 'settled' : 'timed out'} after ${res.waitedMs}ms — frame #${res.state.seq}, stable ${res.state.stableForMs}ms`,
|
|
173
|
+
);
|
|
174
|
+
process.exitCode = res.satisfied ? 0 : 1;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case 'strip': {
|
|
179
|
+
const res = await api.getStrip(device, {
|
|
180
|
+
count: num(flags.count, 5),
|
|
181
|
+
spanMs: flags.spanMs ? num(flags.spanMs) : undefined,
|
|
182
|
+
options,
|
|
183
|
+
});
|
|
184
|
+
const out = flags.out || path.join(process.cwd(), 'simframe-strip.png');
|
|
185
|
+
fs.writeFileSync(out, res.png);
|
|
186
|
+
console.log(
|
|
187
|
+
`${out} — ${res.frames.length} frames over ${res.spanMs}ms (${res.width}x${res.height})`,
|
|
188
|
+
);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case 'devices': {
|
|
193
|
+
const all = await listDevices();
|
|
194
|
+
const shown = flags.all ? all : all.filter((d) => d.state === 'Booted');
|
|
195
|
+
if (flags.json) {
|
|
196
|
+
console.log(JSON.stringify(shown, null, 2));
|
|
197
|
+
} else if (!shown.length) {
|
|
198
|
+
console.log('no booted simulators (pass --all to list every device)');
|
|
199
|
+
} else {
|
|
200
|
+
for (const d of shown) console.log(`${d.state === 'Booted' ? '●' : '○'} ${d.name} ${d.runtime} ${d.udid}`);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case 'doctor': {
|
|
206
|
+
await doctor();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
default:
|
|
211
|
+
process.stderr.write(`unknown command "${command}"\n\n${USAGE}`);
|
|
212
|
+
process.exitCode = 2;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function doctor() {
|
|
217
|
+
const checks = [];
|
|
218
|
+
const add = (name, ok, detail) => checks.push({ name, ok, detail });
|
|
219
|
+
|
|
220
|
+
add('node', true, process.version);
|
|
221
|
+
try {
|
|
222
|
+
const { execFileSync } = await import('node:child_process');
|
|
223
|
+
add('xcrun', true, execFileSync('xcrun', ['--version'], { encoding: 'utf8' }).trim().split('\n')[0]);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
add('xcrun', false, err.message);
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const { execFileSync } = await import('node:child_process');
|
|
229
|
+
execFileSync('sips', ['--version'], { encoding: 'utf8', stdio: 'pipe' });
|
|
230
|
+
add('sips', true, 'available');
|
|
231
|
+
} catch (err) {
|
|
232
|
+
add('sips', false, err.message);
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const booted = await bootedDevices();
|
|
236
|
+
add('booted simulator', booted.length > 0, booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
|
|
237
|
+
if (booted.length) {
|
|
238
|
+
const t0 = Date.now();
|
|
239
|
+
const res = await api.getFrame(booted[0].udid);
|
|
240
|
+
add('capture', true, `frame #${res.state.seq} ${res.width}x${res.height} in ${Date.now() - t0}ms (age ${res.ageMs}ms)`);
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
add('capture', false, err.message);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const c of checks) console.log(`${c.ok ? 'ok ' : 'FAIL'} ${c.name.padEnd(18)} ${c.detail}`);
|
|
247
|
+
process.exitCode = checks.every((c) => c.ok) ? 0 : 1;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
main().catch((err) => {
|
|
251
|
+
process.stderr.write(`simframe: ${err.message}\n`);
|
|
252
|
+
process.exitCode = 1;
|
|
253
|
+
});
|