ispyx 0.0.0-stage → 1.0.1

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +183 -2
  3. package/config.example.json +15 -0
  4. package/package.json +67 -4
  5. package/src/core/RESIDENT.md +45 -0
  6. package/src/core/auth.js +129 -0
  7. package/src/core/guard.js +287 -0
  8. package/src/core/image.js +117 -0
  9. package/src/core/ipc.js +139 -0
  10. package/src/core/launcher.js +70 -0
  11. package/src/core/mode.js +17 -0
  12. package/src/core/motion.js +57 -0
  13. package/src/core/panel/README.md +70 -0
  14. package/src/core/panel/index.js +144 -0
  15. package/src/core/panel/page.html +434 -0
  16. package/src/core/resident.js +271 -0
  17. package/src/core/server.js +832 -0
  18. package/src/effects/appicon.js +46 -0
  19. package/src/effects/scan.js +850 -0
  20. package/src/macos-entry.js +16 -0
  21. package/src/platform/CONTRACT.md +131 -0
  22. package/src/platform/darwin/PROTOCOL.md +82 -0
  23. package/src/platform/darwin/hotkey.js +41 -0
  24. package/src/platform/darwin/index.js +118 -0
  25. package/src/platform/darwin/keys.js +49 -0
  26. package/src/platform/darwin/layer.js +87 -0
  27. package/src/platform/darwin/overlay.js +83 -0
  28. package/src/platform/darwin/panel.js +25 -0
  29. package/src/platform/darwin/rpc-worker.js +132 -0
  30. package/src/platform/darwin/rpc.js +146 -0
  31. package/src/platform/fake/index.js +162 -0
  32. package/src/platform/index.js +13 -0
  33. package/src/platform/win32/auth-dialog.ps1 +140 -0
  34. package/src/platform/win32/autostart.js +54 -0
  35. package/src/platform/win32/dialog.js +41 -0
  36. package/src/platform/win32/hotkey.js +77 -0
  37. package/src/platform/win32/index.js +31 -0
  38. package/src/platform/win32/keys.js +31 -0
  39. package/src/platform/win32/layer.js +120 -0
  40. package/src/platform/win32/overlay.js +265 -0
  41. package/src/platform/win32/pump.js +37 -0
  42. package/src/platform/win32/tray-worker.js +172 -0
  43. package/src/platform/win32/tray.js +103 -0
  44. package/src/platform/win32/win32.js +577 -0
  45. package/src/server.js +14 -0
