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/src/daemon.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The capture loop. Runs detached, one process per simulator, and keeps the
|
|
2
|
+
// newest frame permanently warm on disk so a reader never waits on simctl.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { decodePng } from './png.js';
|
|
6
|
+
import { frameHash, regionSignature, signatureDiff, regionDeltas } from './analyze.js';
|
|
7
|
+
import * as store from './store.js';
|
|
8
|
+
import { isBootedSync, resize, screenshot } from './simctl.js';
|
|
9
|
+
|
|
10
|
+
// Bump whenever the shape of state.json changes, so an upgraded client retires
|
|
11
|
+
// a capture loop left running by an older install instead of misreading it.
|
|
12
|
+
export const STATE_VERSION = 2;
|
|
13
|
+
|
|
14
|
+
export const DEFAULTS = {
|
|
15
|
+
fps: 4,
|
|
16
|
+
idleFps: 1.5,
|
|
17
|
+
idleAfterMs: 2500,
|
|
18
|
+
maxDim: 700,
|
|
19
|
+
ringSize: 24,
|
|
20
|
+
fullKeep: 3,
|
|
21
|
+
changeThreshold: 0.004,
|
|
22
|
+
idleExitMs: 15 * 60_000,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const BOOT_CHECK_MS = 5000;
|
|
26
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
|
|
28
|
+
export async function runDaemon(device, options = {}) {
|
|
29
|
+
const opts = { ...DEFAULTS, ...options };
|
|
30
|
+
const udid = device.udid;
|
|
31
|
+
const p = store.ensureDirs(udid);
|
|
32
|
+
|
|
33
|
+
// Exactly one loop may own a device: two loops would both write ring/1.png
|
|
34
|
+
// and prune each other's frames. meta.json is the ownership record.
|
|
35
|
+
const incumbent = store.readJson(p.meta);
|
|
36
|
+
if (
|
|
37
|
+
incumbent?.pid &&
|
|
38
|
+
incumbent.pid !== process.pid &&
|
|
39
|
+
incumbent.version === STATE_VERSION &&
|
|
40
|
+
store.isProcessAlive(incumbent.pid)
|
|
41
|
+
) {
|
|
42
|
+
return { started: false, reason: `already captured by pid ${incumbent.pid}` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
store.writeAtomic(
|
|
46
|
+
p.meta,
|
|
47
|
+
JSON.stringify(
|
|
48
|
+
{ pid: process.pid, device, options: opts, startedAt: Date.now(), version: STATE_VERSION },
|
|
49
|
+
null,
|
|
50
|
+
2,
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
let seq = 0;
|
|
55
|
+
let prevSignature = null;
|
|
56
|
+
let lastChangeAt = Date.now();
|
|
57
|
+
let consecutiveErrors = 0;
|
|
58
|
+
let lastBootCheck = Date.now();
|
|
59
|
+
let running = true;
|
|
60
|
+
const stop = () => {
|
|
61
|
+
running = false;
|
|
62
|
+
};
|
|
63
|
+
process.on('SIGTERM', stop);
|
|
64
|
+
process.on('SIGINT', stop);
|
|
65
|
+
|
|
66
|
+
const log = (msg) => {
|
|
67
|
+
try {
|
|
68
|
+
fs.appendFileSync(p.log, `${new Date().toISOString()} ${msg}\n`);
|
|
69
|
+
} catch {
|
|
70
|
+
/* logging is best effort */
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
log(`start pid=${process.pid} device=${device.name} udid=${udid}`);
|
|
74
|
+
|
|
75
|
+
while (running) {
|
|
76
|
+
const tickStart = Date.now();
|
|
77
|
+
|
|
78
|
+
if (store.heartbeatAge(udid) > opts.idleExitMs) {
|
|
79
|
+
log('exit: no client heartbeat');
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
// Checking the boot state means shelling out to simctl, which costs more
|
|
83
|
+
// than a capture does; every few seconds is soon enough to notice a shutdown.
|
|
84
|
+
if (tickStart - lastBootCheck > BOOT_CHECK_MS) {
|
|
85
|
+
lastBootCheck = tickStart;
|
|
86
|
+
if (store.readJson(p.meta)?.pid !== process.pid) {
|
|
87
|
+
log('exit: superseded by another capture loop');
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (!isBootedSync(udid)) {
|
|
91
|
+
log('exit: device is no longer booted');
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const nextSeq = seq + 1;
|
|
98
|
+
const fullFile = path.join(p.full, `${nextSeq}.png`);
|
|
99
|
+
const ringFile = path.join(p.ring, `${nextSeq}.png`);
|
|
100
|
+
await screenshot(udid, fullFile, { mask: 'ignored' });
|
|
101
|
+
await resize(fullFile, ringFile, opts.maxDim);
|
|
102
|
+
|
|
103
|
+
const bmp = decodePng(fs.readFileSync(ringFile));
|
|
104
|
+
const signature = regionSignature(bmp);
|
|
105
|
+
const diff = signatureDiff(signature, prevSignature);
|
|
106
|
+
const deltas = regionDeltas(signature, prevSignature);
|
|
107
|
+
const changed = diff > opts.changeThreshold;
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
if (changed || prevSignature === null) lastChangeAt = now;
|
|
110
|
+
|
|
111
|
+
seq = nextSeq;
|
|
112
|
+
prevSignature = signature;
|
|
113
|
+
consecutiveErrors = 0;
|
|
114
|
+
|
|
115
|
+
fs.copyFileSync(ringFile, path.join(p.dir, 'latest.png.tmp'));
|
|
116
|
+
fs.renameSync(path.join(p.dir, 'latest.png.tmp'), path.join(p.dir, 'latest.png'));
|
|
117
|
+
|
|
118
|
+
store.writeAtomic(
|
|
119
|
+
p.state,
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
seq,
|
|
122
|
+
capturedAt: now,
|
|
123
|
+
captureMs: now - tickStart,
|
|
124
|
+
width: bmp.width,
|
|
125
|
+
height: bmp.height,
|
|
126
|
+
hash: frameHash(bmp),
|
|
127
|
+
diff: Number(diff.toFixed(5)),
|
|
128
|
+
changed,
|
|
129
|
+
stableForMs: now - lastChangeAt,
|
|
130
|
+
regions: deltas.map((d) => Number(d.toFixed(4))),
|
|
131
|
+
fullFile,
|
|
132
|
+
ringFile,
|
|
133
|
+
device,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
store.pruneDir(p.ring, opts.ringSize);
|
|
138
|
+
store.pruneDir(p.full, opts.fullKeep);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
consecutiveErrors++;
|
|
141
|
+
log(`capture error (${consecutiveErrors}): ${err.message}`);
|
|
142
|
+
if (consecutiveErrors >= 10) {
|
|
143
|
+
log('exit: too many consecutive capture errors');
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
await sleep(Math.min(5000, 250 * consecutiveErrors));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Back off while the screen sits still, and snap back the moment it moves.
|
|
151
|
+
const idle = Date.now() - lastChangeAt > opts.idleAfterMs;
|
|
152
|
+
const interval = 1000 / (idle ? opts.idleFps : opts.fps);
|
|
153
|
+
const wait = interval - (Date.now() - tickStart);
|
|
154
|
+
if (wait > 0) await sleep(wait);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
log('stopped');
|
|
158
|
+
const outcome = { started: true };
|
|
159
|
+
try {
|
|
160
|
+
// A replacement loop may already have registered itself while this one was
|
|
161
|
+
// winding down; only deregister if meta.json still points at us.
|
|
162
|
+
const meta = store.readJson(p.meta);
|
|
163
|
+
if (meta?.pid === process.pid) {
|
|
164
|
+
store.writeAtomic(p.meta, JSON.stringify({ ...meta, pid: null, stoppedAt: Date.now() }, null, 2));
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
/* nothing useful to do on the way out */
|
|
168
|
+
}
|
|
169
|
+
return outcome;
|
|
170
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Client API shared by the CLI and the MCP server.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { DEFAULTS, STATE_VERSION } from './daemon.js';
|
|
7
|
+
import { decodePng, encodePng, scaleBitmap } from './png.js';
|
|
8
|
+
import { REGION_COLS, regionMap } from './analyze.js';
|
|
9
|
+
import { resolveDevice, resize } from './simctl.js';
|
|
10
|
+
import * as store from './store.js';
|
|
11
|
+
|
|
12
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const CLI = path.join(HERE, 'cli.js');
|
|
14
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
|
|
16
|
+
export const DETAIL_LEVELS = { low: 420, normal: 700, high: 1100, full: 0 };
|
|
17
|
+
|
|
18
|
+
export function resolveMaxDim(detail) {
|
|
19
|
+
if (typeof detail === 'number') return detail;
|
|
20
|
+
if (detail && detail in DETAIL_LEVELS) return DETAIL_LEVELS[detail];
|
|
21
|
+
return DETAIL_LEVELS.normal;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function daemonStatus(udid) {
|
|
25
|
+
const meta = store.readJson(store.paths(udid).meta);
|
|
26
|
+
const pid = meta?.pid ?? null;
|
|
27
|
+
const running = store.isProcessAlive(pid);
|
|
28
|
+
const stale = running && meta?.version !== STATE_VERSION;
|
|
29
|
+
// A loop from an older install is treated as not usable, so callers replace it.
|
|
30
|
+
return { meta, pid, running, stale, alive: running && !stale };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Make sure a capture loop is running for `deviceQuery`, then return once a
|
|
35
|
+
* frame is actually available. Safe to call on every request: it is a stat
|
|
36
|
+
* when the daemon is already up.
|
|
37
|
+
*/
|
|
38
|
+
export async function ensureDaemon(deviceQuery, options = {}) {
|
|
39
|
+
const device = await resolveDevice(deviceQuery);
|
|
40
|
+
const p = store.ensureDirs(device.udid);
|
|
41
|
+
store.touchHeartbeat(device.udid);
|
|
42
|
+
|
|
43
|
+
const existing = daemonStatus(device.udid);
|
|
44
|
+
if (existing.stale) stopDaemon(device.udid);
|
|
45
|
+
// A dead daemon leaves its last state.json behind. Anything captured before
|
|
46
|
+
// we (re)started the loop is not evidence of a live screen, so ignore it.
|
|
47
|
+
const minCapturedAt = existing.alive ? 0 : Date.now();
|
|
48
|
+
if (!existing.alive) {
|
|
49
|
+
if (acquireSpawnLock(p.lock)) {
|
|
50
|
+
try {
|
|
51
|
+
spawnDaemon(device.udid, options);
|
|
52
|
+
} finally {
|
|
53
|
+
// Hold the lock briefly so a burst of callers does not double-spawn.
|
|
54
|
+
setTimeout(() => releaseSpawnLock(p.lock), 1500).unref?.();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const deadline = Date.now() + (options.readyTimeoutMs ?? 8000);
|
|
60
|
+
while (Date.now() < deadline) {
|
|
61
|
+
const state = store.readJson(p.state);
|
|
62
|
+
if (state && state.capturedAt >= minCapturedAt && Date.now() - state.capturedAt < 30_000) {
|
|
63
|
+
return { device, state, started: !existing.alive };
|
|
64
|
+
}
|
|
65
|
+
await sleep(80);
|
|
66
|
+
}
|
|
67
|
+
const tail = readLogTail(p.log);
|
|
68
|
+
throw new Error(`simframe daemon did not produce a frame for ${device.name}${tail ? `\n${tail}` : ''}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function spawnDaemon(udid, options) {
|
|
72
|
+
const args = [CLI, 'daemon', udid];
|
|
73
|
+
for (const key of ['fps', 'maxDim', 'ringSize', 'idleExitMs']) {
|
|
74
|
+
if (options[key] != null) args.push(`--${key}=${options[key]}`);
|
|
75
|
+
}
|
|
76
|
+
const child = spawn(process.execPath, args, {
|
|
77
|
+
detached: true,
|
|
78
|
+
stdio: 'ignore',
|
|
79
|
+
env: process.env,
|
|
80
|
+
});
|
|
81
|
+
child.unref();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function acquireSpawnLock(lockFile) {
|
|
85
|
+
try {
|
|
86
|
+
fs.writeFileSync(lockFile, String(process.pid), { flag: 'wx' });
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
const holder = Number(safeRead(lockFile));
|
|
90
|
+
if (holder && !store.isProcessAlive(holder)) {
|
|
91
|
+
try {
|
|
92
|
+
fs.unlinkSync(lockFile);
|
|
93
|
+
fs.writeFileSync(lockFile, String(process.pid), { flag: 'wx' });
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function releaseSpawnLock(lockFile) {
|
|
104
|
+
try {
|
|
105
|
+
if (safeRead(lockFile) === String(process.pid)) fs.unlinkSync(lockFile);
|
|
106
|
+
} catch {
|
|
107
|
+
/* a stale lock is reclaimed by the next caller */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function safeRead(file) {
|
|
112
|
+
try {
|
|
113
|
+
return fs.readFileSync(file, 'utf8');
|
|
114
|
+
} catch {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readLogTail(file, lines = 6) {
|
|
120
|
+
return safeRead(file).trim().split('\n').slice(-lines).join('\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function stopDaemon(udid) {
|
|
124
|
+
const { pid, running } = daemonStatus(udid);
|
|
125
|
+
if (!running) return false;
|
|
126
|
+
try {
|
|
127
|
+
process.kill(pid, 'SIGTERM');
|
|
128
|
+
return true;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The warm read: newest frame as PNG bytes, with no capture in the request path. */
|
|
135
|
+
export async function getFrame(deviceQuery, { detail = 'normal', options } = {}) {
|
|
136
|
+
const { device, state } = await ensureDaemon(deviceQuery, options);
|
|
137
|
+
const p = store.paths(device.udid);
|
|
138
|
+
const maxDim = resolveMaxDim(detail);
|
|
139
|
+
const nativeMax = Math.max(state.width, state.height);
|
|
140
|
+
|
|
141
|
+
let file = path.join(p.dir, 'latest.png');
|
|
142
|
+
let scaledOnRead = false;
|
|
143
|
+
if (maxDim === 0) {
|
|
144
|
+
file = state.fullFile;
|
|
145
|
+
} else if (maxDim > nativeMax + 8 && fs.existsSync(state.fullFile)) {
|
|
146
|
+
const out = path.join(p.dir, `read-${maxDim}.png`);
|
|
147
|
+
await resize(state.fullFile, out, maxDim);
|
|
148
|
+
file = out;
|
|
149
|
+
scaledOnRead = true;
|
|
150
|
+
} else if (maxDim < nativeMax - 8) {
|
|
151
|
+
const out = path.join(p.dir, `read-${maxDim}.png`);
|
|
152
|
+
await resize(path.join(p.dir, 'latest.png'), out, maxDim);
|
|
153
|
+
file = out;
|
|
154
|
+
scaledOnRead = true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const png = fs.readFileSync(file);
|
|
158
|
+
const bmp = pngSize(png);
|
|
159
|
+
return {
|
|
160
|
+
device,
|
|
161
|
+
state,
|
|
162
|
+
png,
|
|
163
|
+
width: bmp.width,
|
|
164
|
+
height: bmp.height,
|
|
165
|
+
ageMs: Date.now() - state.capturedAt,
|
|
166
|
+
scaledOnRead,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function pngSize(png) {
|
|
171
|
+
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function getState(deviceQuery, { options } = {}) {
|
|
175
|
+
const { device, state } = await ensureDaemon(deviceQuery, options);
|
|
176
|
+
return {
|
|
177
|
+
device,
|
|
178
|
+
state,
|
|
179
|
+
ageMs: Date.now() - state.capturedAt,
|
|
180
|
+
map: regionMap(state.regions || [], REGION_COLS),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Wait for the screen to settle (`mode: 'stable'`) or to move away from what it
|
|
186
|
+
* shows right now (`mode: 'change'`). Removes the screenshot-retry loop.
|
|
187
|
+
*/
|
|
188
|
+
export async function waitFor(
|
|
189
|
+
deviceQuery,
|
|
190
|
+
{ mode = 'stable', stableMs = 600, timeoutMs = 8000, baselineHash, options } = {},
|
|
191
|
+
) {
|
|
192
|
+
const { device, state: first } = await ensureDaemon(deviceQuery, options);
|
|
193
|
+
const p = store.paths(device.udid);
|
|
194
|
+
const baseline = baselineHash || first.hash;
|
|
195
|
+
const deadline = Date.now() + timeoutMs;
|
|
196
|
+
let last = first;
|
|
197
|
+
|
|
198
|
+
while (Date.now() < deadline) {
|
|
199
|
+
const state = store.readJson(p.state);
|
|
200
|
+
if (state) {
|
|
201
|
+
last = state;
|
|
202
|
+
if (mode === 'change') {
|
|
203
|
+
if (state.hash !== baseline) return { device, state, satisfied: true, mode, waitedMs: timeoutMs - (deadline - Date.now()) };
|
|
204
|
+
} else if (state.stableForMs >= stableMs) {
|
|
205
|
+
return { device, state, satisfied: true, mode, waitedMs: timeoutMs - (deadline - Date.now()) };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
await sleep(60);
|
|
209
|
+
}
|
|
210
|
+
return { device, state: last, satisfied: false, mode, waitedMs: timeoutMs };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Tile the most recent frames into one image. A transition or animation becomes
|
|
215
|
+
* legible in a single tool call instead of a sequence of them.
|
|
216
|
+
*/
|
|
217
|
+
export async function getStrip(deviceQuery, { count = 5, spanMs, thumbMaxDim = 240, options } = {}) {
|
|
218
|
+
const { device } = await ensureDaemon(deviceQuery, options);
|
|
219
|
+
const p = store.paths(device.udid);
|
|
220
|
+
let entries = fs
|
|
221
|
+
.readdirSync(p.ring)
|
|
222
|
+
.filter((n) => n.endsWith('.png'))
|
|
223
|
+
.map((n) => ({ seq: parseInt(n, 10), file: path.join(p.ring, n) }))
|
|
224
|
+
.filter((e) => Number.isFinite(e.seq))
|
|
225
|
+
.sort((a, b) => a.seq - b.seq);
|
|
226
|
+
|
|
227
|
+
entries = entries.map((e) => ({ ...e, mtimeMs: safeMtime(e.file) })).filter((e) => e.mtimeMs);
|
|
228
|
+
if (spanMs) {
|
|
229
|
+
const cutoff = Date.now() - spanMs;
|
|
230
|
+
const within = entries.filter((e) => e.mtimeMs >= cutoff);
|
|
231
|
+
if (within.length) entries = within;
|
|
232
|
+
}
|
|
233
|
+
entries = entries.slice(-Math.max(1, count));
|
|
234
|
+
if (!entries.length) throw new Error('no frames buffered yet');
|
|
235
|
+
|
|
236
|
+
const frames = entries.map((e) => decodePng(fs.readFileSync(e.file)));
|
|
237
|
+
const ratio = frames[0].height / frames[0].width;
|
|
238
|
+
const tw = Math.max(40, Math.round(thumbMaxDim / Math.max(1, ratio)));
|
|
239
|
+
const th = Math.round(tw * ratio);
|
|
240
|
+
const gap = 6;
|
|
241
|
+
const width = frames.length * tw + gap * (frames.length - 1);
|
|
242
|
+
const sheet = { width, height: th, data: Buffer.alloc(width * th * 4, 0) };
|
|
243
|
+
|
|
244
|
+
frames.forEach((frame, i) => {
|
|
245
|
+
const thumb = scaleBitmap(frame, tw, th);
|
|
246
|
+
const x0 = i * (tw + gap);
|
|
247
|
+
for (let y = 0; y < th; y++) {
|
|
248
|
+
thumb.data.copy(sheet.data, (y * width + x0) * 4, y * tw * 4, (y + 1) * tw * 4);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const t0 = entries[0].mtimeMs;
|
|
253
|
+
return {
|
|
254
|
+
device,
|
|
255
|
+
png: encodePng(sheet),
|
|
256
|
+
width,
|
|
257
|
+
height: th,
|
|
258
|
+
frames: entries.map((e) => ({ seq: e.seq, offsetMs: Math.round(e.mtimeMs - t0) })),
|
|
259
|
+
spanMs: Math.round(entries[entries.length - 1].mtimeMs - t0),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function safeMtime(file) {
|
|
264
|
+
try {
|
|
265
|
+
return fs.statSync(file).mtimeMs;
|
|
266
|
+
} catch {
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export { DEFAULTS, store };
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// MCP server. The point of every tool here is that the expensive part
|
|
2
|
+
// (capturing) already happened in the background, so a call is a file read.
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import { REGION_COLS, REGION_ROWS, regionMap } from './analyze.js';
|
|
11
|
+
import * as api from './index.js';
|
|
12
|
+
import { bootedDevices } from './simctl.js';
|
|
13
|
+
import * as store from './store.js';
|
|
14
|
+
|
|
15
|
+
const deviceProp = {
|
|
16
|
+
device: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Simulator UDID or name substring. Defaults to the booted simulator.',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const TOOLS = [
|
|
23
|
+
{
|
|
24
|
+
name: 'sim_look',
|
|
25
|
+
description:
|
|
26
|
+
'Look at the iOS Simulator screen right now. Returns the newest buffered frame immediately — a background capture loop keeps it warm, so there is no screenshot wait. Use this instead of taking a screenshot. Prefer detail "low" for layout checks and "high" only when you must read small text.',
|
|
27
|
+
inputSchema: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
...deviceProp,
|
|
31
|
+
detail: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
enum: ['low', 'normal', 'high', 'full'],
|
|
34
|
+
description:
|
|
35
|
+
'Image size: low (~420px, cheapest), normal (~700px, default), high (~1100px, readable small text), full (native resolution).',
|
|
36
|
+
},
|
|
37
|
+
maxAgeMs: {
|
|
38
|
+
type: 'number',
|
|
39
|
+
description:
|
|
40
|
+
'If the buffered frame is older than this, wait for a fresher one (default 900).',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'sim_state',
|
|
47
|
+
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
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'sim_wait',
|
|
53
|
+
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.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
...deviceProp,
|
|
59
|
+
mode: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
enum: ['stable', 'change'],
|
|
62
|
+
description: 'stable: wait for the screen to stop moving. change: wait for it to differ from now.',
|
|
63
|
+
},
|
|
64
|
+
stableMs: { type: 'number', description: 'How long the screen must hold still for mode "stable" (default 600).' },
|
|
65
|
+
timeoutMs: { type: 'number', description: 'Give up after this long (default 8000).' },
|
|
66
|
+
includeImage: { type: 'boolean', description: 'Attach the resulting frame as an image (default true).' },
|
|
67
|
+
detail: { type: 'string', enum: ['low', 'normal', 'high', 'full'] },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'sim_strip',
|
|
73
|
+
description:
|
|
74
|
+
'Return the last few buffered frames tiled into ONE image, left to right, oldest first. Lets you understand a transition, animation or flicker in a single call instead of a burst of screenshots. Frames are already buffered, so this looks backwards in time — it does not wait.',
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
...deviceProp,
|
|
79
|
+
count: { type: 'number', description: 'How many frames to tile (default 5, max 12).' },
|
|
80
|
+
spanMs: { type: 'number', description: 'Only include frames from the last N milliseconds.' },
|
|
81
|
+
thumbMaxDim: { type: 'number', description: 'Height budget per frame in pixels (default 240).' },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'sim_capture',
|
|
87
|
+
description:
|
|
88
|
+
'Inspect or control the background capture loops: action "status" (what is running and how fresh), "start", "stop". Capture starts automatically on first use, so you rarely need this.',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
action: { type: 'string', enum: ['status', 'start', 'stop'] },
|
|
93
|
+
...deviceProp,
|
|
94
|
+
fps: { type: 'number', description: 'Capture rate while the screen is moving (default 4).' },
|
|
95
|
+
},
|
|
96
|
+
required: ['action'],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'sim_devices',
|
|
101
|
+
description: 'List booted iOS simulators that simframe can capture.',
|
|
102
|
+
inputSchema: { type: 'object', properties: {} },
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const text = (s) => ({ type: 'text', text: s });
|
|
107
|
+
const image = (png) => ({ type: 'image', data: png.toString('base64'), mimeType: 'image/png' });
|
|
108
|
+
|
|
109
|
+
function header(device, state, ageMs, extra = '') {
|
|
110
|
+
return (
|
|
111
|
+
`${device.name} · ${device.runtime} · frame #${state.seq} · ${ageMs}ms old · ` +
|
|
112
|
+
`${state.width}x${state.height} · still for ${state.stableForMs}ms${extra ? ` · ${extra}` : ''}`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function serve({ device: defaultDevice, options = {} } = {}) {
|
|
117
|
+
const server = new Server(
|
|
118
|
+
{ name: 'simframe', version: '0.1.0' },
|
|
119
|
+
{ capabilities: { tools: {} } },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
123
|
+
|
|
124
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
125
|
+
const args = req.params.arguments || {};
|
|
126
|
+
const target = args.device || defaultDevice;
|
|
127
|
+
try {
|
|
128
|
+
switch (req.params.name) {
|
|
129
|
+
case 'sim_look':
|
|
130
|
+
return await look(target, args, options);
|
|
131
|
+
case 'sim_state':
|
|
132
|
+
return await state(target, options);
|
|
133
|
+
case 'sim_wait':
|
|
134
|
+
return await wait(target, args, options);
|
|
135
|
+
case 'sim_strip':
|
|
136
|
+
return await strip(target, args, options);
|
|
137
|
+
case 'sim_capture':
|
|
138
|
+
return await capture(target, args, options);
|
|
139
|
+
case 'sim_devices':
|
|
140
|
+
return await devices();
|
|
141
|
+
default:
|
|
142
|
+
throw new Error(`unknown tool ${req.params.name}`);
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { isError: true, content: [text(`simframe: ${err.message}`)] };
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await server.connect(new StdioServerTransport());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function look(target, args, options) {
|
|
153
|
+
const maxAge = args.maxAgeMs ?? 900;
|
|
154
|
+
let res = await api.getFrame(target, { detail: args.detail ?? 'normal', options });
|
|
155
|
+
if (res.ageMs > maxAge) {
|
|
156
|
+
// Freshness was requested; the loop is already running, so just let it tick.
|
|
157
|
+
const deadline = Date.now() + Math.min(2000, maxAge * 3);
|
|
158
|
+
const p = store.paths(res.device.udid);
|
|
159
|
+
while (Date.now() < deadline) {
|
|
160
|
+
const next = store.readJson(p.state);
|
|
161
|
+
if (next && Date.now() - next.capturedAt <= maxAge) break;
|
|
162
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
163
|
+
}
|
|
164
|
+
res = await api.getFrame(target, { detail: args.detail ?? 'normal', options });
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
content: [text(header(res.device, res.state, res.ageMs)), image(res.png)],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function state(target, options) {
|
|
172
|
+
const res = await api.getState(target, { options });
|
|
173
|
+
const s = res.state;
|
|
174
|
+
const body = [
|
|
175
|
+
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)] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function wait(target, args, options) {
|
|
185
|
+
const res = await api.waitFor(target, {
|
|
186
|
+
mode: args.mode ?? 'stable',
|
|
187
|
+
stableMs: args.stableMs ?? 600,
|
|
188
|
+
timeoutMs: args.timeoutMs ?? 8000,
|
|
189
|
+
options,
|
|
190
|
+
});
|
|
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'}`;
|
|
194
|
+
const content = [text(`${note}\n${header(res.device, res.state, Date.now() - res.state.capturedAt)}`)];
|
|
195
|
+
if (args.includeImage !== false) {
|
|
196
|
+
const frame = await api.getFrame(target, { detail: args.detail ?? 'normal', options });
|
|
197
|
+
content.push(image(frame.png));
|
|
198
|
+
}
|
|
199
|
+
return { content };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function strip(target, args, options) {
|
|
203
|
+
const res = await api.getStrip(target, {
|
|
204
|
+
count: Math.min(12, args.count ?? 5),
|
|
205
|
+
spanMs: args.spanMs,
|
|
206
|
+
thumbMaxDim: args.thumbMaxDim ?? 240,
|
|
207
|
+
options,
|
|
208
|
+
});
|
|
209
|
+
const offsets = res.frames.map((f) => `+${f.offsetMs}ms`).join(' ');
|
|
210
|
+
return {
|
|
211
|
+
content: [
|
|
212
|
+
text(
|
|
213
|
+
`${res.device.name} · ${res.frames.length} frames spanning ${res.spanMs}ms, oldest first\n${offsets}`,
|
|
214
|
+
),
|
|
215
|
+
image(res.png),
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function capture(target, args, options) {
|
|
221
|
+
if (args.action === 'start') {
|
|
222
|
+
const res = await api.ensureDaemon(target, { ...options, fps: args.fps ?? options.fps });
|
|
223
|
+
return {
|
|
224
|
+
content: [
|
|
225
|
+
text(
|
|
226
|
+
`${res.started ? 'started' : 'already running'} — ${res.device.name}, frame #${res.state.seq}`,
|
|
227
|
+
),
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (args.action === 'stop') {
|
|
232
|
+
const udids = target ? [(await api.ensureDaemon(target, options)).device.udid] : listStateDirs();
|
|
233
|
+
const stopped = udids.filter((u) => api.stopDaemon(u));
|
|
234
|
+
return { content: [text(`stopped ${stopped.length} capture loop(s)`)] };
|
|
235
|
+
}
|
|
236
|
+
const rows = listStateDirs().map((udid) => {
|
|
237
|
+
const { meta, pid, alive } = api.daemonStatus(udid);
|
|
238
|
+
const s = store.readJson(store.paths(udid).state);
|
|
239
|
+
return `${alive ? '●' : '○'} ${meta?.device?.name ?? udid} pid=${pid ?? '-'} frame=#${s?.seq ?? '-'} age=${s ? Date.now() - s.capturedAt : '-'}ms`;
|
|
240
|
+
});
|
|
241
|
+
return { content: [text(rows.join('\n') || 'no capture loops running')] };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function listStateDirs() {
|
|
245
|
+
try {
|
|
246
|
+
return fs.readdirSync(store.ROOT);
|
|
247
|
+
} catch {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function devices() {
|
|
253
|
+
const booted = await bootedDevices();
|
|
254
|
+
if (!booted.length) return { content: [text('no booted simulators')] };
|
|
255
|
+
return {
|
|
256
|
+
content: [text(booted.map((d) => `${d.name} · ${d.runtime} · ${d.udid}`).join('\n'))],
|
|
257
|
+
};
|
|
258
|
+
}
|