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,57 @@
|
|
|
1
|
+
// Human-like pointer paths: a curved cubic Bezier with a smooth (minimum-jerk) speed profile.
|
|
2
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
3
|
+
const between = (rand, lo, hi) => lo + (hi - lo) * rand();
|
|
4
|
+
|
|
5
|
+
/** Slow start, fast middle, slow arrival. t in 0..1 -> progress in 0..1. */
|
|
6
|
+
export const ease = (t) => t * t * t * (10 + t * (-15 + 6 * t));
|
|
7
|
+
|
|
8
|
+
/** Returns point(t) for a gently curved path from `from` to `to`. The bend side and size are random. */
|
|
9
|
+
export function curvedPath(from, to, rand = Math.random) {
|
|
10
|
+
const dx = to.x - from.x;
|
|
11
|
+
const dy = to.y - from.y;
|
|
12
|
+
const dist = Math.hypot(dx, dy);
|
|
13
|
+
if (dist < 1) return () => ({ x: to.x, y: to.y });
|
|
14
|
+
const nx = -dy / dist; // unit normal
|
|
15
|
+
const ny = dx / dist;
|
|
16
|
+
const side = rand() < 0.5 ? -1 : 1;
|
|
17
|
+
const bend1 = side * dist * between(rand, 0.08, 0.22);
|
|
18
|
+
const bend2 = side * dist * between(rand, 0.0, 0.14);
|
|
19
|
+
const c1 = { x: from.x + dx * 0.3 + nx * bend1, y: from.y + dy * 0.3 + ny * bend1 };
|
|
20
|
+
const c2 = { x: from.x + dx * 0.72 + nx * bend2, y: from.y + dy * 0.72 + ny * bend2 };
|
|
21
|
+
return (t) => {
|
|
22
|
+
const u = 1 - t;
|
|
23
|
+
const a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t;
|
|
24
|
+
return {
|
|
25
|
+
x: a * from.x + b * c1.x + c * c2.x + d * to.x,
|
|
26
|
+
y: a * from.y + b * c1.y + c * c2.y + d * to.y,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Random travel time in milliseconds, 300..1000 by default. */
|
|
32
|
+
export const randomDuration = (min = 300, max = 1000, rand = Math.random) => Math.round(between(rand, min, max));
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-character pauses (ms) for human-paced typing: random between min and max, a little longer after
|
|
36
|
+
* spaces and punctuation, now and then a short "thinking" pause. If the total would exceed capMs the
|
|
37
|
+
* pauses are scaled down so long texts still finish (use the clipboard for really long text).
|
|
38
|
+
*/
|
|
39
|
+
export const MIN_TYPE_WAIT_MS = 8; // apps drop or repeat characters when they arrive faster than this
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pause (ms) after each character. `speed` divides every wait (2 = twice as fast, 0.5 = twice as slow);
|
|
43
|
+
* 0 = a fixed minimum wait after every character. The cap applies after speed; no wait is ever under 8 ms.
|
|
44
|
+
*/
|
|
45
|
+
export function typingDelays(text, { min = 60, max = 200, capMs = 60000, speed = 1 } = {}, rand = Math.random) {
|
|
46
|
+
const chars = [...text];
|
|
47
|
+
if (!(speed > 0)) return chars.map(() => MIN_TYPE_WAIT_MS);
|
|
48
|
+
const d = chars.map((ch, i) => {
|
|
49
|
+
let ms = between(rand, min, max);
|
|
50
|
+
if (/[\s.,;:!?]/.test(ch)) ms *= between(rand, 1.2, 1.8);
|
|
51
|
+
if (i > 0 && rand() < 0.03) ms += between(rand, 250, 600);
|
|
52
|
+
return ms / speed;
|
|
53
|
+
});
|
|
54
|
+
const total = d.reduce((a, b) => a + b, 0);
|
|
55
|
+
const k = total > capMs ? capMs / total : 1;
|
|
56
|
+
return d.map((ms) => Math.max(MIN_TYPE_WAIT_MS, Math.round(ms * k)));
|
|
57
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Panel
|
|
2
|
+
|
|
3
|
+
The Ispyx window: one page, `page.html`, used on both platforms.
|
|
4
|
+
|
|
5
|
+
- Windows: served on loopback by `index.js` and shown in an Edge `--app` window.
|
|
6
|
+
- macOS: loaded in a WKWebView by the app, no server.
|
|
7
|
+
|
|
8
|
+
The page is a single self-contained file (inline CSS and JS, no CDN, no external hosts, only relative URLs). It follows the macOS status window: icon, headline, light/dark, green accent, about 480px wide.
|
|
9
|
+
|
|
10
|
+
## Host contract
|
|
11
|
+
|
|
12
|
+
The page talks to its host only through:
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
window.ispyx = {
|
|
16
|
+
getState(): Promise<State>,
|
|
17
|
+
action(name: string, value?: any): Promise<any>,
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
If `window.ispyx` is undefined when the page script runs, the page installs a fetch-based one: `getState` is `GET api/state`, `action` is `POST api/action` with `{name, value}` (relative URLs). macOS injects its own `window.ispyx` before the page script runs. The page polls `getState()` every second (the Windows server closes itself when polling stops).
|
|
22
|
+
|
|
23
|
+
Icon: `<img src="icon.png">` and `<link rel="icon" href="icon.png">`, relative. The Windows server serves `/icon.png`; the macOS host must resolve `icon.png` next to the page.
|
|
24
|
+
|
|
25
|
+
### State
|
|
26
|
+
|
|
27
|
+
Only `version` and `platform` are required. Every other key is optional and the page hides the section whose data is absent.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
{ version, platform: 'win32' | 'darwin', pid, uptimeMs, clients,
|
|
31
|
+
recent: [{ time, tool, summary }], // time = epoch ms
|
|
32
|
+
stopLabel, // e.g. "Esc x3" or "⌥ Option + Esc"
|
|
33
|
+
effectsEnabled, passwordSet, denylist: [],
|
|
34
|
+
autostart: boolean, autostartLabel?, // label defaults by platform
|
|
35
|
+
permissions: [{ id: 'accessibility' | 'screen-recording', title, detail, granted }],
|
|
36
|
+
connect: { claudeCodeCommand, desktopConfigJson, desktopConfigPath } }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Sections, top to bottom: header (icon, headline, status, version/uptime/pid), Permissions (`permissions`), Connect to your AI app (`connect`; "Show Config File" only with `desktopConfigPath`), usage note (uses `stopLabel`), General (`autostart`, `effectsEnabled`), Activity (`clients`, `recent`), Safety (`passwordSet`, `denylist`), footer button: Quit on `win32`, Close on `darwin`. The headline reads "Ispyx needs your permission" while any permission is not granted.
|
|
40
|
+
|
|
41
|
+
### Actions
|
|
42
|
+
|
|
43
|
+
| name | value | notes |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `quit` | none | Windows footer button (after a confirm) |
|
|
46
|
+
| `close` | none | macOS footer button; closes the window. The Windows server accepts and ignores it |
|
|
47
|
+
| `setAutostart` | boolean | |
|
|
48
|
+
| `setEffects` | boolean | |
|
|
49
|
+
| `openLog` | none | Windows footer link |
|
|
50
|
+
| `requestPermission` | permission id | "Allow" |
|
|
51
|
+
| `openPermissionSettings` | permission id | "Open Settings" |
|
|
52
|
+
| `showConfigFile` | none | reveal `desktopConfigPath` (or its folder) |
|
|
53
|
+
| `copy` | text | clipboard fallback; the page tries `navigator.clipboard` first |
|
|
54
|
+
|
|
55
|
+
## Windows server (`index.js`)
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import { startPanel } from './panel/index.js';
|
|
59
|
+
const panel = await startPanel({ getState, actions }); // { url, token, port, close() }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- `actions` is the host's object. The whitelist above is explicit; a name is callable only if `actions` defines it (except `close`, which is a no-op). `copy`, `requestPermission`, `openPermissionSettings` need a string value (permission ids are checked against `accessibility` / `screen-recording`).
|
|
63
|
+
- `resident.js` `getState()` adds `connect` on Windows (`claude mcp add ispyx --scope user -- ispyx mcp`, the `mcpServers` JSON, `%APPDATA%\Claude\claude_desktop_config.json`); its `showConfigFile` opens Explorer on the file or its folder.
|
|
64
|
+
- Any `password`/`passwordHash` field in state is stripped.
|
|
65
|
+
|
|
66
|
+
Security: bound to 127.0.0.1 on a random port; random 128-bit token. `panel.url` carries `?t=<token>` once; the server answers with an HttpOnly, SameSite=Strict cookie and redirects to `/`. Every request needs that cookie and a Host of `127.0.0.1:<port>` or `localhost:<port>` (DNS-rebinding guard). POSTs additionally need a same-origin `Origin` and `application/json`. No CORS headers, `Cache-Control: no-store`, CSP `default-src 'self'`. Only `page.html` and the icon are served, so no path traversal.
|
|
67
|
+
|
|
68
|
+
Endpoints: `GET /`, `GET /icon.png`, `GET /api/state`, `POST /api/action {name, value}`.
|
|
69
|
+
|
|
70
|
+
Test: `npm run panel-test`.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Local "Open" panel: a tiny loopback-only HTTP server serving one page that shows
|
|
2
|
+
// resident status and flips a few settings. Zero deps (node:http).
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const PAGE = fileURLToPath(new URL('./page.html', import.meta.url));
|
|
9
|
+
const COOKIE = 'ispyx_panel';
|
|
10
|
+
// Explicit whitelist; an action is also callable only if the host's `actions` object defines it.
|
|
11
|
+
const ACTIONS = ['quit', 'setAutostart', 'setEffects', 'openLog', 'copy', 'close', 'requestPermission', 'openPermissionSettings', 'showConfigFile', 'clearRecent', 'addDeny', 'removeDeny', 'setPassword', 'setAuthRequired'];
|
|
12
|
+
const BOOL_ACTIONS = new Set(['setAutostart', 'setEffects']);
|
|
13
|
+
const STRING_ACTIONS = new Set(['copy', 'requestPermission', 'openPermissionSettings', 'addDeny']); // value must be a short string
|
|
14
|
+
// value is an object of short strings (and a boolean for setAuthRequired.on)
|
|
15
|
+
const OBJECT_ACTIONS = { setPassword: ['current', 'next'], setAuthRequired: ['current'], removeDeny: ['name', 'current'] };
|
|
16
|
+
const objectOk = (name, v) => {
|
|
17
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
|
|
18
|
+
if (name === 'setAuthRequired' && typeof v.on !== 'boolean') return false;
|
|
19
|
+
return OBJECT_ACTIONS[name].every((k) => v[k] === undefined || (typeof v[k] === 'string' && v[k].length <= 200));
|
|
20
|
+
};
|
|
21
|
+
const PERMISSION_IDS = new Set(['accessibility', 'screen-recording']);
|
|
22
|
+
|
|
23
|
+
function safeEq(a, b) {
|
|
24
|
+
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
|
|
25
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseCookies(h) {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const part of String(h || '').split(';')) {
|
|
31
|
+
const i = part.indexOf('=');
|
|
32
|
+
if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function startPanel({ getState, actions, port = 0 }) {
|
|
38
|
+
const token = crypto.randomBytes(16).toString('hex'); // 128 bit
|
|
39
|
+
let boundPort = 0;
|
|
40
|
+
const hostOk = (h) => h === `127.0.0.1:${boundPort}` || h === `localhost:${boundPort}`;
|
|
41
|
+
const authed = (req, url) => {
|
|
42
|
+
if (safeEq(parseCookies(req.headers.cookie)[COOKIE] || '', token)) return true;
|
|
43
|
+
return false;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const send = (res, code, body, type = 'application/json; charset=utf-8', extra = {}) => {
|
|
47
|
+
res.writeHead(code, {
|
|
48
|
+
'Content-Type': type,
|
|
49
|
+
'Cache-Control': 'no-store',
|
|
50
|
+
'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'",
|
|
51
|
+
'X-Content-Type-Options': 'nosniff',
|
|
52
|
+
'Referrer-Policy': 'no-referrer',
|
|
53
|
+
...extra,
|
|
54
|
+
});
|
|
55
|
+
res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
|
|
56
|
+
};
|
|
57
|
+
const deny = (res, code = 403, msg = 'forbidden') => send(res, code, { error: msg });
|
|
58
|
+
|
|
59
|
+
const readJson = (req) => new Promise((resolve, reject) => {
|
|
60
|
+
let size = 0; const chunks = [];
|
|
61
|
+
req.on('data', (c) => { size += c.length; if (size > 4096) { reject(new Error('too large')); req.destroy(); } else chunks.push(c); });
|
|
62
|
+
req.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null')); } catch (e) { reject(e); } });
|
|
63
|
+
req.on('error', reject);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
let iconPng = null;
|
|
67
|
+
const server = http.createServer(async (req, res) => {
|
|
68
|
+
try {
|
|
69
|
+
if (!hostOk(req.headers.host)) return deny(res);
|
|
70
|
+
const url = new URL(req.url, `http://127.0.0.1:${boundPort}`);
|
|
71
|
+
|
|
72
|
+
// First load: ?t=<token> sets the cookie, then redirects to a clean URL.
|
|
73
|
+
if (req.method === 'GET' && url.pathname === '/' && url.searchParams.has('t')) {
|
|
74
|
+
if (!safeEq(url.searchParams.get('t'), token)) return deny(res);
|
|
75
|
+
return send(res, 302, '', 'text/plain', {
|
|
76
|
+
Location: '/',
|
|
77
|
+
'Set-Cookie': `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (!authed(req, url)) return deny(res);
|
|
81
|
+
|
|
82
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
83
|
+
return send(res, 200, await readFile(PAGE), 'text/html; charset=utf-8');
|
|
84
|
+
}
|
|
85
|
+
if (req.method === 'GET' && url.pathname === '/icon.png') { // the app icon, same as the tray and the macOS app
|
|
86
|
+
iconPng ??= (async () => {
|
|
87
|
+
const [{ renderIcon }, { encodePng }] = await Promise.all([import('../../effects/appicon.js'), import('../image.js')]);
|
|
88
|
+
return encodePng(renderIcon(128, { tight: true }), 128, 128);
|
|
89
|
+
})();
|
|
90
|
+
return send(res, 200, await iconPng, 'image/png');
|
|
91
|
+
}
|
|
92
|
+
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
93
|
+
const s = { ...(await getState()) };
|
|
94
|
+
delete s.password; delete s.passwordHash; // never expose
|
|
95
|
+
s.passwordSet = !!s.passwordSet;
|
|
96
|
+
return send(res, 200, s);
|
|
97
|
+
}
|
|
98
|
+
if (req.method === 'POST' && url.pathname === '/api/action') {
|
|
99
|
+
if (req.headers.origin !== `http://${req.headers.host}`) return deny(res);
|
|
100
|
+
if (!/^application\/json\b/i.test(req.headers['content-type'] || '')) return deny(res, 415, 'json only');
|
|
101
|
+
let body;
|
|
102
|
+
try { body = await readJson(req); } catch { return deny(res, 400, 'bad json'); }
|
|
103
|
+
const name = body && body.name;
|
|
104
|
+
if (typeof name !== 'string' || !ACTIONS.includes(name) || (name !== 'close' && typeof actions?.[name] !== 'function')) {
|
|
105
|
+
return deny(res, 400, 'unknown action');
|
|
106
|
+
}
|
|
107
|
+
if (BOOL_ACTIONS.has(name) && typeof body.value !== 'boolean') return deny(res, 400, 'boolean value required');
|
|
108
|
+
if (STRING_ACTIONS.has(name) && (typeof body.value !== 'string' || body.value.length > 3000)) return deny(res, 400, 'string value required');
|
|
109
|
+
if (OBJECT_ACTIONS[name] && !objectOk(name, body.value)) return deny(res, 400, 'bad value');
|
|
110
|
+
if ((name === 'requestPermission' || name === 'openPermissionSettings') && !PERMISSION_IDS.has(body.value)) return deny(res, 400, 'unknown permission');
|
|
111
|
+
if (name === 'close') return send(res, 200, { ok: true }); // macOS-only; nothing to close here
|
|
112
|
+
// Reply first for quit, since it may tear this server down.
|
|
113
|
+
if (name === 'quit') {
|
|
114
|
+
send(res, 200, { ok: true });
|
|
115
|
+
setTimeout(() => { Promise.resolve().then(() => actions.quit()).catch(() => {}); }, 50);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await actions[name](BOOL_ACTIONS.has(name) || STRING_ACTIONS.has(name) || OBJECT_ACTIONS[name] ? body.value : undefined);
|
|
119
|
+
return send(res, 200, { ok: true });
|
|
120
|
+
}
|
|
121
|
+
return deny(res, 404, 'not found');
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if (!res.headersSent && e && e.expose) send(res, 400, { error: String(e.message).slice(0, 200) }); // a refusal meant for the person at the panel
|
|
124
|
+
else if (!res.headersSent) send(res, 500, { error: 'internal error' });
|
|
125
|
+
else res.end();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await new Promise((resolve, reject) => {
|
|
130
|
+
server.once('error', reject);
|
|
131
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
132
|
+
});
|
|
133
|
+
boundPort = server.address().port;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
url: `http://127.0.0.1:${boundPort}/?t=${token}`,
|
|
137
|
+
token,
|
|
138
|
+
port: boundPort,
|
|
139
|
+
close: () => new Promise((resolve) => {
|
|
140
|
+
server.close(() => resolve());
|
|
141
|
+
server.closeAllConnections?.();
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}
|