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.
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 +47 -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,41 @@
1
+ // Native password window for the auth gate (Windows: PowerShell + WinForms).
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
7
+ const DIALOG = path.join(HERE, 'auth-dialog.ps1');
8
+
9
+ /**
10
+ * Real password window. Resolves to the dialog's JSON, or
11
+ * { cancel: true, reason } where reason says why: 'timed out', 'closed by the user', or 'dialog failed: ...'.
12
+ */
13
+ export function showDialog(opts, ms) {
14
+ return new Promise((resolve) => {
15
+ const args = ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-File', DIALOG,
16
+ '-Mode', opts.mode,
17
+ '-Message', ` ${opts.message || ''}`, // leading space: a bare "-" would be read as a parameter name
18
+ '-Seconds', String(Math.max(1, Math.ceil(ms / 1000))),
19
+ '-Value', String(opts.value), '-Unit', opts.unit];
20
+ const p = spawn('powershell.exe', args, { windowsHide: false, stdio: ['ignore', 'pipe', 'pipe'] });
21
+ let out = '';
22
+ let err = '';
23
+ let killed = false;
24
+ p.stdout.on('data', (d) => { out += d; });
25
+ p.stderr.on('data', (d) => { err += d; });
26
+ const kill = setTimeout(() => { killed = true; try { p.kill(); } catch { /* gone */ } }, ms + 1500);
27
+ const finish = (code) => {
28
+ clearTimeout(kill);
29
+ try {
30
+ const r = JSON.parse(out.trim().split('\n').pop());
31
+ if (r.cancel) r.reason = r.reason === 'timeout' ? 'timed out' : 'closed by the user';
32
+ resolve(r);
33
+ } catch {
34
+ const why = killed ? 'timed out' : `dialog failed (exit ${code}): ${(err.trim().split('\n')[0] || 'no output').slice(0, 120)}`;
35
+ resolve({ cancel: true, reason: why });
36
+ }
37
+ };
38
+ p.on('close', finish);
39
+ p.on('error', (e) => { clearTimeout(kill); resolve({ cancel: true, reason: `dialog failed to start: ${e.message}` }); });
40
+ });
41
+ }
@@ -0,0 +1,77 @@
1
+ // Emergency stop hotkey: Alt+Esc pressed by the person (not by this server).
2
+ // Primary path is a low-level keyboard hook, which can tell real keystrokes from injected ones and swallows
3
+ // the Esc, so Windows does not also cycle windows. A polling fallback in the guard covers moments when the
4
+ // hook is not serviced (long synchronous work).
5
+ import koffi from 'koffi';
6
+ import * as w from './win32.js';
7
+ import { ensurePump } from './pump.js';
8
+
9
+ const user32 = koffi.load('user32.dll');
10
+ const HookProc = koffi.proto('intptr_t StopHookProc(int code, uintptr_t wp, intptr_t lp)');
11
+ const SetWindowsHookExW = user32.func('intptr_t SetWindowsHookExW(int id, StopHookProc *proc, intptr_t mod, uint32 tid)');
12
+ const UnhookWindowsHookEx = user32.func('bool UnhookWindowsHookEx(intptr_t h)');
13
+ const CallNextHookEx = user32.func('intptr_t CallNextHookEx(intptr_t h, int code, uintptr_t wp, intptr_t lp)');
14
+ const GetAsyncKeyState = user32.func('int16_t GetAsyncKeyState(int vk)');
15
+
16
+ const VK_ESCAPE = 0x1b;
17
+ const LLKHF_INJECTED = 0x10;
18
+ const LLKHF_ALTDOWN = 0x20;
19
+
20
+ /** True while every key of the chord is down right now (used as a fallback; injected keys also count). */
21
+ export const chordDown = (vks) => vks.every((vk) => (GetAsyncKeyState(vk) & 0x8000) !== 0);
22
+
23
+ export const HOOK_LINGER_MS = 30000;
24
+
25
+ /**
26
+ * Alt+Esc hook that exists only while the server is working: touch() installs it (if absent) and pushes the
27
+ * removal back HOOK_LINGER_MS, so idle time and the user's own typing never go through this process.
28
+ */
29
+ export function installStopHotkey(onStop, lingerMs = HOOK_LINGER_MS) {
30
+ let hook = 0;
31
+ let swallowUp = false;
32
+ let timer = null;
33
+ const proc = koffi.register((code, wp, lp) => {
34
+ try {
35
+ if (code === 0) {
36
+ const buf = Buffer.alloc(24);
37
+ w._raw.CopyFromMem(buf, lp, 24);
38
+ const vk = buf.readUInt32LE(0);
39
+ const flags = buf.readUInt32LE(8);
40
+ if (vk === VK_ESCAPE && !(flags & LLKHF_INJECTED)) {
41
+ const down = wp === 0x100 || wp === 0x104;
42
+ if (down && flags & LLKHF_ALTDOWN) {
43
+ if (!swallowUp) { // key auto-repeat while held counts as one press
44
+ swallowUp = true;
45
+ setImmediate(() => onStop('Alt+Esc'));
46
+ }
47
+ return 1; // swallowed: no window cycling
48
+ }
49
+ if (!down && swallowUp) {
50
+ swallowUp = false;
51
+ return 1;
52
+ }
53
+ }
54
+ }
55
+ } catch { /* never let a hook error break the keyboard */ }
56
+ return CallNextHookEx(hook, code, wp, lp);
57
+ }, koffi.pointer(HookProc));
58
+ const remove = () => {
59
+ timer = null;
60
+ if (hook) UnhookWindowsHookEx(hook);
61
+ hook = 0;
62
+ };
63
+ return {
64
+ touch() {
65
+ ensurePump(lingerMs + 2000); // the hook must be serviced quickly or the user's own typing lags
66
+ if (!hook) {
67
+ hook = SetWindowsHookExW(13, proc, 0, 0); // WH_KEYBOARD_LL
68
+ if (!hook) process.stderr.write('ispyx: stop hotkey hook not installed; using polling only\n');
69
+ }
70
+ if (timer) clearTimeout(timer);
71
+ timer = setTimeout(remove, lingerMs);
72
+ timer.unref();
73
+ },
74
+ active: () => !!hook,
75
+ stop: remove,
76
+ };
77
+ }
@@ -0,0 +1,31 @@
1
+ // Windows platform layer: the API every platform exports (see ../CONTRACT.md).
2
+ import path from 'node:path';
3
+
4
+ export * from './win32.js';
5
+ export { chordDown, installStopHotkey, HOOK_LINGER_MS } from './hotkey.js';
6
+ export { Overlay } from './overlay.js';
7
+ export { parseChord, parseSequence, insertsContent } from './keys.js';
8
+ export { showDialog } from './dialog.js';
9
+ export { ensurePump, pumpOnce } from './pump.js';
10
+
11
+ export const name = 'win32';
12
+ export const capabilities = { elevation: true };
13
+ export const stopLabel = 'Alt+Esc';
14
+
15
+ /** Where the password hash and other per-user state live. */
16
+ export const dataDir = () => {
17
+ const roaming = process.env.APPDATA || path.join(process.env.USERPROFILE || '.', 'AppData', 'Roaming');
18
+ const fs = process.getBuiltinModule('node:fs');
19
+ const dir = path.join(roaming, 'ispyx');
20
+ const old = path.join(roaming, 'win-input-mcp');
21
+ try { // one-time migration from the old name: copy auth.json, leave the old folder alone
22
+ if (!fs.existsSync(dir) && fs.existsSync(path.join(old, 'auth.json'))) {
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ fs.copyFileSync(path.join(old, 'auth.json'), path.join(dir, 'auth.json'));
25
+ }
26
+ } catch { /* best effort: a fresh folder just asks for a new password */ }
27
+ return dir;
28
+ };
29
+ export { Layer, textMask } from './layer.js';
30
+ export { Tray } from './tray.js';
31
+ export { autostart } from './autostart.js';
@@ -0,0 +1,31 @@
1
+ // "ctrl+shift+t" style chords -> Windows virtual-key codes.
2
+ const NAMED = {
3
+ win: 0x5b, windows: 0x5b, meta: 0x5b, cmd: 0x5b, super: 0x5b, lwin: 0x5b, rwin: 0x5c,
4
+ ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, menu: 0x12,
5
+ enter: 0x0d, return: 0x0d, esc: 0x1b, escape: 0x1b, tab: 0x09, space: 0x20,
6
+ backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
7
+ home: 0x24, end: 0x23, pageup: 0x21, pgup: 0x21, pagedown: 0x22, pgdn: 0x22,
8
+ left: 0x25, up: 0x26, right: 0x27, down: 0x28,
9
+ ',': 0xbc, '.': 0xbe, '-': 0xbd, '=': 0xbb, '/': 0xbf, ';': 0xba, "'": 0xde,
10
+ '[': 0xdb, ']': 0xdd, '\\': 0xdc, '`': 0xc0,
11
+ };
12
+
13
+ export function parseChord(chord) {
14
+ const parts = chord.trim().toLowerCase().split('+').map((s) => s.trim()).filter(Boolean);
15
+ if (!parts.length) throw new Error('Empty key chord.');
16
+ return parts.map((p) => {
17
+ if (NAMED[p] !== undefined) return NAMED[p];
18
+ if (/^f([1-9]|1\d|2[0-4])$/.test(p)) return 0x70 + Number(p.slice(1)) - 1;
19
+ if (/^[a-z0-9]$/.test(p)) return p.toUpperCase().charCodeAt(0);
20
+ throw new Error(`Unknown key "${p}". Use names like ctrl, shift, alt, enter, tab, esc, f5, a-z, 0-9.`);
21
+ });
22
+ }
23
+
24
+ /** "ctrl+a ctrl+c" -> two chords. */
25
+ export const parseSequence = (s) => s.trim().split(/\s+/).filter(Boolean).map(parseChord);
26
+
27
+ /** Chords that would insert clipboard/other content, which must not go into a password field. */
28
+ export function insertsContent(vks) {
29
+ const has = (k) => vks.includes(k);
30
+ return (has(0x11) && has(0x56)) || (has(0x10) && has(0x2d));
31
+ }
@@ -0,0 +1,120 @@
1
+ // Windows effect window: a layered, click-through window backed by a DIB section, plus GDI text masks.
2
+ // Pixels are drawn in software (src/effects) into `layer.px`; nothing here depends on GDI+.
3
+ import koffi from 'koffi';
4
+
5
+ const user32 = koffi.load('user32.dll');
6
+ const gdi32 = koffi.load('gdi32.dll');
7
+ const kernel32 = koffi.load('kernel32.dll');
8
+
9
+ const POINT = koffi.struct('SC_POINT', { x: 'int32', y: 'int32' });
10
+ const SIZE = koffi.struct('SC_SIZE', { cx: 'int32', cy: 'int32' });
11
+ const BLEND = koffi.struct('SC_BLEND', { op: 'uint8', flags: 'uint8', alpha: 'uint8', format: 'uint8' });
12
+ const BITMAPINFO = koffi.struct('SC_BITMAPINFO', {
13
+ biSize: 'uint32', biWidth: 'int32', biHeight: 'int32', biPlanes: 'uint16', biBitCount: 'uint16', biCompression: 'uint32',
14
+ biSizeImage: 'uint32', biXPels: 'int32', biYPels: 'int32', biClrUsed: 'uint32', biClrImportant: 'uint32', bmiColors: 'uint32',
15
+ });
16
+ const WndProc = koffi.proto('intptr_t ScWndProc(intptr_t h, uint32 msg, uintptr_t wp, intptr_t lp)');
17
+ const WNDCLASSEXW = koffi.struct('SC_WNDCLASSEXW', {
18
+ cbSize: 'uint32', style: 'uint32', lpfnWndProc: koffi.pointer(WndProc), cbClsExtra: 'int32', cbWndExtra: 'int32',
19
+ hInstance: 'intptr_t', hIcon: 'intptr_t', hCursor: 'intptr_t', hbrBackground: 'intptr_t',
20
+ lpszMenuName: 'str16', lpszClassName: 'str16', hIconSm: 'intptr_t',
21
+ });
22
+
23
+ const f = {
24
+ RegisterClassExW: user32.func('uint16 RegisterClassExW(SC_WNDCLASSEXW *c)'),
25
+ CreateWindowExW: user32.func('intptr_t CreateWindowExW(uint32 ex, str16 cls, str16 title, uint32 style, int x, int y, int w, int h, intptr_t parent, intptr_t menu, intptr_t inst, intptr_t param)'),
26
+ DefWindowProcW: user32.func('intptr_t DefWindowProcW(intptr_t h, uint32 msg, uintptr_t wp, intptr_t lp)'),
27
+ DestroyWindow: user32.func('bool DestroyWindow(intptr_t h)'),
28
+ ShowWindow: user32.func('bool ShowWindow(intptr_t h, int cmd)'),
29
+ UpdateLayeredWindow: user32.func('bool UpdateLayeredWindow(intptr_t h, intptr_t dst, SC_POINT *pos, SC_SIZE *size, intptr_t src, SC_POINT *from, uint32 key, SC_BLEND *blend, uint32 flags)'),
30
+ GetDC: user32.func('intptr_t GetDC(intptr_t h)'),
31
+ ReleaseDC: user32.func('int ReleaseDC(intptr_t h, intptr_t dc)'),
32
+ CreateCompatibleDC: gdi32.func('intptr_t CreateCompatibleDC(intptr_t dc)'),
33
+ DeleteDC: gdi32.func('bool DeleteDC(intptr_t dc)'),
34
+ CreateDIBSection: gdi32.func('intptr_t CreateDIBSection(intptr_t dc, SC_BITMAPINFO *bi, uint32 usage, _Out_ void **bits, intptr_t section, uint32 offset)'),
35
+ SelectObject: gdi32.func('intptr_t SelectObject(intptr_t dc, intptr_t o)'),
36
+ DeleteObject: gdi32.func('bool DeleteObject(intptr_t o)'),
37
+ GetModuleHandleW: kernel32.func('intptr_t GetModuleHandleW(str16 n)'),
38
+ CreateFontW: gdi32.func('intptr_t CreateFontW(int h, int w, int esc, int ori, int weight, uint32 it, uint32 ul, uint32 so, uint32 cs, uint32 op, uint32 clip, uint32 q, uint32 pf, str16 face)'),
39
+ TextOutW: gdi32.func('bool TextOutW(intptr_t dc, int x, int y, str16 text, int n)'),
40
+ GetTextExtentPoint32W: gdi32.func('bool GetTextExtentPoint32W(intptr_t dc, str16 text, int n, _Out_ SC_SIZE *size)'),
41
+ SetBkMode: gdi32.func('int SetBkMode(intptr_t dc, int m)'),
42
+ SetTextColor: gdi32.func('uint32 SetTextColor(intptr_t dc, uint32 c)'),
43
+ };
44
+
45
+ const CLASS = 'WinInputMcpScan';
46
+ let proc = null;
47
+ function registerClass() {
48
+ if (proc) return;
49
+ proc = koffi.register((h, msg, wp, lp) => (msg === 0x84 ? -1 : f.DefWindowProcW(h, msg, wp, lp)), koffi.pointer(WndProc));
50
+ f.RegisterClassExW({
51
+ cbSize: koffi.sizeof(WNDCLASSEXW), style: 0, lpfnWndProc: proc, cbClsExtra: 0, cbWndExtra: 0, hInstance: f.GetModuleHandleW(null),
52
+ hIcon: 0, hCursor: 0, hbrBackground: 0, lpszMenuName: null, lpszClassName: CLASS, hIconSm: 0,
53
+ });
54
+ }
55
+
56
+ /** One layered window with a premultiplied-BGRA pixel buffer. */
57
+ export class Layer {
58
+ constructor(width, height) {
59
+ registerClass();
60
+ this.width = width;
61
+ this.height = height;
62
+ // TOPMOST | TOOLWINDOW | LAYERED | TRANSPARENT (click-through) | NOACTIVATE ; WS_POPUP
63
+ this.hwnd = f.CreateWindowExW(0x08 | 0x80 | 0x80000 | 0x20 | 0x08000000, CLASS, '', 0x80000000, 0, 0, width, height, 0, 0, f.GetModuleHandleW(null), 0);
64
+ if (!this.hwnd) throw new Error('CreateWindowEx failed');
65
+ const screen = f.GetDC(0);
66
+ this.dc = f.CreateCompatibleDC(screen);
67
+ const out = [null];
68
+ this.bmp = f.CreateDIBSection(screen, { biSize: 40, biWidth: width, biHeight: -height, biPlanes: 1, biBitCount: 32, biCompression: 0, biSizeImage: 0, biXPels: 0, biYPels: 0, biClrUsed: 0, biClrImportant: 0, bmiColors: 0 }, 0, out, 0, 0);
69
+ f.ReleaseDC(0, screen);
70
+ if (!this.bmp || !out[0]) throw new Error('CreateDIBSection failed');
71
+ this.old = f.SelectObject(this.dc, this.bmp);
72
+ this.px = new Uint8Array(koffi.view(out[0], width * height * 4));
73
+ this.shown = false;
74
+ }
75
+
76
+ /** Shows the `width` x `height` part of the buffer starting at row `srcY` at (x, y) with constant opacity `alpha` (0-255). */
77
+ present(x, y, alpha, height = this.height, width = this.width, srcY = 0) {
78
+ f.UpdateLayeredWindow(this.hwnd, 0, { x: Math.round(x), y: Math.round(y) }, { cx: Math.max(1, Math.min(this.width, Math.round(width))), cy: Math.max(1, Math.min(this.height, Math.round(height))) }, this.dc, { x: 0, y: Math.max(0, Math.round(srcY)) }, 0,
79
+ { op: 0, flags: 0, alpha: Math.max(0, Math.min(255, Math.round(alpha))), format: 1 }, 2); // ULW_ALPHA, AC_SRC_ALPHA
80
+ if (!this.shown) {
81
+ f.ShowWindow(this.hwnd, 4); // SW_SHOWNOACTIVATE
82
+ this.shown = true;
83
+ }
84
+ }
85
+
86
+ destroy() {
87
+ try {
88
+ f.ShowWindow(this.hwnd, 0);
89
+ f.SelectObject(this.dc, this.old);
90
+ f.DeleteObject(this.bmp);
91
+ f.DeleteDC(this.dc);
92
+ f.DestroyWindow(this.hwnd);
93
+ } catch { /* already gone */ }
94
+ }
95
+ }
96
+
97
+ /** Anti-aliased text as a coverage mask (0-255 per pixel), drawn by GDI white on black and read back. */
98
+ export function textMask(text, heightPx = 22, weight = 600) {
99
+ const screen = f.GetDC(0);
100
+ const dc = f.CreateCompatibleDC(screen);
101
+ const font = f.CreateFontW(-heightPx, 0, 0, 0, weight, 0, 0, 0, 1, 0, 0, 4, 0, 'Segoe UI'); // ANTIALIASED_QUALITY
102
+ const oldFont = f.SelectObject(dc, font);
103
+ const size = {};
104
+ f.GetTextExtentPoint32W(dc, text, text.length, size);
105
+ const width = Math.max(1, size.cx + 2), height = Math.max(1, size.cy + 2);
106
+ const out = [null];
107
+ const bmp = f.CreateDIBSection(screen, { biSize: 40, biWidth: width, biHeight: -height, biPlanes: 1, biBitCount: 32, biCompression: 0, biSizeImage: 0, biXPels: 0, biYPels: 0, biClrUsed: 0, biClrImportant: 0, bmiColors: 0 }, 0, out, 0, 0);
108
+ const old = f.SelectObject(dc, bmp);
109
+ const px = new Uint8Array(koffi.view(out[0], width * height * 4));
110
+ px.fill(0);
111
+ f.SetBkMode(dc, 1);
112
+ f.SetTextColor(dc, 0xffffff);
113
+ f.TextOutW(dc, 1, 1, text, text.length);
114
+ const mask = new Uint8Array(width * height);
115
+ for (let i = 0; i < mask.length; i++) mask[i] = Math.max(px[i * 4], px[i * 4 + 1], px[i * 4 + 2]);
116
+ f.SelectObject(dc, old); f.DeleteObject(bmp);
117
+ f.SelectObject(dc, oldFont); f.DeleteObject(font);
118
+ f.DeleteDC(dc); f.ReleaseDC(0, screen);
119
+ return { width, height, mask };
120
+ }
@@ -0,0 +1,265 @@
1
+ // A small always-on-top pill that shows while the server works, drawn like the macOS one: dark body with a tint of the
2
+ // accent colour (green working, amber cancelled, red stopped), accent outline and dot, white text. It is click-through
3
+ // (mouse input goes to whatever is underneath), never takes focus, and has no title so list_windows skips it.
4
+ // Rendered into a per-pixel-alpha bitmap so the rounded edge is smooth.
5
+ import koffi from 'koffi';
6
+ import * as w from './win32.js';
7
+ import { ensurePump, pumpOnce } from './pump.js';
8
+
9
+ const user32 = koffi.load('user32.dll');
10
+ const gdi32 = koffi.load('gdi32.dll');
11
+ const kernel32 = koffi.load('kernel32.dll');
12
+
13
+ const WndProc = koffi.proto('intptr_t OvWndProc(intptr_t h, uint32 msg, uintptr_t wp, intptr_t lp)');
14
+ const WNDCLASSEXW = koffi.struct('OV_WNDCLASSEXW', {
15
+ cbSize: 'uint32', style: 'uint32', lpfnWndProc: koffi.pointer(WndProc), cbClsExtra: 'int32', cbWndExtra: 'int32',
16
+ hInstance: 'intptr_t', hIcon: 'intptr_t', hCursor: 'intptr_t', hbrBackground: 'intptr_t',
17
+ lpszMenuName: 'str16', lpszClassName: 'str16', hIconSm: 'intptr_t',
18
+ });
19
+ const POINT = koffi.struct('OV_POINT', { x: 'int32', y: 'int32' });
20
+ const SIZE = koffi.struct('OV_SIZE', { cx: 'int32', cy: 'int32' });
21
+ const RECT = koffi.struct('OV_RECT', { left: 'int32', top: 'int32', right: 'int32', bottom: 'int32' });
22
+ const BLEND = koffi.struct('OV_BLEND', { op: 'uint8', flags: 'uint8', alpha: 'uint8', format: 'uint8' });
23
+ const BITMAPINFO = koffi.struct('OV_BITMAPINFO', {
24
+ size: 'uint32', width: 'int32', height: 'int32', planes: 'uint16', bitCount: 'uint16', compression: 'uint32',
25
+ sizeImage: 'uint32', xPels: 'int32', yPels: 'int32', clrUsed: 'uint32', clrImportant: 'uint32',
26
+ });
27
+
28
+ const f = {
29
+ RegisterClassExW: user32.func('uint16 RegisterClassExW(OV_WNDCLASSEXW *c)'),
30
+ CreateWindowExW: user32.func('intptr_t CreateWindowExW(uint32 ex, str16 cls, str16 title, uint32 style, int x, int y, int w, int h, intptr_t parent, intptr_t menu, intptr_t inst, intptr_t param)'),
31
+ DefWindowProcW: user32.func('intptr_t DefWindowProcW(intptr_t h, uint32 msg, uintptr_t wp, intptr_t lp)'),
32
+ ShowWindow: user32.func('bool ShowWindow(intptr_t h, int cmd)'),
33
+ SetWindowPos: user32.func('bool SetWindowPos(intptr_t h, intptr_t after, int x, int y, int cx, int cy, uint32 flags)'),
34
+ GetDC: user32.func('intptr_t GetDC(intptr_t h)'),
35
+ ReleaseDC: user32.func('int ReleaseDC(intptr_t h, intptr_t dc)'),
36
+ DrawTextW: user32.func('int DrawTextW(intptr_t dc, str16 text, int n, OV_RECT *r, uint32 flags)'),
37
+ UpdateLayeredWindow: user32.func('bool UpdateLayeredWindow(intptr_t h, intptr_t dst, OV_POINT *pos, OV_SIZE *size, intptr_t src, OV_POINT *srcPos, uint32 key, OV_BLEND *blend, uint32 flags)'),
38
+ CreateCompatibleDC: gdi32.func('intptr_t CreateCompatibleDC(intptr_t dc)'),
39
+ DeleteDC: gdi32.func('bool DeleteDC(intptr_t dc)'),
40
+ CreateDIBSection: gdi32.func('intptr_t CreateDIBSection(intptr_t dc, OV_BITMAPINFO *bi, uint32 usage, _Out_ void **bits, intptr_t section, uint32 offset)'),
41
+ CreateFontW: gdi32.func('intptr_t CreateFontW(int h, int w, int esc, int ori, int weight, uint32 it, uint32 ul, uint32 so, uint32 cs, uint32 op, uint32 clip, uint32 q, uint32 pf, str16 face)'),
42
+ DeleteObject: gdi32.func('bool DeleteObject(intptr_t o)'),
43
+ SelectObject: gdi32.func('intptr_t SelectObject(intptr_t dc, intptr_t o)'),
44
+ SetBkMode: gdi32.func('int SetBkMode(intptr_t dc, int m)'),
45
+ SetTextColor: gdi32.func('uint32 SetTextColor(intptr_t dc, uint32 c)'),
46
+ GetTextExtentPoint32W: gdi32.func('bool GetTextExtentPoint32W(intptr_t dc, str16 text, int n, _Out_ OV_SIZE *size)'),
47
+ GetModuleHandleW: kernel32.func('intptr_t GetModuleHandleW(str16 n)'),
48
+ };
49
+
50
+ export const OVERLAY_CLASS = 'WinInputMcpOverlay';
51
+ const HEIGHT = 40;
52
+ const LEFT_PAD = 38;
53
+ const RIGHT_PAD = 22;
54
+ const MIN_WIDTH = 300;
55
+ const SS = 3; // text is drawn SS times larger and averaged down: smooth grayscale edges like the macOS banner
56
+ const BODY_ALPHA = 0.92;
57
+ const BASE = [20, 23, 36]; // dark navy body (r, g, b)
58
+ const ACCENTS = { working: [51, 204, 107], cancelled: [250, 184, 38], stopped: [237, 66, 66] };
59
+
60
+ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
61
+
62
+ export class Overlay {
63
+ constructor({ enabled = true, hideAfterMs = 1500, stopLabel = 'Alt+Esc' } = {}) {
64
+ this.stopLabel = stopLabel;
65
+ this.enabled = enabled;
66
+ this.hideAfterMs = hideAfterMs;
67
+ this.active = 0;
68
+ this.hwnd = 0;
69
+ this.timer = null;
70
+ this.bitmap = 0;
71
+ }
72
+
73
+ _create() {
74
+ if (this.hwnd || !this.enabled) return;
75
+ try {
76
+ this.font = f.CreateFontW(-18 * SS, 0, 0, 0, 700, 0, 0, 0, 1, 0, 0, 4, 0, 'Segoe UI');
77
+ this.proc = koffi.register((h, msg, wp, lp) => (msg === 0x84 ? -1 : f.DefWindowProcW(h, msg, wp, lp)), koffi.pointer(WndProc)); // WM_NCHITTEST -> HTTRANSPARENT
78
+ const inst = f.GetModuleHandleW(null);
79
+ f.RegisterClassExW({
80
+ cbSize: koffi.sizeof(WNDCLASSEXW), style: 0, lpfnWndProc: this.proc, cbClsExtra: 0, cbWndExtra: 0, hInstance: inst,
81
+ hIcon: 0, hCursor: 0, hbrBackground: 0, lpszMenuName: null, lpszClassName: OVERLAY_CLASS, hIconSm: 0,
82
+ });
83
+ // WS_EX_TOPMOST | TOOLWINDOW | LAYERED | TRANSPARENT (click-through) | NOACTIVATE ; WS_POPUP
84
+ this.hwnd = f.CreateWindowExW(0x08 | 0x80 | 0x80000 | 0x20 | 0x08000000, OVERLAY_CLASS, '', 0x80000000,
85
+ 0, 0, MIN_WIDTH, HEIGHT, 0, 0, inst, 0);
86
+ if (!this.hwnd) throw new Error('CreateWindowEx failed');
87
+ // Windows only delivers hit-test messages while someone pumps; do it here, on this thread.
88
+ ensurePump();
89
+ } catch (e) {
90
+ process.stderr.write(`ispyx: overlay disabled (${e.message})\n`);
91
+ this.enabled = false;
92
+ this.hwnd = 0;
93
+ }
94
+ }
95
+
96
+ /** Draw the pill into a bitmap and hand it to the layered window. */
97
+ _paint(text, kind) {
98
+ const screenDc = f.GetDC(0);
99
+ const dc = f.CreateCompatibleDC(screenDc);
100
+ const oldFont = f.SelectObject(dc, this.font);
101
+ const ext = {};
102
+ f.GetTextExtentPoint32W(dc, text, text.length, ext);
103
+ ext.cx = Math.ceil(ext.cx / SS);
104
+ const mon = w.listMonitors().find((m) => m.primary) ?? { left: 0, top: 0, width: 1280 };
105
+ const W = Math.round(Math.min(Math.max(MIN_WIDTH, ext.cx + LEFT_PAD + RIGHT_PAD), Math.min(1400, mon.width - 40)));
106
+ const H = HEIGHT;
107
+
108
+ const bits = [null];
109
+ const bmp = f.CreateDIBSection(dc, { size: 40, width: W, height: -H, planes: 1, bitCount: 32, compression: 0, sizeImage: 0, xPels: 0, yPels: 0, clrUsed: 0, clrImportant: 0 }, 0, bits, 0, 0);
110
+ if (!bmp || !bits[0]) {
111
+ f.SelectObject(dc, oldFont); f.DeleteDC(dc); f.ReleaseDC(0, screenDc);
112
+ throw new Error('CreateDIBSection failed');
113
+ }
114
+ const old = f.SelectObject(dc, bmp);
115
+ const px = new Uint8Array(koffi.view(bits[0], W * H * 4));
116
+ const mask = new Float32Array(W * H);
117
+ const accent = ACCENTS[kind] ?? ACCENTS.working;
118
+
119
+ // Same geometry as the macOS banner: a capsule, outline 1.5 wide centred 1.5 in from the edge, dot at x=18..28.
120
+ const cy = H / 2;
121
+ const outer = H / 2 - 0.75;
122
+ const inner = H / 2 - 2.25;
123
+ const x0 = outer + 0.75;
124
+ const x1 = W - outer - 0.75;
125
+ const body = BASE.map((c, i) => c * 0.84 + accent[i] * 0.16);
126
+ const line = BASE.map((c, i) => c * 0.15 + accent[i] * 0.85);
127
+ for (let y = 0; y < H; y++) {
128
+ for (let x = 0; x < W; x++) {
129
+ const qx = x + 0.5;
130
+ const qy = y + 0.5;
131
+ const d = Math.hypot(qx - Math.min(Math.max(qx, x0), x1), qy - cy);
132
+ const fill = clamp01(outer - d + 0.5);
133
+ const core = clamp01(inner - d + 0.5); // 1 inside the outline, 0 on it
134
+ const dot = clamp01(5 - Math.hypot(qx - 23, qy - cy) + 0.5);
135
+ const i = (y * W + x) * 4;
136
+ mask[y * W + x] = fill * BODY_ALPHA;
137
+ for (let k = 0; k < 3; k++) {
138
+ const base = line[k] * (1 - core) + body[k] * core;
139
+ px[i + (2 - k)] = Math.round(base * (1 - dot) + accent[k] * dot); // BGRA
140
+ }
141
+ px[i + 3] = 255;
142
+ }
143
+ }
144
+
145
+ // Text: white on black at SS x size, then each SS x SS block becomes one coverage value.
146
+ const tw = (W - LEFT_PAD - RIGHT_PAD) * SS;
147
+ const th = H * SS;
148
+ const tb = [null];
149
+ const tbmp = f.CreateDIBSection(dc, { size: 40, width: tw, height: -th, planes: 1, bitCount: 32, compression: 0, sizeImage: 0, xPels: 0, yPels: 0, clrUsed: 0, clrImportant: 0 }, 0, tb, 0, 0);
150
+ if (tbmp && tb[0]) {
151
+ const tdc = f.CreateCompatibleDC(screenDc);
152
+ const told = f.SelectObject(tdc, tbmp);
153
+ const tf = f.SelectObject(tdc, this.font);
154
+ const big = new Uint8Array(koffi.view(tb[0], tw * th * 4));
155
+ big.fill(0);
156
+ f.SetBkMode(tdc, 1);
157
+ f.SetTextColor(tdc, 0xffffff);
158
+ f.DrawTextW(tdc, text, -1, { left: 0, top: 0, right: tw, bottom: th }, 0x4 | 0x20 | 0x8000); // VCENTER | SINGLELINE | END_ELLIPSIS
159
+ for (let y = 0; y < H; y++) {
160
+ for (let x = 0; x < W - LEFT_PAD - RIGHT_PAD; x++) {
161
+ let sum = 0;
162
+ for (let dy = 0; dy < SS; dy++) for (let dx = 0; dx < SS; dx++) sum += big[(((y * SS + dy) * tw) + x * SS + dx) * 4 + 1];
163
+ const c = sum / (255 * SS * SS);
164
+ if (c <= 0) continue;
165
+ const i = (y * W + x + LEFT_PAD) * 4;
166
+ for (let k = 0; k < 3; k++) px[i + k] = Math.round(px[i + k] * (1 - c) + 255 * c);
167
+ }
168
+ }
169
+ f.SelectObject(tdc, tf); f.SelectObject(tdc, told); f.DeleteObject(tbmp); f.DeleteDC(tdc);
170
+ }
171
+
172
+ // Premultiply by the capsule coverage.
173
+ for (let p = 0; p < W * H; p++) {
174
+ const a = mask[p];
175
+ const i = p * 4;
176
+ px[i] = Math.round(px[i] * a);
177
+ px[i + 1] = Math.round(px[i + 1] * a);
178
+ px[i + 2] = Math.round(px[i + 2] * a);
179
+ px[i + 3] = Math.round(a * 255);
180
+ }
181
+
182
+ const x = mon.left + Math.round((mon.width - W) / 2);
183
+ const ok = f.UpdateLayeredWindow(this.hwnd, screenDc, { x, y: mon.top + 6 }, { cx: W, cy: H }, dc, { x: 0, y: 0 }, 0,
184
+ { op: 0, flags: 0, alpha: 255, format: 1 }, 2); // AC_SRC_OVER, AC_SRC_ALPHA ; ULW_ALPHA
185
+
186
+ f.SelectObject(dc, old);
187
+ f.SelectObject(dc, oldFont);
188
+ f.DeleteObject(bmp);
189
+ f.DeleteDC(dc);
190
+ f.ReleaseDC(0, screenDc);
191
+ if (!ok) throw new Error('UpdateLayeredWindow failed');
192
+ }
193
+
194
+ _show(text, kind = 'working') {
195
+ this._create();
196
+ if (!this.hwnd) return;
197
+ try {
198
+ this._paint(text, kind);
199
+ } catch (e) {
200
+ process.stderr.write(`ispyx: overlay disabled (${e.message})\n`);
201
+ this.enabled = false;
202
+ return;
203
+ }
204
+ f.SetWindowPos(this.hwnd, -1, 0, 0, 0, 0, 0x1 | 0x2 | 0x10); // HWND_TOPMOST, NOSIZE|NOMOVE|NOACTIVATE
205
+ f.ShowWindow(this.hwnd, 4); // SW_SHOWNOACTIVATE
206
+ }
207
+
208
+ _hide() {
209
+ if (this.hwnd) f.ShowWindow(this.hwnd, 0);
210
+ }
211
+
212
+ /** A tool started: show the banner. Call done() when it ends. */
213
+ begin(label) {
214
+ if (!this.enabled) return;
215
+ this.active++;
216
+ clearTimeout(this.timer);
217
+ this._show(`ispyx is working: ${label} | To stop press ${this.stopLabel}`, 'working');
218
+ }
219
+
220
+ done() {
221
+ if (!this.enabled) return;
222
+ this.active = Math.max(0, this.active - 1);
223
+ if (this.active === 0) {
224
+ clearTimeout(this.timer);
225
+ this.timer = setTimeout(() => this._hide(), this.hideAfterMs);
226
+ this.timer.unref();
227
+ }
228
+ }
229
+
230
+ /** The user stopped the server: turn the banner red and keep it for a few seconds. */
231
+ stop(reason) {
232
+ if (!this.enabled) return;
233
+ clearTimeout(this.timer);
234
+ this._show(`ispyx STOPPED (${reason})`, 'stopped');
235
+ this.timer = setTimeout(() => this._hide(), 5000);
236
+ this.timer.unref();
237
+ }
238
+
239
+ /** First stop press: the running tool was cancelled; a second press within the window shuts the server down. */
240
+ cancel(reason, windowMs) {
241
+ if (!this.enabled) return;
242
+ this.active = 0;
243
+ clearTimeout(this.timer);
244
+ this._show(`ispyx CANCELLED (${reason}) | press again to shut down`, 'cancelled');
245
+ this.timer = setTimeout(() => this._hide(), windowMs);
246
+ this.timer.unref();
247
+ }
248
+
249
+ /** Hide the banner while the agent takes a screenshot, so it never covers what the agent needs to see. */
250
+ async hiddenDuring(fn) {
251
+ const visible = this.hwnd && this.enabled && w._raw.IsWindowVisible(this.hwnd);
252
+ if (visible) {
253
+ this._hide();
254
+ await w.sleep(60); // let the compositor drop it
255
+ }
256
+ try {
257
+ return await fn();
258
+ } finally {
259
+ if (visible) {
260
+ f.ShowWindow(this.hwnd, 4);
261
+ pumpOnce();
262
+ }
263
+ }
264
+ }
265
+ }
@@ -0,0 +1,37 @@
1
+ // Windows only calls window procedures and low-level hooks while the owning thread pumps messages.
2
+ // Node has no message loop of its own, so a timer does it, but only while something needs it: the keyboard hook
3
+ // or an effect window. Anything that installs a hook must hold the pump for as long as the hook exists (a
4
+ // low-level hook that is not serviced makes the user's own typing lag). When nothing holds it, polling stops.
5
+ // The tray icon does not use this: it has its own thread (tray-worker.js).
6
+ import koffi from 'koffi';
7
+
8
+ const user32 = koffi.load('user32.dll');
9
+ const MSG = koffi.struct('PUMP_MSG', {
10
+ hwnd: 'intptr_t', message: 'uint32', wParam: 'uintptr_t', lParam: 'intptr_t', time: 'uint32', ptx: 'int32', pty: 'int32', lPrivate: 'uint32',
11
+ });
12
+ const PeekMessageW = user32.func('bool PeekMessageW(_Out_ PUMP_MSG *m, intptr_t h, uint32 min, uint32 max, uint32 remove)');
13
+ const DispatchMessageW = user32.func('intptr_t DispatchMessageW(PUMP_MSG *m)');
14
+
15
+ const PUMP_MS = 10;
16
+ const DEFAULT_HOLD_MS = 10000;
17
+
18
+ let timer = null;
19
+ let until = 0;
20
+
21
+ export function pumpOnce() {
22
+ const m = {};
23
+ for (let i = 0; i < 50 && PeekMessageW(m, 0, 0, 0, 1); i++) DispatchMessageW(m);
24
+ }
25
+
26
+ function tick() {
27
+ pumpOnce();
28
+ if (Date.now() >= until) { timer = null; return; }
29
+ timer = setTimeout(tick, PUMP_MS);
30
+ timer.unref();
31
+ }
32
+
33
+ /** Run the pump for at least holdMs from now (default 10 s); every call extends it. */
34
+ export function ensurePump(holdMs = DEFAULT_HOLD_MS) {
35
+ until = Math.max(until, Date.now() + holdMs);
36
+ if (!timer) tick();
37
+ }