@@ -0,0 +1,287 @@
1
+ // Safety layer: config, process denylist, emergency stop, action log.
2
+ import fs from 'node:fs';
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import w from '../platform/index.js';
7
+ const { chordDown, parseChord } = w;
8
+
9
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
10
+
11
+ export class ToolError extends Error {}
12
+
13
+ const DEFAULTS = {
14
+ // acts anywhere on the desktop; only the denylist (password managers) is refused
15
+ denylist: w.name === 'darwin'
16
+ ? ['1password', 'bitwarden', 'keepassxc', 'keychain access', 'enpass']
17
+ : ['keepass.exe', 'keepassxc.exe', '1password.exe', 'bitwarden.exe'],
18
+ // inside Ispyx.app the source tree is read-only and signed: keep the log in the per-user data directory there
19
+ logPath: w.name === 'darwin' ? path.join(w.dataDir(), 'logs', 'actions.jsonl') : path.join(ROOT, 'logs', 'actions.jsonl'),
20
+ stopChord: 'alt+esc', // emergency stop keys (env WIN_INPUT_STOP_CHORD, meant for tests)
21
+ overlay: true, // banner on screen while a tool runs
22
+ animations: true, // eye + scan line after a screenshot (the next call waits for it)
23
+ typeSpeed: 1, // default speed multiplier for typing waits (2 = half the waits, 0 = fixed 8 ms); env WIN_INPUT_TYPE_SPEED
24
+ typeMinMs: 40,
25
+ typeMaxMs: 120,
26
+ typeCapMs: 60000, // longer texts are sped up to finish within this
27
+ moveMinMs: 300,
28
+ moveMaxMs: 1000,
29
+ maxTypeLength: 10000,
30
+ auth: false, // ask for a password (src/auth.js) before the first tool call; env WIN_INPUT_AUTH
31
+ authFile: '', // where the password hash and unlock time live; default %APPDATA%\ispyx\auth.json
32
+ };
33
+
34
+ /**
35
+ * Reads a setting from the environment: ISPYX_<NAME> wins, the old WIN_INPUT_<NAME> is the fallback.
36
+ * (The old config variable was WIN_INPUT_MCP_CONFIG; ISPYX_CONFIG falls back to it.)
37
+ */
38
+ export function env(name, source = process.env) {
39
+ const v = source[`ISPYX_${name}`];
40
+ if (v !== undefined && v !== '') return v;
41
+ const old = source[`WIN_INPUT_${name}`] ?? (name === 'CONFIG' ? source.WIN_INPUT_MCP_CONFIG : undefined);
42
+ return old === '' ? undefined : old;
43
+ }
44
+
45
+ export const userConfigPath = () => { try { return path.join(w.dataDir(), 'config.json'); } catch { return null; } };
46
+
47
+ /** Writes settings changed in the panel (denylist, auth, animations) into the per-user config.json, keeping other keys. */
48
+ export function saveUserConfig(patch) {
49
+ const file = userConfigPath();
50
+ if (!file) throw new Error('No place to save settings.');
51
+ let cur = {};
52
+ try { cur = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* new file */ }
53
+ fs.mkdirSync(path.dirname(file), { recursive: true });
54
+ fs.writeFileSync(file, JSON.stringify({ ...cur, ...patch }, null, 2));
55
+ }
56
+
57
+ export function loadConfig(source = process.env) {
58
+ const e = (name) => env(name, source);
59
+ let cfg = { ...DEFAULTS };
60
+ // The package folder is read-only on macOS and gets replaced on update everywhere, so what the panel saves lives
61
+ // in the per-user data folder (config.json there). On macOS it replaces the package config, elsewhere it is
62
+ // layered on top of it.
63
+ const userCfg = e('CONFIG') ? null : userConfigPath();
64
+ const files = e('CONFIG') ? [e('CONFIG')] : [path.join(ROOT, 'config.json'), userCfg];
65
+ for (const file of files) {
66
+ if (!file || !fs.existsSync(file) || (w.name === 'darwin' && file !== userCfg && fs.existsSync(userCfg))) continue;
67
+ try {
68
+ cfg = { ...cfg, ...JSON.parse(fs.readFileSync(file, 'utf8')) };
69
+ } catch (e) {
70
+ throw new Error(`Config file ${file} is not valid JSON: ${e.message}`);
71
+ }
72
+ }
73
+ if (e('DENYLIST')) cfg.denylist = e('DENYLIST').split(',');
74
+ // test-only knob: lets a test server use its own stop chord, so tests never press Alt+Esc,
75
+ // which would also stop every other running server
76
+ if (e('STOP_CHORD')) cfg.stopChord = e('STOP_CHORD');
77
+ if (e('AUTH')) cfg.auth = !/^(0|false|no)$/i.test(e('AUTH'));
78
+ if (e('AUTH_FILE')) cfg.authFile = e('AUTH_FILE');
79
+ if (e('TYPE_SPEED')) cfg.typeSpeed = Number(e('TYPE_SPEED'));
80
+ if (e('OVERLAY')) cfg.overlay = !/^(0|false|no)$/i.test(e('OVERLAY'));
81
+ if (e('ANIMATIONS')) cfg.animations = !/^(0|false|no)$/i.test(e('ANIMATIONS'));
82
+ cfg.denylist = cfg.denylist.map((s) => String(s).trim().toLowerCase()).filter(Boolean);
83
+ if (e('LOG')) cfg.logPath = e('LOG');
84
+ cfg.logPath = path.resolve(ROOT, cfg.logPath);
85
+ return cfg;
86
+ }
87
+
88
+ export const callContext = new AsyncLocalStorage(); // { epoch } of the tool call being served
89
+ const SHUTDOWN_WINDOW_MS = 5000;
90
+
91
+ export class Guard {
92
+ constructor(config) {
93
+ this.config = config;
94
+ this.epoch = 0; // bumped by every stop press; a call that started under an older epoch is cancelled
95
+ this.lastStopAt = 0;
96
+ this.chordSeen = false;
97
+ this.onShutdown = () => process.exit(0);
98
+ this.stopVks = parseChord(config.stopChord);
99
+ this.stopIsDefault = config.stopChord === 'alt+esc';
100
+ // the default chord is shown with the platform's own name (Alt+Esc / Option+Esc)
101
+ this.stopLabel = this.stopIsDefault ? (w.stopLabel ?? 'Alt+Esc') : config.stopChord.split('+').map((s) => s[0].toUpperCase() + s.slice(1)).join('+');
102
+ this.overlay = null;
103
+ this.lastWindow = 0;
104
+ this.shots = new Map(); // hwnd -> { scale, ox, oy } of the last window screenshot (ox/oy: crop origin in window pixels)
105
+ this.lastShot = null; // { rect, scale } of the last global screenshot
106
+ this.heldKeys = new Set();
107
+ this.heldButtons = new Set();
108
+ fs.mkdirSync(path.dirname(config.logPath), { recursive: true });
109
+ }
110
+
111
+ /** Poll the stop chord, as a fallback for moments when the keyboard hook is not serviced. */
112
+ startWatching() {
113
+ const t = setInterval(() => this.pollChord(), 15);
114
+ t.unref();
115
+ }
116
+
117
+ /** One physical press of the chord seen by polling (edge only, so holding the keys counts once). */
118
+ pollChord() {
119
+ const down = chordDown(this.stopVks) && !this.heldKeys.has(this.stopVks[this.stopVks.length - 1]);
120
+ if (down && !this.chordSeen) this.trigger(this.stopLabel);
121
+ this.chordSeen = down;
122
+ }
123
+
124
+ /**
125
+ * The user pressed the stop chord. First press: cancel every running or queued call (the next call works
126
+ * normally). A second press within SHUTDOWN_WINDOW_MS: release everything and shut this server down.
127
+ */
128
+ trigger(reason) {
129
+ const now = Date.now();
130
+ if (now - this.lastStopAt < 250) return; // hook and polling saw the same press
131
+ const second = this.lastStopAt && now - this.lastStopAt < SHUTDOWN_WINDOW_MS;
132
+ this.lastStopAt = now;
133
+ this.chordSeen = true;
134
+ this.epoch++;
135
+ this.releaseAll();
136
+ if (second) {
137
+ this.log({ tool: 'kill_switch', note: reason + ' twice: shutting down' });
138
+ this.overlay?.stop('shutting down');
139
+ setTimeout(() => this.onShutdown(), 1200);
140
+ } else {
141
+ this.log({ tool: 'kill_switch', note: reason + ': cancelled' });
142
+ this.overlay?.cancel(reason, SHUTDOWN_WINDOW_MS);
143
+ }
144
+ }
145
+
146
+ /** Throws when the user pressed the stop chord since the current call was received. */
147
+ checkKill() {
148
+ this.pollChord();
149
+ const ctx = callContext.getStore();
150
+ if (ctx && ctx.epoch !== this.epoch) {
151
+ throw new ToolError(`CANCELLED BY THE USER (${this.stopLabel}). The action was interrupted. Stop what you were doing and tell the user; carry on only if they ask. ` +
152
+ `(Pressing ${this.stopLabel} again within ${SHUTDOWN_WINDOW_MS / 1000} s shuts the server down.)`);
153
+ }
154
+ }
155
+
156
+ checkSession() {
157
+ if (w.isScreenLocked()) throw new ToolError('The screen is locked (or a secure prompt is showing), so no input can be sent or captured. Unlock it and try again.');
158
+ }
159
+
160
+ denied(win) {
161
+ if (!win.process) return false;
162
+ if (w.name !== 'darwin') return this.config.denylist.includes(win.process);
163
+ // macOS: app names ("1Password") match case-insensitively, also helpers like "1Password Mini"
164
+ const p = win.process.toLowerCase();
165
+ return this.config.denylist.some((d) => p === d || p.startsWith(d + ' '));
166
+ }
167
+
168
+ /** Release anything this server still holds down (kill switch, shutdown, errors). */
169
+ releaseAll() {
170
+ try {
171
+ if (this.heldKeys.size) w.keysUp([...this.heldKeys]);
172
+ for (const b of this.heldButtons) w.mouseUp(b);
173
+ } catch { /* nothing more to do */ }
174
+ this.heldKeys.clear();
175
+ this.heldButtons.clear();
176
+ }
177
+
178
+ /** Throws a plain error unless the window may be acted on. */
179
+ assertUsable(win, { needVisible = true } = {}) {
180
+ if (!win.process) {
181
+ throw new ToolError(`Cannot identify the program that owns "${win.title}"${w.capabilities?.elevation === false ? '' : '; it may be elevated'}. Refused.`);
182
+ }
183
+ if (this.denied(win)) {
184
+ throw new ToolError(`Refused: "${win.title}" belongs to ${win.process}, which is on the denylist.`);
185
+ }
186
+ if (w.capabilities?.elevation !== false && win.elevated && !w.selfIsElevated()) {
187
+ throw new ToolError(`"${win.title}" is running as administrator, and Windows blocks input from a normal program to an elevated window. Run it without elevation, or start this server elevated.`);
188
+ }
189
+ if (needVisible && win.minimized) {
190
+ throw new ToolError(`"${win.title}" is minimized. Use focus_window first.`);
191
+ }
192
+ }
193
+
194
+ /** Resolve {hwnd,title} (or the last used window) to a described window. */
195
+ resolve({ hwnd, title } = {}) {
196
+ if (hwnd) {
197
+ if (!w.windowExists(hwnd)) throw new ToolError(`Window ${hwnd} no longer exists.`);
198
+ return w.describeWindow(hwnd);
199
+ }
200
+ if (title) {
201
+ const q = title.toLowerCase();
202
+ const hits = w.listWindows().filter((x) => x.title.toLowerCase().includes(q));
203
+ if (!hits.length) throw new ToolError(`No visible window title contains "${title}". Call list_windows to see what is open.`);
204
+ return hits.find((x) => !this.denied(x)) || hits[0];
205
+ }
206
+ if (this.lastWindow && w.windowExists(this.lastWindow)) return w.describeWindow(this.lastWindow);
207
+ const fg = w.foregroundWindow();
208
+ if (fg) return w.describeWindow(fg);
209
+ throw new ToolError('No window given. Pass hwnd or title, or use list_windows first.');
210
+ }
211
+
212
+ /** Window-relative screenshot pixels -> absolute screen pixels, with safety checks. */
213
+ toScreen(win, x, y, { scale } = {}) {
214
+ const rect = w.getRect(win.hwnd);
215
+ if (!rect) throw new ToolError(`Cannot read the position of "${win.title}".`);
216
+ const shot = this.shots.get(win.hwnd) || { scale: 1, ox: 0, oy: 0 };
217
+ const s = scale || shot.scale;
218
+ const sx = Math.round(rect.left + shot.ox + x / s);
219
+ const sy = Math.round(rect.top + shot.oy + y / s);
220
+ if (sx < rect.left || sy < rect.top || sx >= rect.left + rect.width || sy >= rect.top + rect.height) {
221
+ throw new ToolError(`Point (${x}, ${y}) is outside "${win.title}" (window is ${Math.round(rect.width)}x${Math.round(rect.height)} px).`);
222
+ }
223
+ return { sx, sy, rect, scale: s };
224
+ }
225
+
226
+ /** Absolute / last-screenshot coordinates -> virtual-desktop pixels, with safety checks. */
227
+ toScreenGlobal(x, y, space) {
228
+ let sx = x;
229
+ let sy = y;
230
+ if (space === 'shot') {
231
+ if (!this.lastShot) throw new ToolError('No screenshot_screen taken yet, so there is nothing to map "shot" coordinates from.');
232
+ sx = this.lastShot.rect.left + x / this.lastShot.scale;
233
+ sy = this.lastShot.rect.top + y / this.lastShot.scale;
234
+ }
235
+ sx = Math.round(sx);
236
+ sy = Math.round(sy);
237
+ const v = w.virtualScreen();
238
+ if (sx < v.x || sy < v.y || sx >= v.x + v.width || sy >= v.y + v.height) {
239
+ throw new ToolError(`Point (${sx}, ${sy}) is outside the desktop (${v.x},${v.y} to ${v.x + v.width - 1},${v.y + v.height - 1}).`);
240
+ }
241
+ return { sx, sy };
242
+ }
243
+
244
+ /** Whatever window sits under a screen point must be usable (denylist, elevation). */
245
+ assertPointUsable(sx, sy) {
246
+ const top = w.rootWindowAt(sx, sy);
247
+ if (!top) return null;
248
+ const o = w.describeWindow(top);
249
+ this.assertUsable(o, { needVisible: false });
250
+ return o;
251
+ }
252
+
253
+ /** The point must really belong to the target window, not to something covering it. */
254
+ assertPointOwned(win, sx, sy) {
255
+ const top = w.rootWindowAt(sx, sy);
256
+ if (top !== win.hwnd) {
257
+ const o = top ? w.describeWindow(top) : null;
258
+ throw new ToolError(`At that point another window is in front ("${o ? o.title : 'unknown'}"). Focus "${win.title}" or move the covering window first.`);
259
+ }
260
+ }
261
+
262
+ async focus(win) {
263
+ if (this.denied(win)) this.assertUsable(win, { needVisible: false });
264
+ const ok = await w.bringToForeground(win.hwnd);
265
+ if (!ok) {
266
+ throw new ToolError(`${w.name === 'darwin' ? 'macOS' : 'Windows'} refused to bring "${win.title}" to the front${w.name === 'darwin' ? '' : ' (foreground lock)'}. Click the window yourself once, then retry.`);
267
+ }
268
+ this.lastWindow = win.hwnd;
269
+ }
270
+
271
+ /** Make `win` the foreground window and verify the denylist against what is really in front. */
272
+ async prepareKeyboard(win) {
273
+ await this.focus(win);
274
+ const fg = w.describeWindow(w.foregroundWindow());
275
+ this.assertUsable(fg);
276
+ }
277
+
278
+ log(entry) {
279
+ try {
280
+ fs.appendFileSync(this.config.logPath, JSON.stringify({ time: new Date().toISOString(), ...entry }) + '\n');
281
+ } catch (e) {
282
+ process.stderr.write(`ispyx: could not write action log: ${e.message}\n`);
283
+ }
284
+ }
285
+ }
286
+
287
+ export const winSummary = (x) => (x ? { hwnd: x.hwnd, title: x.title, process: x.process } : null);
@@ -0,0 +1,117 @@
1
+ // BGRA pixel buffers -> downscaled PNG. No dependencies beyond node:zlib.
2
+ import zlib from 'node:zlib';
3
+
4
+ const CRC_TABLE = (() => {
5
+ const t = new Uint32Array(256);
6
+ for (let n = 0; n < 256; n++) {
7
+ let c = n;
8
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
9
+ t[n] = c >>> 0;
10
+ }
11
+ return t;
12
+ })();
13
+
14
+ function crc32(buf) {
15
+ let c = 0xffffffff;
16
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
17
+ return (c ^ 0xffffffff) >>> 0;
18
+ }
19
+
20
+ function chunk(type, data) {
21
+ const len = Buffer.alloc(4);
22
+ len.writeUInt32BE(data.length);
23
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
24
+ const crc = Buffer.alloc(4);
25
+ crc.writeUInt32BE(crc32(body));
26
+ return Buffer.concat([len, body, crc]);
27
+ }
28
+
29
+ export function encodePng(rgba, width, height) {
30
+ const stride = width * 4;
31
+ const raw = Buffer.alloc((stride + 1) * height);
32
+ for (let y = 0; y < height; y++) {
33
+ raw[y * (stride + 1)] = 0; // filter: none
34
+ rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
35
+ }
36
+ const ihdr = Buffer.alloc(13);
37
+ ihdr.writeUInt32BE(width, 0);
38
+ ihdr.writeUInt32BE(height, 4);
39
+ ihdr[8] = 8; // bit depth
40
+ ihdr[9] = 6; // RGBA
41
+ return Buffer.concat([
42
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
43
+ chunk('IHDR', ihdr),
44
+ chunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
45
+ chunk('IEND', Buffer.alloc(0)),
46
+ ]);
47
+ }
48
+
49
+ /** Area-average downscale of a top-down BGRA buffer to outW x outH, returned as opaque RGBA. */
50
+ export function toRgbaScaled(bgra, w, h, outW, outH) {
51
+ const out = Buffer.alloc(outW * outH * 4);
52
+ const xr = w / outW;
53
+ const yr = h / outH;
54
+ for (let oy = 0; oy < outH; oy++) {
55
+ const y0 = Math.floor(oy * yr);
56
+ const y1 = Math.max(y0 + 1, Math.min(h, Math.floor((oy + 1) * yr)));
57
+ for (let ox = 0; ox < outW; ox++) {
58
+ const x0 = Math.floor(ox * xr);
59
+ const x1 = Math.max(x0 + 1, Math.min(w, Math.floor((ox + 1) * xr)));
60
+ let r = 0, g = 0, b = 0, n = 0;
61
+ for (let y = y0; y < y1; y++) {
62
+ let i = (y * w + x0) * 4;
63
+ for (let x = x0; x < x1; x++, i += 4) {
64
+ b += bgra[i]; g += bgra[i + 1]; r += bgra[i + 2]; n++;
65
+ }
66
+ }
67
+ const o = (oy * outW + ox) * 4;
68
+ out[o] = Math.round(r / n);
69
+ out[o + 1] = Math.round(g / n);
70
+ out[o + 2] = Math.round(b / n);
71
+ out[o + 3] = 255;
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** Crop a capture to region {x,y,width,height} (window pixels), clamped to its bounds. Returns null if nothing is left. */
78
+ export function cropCapture(cap, region) {
79
+ const x0 = Math.max(0, Math.round(region.x));
80
+ const y0 = Math.max(0, Math.round(region.y));
81
+ const x1 = Math.min(cap.width, Math.round(region.x + region.width));
82
+ const y1 = Math.min(cap.height, Math.round(region.y + region.height));
83
+ if (x1 <= x0 || y1 <= y0) return null;
84
+ const w = x1 - x0;
85
+ const h = y1 - y0;
86
+ const bits = Buffer.alloc(w * h * 4);
87
+ // subarray + set: cap.bits may be a plain Uint8Array (macOS), which has no Buffer.copy.
88
+ for (let y = 0; y < h; y++) bits.set(cap.bits.subarray(((y0 + y) * cap.width + x0) * 4, ((y0 + y) * cap.width + x1) * 4), y * w * 4);
89
+ return { ...cap, bits, width: w, height: h, region: { x: x0, y: y0, width: w, height: h } };
90
+ }
91
+
92
+ /** Capture result -> { png, width, height, scale } where scale = output pixels per screen pixel. */
93
+ export function shrinkToPng(cap, maxWidth) {
94
+ const outW = Math.max(1, Math.min(cap.width, Math.floor(maxWidth)));
95
+ const scale = outW / cap.width;
96
+ const outH = Math.max(1, Math.round(cap.height * scale));
97
+ const rgba = toRgbaScaled(cap.bits, cap.width, cap.height, outW, outH);
98
+ return { png: encodePng(rgba, outW, outH), width: outW, height: outH, scale, rgba };
99
+ }
100
+
101
+ /** Minimal decoder for PNGs written by encodePng (filter 0, RGBA 8-bit). Used by tests. */
102
+ export function decodePng(png) {
103
+ let p = 8, w = 0, h = 0;
104
+ const idat = [];
105
+ while (p < png.length) {
106
+ const len = png.readUInt32BE(p);
107
+ const type = png.toString('ascii', p + 4, p + 8);
108
+ const data = png.subarray(p + 8, p + 8 + len);
109
+ if (type === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); }
110
+ if (type === 'IDAT') idat.push(data);
111
+ p += 12 + len;
112
+ }
113
+ const raw = zlib.inflateSync(Buffer.concat(idat));
114
+ const rgba = Buffer.alloc(w * h * 4);
115
+ for (let y = 0; y < h; y++) raw.copy(rgba, y * w * 4, y * (w * 4 + 1) + 1, (y + 1) * (w * 4 + 1));
116
+ return { width: w, height: h, rgba };
117
+ }
@@ -0,0 +1,139 @@
1
+ // One working server per desktop: the first ispyx process listens on a named pipe and does the work;
2
+ // every other process (one per Claude instance) stays a normal MCP server toward its own Claude and forwards
3
+ // the tool calls over the pipe. Protocol: one JSON line each way per call.
4
+ import net from 'node:net';
5
+ import os from 'node:os';
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ /**
11
+ * Same user + same ISPYX_* (or legacy WIN_INPUT_*) settings = same endpoint, so test servers with their own env never meet the live one.
12
+ * Windows: a named pipe. Elsewhere: a unix socket in the temp directory (per user on macOS), mode 0600.
13
+ * ISPYX_PIPE names the endpoint outright (Ispyx.app: its resident and the MCP clients' forwarders start in different
14
+ * environments, so a temp-directory path could differ between them).
15
+ */
16
+ export function pipeName() {
17
+ if (process.env.ISPYX_PIPE) return process.env.ISPYX_PIPE;
18
+ const env = Object.entries(process.env).filter(([k]) => k.startsWith('ISPYX_') || k.startsWith('WIN_INPUT_')).sort().map(([k, v]) => `${k}=${v}`).join('\n');
19
+ const user = os.userInfo().username.replace(/[^\w.-]/g, '_');
20
+ const id = `ispyx-${user}-${crypto.createHash('sha1').update(env).digest('hex').slice(0, 8)}`;
21
+ if (process.platform === 'win32') return `\\\\.\\pipe\\${id}`;
22
+ return path.join(os.tmpdir(), `${id}.sock`); // keep short: unix socket paths are limited to ~104 bytes on macOS
23
+ }
24
+
25
+ const isPipe = (name) => name.startsWith('\\\\.\\pipe\\');
26
+
27
+ /** True when something accepts connections at the unix socket path. */
28
+ function answers(name) {
29
+ return new Promise((resolve) => {
30
+ const s = net.connect(name);
31
+ s.once('connect', () => { s.destroy(); resolve(true); });
32
+ s.once('error', () => { s.destroy(); resolve(false); });
33
+ });
34
+ }
35
+
36
+ /** Reads newline-delimited JSON from a stream. */
37
+ function lines(stream, onLine) {
38
+ let buf = '';
39
+ stream.setEncoding('utf8');
40
+ stream.on('data', (d) => {
41
+ buf += d;
42
+ let i;
43
+ while ((i = buf.indexOf('\n')) >= 0) {
44
+ const l = buf.slice(0, i);
45
+ buf = buf.slice(i + 1);
46
+ if (l) onLine(l);
47
+ }
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Tries to become the owner. Resolves with { close, clients() } when this process now listens,
53
+ * or null when another process already owns the pipe.
54
+ */
55
+ export function listenOwner(name, run) {
56
+ return new Promise((resolve, reject) => {
57
+ let clients = 0;
58
+ const watchers = new Set(); // other servers waiting for a shutdown order
59
+ const socks = new Set();
60
+ const srv = net.createServer((sock) => {
61
+ clients++;
62
+ socks.add(sock);
63
+ sock.on('close', () => { clients--; watchers.delete(sock); socks.delete(sock); });
64
+ sock.on('error', () => {});
65
+ lines(sock, async (l) => {
66
+ let req;
67
+ try { req = JSON.parse(l); } catch { return; }
68
+ if (req.watch) { watchers.add(sock); return; }
69
+ let reply;
70
+ try { reply = { id: req.id, result: await run(req.name, req.args ?? {}) }; }
71
+ catch (e) { reply = { id: req.id, error: e.message }; }
72
+ if (!sock.destroyed) sock.write(JSON.stringify(reply) + '\n');
73
+ });
74
+ });
75
+ let retried = false;
76
+ srv.on('error', async (e) => {
77
+ if (e.code !== 'EADDRINUSE') return reject(e);
78
+ // a unix socket file outlives a crashed owner: when nobody answers, remove it and try once more
79
+ if (!isPipe(name) && !retried && !(await answers(name))) {
80
+ retried = true;
81
+ try { fs.unlinkSync(name); } catch { /* already gone */ }
82
+ return srv.listen(name);
83
+ }
84
+ resolve(null);
85
+ });
86
+ srv.on('listening', () => {
87
+ if (!isPipe(name)) { try { fs.chmodSync(name, 0o600); } catch { /* best effort */ } }
88
+ resolve({
89
+ close: () => { srv.close(); for (const s of socks) s.destroy(); },
90
+ clients: () => clients,
91
+ /** Servers holding a watch connection (resident model: one per connected `ispyx mcp`). */
92
+ watchers: () => watchers.size,
93
+ /** Tells every other server on this pipe to exit. */
94
+ broadcastShutdown: () => { for (const s of watchers) { try { s.write(JSON.stringify({ shutdown: true }) + '\n'); } catch { /* gone */ } } },
95
+ });
96
+ });
97
+ srv.listen(name);
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Keeps a connection to the owner open so it can order this server to exit (the owner's second stop press).
103
+ * Reconnects when the owner changes. Stops retrying once onShutdown ran or isOwner() is true.
104
+ */
105
+ export function watchOwner(name, onShutdown, isOwner) {
106
+ const attempt = () => {
107
+ if (isOwner()) return;
108
+ const sock = net.connect(name);
109
+ sock.on('connect', () => sock.write(JSON.stringify({ watch: true }) + '\n'));
110
+ sock.on('error', () => {});
111
+ sock.on('close', () => { const t = setTimeout(attempt, 1000); t.unref(); });
112
+ lines(sock, (l) => { try { if (JSON.parse(l).shutdown) onShutdown(); } catch { /* ignore */ } });
113
+ sock.unref();
114
+ };
115
+ attempt();
116
+ }
117
+
118
+ export class OwnerGone extends Error {}
119
+
120
+ /** Forwards one call to the owner. Throws OwnerGone when nobody listens (so the caller can take over). */
121
+ export function callOwner(name, tool, args) {
122
+ return new Promise((resolve, reject) => {
123
+ const sock = net.connect(name);
124
+ let connected = false;
125
+ let done = false;
126
+ const finish = (fn, v) => { if (!done) { done = true; sock.destroy(); fn(v); } };
127
+ sock.on('connect', () => {
128
+ connected = true;
129
+ sock.write(JSON.stringify({ id: 1, name: tool, args }) + '\n');
130
+ });
131
+ sock.on('error', (e) => finish(reject, connected ? new Error(`The ispyx server that was doing the work went away (${e.code ?? e.message}). Try the call again.`) : new OwnerGone(e.code)));
132
+ sock.on('close', () => finish(reject, connected ? new Error('The ispyx server that was doing the work went away. Try the call again.') : new OwnerGone('closed')));
133
+ lines(sock, (l) => {
134
+ const r = JSON.parse(l);
135
+ if (r.error !== undefined) finish(reject, new Error(r.error));
136
+ else finish(resolve, r.result);
137
+ });
138
+ });
139
+ }
@@ -0,0 +1,70 @@
1
+ // Starting and reaching the resident app (resident model). No platform import: usable before anything is loaded.
2
+ import { spawn } from 'node:child_process';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { callOwner, OwnerGone, pipeName } from './ipc.js';
5
+
6
+ const ENTRY = fileURLToPath(new URL('../server.js', import.meta.url));
7
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
+
9
+ /** Inside Ispyx.app (macOS) the app starts and watches the resident: the bundle path, else null. */
10
+ function macApp() {
11
+ if (process.platform !== 'darwin') return null;
12
+ const m = /^(.*?\.app)\/Contents\/Resources\//.exec(ENTRY);
13
+ return m ? m[1] : null;
14
+ }
15
+
16
+ /** Starts `ispyx --resident` detached and hidden, with this process's environment (same pipe). Inside Ispyx.app it
17
+ * opens the app instead, which starts the resident itself. */
18
+ export function spawnResident() {
19
+ const app = macApp();
20
+ const child = app
21
+ ? spawn('open', ['-g', app, '--args', '--background'], { stdio: 'ignore', detached: true })
22
+ : spawn(process.execPath, [ENTRY, '--resident'], { detached: true, windowsHide: true, stdio: 'ignore' });
23
+ child.on('error', () => {});
24
+ child.unref();
25
+ }
26
+
27
+ /** True when a resident answers on the pipe. */
28
+ export async function residentRunning(pipe = pipeName()) {
29
+ try { await callOwner(pipe, '__ping', {}); return true; } catch (e) { if (e instanceof OwnerGone) return false; return true; }
30
+ }
31
+
32
+ /** Makes sure a resident listens, starting one if needed; waits up to timeoutMs. Resolves true when it answers. */
33
+ export async function ensureResident(pipe = pipeName(), timeoutMs = 10000) {
34
+ if (await residentRunning(pipe)) return true;
35
+ spawnResident();
36
+ const end = Date.now() + timeoutMs;
37
+ while (Date.now() < end) {
38
+ await sleep(150);
39
+ if (await residentRunning(pipe)) return true;
40
+ }
41
+ return false;
42
+ }
43
+
44
+ /** Opens a URL in the default browser (Windows). */
45
+ export function openUrl(url) {
46
+ const c = spawn('cmd.exe', ['/c', 'start', '', url], { windowsHide: true, stdio: 'ignore', detached: true });
47
+ c.on('error', () => {});
48
+ c.unref();
49
+ }
50
+
51
+ /** `ispyx` with a console: start the resident if none runs, open its panel, leave. */
52
+ export async function runLauncher() {
53
+ const pipe = pipeName();
54
+ const wasRunning = await residentRunning(pipe);
55
+ if (!(await ensureResident(pipe))) {
56
+ process.stderr.write('ispyx: could not start the resident app (see %APPDATA%\\ispyx\\ispyx.log).\n');
57
+ process.exit(1);
58
+ }
59
+ // Starting it (login, first click) just puts the icon in the tray; clicking the shortcut again opens the panel.
60
+ if (wasRunning) {
61
+ try { await callOwner(pipe, '__panel', {}); } catch { /* no panel: the tray icon is enough */ }
62
+ }
63
+ process.exit(0);
64
+ }
65
+
66
+ /** `ispyx quit`: asks the resident to exit; exits 0 either way. */
67
+ export async function runQuit() {
68
+ try { await callOwner(pipeName(), '__quit', {}); } catch { /* not running, or gone before answering */ }
69
+ process.exit(0);
70
+ }
@@ -0,0 +1,17 @@
1
+ // Which role this process plays. Pure (no platform import), so the entry point can decide before loading anything.
2
+ // classic npx on macOS / Linux / fake: every process is an MCP server, one of them owns the pipe
3
+ // (Ispyx.app passes --resident / mcp explicitly)
4
+ // launcher Windows, no args, console attached: start the resident app if needed, then exit
5
+ // resident `--resident`: the single owner of the pipe (tray, panel, tool queue); no stdio
6
+ // mcp `mcp`: thin stdio MCP server forwarding every call to the resident
7
+ // quit `quit`: ask the resident to exit
8
+ // Explicit arguments work on any platform (tests use them with the fake platform). Without arguments only
9
+ // Windows changes: a console means launcher, a pipe on stdin (an old MCP registration) means mcp.
10
+ export function resolveMode(argv = process.argv.slice(2), env = process.env, platform = process.platform, isTTY = !!process.stdin.isTTY) {
11
+ if (argv.includes('--resident')) return 'resident';
12
+ if (argv.includes('mcp')) return 'mcp';
13
+ if (argv.includes('quit') || argv.includes('--quit')) return 'quit';
14
+ const target = env.ISPYX_PLATFORM || platform;
15
+ if (target === 'win32') return isTTY ? 'launcher' : 'mcp';
16
+ return 'classic';
17
+ }