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/src/png.js ADDED
@@ -0,0 +1,191 @@
1
+ // Minimal PNG codec. sips only ever hands us 8-bit non-interlaced RGB/RGBA, so
2
+ // that is all we decode; encoding is filter-0 RGBA, which is all we produce.
3
+ import zlib from 'node:zlib';
4
+
5
+ const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
6
+
7
+ /** @typedef {{ width: number, height: number, data: Buffer }} Bitmap RGBA, 4 bytes per pixel. */
8
+
9
+ /** @returns {Bitmap} */
10
+ export function decodePng(buf) {
11
+ if (!buf.subarray(0, 8).equals(SIG)) throw new Error('not a PNG');
12
+ let off = 8;
13
+ let hdr = null;
14
+ const idat = [];
15
+ while (off + 8 <= buf.length) {
16
+ const len = buf.readUInt32BE(off);
17
+ const type = buf.toString('ascii', off + 4, off + 8);
18
+ const body = buf.subarray(off + 8, off + 8 + len);
19
+ if (type === 'IHDR') {
20
+ hdr = {
21
+ width: body.readUInt32BE(0),
22
+ height: body.readUInt32BE(4),
23
+ depth: body[8],
24
+ colorType: body[9],
25
+ interlace: body[12],
26
+ };
27
+ } else if (type === 'IDAT') {
28
+ idat.push(body);
29
+ } else if (type === 'IEND') {
30
+ break;
31
+ }
32
+ off += 12 + len;
33
+ }
34
+ if (!hdr) throw new Error('PNG has no IHDR');
35
+ if (hdr.depth !== 8) throw new Error(`unsupported PNG bit depth ${hdr.depth}`);
36
+ if (hdr.interlace !== 0) throw new Error('interlaced PNG is unsupported');
37
+ const channels = hdr.colorType === 6 ? 4 : hdr.colorType === 2 ? 3 : 0;
38
+ if (!channels) throw new Error(`unsupported PNG color type ${hdr.colorType}`);
39
+
40
+ const raw = zlib.inflateSync(Buffer.concat(idat));
41
+ const { width, height } = hdr;
42
+ const stride = width * channels;
43
+ const out = Buffer.allocUnsafe(width * height * 4);
44
+ let prev = Buffer.alloc(stride);
45
+ const line = Buffer.allocUnsafe(stride);
46
+
47
+ for (let y = 0; y < height; y++) {
48
+ const start = y * (stride + 1);
49
+ const filter = raw[start];
50
+ raw.copy(line, 0, start + 1, start + 1 + stride);
51
+ unfilter(filter, line, prev, channels, stride);
52
+ if (channels === 4) {
53
+ line.copy(out, y * width * 4);
54
+ } else {
55
+ for (let x = 0; x < width; x++) {
56
+ const s = x * 3;
57
+ const d = (y * width + x) * 4;
58
+ out[d] = line[s];
59
+ out[d + 1] = line[s + 1];
60
+ out[d + 2] = line[s + 2];
61
+ out[d + 3] = 255;
62
+ }
63
+ }
64
+ prev = Buffer.from(line);
65
+ }
66
+ return { width, height, data: out };
67
+ }
68
+
69
+ function unfilter(filter, line, prev, bpp, stride) {
70
+ switch (filter) {
71
+ case 0:
72
+ return;
73
+ case 1:
74
+ for (let i = bpp; i < stride; i++) line[i] = (line[i] + line[i - bpp]) & 0xff;
75
+ return;
76
+ case 2:
77
+ for (let i = 0; i < stride; i++) line[i] = (line[i] + prev[i]) & 0xff;
78
+ return;
79
+ case 3:
80
+ for (let i = 0; i < stride; i++) {
81
+ const left = i >= bpp ? line[i - bpp] : 0;
82
+ line[i] = (line[i] + ((left + prev[i]) >> 1)) & 0xff;
83
+ }
84
+ return;
85
+ case 4:
86
+ for (let i = 0; i < stride; i++) {
87
+ const a = i >= bpp ? line[i - bpp] : 0;
88
+ const b = prev[i];
89
+ const c = i >= bpp ? prev[i - bpp] : 0;
90
+ const p = a + b - c;
91
+ const pa = Math.abs(p - a);
92
+ const pb = Math.abs(p - b);
93
+ const pc = Math.abs(p - c);
94
+ const pred = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
95
+ line[i] = (line[i] + pred) & 0xff;
96
+ }
97
+ return;
98
+ default:
99
+ throw new Error(`unknown PNG filter ${filter}`);
100
+ }
101
+ }
102
+
103
+ /** @param {Bitmap} bmp */
104
+ export function encodePng(bmp) {
105
+ const { width, height, data } = bmp;
106
+ const stride = width * 4;
107
+ const raw = Buffer.allocUnsafe(height * (stride + 1));
108
+ for (let y = 0; y < height; y++) {
109
+ raw[y * (stride + 1)] = 0;
110
+ data.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
111
+ }
112
+ const ihdr = Buffer.alloc(13);
113
+ ihdr.writeUInt32BE(width, 0);
114
+ ihdr.writeUInt32BE(height, 4);
115
+ ihdr[8] = 8;
116
+ ihdr[9] = 6;
117
+ return Buffer.concat([
118
+ SIG,
119
+ chunk('IHDR', ihdr),
120
+ chunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
121
+ chunk('IEND', Buffer.alloc(0)),
122
+ ]);
123
+ }
124
+
125
+ function chunk(type, body) {
126
+ const head = Buffer.allocUnsafe(8);
127
+ head.writeUInt32BE(body.length, 0);
128
+ head.write(type, 4, 'ascii');
129
+ const crc = Buffer.allocUnsafe(4);
130
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
131
+ return Buffer.concat([head, body, crc]);
132
+ }
133
+
134
+ const CRC_TABLE = (() => {
135
+ const t = new Int32Array(256);
136
+ for (let n = 0; n < 256; n++) {
137
+ let c = n;
138
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
139
+ t[n] = c;
140
+ }
141
+ return t;
142
+ })();
143
+
144
+ function crc32(buf) {
145
+ let c = -1;
146
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
147
+ return (c ^ -1) >>> 0;
148
+ }
149
+
150
+ /**
151
+ * Box-average a bitmap down to a small grayscale grid. This is the basis for
152
+ * every cheap comparison simframe makes: hashes, diffs and region maps.
153
+ * @param {Bitmap} bmp
154
+ * @returns {{ cols: number, rows: number, gray: Uint8Array }}
155
+ */
156
+ export function grayGrid(bmp, cols, rows) {
157
+ const gray = new Uint8Array(cols * rows);
158
+ const { width, height, data } = bmp;
159
+ for (let ry = 0; ry < rows; ry++) {
160
+ const y0 = Math.floor((ry * height) / rows);
161
+ const y1 = Math.max(y0 + 1, Math.floor(((ry + 1) * height) / rows));
162
+ for (let rx = 0; rx < cols; rx++) {
163
+ const x0 = Math.floor((rx * width) / cols);
164
+ const x1 = Math.max(x0 + 1, Math.floor(((rx + 1) * width) / cols));
165
+ let sum = 0;
166
+ let n = 0;
167
+ for (let y = y0; y < y1; y++) {
168
+ for (let x = x0; x < x1; x++) {
169
+ const i = (y * width + x) * 4;
170
+ sum += (data[i] * 299 + data[i + 1] * 587 + data[i + 2] * 114) / 1000;
171
+ n++;
172
+ }
173
+ }
174
+ gray[ry * cols + rx] = Math.round(sum / n);
175
+ }
176
+ }
177
+ return { cols, rows, gray };
178
+ }
179
+
180
+ /** Nearest-neighbour scale. Only used for contact sheets, where speed beats quality. */
181
+ export function scaleBitmap(bmp, width, height) {
182
+ const out = Buffer.allocUnsafe(width * height * 4);
183
+ for (let y = 0; y < height; y++) {
184
+ const sy = Math.min(bmp.height - 1, Math.floor((y * bmp.height) / height));
185
+ for (let x = 0; x < width; x++) {
186
+ const sx = Math.min(bmp.width - 1, Math.floor((x * bmp.width) / width));
187
+ bmp.data.copy(out, (y * width + x) * 4, (sy * bmp.width + sx) * 4, (sy * bmp.width + sx) * 4 + 4);
188
+ }
189
+ }
190
+ return { width, height, data: out };
191
+ }
package/src/simctl.js ADDED
@@ -0,0 +1,104 @@
1
+ // Everything that shells out to Xcode's command line tools.
2
+ import { execFile, execFileSync } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+
5
+ const run = promisify(execFile);
6
+
7
+ // `simctl list` costs ~130ms, which would otherwise dominate every warm read,
8
+ // so the parsed list is cached for a few seconds.
9
+ const DEVICE_CACHE_MS = 4000;
10
+ let deviceCache = { at: 0, devices: null, inflight: null };
11
+
12
+ /** @returns {Promise<Array<{udid: string, name: string, runtime: string, state: string}>>} */
13
+ export async function listDevices({ maxAgeMs = DEVICE_CACHE_MS } = {}) {
14
+ if (deviceCache.devices && Date.now() - deviceCache.at <= maxAgeMs) return deviceCache.devices;
15
+ if (deviceCache.inflight) return deviceCache.inflight;
16
+ deviceCache.inflight = fetchDevices()
17
+ .then((devices) => {
18
+ deviceCache = { at: Date.now(), devices, inflight: null };
19
+ return devices;
20
+ })
21
+ .catch((err) => {
22
+ deviceCache.inflight = null;
23
+ throw err;
24
+ });
25
+ return deviceCache.inflight;
26
+ }
27
+
28
+ async function fetchDevices() {
29
+ const { stdout } = await run('xcrun', ['simctl', 'list', 'devices', '--json'], {
30
+ maxBuffer: 8 << 20,
31
+ });
32
+ const parsed = JSON.parse(stdout);
33
+ const out = [];
34
+ for (const [runtime, devices] of Object.entries(parsed.devices || {})) {
35
+ for (const d of devices) {
36
+ out.push({
37
+ udid: d.udid,
38
+ name: d.name,
39
+ runtime: runtime.replace('com.apple.CoreSimulator.SimRuntime.', '').replace(/-/g, '.'),
40
+ state: d.state,
41
+ });
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export async function bootedDevices(opts) {
48
+ return (await listDevices(opts)).filter((d) => d.state === 'Booted');
49
+ }
50
+
51
+ /**
52
+ * Resolve a user-supplied device string (UDID, exact name, or substring) to one
53
+ * booted device. Prefers booted devices; falls back to a clear error listing
54
+ * what is actually available.
55
+ */
56
+ export async function resolveDevice(query, opts) {
57
+ const all = await listDevices(opts);
58
+ const booted = all.filter((d) => d.state === 'Booted');
59
+ if (!query) {
60
+ if (booted.length === 0) throw new Error('no booted simulator (open Simulator.app or run `xcrun simctl boot <udid>`)');
61
+ return booted[0];
62
+ }
63
+ const q = query.toLowerCase();
64
+ const pools = [booted, all];
65
+ for (const pool of pools) {
66
+ const exact = pool.find((d) => d.udid.toLowerCase() === q || d.name.toLowerCase() === q);
67
+ if (exact) return exact;
68
+ const partial = pool.filter((d) => d.name.toLowerCase().includes(q));
69
+ if (partial.length === 1) return partial[0];
70
+ if (partial.length > 1) {
71
+ throw new Error(`"${query}" matches ${partial.length} devices: ${partial.map((d) => d.name).join(', ')}`);
72
+ }
73
+ }
74
+ throw new Error(`no simulator matches "${query}"; booted: ${booted.map((d) => d.name).join(', ') || 'none'}`);
75
+ }
76
+
77
+ export function isBootedSync(udid) {
78
+ try {
79
+ const out = execFileSync('xcrun', ['simctl', 'list', 'devices', '--json'], {
80
+ maxBuffer: 8 << 20,
81
+ encoding: 'utf8',
82
+ });
83
+ const parsed = JSON.parse(out);
84
+ for (const devices of Object.values(parsed.devices || {})) {
85
+ const hit = devices.find((d) => d.udid === udid);
86
+ if (hit) return hit.state === 'Booted';
87
+ }
88
+ } catch {
89
+ /* treat an unreadable device list as "still booted" and retry next tick */
90
+ return true;
91
+ }
92
+ return false;
93
+ }
94
+
95
+ export async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
96
+ await run('xcrun', ['simctl', 'io', udid, 'screenshot', '--type=png', `--mask=${mask}`, outFile], {
97
+ timeout: 10_000,
98
+ });
99
+ }
100
+
101
+ /** Resample with sips, which ships with macOS, so simframe needs no image deps. */
102
+ export async function resize(inFile, outFile, maxDim) {
103
+ await run('sips', ['-Z', String(maxDim), inFile, '--out', outFile], { timeout: 10_000 });
104
+ }
package/src/store.js ADDED
@@ -0,0 +1,94 @@
1
+ // On-disk layout for a device's frame cache. Files are the whole IPC mechanism:
2
+ // the daemon renames completed frames into place, readers just stat and read.
3
+ // A rename is atomic, so a reader can never observe a half-written frame.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ export const ROOT = process.env.SIMFRAME_HOME || path.join(os.homedir(), '.simframe');
9
+
10
+ export function deviceDir(udid) {
11
+ return path.join(ROOT, udid);
12
+ }
13
+
14
+ export function paths(udid) {
15
+ const dir = deviceDir(udid);
16
+ return {
17
+ dir,
18
+ ring: path.join(dir, 'ring'),
19
+ full: path.join(dir, 'full'),
20
+ state: path.join(dir, 'state.json'),
21
+ meta: path.join(dir, 'meta.json'),
22
+ heartbeat: path.join(dir, 'heartbeat'),
23
+ log: path.join(dir, 'daemon.log'),
24
+ lock: path.join(dir, 'daemon.lock'),
25
+ };
26
+ }
27
+
28
+ export function ensureDirs(udid) {
29
+ const p = paths(udid);
30
+ fs.mkdirSync(p.ring, { recursive: true });
31
+ fs.mkdirSync(p.full, { recursive: true });
32
+ return p;
33
+ }
34
+
35
+ export function writeAtomic(file, data) {
36
+ const tmp = `${file}.tmp-${process.pid}`;
37
+ fs.writeFileSync(tmp, data);
38
+ fs.renameSync(tmp, file);
39
+ }
40
+
41
+ export function readJson(file) {
42
+ try {
43
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function touchHeartbeat(udid) {
50
+ const p = paths(udid);
51
+ try {
52
+ fs.mkdirSync(p.dir, { recursive: true });
53
+ writeAtomic(p.heartbeat, String(Date.now()));
54
+ } catch {
55
+ /* a missing heartbeat only costs the daemon an earlier idle exit */
56
+ }
57
+ }
58
+
59
+ export function heartbeatAge(udid) {
60
+ const raw = readJson(paths(udid).heartbeat);
61
+ if (typeof raw === 'number') return Date.now() - raw;
62
+ try {
63
+ return Date.now() - fs.statSync(paths(udid).heartbeat).mtimeMs;
64
+ } catch {
65
+ return Infinity;
66
+ }
67
+ }
68
+
69
+ export function pruneDir(dir, keep) {
70
+ let names;
71
+ try {
72
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.png'));
73
+ } catch {
74
+ return;
75
+ }
76
+ names.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
77
+ for (const name of names.slice(0, Math.max(0, names.length - keep))) {
78
+ try {
79
+ fs.unlinkSync(path.join(dir, name));
80
+ } catch {
81
+ /* another process may have pruned it already */
82
+ }
83
+ }
84
+ }
85
+
86
+ export function isProcessAlive(pid) {
87
+ if (!pid) return false;
88
+ try {
89
+ process.kill(pid, 0);
90
+ return true;
91
+ } catch (err) {
92
+ return err.code === 'EPERM';
93
+ }
94
+ }