ispyx 0.0.0-stage → 1.0.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 +183 -2
- package/config.example.json +15 -0
- package/package.json +47 -4
- package/src/core/RESIDENT.md +45 -0
- package/src/core/auth.js +129 -0
- package/src/core/guard.js +287 -0
- package/src/core/image.js +117 -0
- package/src/core/ipc.js +139 -0
- package/src/core/launcher.js +70 -0
- package/src/core/mode.js +17 -0
- package/src/core/motion.js +57 -0
- package/src/core/panel/README.md +70 -0
- package/src/core/panel/index.js +144 -0
- package/src/core/panel/page.html +434 -0
- package/src/core/resident.js +271 -0
- package/src/core/server.js +832 -0
- package/src/effects/appicon.js +46 -0
- package/src/effects/scan.js +850 -0
- package/src/macos-entry.js +16 -0
- package/src/platform/CONTRACT.md +131 -0
- package/src/platform/darwin/PROTOCOL.md +82 -0
- package/src/platform/darwin/hotkey.js +41 -0
- package/src/platform/darwin/index.js +118 -0
- package/src/platform/darwin/keys.js +49 -0
- package/src/platform/darwin/layer.js +87 -0
- package/src/platform/darwin/overlay.js +83 -0
- package/src/platform/darwin/panel.js +25 -0
- package/src/platform/darwin/rpc-worker.js +132 -0
- package/src/platform/darwin/rpc.js +146 -0
- package/src/platform/fake/index.js +162 -0
- package/src/platform/index.js +13 -0
- package/src/platform/win32/auth-dialog.ps1 +140 -0
- package/src/platform/win32/autostart.js +54 -0
- package/src/platform/win32/dialog.js +41 -0
- package/src/platform/win32/hotkey.js +77 -0
- package/src/platform/win32/index.js +31 -0
- package/src/platform/win32/keys.js +31 -0
- package/src/platform/win32/layer.js +120 -0
- package/src/platform/win32/overlay.js +265 -0
- package/src/platform/win32/pump.js +37 -0
- package/src/platform/win32/tray-worker.js +172 -0
- package/src/platform/win32/tray.js +103 -0
- package/src/platform/win32/win32.js +577 -0
- package/src/server.js +14 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// The resident app (`ispyx --resident`): the one process per user that owns the pipe and does the work.
|
|
2
|
+
// Windows model; also runnable on the fake platform for tests. See RESIDENT.md for the whole picture.
|
|
3
|
+
//
|
|
4
|
+
// Hooks into sibling modules (all optional; a missing one is skipped, never fatal):
|
|
5
|
+
//
|
|
6
|
+
// platform.Tray class, from src/platform/win32/tray.js:
|
|
7
|
+
// new Tray({ tooltip, onOpen, onQuit, getStatus }) .destroy()
|
|
8
|
+
// onOpen(): user clicked the icon -> we open the panel. onQuit(): menu "Quit" -> shutdown.
|
|
9
|
+
// getStatus(): { text, clients, uptimeMs, stopLabel, effectsEnabled }, polled for the menu.
|
|
10
|
+
// platform.autostart { get(): boolean, set(bool): void }, from src/platform/win32/autostart.js
|
|
11
|
+
// ./panel/index.js startPanel({ getState, actions }) -> { url, close() } (local web panel)
|
|
12
|
+
//
|
|
13
|
+
// getState() -> { version, platform, pid, uptimeMs, clients, recent: [{ time, tool, summary }],
|
|
14
|
+
// stopLabel, effectsEnabled, passwordSet, denylist, autostart, connect (win32) }
|
|
15
|
+
// actions -> { quit(), setAutostart(bool), setEffects(bool), clearRecent(), showConfigFile() }
|
|
16
|
+
//
|
|
17
|
+
// Control calls over the pipe (names start with "__", handled here, never reach the tools):
|
|
18
|
+
// __ping -> 'pong' __panel -> panel url ('' if none) __quit -> 'bye' and the resident shuts down.
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
import { saveUserConfig } from './guard.js';
|
|
25
|
+
import { PasswordError } from './auth.js';
|
|
26
|
+
|
|
27
|
+
/** A refusal whose message goes to the person at the panel. */
|
|
28
|
+
class PanelError extends PasswordError {}
|
|
29
|
+
|
|
30
|
+
const MAX_RECENT = 200; // older calls are only in the action log
|
|
31
|
+
const MAX_LOG = 256 * 1024;
|
|
32
|
+
export const recent = [];
|
|
33
|
+
let totalCalls = 0; // since this resident started
|
|
34
|
+
|
|
35
|
+
const VERSION = (() => {
|
|
36
|
+
try { return JSON.parse(fs.readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')).version; } catch { return '0.0.0'; }
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
/** Short, secret-free description of a call for the panel (typed text only as a length). */
|
|
40
|
+
export function summarize(name, args = {}) {
|
|
41
|
+
const parts = [];
|
|
42
|
+
for (const [k, v] of Object.entries(args)) {
|
|
43
|
+
if (v === undefined || v === null) continue;
|
|
44
|
+
if (typeof v === 'string') parts.push(`${k}=${(name === 'type_text' || name === 'set_clipboard') ? v.length + ' chars' : JSON.stringify(v.slice(0, 40))}`);
|
|
45
|
+
else if (typeof v === 'number' || typeof v === 'boolean') parts.push(`${k}=${v}`);
|
|
46
|
+
}
|
|
47
|
+
return parts.join(' ').slice(0, 100);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function recordCall(name, args) {
|
|
51
|
+
totalCalls++;
|
|
52
|
+
recent.unshift({ time: Date.now(), tool: name, summary: summarize(name, args) });
|
|
53
|
+
if (recent.length > MAX_RECENT) recent.length = MAX_RECENT;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Sends stdout/stderr/uncaught errors to the log file (the resident has no console). Small rotation: .log -> .log.1 */
|
|
57
|
+
export function setupLogging(dir) {
|
|
58
|
+
let file;
|
|
59
|
+
try {
|
|
60
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
61
|
+
file = path.join(dir, 'ispyx.log');
|
|
62
|
+
try { if (fs.statSync(file).size > MAX_LOG) fs.renameSync(file, file + '.1'); } catch { /* none yet */ }
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
const write = (level, parts) => {
|
|
65
|
+
const msg = parts.map((p) => (p instanceof Error ? p.stack : typeof p === 'string' ? p : JSON.stringify(p))).join(' ');
|
|
66
|
+
try { fs.appendFileSync(file, `${new Date().toISOString()} ${level} ${msg}\n`); } catch { /* nowhere to report */ }
|
|
67
|
+
};
|
|
68
|
+
for (const [fn, level] of [['log', 'info'], ['info', 'info'], ['warn', 'warn'], ['error', 'error']]) console[fn] = (...a) => write(level, a);
|
|
69
|
+
process.stdout.write = (s) => { write('out', [String(s).trimEnd()]); return true; };
|
|
70
|
+
process.stderr.write = (s) => { write('err', [String(s).trimEnd()]); return true; };
|
|
71
|
+
process.on('uncaughtException', (e) => write('fatal', [e]));
|
|
72
|
+
process.on('unhandledRejection', (e) => write('rejection', [e]));
|
|
73
|
+
write('info', [`resident start pid=${process.pid} v${VERSION}`]);
|
|
74
|
+
return { file, write };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Windows: how to connect an AI app (shown in the panel). Claude Desktop reads %APPDATA%\Claude\claude_desktop_config.json. */
|
|
78
|
+
function connectInfo() {
|
|
79
|
+
const appData = process.env.APPDATA || path.join(process.env.USERPROFILE || '', 'AppData', 'Roaming');
|
|
80
|
+
return {
|
|
81
|
+
claudeCodeCommand: 'claude mcp add ispyx --scope user -- ispyx mcp',
|
|
82
|
+
desktopConfigJson: JSON.stringify({ mcpServers: { ispyx: { command: 'ispyx', args: ['mcp'] } } }, null, 2),
|
|
83
|
+
desktopConfigPath: path.join(appData, 'Claude', 'claude_desktop_config.json'),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Explorer with the file selected, or its folder when the file is not there yet. */
|
|
88
|
+
function revealFile(file) {
|
|
89
|
+
const exists = (f) => { try { return fs.existsSync(f); } catch { return false; } };
|
|
90
|
+
const args = exists(file) ? [`/select,${file}`] : exists(path.dirname(file)) ? [path.dirname(file)] : null;
|
|
91
|
+
if (!args) return false;
|
|
92
|
+
const c = spawn('explorer.exe', args, { stdio: 'ignore', detached: true });
|
|
93
|
+
c.on('error', () => {});
|
|
94
|
+
c.unref();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const PANEL_IDLE_MS = 120000; // the open page polls every second (a hidden window is throttled to ~1/min); longer than that: gone
|
|
99
|
+
|
|
100
|
+
/** Shows the panel as an app-style window (no tabs or address bar) when Edge is there, else in the default browser. */
|
|
101
|
+
function openUrl(url) {
|
|
102
|
+
const edge = [process.env['ProgramFiles(x86)'], process.env.ProgramFiles].filter(Boolean)
|
|
103
|
+
.map((d) => `${d}\\Microsoft\\Edge\\Application\\msedge.exe`).find((p) => fs.existsSync(p));
|
|
104
|
+
const c = edge
|
|
105
|
+
? spawn(edge, [`--app=${url}`], { windowsHide: false, stdio: 'ignore', detached: true })
|
|
106
|
+
: spawn('cmd.exe', ['/c', 'start', '', url], { windowsHide: true, stdio: 'ignore', detached: true });
|
|
107
|
+
c.on('error', () => {});
|
|
108
|
+
c.unref();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const safe = (fn, dflt) => { try { return fn(); } catch { return dflt; } };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The part of the panel that lives in the server, the same on every platform: activity, effects, password and
|
|
115
|
+
* denylist. ctx: { w (platform), config, owner, guard, auth, scan, clients() }. Windows wraps it in startResident
|
|
116
|
+
* (tray, autostart, connect info); on macOS Ispyx.app shows the panel and asks the owner for this part over its
|
|
117
|
+
* socket (platform/darwin servePanel), adding permissions, autostart and connect info itself.
|
|
118
|
+
*/
|
|
119
|
+
export function panelModel(ctx) {
|
|
120
|
+
const { w, config, guard, auth, scan, clients } = ctx;
|
|
121
|
+
const t0 = Date.now();
|
|
122
|
+
const save = (patch) => {
|
|
123
|
+
try { saveUserConfig(patch); } catch (e) { throw new PanelError(`Could not save the setting: ${e.message}`); }
|
|
124
|
+
};
|
|
125
|
+
const getState = () => ({
|
|
126
|
+
version: VERSION,
|
|
127
|
+
platform: w.name,
|
|
128
|
+
pid: process.pid,
|
|
129
|
+
uptimeMs: Date.now() - t0,
|
|
130
|
+
clients: clients(),
|
|
131
|
+
recent: recent.slice(),
|
|
132
|
+
recentTotal: totalCalls,
|
|
133
|
+
stopLabel: guard.stopLabel,
|
|
134
|
+
effectsEnabled: !!scan.enabled,
|
|
135
|
+
passwordSet: safe(() => auth.passwordSet(), false),
|
|
136
|
+
authRequired: !!config.auth,
|
|
137
|
+
denylist: config.denylist.slice(),
|
|
138
|
+
});
|
|
139
|
+
const actions = {
|
|
140
|
+
setEffects: (on) => { scan.enabled = !!on; config.animations = !!on; save({ animations: !!on }); return !!on; },
|
|
141
|
+
setPassword: ({ current, next } = {}) => { auth.setPassword(next, current); },
|
|
142
|
+
// Turning the requirement on needs a password to exist; turning it off needs the current one.
|
|
143
|
+
setAuthRequired: ({ on, current } = {}) => {
|
|
144
|
+
if (on) {
|
|
145
|
+
if (!auth.passwordSet()) throw new PanelError('Set a password first.');
|
|
146
|
+
} else if (auth.passwordSet() && !auth.verify(current)) throw new PanelError('The password is wrong.');
|
|
147
|
+
auth.enabled = config.auth = !!on;
|
|
148
|
+
save({ auth: !!on });
|
|
149
|
+
},
|
|
150
|
+
addDeny: (value) => {
|
|
151
|
+
const name = String(value ?? '').trim().toLowerCase();
|
|
152
|
+
if (!/^[\w][\w .()+-]{0,79}$/.test(name)) throw new PanelError(w.name === 'darwin' ? 'Use an app name such as KeePassXC.' : 'Use a process name such as keepass.exe.');
|
|
153
|
+
if (config.denylist.includes(name)) throw new PanelError('Already on the list.');
|
|
154
|
+
config.denylist.push(name);
|
|
155
|
+
save({ denylist: config.denylist.slice() });
|
|
156
|
+
},
|
|
157
|
+
// Taking a program off the list lowers protection, so it asks for the password when one is set.
|
|
158
|
+
removeDeny: ({ name, current } = {}) => {
|
|
159
|
+
if (auth.passwordSet() && !auth.verify(current)) throw new PanelError('The password is wrong.');
|
|
160
|
+
const i = config.denylist.indexOf(String(name ?? '').toLowerCase());
|
|
161
|
+
if (i < 0) return;
|
|
162
|
+
config.denylist.splice(i, 1);
|
|
163
|
+
save({ denylist: config.denylist.slice() });
|
|
164
|
+
},
|
|
165
|
+
clearRecent: () => { recent.length = 0; },
|
|
166
|
+
};
|
|
167
|
+
return { getState, actions };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Starts tray, panel and the idle watchdog. ctx: { w (platform), config, owner, guard, auth, scan, shutdown(), log }.
|
|
172
|
+
* Returns { panelUrl(), stop() }.
|
|
173
|
+
*/
|
|
174
|
+
export async function startResident(ctx) {
|
|
175
|
+
const { w, owner, shutdown, log } = ctx;
|
|
176
|
+
let lastActive = Date.now();
|
|
177
|
+
const autostart = w.autostart ?? (w.name === 'win32' ? (await import('../platform/win32/autostart.js').catch(() => null))?.autostart ?? null : null);
|
|
178
|
+
|
|
179
|
+
const model = panelModel({ ...ctx, clients: () => owner.watchers() });
|
|
180
|
+
const getState = () => ({
|
|
181
|
+
...model.getState(),
|
|
182
|
+
autostart: safe(() => !!autostart?.get(), false),
|
|
183
|
+
...(w.name === 'win32' ? { connect: connectInfo() } : {}),
|
|
184
|
+
});
|
|
185
|
+
const actions = {
|
|
186
|
+
...model.actions,
|
|
187
|
+
quit: () => shutdown(),
|
|
188
|
+
setAutostart: (on) => { autostart?.set(!!on); return safe(() => !!autostart?.get(), false); },
|
|
189
|
+
showConfigFile: () => revealFile(connectInfo().desktopConfigPath),
|
|
190
|
+
openLog: () => { if (log?.file) { const c = spawn('notepad.exe', [log.file], { stdio: 'ignore', detached: true }); c.on('error', () => {}); c.unref(); } },
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// The panel (a loopback web page) exists only while its window is open: started on "Open", closed when the page
|
|
194
|
+
// stops polling (window closed), so the resident normally holds no listening port.
|
|
195
|
+
let panel = null, starting = null, lastPoll = 0, panelTimer = null;
|
|
196
|
+
const closePanel = () => {
|
|
197
|
+
if (panelTimer) { clearInterval(panelTimer); panelTimer = null; }
|
|
198
|
+
try { panel?.close(); } catch { /* ignore */ }
|
|
199
|
+
panel = null;
|
|
200
|
+
};
|
|
201
|
+
// Quitting must not leave an orphaned window behind: ask the app-style Edge window (class Chrome_WidgetWin_1,
|
|
202
|
+
// titled "Ispyx") to close. The page also tries window.close() itself when it loses the connection.
|
|
203
|
+
const panelWindows = () => {
|
|
204
|
+
if (w.name !== 'win32') return [];
|
|
205
|
+
try { return w.listWindows().filter((x) => x.title === 'Ispyx' && /^(msedge|chrome|brave)\.exe$/i.test(x.process)); } catch { return []; }
|
|
206
|
+
};
|
|
207
|
+
const closePanelWindow = () => {
|
|
208
|
+
for (const x of panelWindows()) w._raw?.PostMessageW?.(x.hwnd, 0x10 /* WM_CLOSE */, 0, 0);
|
|
209
|
+
};
|
|
210
|
+
const openPanel = async () => {
|
|
211
|
+
try {
|
|
212
|
+
if (!panel) {
|
|
213
|
+
starting ??= (async () => {
|
|
214
|
+
const mod = await import('./panel/index.js').catch(() => null);
|
|
215
|
+
lastPoll = Date.now();
|
|
216
|
+
panel = (await mod?.startPanel?.({ getState: () => { lastPoll = Date.now(); return getState(); }, actions })) ?? null;
|
|
217
|
+
if (panel) {
|
|
218
|
+
panelTimer = setInterval(() => { if (Date.now() - lastPoll > PANEL_IDLE_MS) closePanel(); }, 5000);
|
|
219
|
+
panelTimer.unref?.();
|
|
220
|
+
}
|
|
221
|
+
})().finally(() => { starting = null; });
|
|
222
|
+
await starting;
|
|
223
|
+
}
|
|
224
|
+
lastPoll = Date.now();
|
|
225
|
+
// already open: bring that window to the front instead of opening another one
|
|
226
|
+
const open = panelWindows();
|
|
227
|
+
if (open.length && w.bringToForeground) { await w.bringToForeground(open[0].hwnd); return; }
|
|
228
|
+
if (panel?.url) openUrl(panel.url);
|
|
229
|
+
} catch (e) { log?.write('error', ['panel failed:', e]); }
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// macOS: Ispyx.app shows the panel and asks for this state (it adds permissions, start at login, connect info)
|
|
233
|
+
const stopServing = w.servePanel?.({ getState, actions });
|
|
234
|
+
|
|
235
|
+
let tray = null;
|
|
236
|
+
try {
|
|
237
|
+
const Tray = w.Tray ?? (w.name === 'win32' ? (await import('../platform/win32/tray.js').catch(() => null))?.Tray : null);
|
|
238
|
+
if (Tray) {
|
|
239
|
+
tray = new Tray({
|
|
240
|
+
tooltip: 'ispyx',
|
|
241
|
+
onOpen: openPanel,
|
|
242
|
+
onQuit: () => shutdown(),
|
|
243
|
+
getStatus: () => { const s = getState(); return { text: `ispyx - ${s.clients} client${s.clients === 1 ? '' : 's'}`, clients: s.clients, uptimeMs: s.uptimeMs, stopLabel: s.stopLabel, effectsEnabled: s.effectsEnabled }; },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
} catch (e) { log?.write('error', ['tray failed:', e]); }
|
|
247
|
+
|
|
248
|
+
// test/cleanup aid: with ISPYX_IDLE_EXIT_MS the resident leaves once no `ispyx mcp` is connected for that long
|
|
249
|
+
const idleMs = Number(process.env.ISPYX_IDLE_EXIT_MS) || 0;
|
|
250
|
+
let timer = null;
|
|
251
|
+
if (idleMs > 0) {
|
|
252
|
+
timer = setInterval(() => {
|
|
253
|
+
if (owner.watchers() > 0 || owner.clients() > 0) lastActive = Date.now();
|
|
254
|
+
else if (Date.now() - lastActive > idleMs) shutdown();
|
|
255
|
+
}, 500);
|
|
256
|
+
timer.unref?.();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
panelUrl: () => panel?.url ?? '',
|
|
261
|
+
openPanel,
|
|
262
|
+
touch: () => { lastActive = Date.now(); },
|
|
263
|
+
stop: () => {
|
|
264
|
+
if (timer) clearInterval(timer);
|
|
265
|
+
stopServing?.();
|
|
266
|
+
try { tray?.destroy(); } catch { /* ignore */ }
|
|
267
|
+
closePanel();
|
|
268
|
+
closePanelWindow();
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|