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,132 @@
1
+ // Worker thread of rpc.js: owns the socket to Ispyx.app. See rpc.js for the shared-memory layout.
2
+ import { parentPort, workerData } from 'node:worker_threads';
3
+ import net from 'node:net';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ const { sab, bigPort, endpoint, autostart, waitMs, notRunningMessage } = workerData;
7
+ const i32 = new Int32Array(sab, 0, 8);
8
+ const bytes = new Uint8Array(sab);
9
+ const DATA = 64; // byte offset of the payload area
10
+ const CAP = sab.byteLength - DATA;
11
+ // header (Int32 index): 0 state (0 waiting, 1 ready), 1 id answered, 2 byte length, 3 status, 4 id the main thread waits for
12
+ const OK = 0, ERR = 1, BIG = 2;
13
+
14
+ let sock = null;
15
+ let connecting = null;
16
+ let launched = false;
17
+ let rest = '';
18
+ const pending = new Map(); // id -> 'sync' | 'async' | 'quiet'
19
+
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+
22
+ function parseEndpoint(ep) {
23
+ const m = /^(localhost|\d{1,3}(?:\.\d{1,3}){3}):(\d+)$/.exec(ep);
24
+ return m ? { host: m[1], port: Number(m[2]) } : { path: ep };
25
+ }
26
+
27
+ function connectOnce() {
28
+ return new Promise((resolve, reject) => {
29
+ const s = net.connect(parseEndpoint(endpoint));
30
+ const onErr = (e) => { s.destroy(); reject(e); };
31
+ s.once('error', onErr);
32
+ s.once('connect', () => {
33
+ s.off('error', onErr);
34
+ s.setNoDelay(true);
35
+ s.setEncoding('utf8');
36
+ s.on('data', onData);
37
+ s.on('error', () => {});
38
+ s.on('close', () => {
39
+ if (sock === s) { sock = null; failAll('Ispyx closed the connection (the app quit or restarted). Try the call again.'); }
40
+ });
41
+ resolve(s);
42
+ });
43
+ });
44
+ }
45
+
46
+ async function connect() {
47
+ const deadline = Date.now() + waitMs;
48
+ let last;
49
+ for (;;) {
50
+ try {
51
+ return await connectOnce();
52
+ } catch (e) {
53
+ last = e;
54
+ }
55
+ if (autostart && !launched) {
56
+ launched = true;
57
+ try { spawnSync('open', ['-g', '-a', 'Ispyx', '--args', '--background'], { stdio: 'ignore', timeout: 5000 }); } catch { /* reported below */ }
58
+ }
59
+ if (Date.now() >= deadline) break;
60
+ await sleep(150);
61
+ }
62
+ throw new Error(`${notRunningMessage} (${last?.code ?? last?.message ?? 'cannot connect'})`);
63
+ }
64
+
65
+ function ensureConnected() {
66
+ if (sock) return null;
67
+ connecting ??= connect().then((s) => { sock = s; }).finally(() => { connecting = null; });
68
+ return connecting;
69
+ }
70
+
71
+ function reply(id, mode, ok, line, errText) {
72
+ if (mode === 'quiet') return;
73
+ if (mode === 'async') {
74
+ parentPort.postMessage(ok ? { id, line } : { id, error: errText });
75
+ return;
76
+ }
77
+ if (Atomics.load(i32, 4) !== id) return; // the main thread gave up on this call
78
+ let status = ok ? OK : ERR;
79
+ const payload = Buffer.from(ok ? line : errText, 'utf8');
80
+ if (payload.length > CAP) {
81
+ bigPort.postMessage({ id, status, text: payload.toString('utf8') });
82
+ status = BIG;
83
+ i32[2] = 0;
84
+ } else {
85
+ // (the shared area is only touched for the call the main thread currently waits for, checked above)
86
+ bytes.set(payload, DATA);
87
+ i32[2] = payload.length;
88
+ }
89
+ i32[1] = id;
90
+ i32[3] = status;
91
+ Atomics.store(i32, 0, 1);
92
+ Atomics.notify(i32, 0);
93
+ }
94
+
95
+ function failAll(text) {
96
+ for (const [id, mode] of pending) reply(id, mode, false, null, text);
97
+ pending.clear();
98
+ }
99
+
100
+ function onData(chunk) {
101
+ rest += chunk;
102
+ let nl;
103
+ while ((nl = rest.indexOf('\n')) >= 0) {
104
+ const line = rest.slice(0, nl);
105
+ rest = rest.slice(nl + 1);
106
+ if (!line.trim()) continue;
107
+ let msg;
108
+ try { msg = JSON.parse(line); } catch { continue; }
109
+ if (msg.event !== undefined && msg.id === undefined) {
110
+ parentPort.postMessage({ event: msg.event, data: msg });
111
+ continue;
112
+ }
113
+ const mode = pending.get(msg.id);
114
+ if (!mode) continue;
115
+ pending.delete(msg.id);
116
+ reply(msg.id, mode, msg.ok !== false, line, String(msg.error ?? 'Ispyx reported an error.'));
117
+ }
118
+ }
119
+
120
+ function send(req) {
121
+ pending.set(req.id, req.mode);
122
+ sock.write(JSON.stringify({ id: req.id, fn: req.fn, args: req.args }) + '\n');
123
+ }
124
+
125
+ parentPort.on('message', (req) => {
126
+ if (sock) {
127
+ send(req);
128
+ return;
129
+ }
130
+ rest = '';
131
+ ensureConnected().then(() => send(req), (e) => reply(req.id, req.mode, false, null, e.message));
132
+ });
@@ -0,0 +1,146 @@
1
+ // Client for Ispyx.app's JSON-lines socket (see PROTOCOL.md).
2
+ //
3
+ // The socket lives in a worker thread (rpc-worker.js). The core wants synchronous platform calls, so callSync()
4
+ // posts the request to the worker and blocks the main thread in Atomics.wait until the worker has written the
5
+ // response into shared memory:
6
+ //
7
+ // SharedArrayBuffer: [ header: 8 x Int32 | payload area (CAPACITY bytes) ]
8
+ // header[0] state (0 waiting, 1 ready) header[1] id answered header[2] payload byte length
9
+ // header[3] status (0 ok = the raw response line, 1 error = message text, 2 too big for the area)
10
+ // header[4] id the main thread is waiting for (the worker drops answers for calls that timed out)
11
+ // Responses larger than CAPACITY (rare: pixels travel in files) come over a MessagePort that the main thread
12
+ // reads synchronously with receiveMessageOnPort().
13
+ //
14
+ // callAsync() is the same call answered through the worker's message port (does not block). post() sends and
15
+ // forgets (layerPresent, overlay updates): order is preserved because everything goes through one socket.
16
+ // Events from the app (`stop`, `permissions`) arrive through onEvent() on the main thread's event loop.
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { Worker, MessageChannel, receiveMessageOnPort } from 'node:worker_threads';
20
+
21
+ const CAPACITY = 4 * 1024 * 1024;
22
+ const HEADER = 64;
23
+ const SYNC_TIMEOUT_MS = 30000;
24
+
25
+ export const defaultEndpoint = () => path.join(os.homedir(), 'Library', 'Application Support', 'Ispyx', 'helper.sock');
26
+ export const NOT_RUNNING = 'Cannot reach Ispyx.app. Install Ispyx.app (drag it to Applications) and start it once, then try again';
27
+
28
+ let worker = null;
29
+ let i32 = null;
30
+ let sab = null;
31
+ let bigPort = null;
32
+ let nextId = 1;
33
+ let refs = 0;
34
+ const asyncPending = new Map(); // id -> { resolve, reject }
35
+ const listeners = new Map(); // event name -> Set<cb>
36
+
37
+ function start() {
38
+ if (worker) return;
39
+ const override = process.env.ISPYX_HELPER_ENDPOINT;
40
+ sab = new SharedArrayBuffer(HEADER + CAPACITY);
41
+ i32 = new Int32Array(sab, 0, 8);
42
+ const ch = new MessageChannel();
43
+ bigPort = ch.port1;
44
+ worker = new Worker(new URL('./rpc-worker.js', import.meta.url), {
45
+ workerData: {
46
+ sab,
47
+ bigPort: ch.port2,
48
+ endpoint: override || defaultEndpoint(),
49
+ // a resident started by Ispyx.app never opens the app again: when it cannot reach the app, the app is gone
50
+ autostart: !override && !process.env.ISPYX_APP_PID,
51
+ // started by Ispyx.app: an app that does not answer is gone, so give up fast (the resident then leaves)
52
+ waitMs: Number(process.env.ISPYX_HELPER_WAIT_MS) || (override ? 2000 : process.env.ISPYX_APP_PID ? 1000 : 10000),
53
+ notRunningMessage: NOT_RUNNING,
54
+ },
55
+ transferList: [ch.port2],
56
+ });
57
+ worker.unref();
58
+ worker.on('message', onWorkerMessage);
59
+ worker.on('error', (e) => { process.stderr.write(`ispyx: helper client crashed: ${e.message}\n`); worker = null; failAsync(e.message); });
60
+ }
61
+
62
+ function ref() { if (refs++ === 0) worker?.ref(); }
63
+ function unref() { if (--refs === 0) worker?.unref(); }
64
+
65
+ function failAsync(text) {
66
+ for (const p of asyncPending.values()) p.reject(new Error(text));
67
+ asyncPending.clear();
68
+ refs = 0;
69
+ }
70
+
71
+ function onWorkerMessage(m) {
72
+ if (m.event !== undefined) {
73
+ for (const cb of listeners.get(m.event) ?? []) {
74
+ try { cb(m.data); } catch (e) { process.stderr.write(`ispyx: event handler failed: ${e.message}\n`); }
75
+ }
76
+ return;
77
+ }
78
+ const p = asyncPending.get(m.id);
79
+ if (!p) return;
80
+ asyncPending.delete(m.id);
81
+ unref();
82
+ if (m.error !== undefined) p.reject(new Error(m.error));
83
+ else p.resolve(JSON.parse(m.line).result);
84
+ }
85
+
86
+ /** Blocking call: returns the result or throws Error(message from the app). */
87
+ export function callSync(fn, ...args) {
88
+ start();
89
+ const id = nextId++;
90
+ Atomics.store(i32, 0, 0);
91
+ Atomics.store(i32, 4, id);
92
+ worker.postMessage({ id, fn, args, mode: 'sync' });
93
+ for (;;) {
94
+ if (Atomics.wait(i32, 0, 0, SYNC_TIMEOUT_MS) === 'timed-out') {
95
+ Atomics.store(i32, 4, 0);
96
+ throw new Error(`Ispyx did not answer ${fn} within ${SYNC_TIMEOUT_MS / 1000} s. Check that Ispyx.app is running and not waiting for a permission prompt.`);
97
+ }
98
+ if (Atomics.load(i32, 1) === id) break;
99
+ Atomics.store(i32, 0, 0); // an answer to an older call that arrived late: keep waiting
100
+ }
101
+ let status = i32[3];
102
+ let text;
103
+ if (status === 2) { // too big for the shared area: the worker sent it through the port
104
+ const m = receiveMessageOnPort(bigPort)?.message;
105
+ if (!m) throw new Error('Internal error: a large response from Ispyx was lost.');
106
+ ({ status, text } = m);
107
+ } else {
108
+ text = Buffer.from(sab, HEADER, i32[2]).toString('utf8');
109
+ }
110
+ if (status === 1) throw new Error(text);
111
+ return JSON.parse(text).result;
112
+ }
113
+
114
+ /** Non-blocking call: Promise of the result. */
115
+ export function callAsync(fn, ...args) {
116
+ start();
117
+ const id = nextId++;
118
+ return new Promise((resolve, reject) => {
119
+ asyncPending.set(id, { resolve, reject });
120
+ ref();
121
+ worker.postMessage({ id, fn, args, mode: 'async' });
122
+ });
123
+ }
124
+
125
+ /** Fire and forget: the answer (and any error) is dropped. Ordered with every other call. */
126
+ export function post(fn, ...args) {
127
+ start();
128
+ worker.postMessage({ id: nextId++, fn, args, mode: 'quiet' });
129
+ }
130
+
131
+ /** Subscribe to an app event ('stop', 'permissions'). Returns an unsubscribe function. */
132
+ export function onEvent(name, cb) {
133
+ start();
134
+ if (!listeners.has(name)) listeners.set(name, new Set());
135
+ listeners.get(name).add(cb);
136
+ return () => listeners.get(name)?.delete(cb);
137
+ }
138
+
139
+ /** Stop the worker (tests). */
140
+ export async function close() {
141
+ if (!worker) return;
142
+ const w = worker;
143
+ worker = null;
144
+ failAsync('closed');
145
+ await w.terminate();
146
+ }
@@ -0,0 +1,162 @@
1
+ // Fake platform: no OS access, everything in memory. Selected with ISPYX_PLATFORM=fake (tests). It exports the
2
+ // whole surface of ../CONTRACT.md. Calls that change state are recorded in `calls`, so tests can assert on them.
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ export const name = 'fake';
7
+ export const capabilities = { elevation: true };
8
+ export const stopLabel = 'Alt+Esc';
9
+
10
+ /** Everything the core asked the fake to do: [{ fn, args }], oldest first. Tests may reset it with `calls.length = 0`. */
11
+ export const calls = [];
12
+ const record = (fn, ...args) => { calls.push({ fn, args }); };
13
+
14
+ /** Every Layer ever created (tests inspect `present` calls and `destroyed`). */
15
+ export const layers = [];
16
+
17
+ export const init = () => {};
18
+ export const dataDir = () => path.join(os.tmpdir(), 'ispyx-fake');
19
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+
21
+ // ---- windows, monitors, pointer ----
22
+ export const monitors = [
23
+ { left: 0, top: 0, width: 1920, height: 1080, primary: true },
24
+ { left: 1920, top: 0, width: 1920, height: 1080, primary: false },
25
+ ];
26
+ export const windows = [
27
+ { hwnd: 101, title: 'Fake Notepad', className: 'FakeNotepad', pid: 1001, process: 'notepad.exe', elevated: false, minimized: false, rect: { left: 100, top: 100, width: 800, height: 600 } },
28
+ { hwnd: 102, title: 'Fake Browser', className: 'FakeBrowser', pid: 1002, process: 'browser.exe', elevated: false, minimized: false, rect: { left: 1000, top: 200, width: 900, height: 700 } },
29
+ { hwnd: 103, title: 'Fake Second Screen', className: 'FakeApp', pid: 1003, process: 'app.exe', elevated: false, minimized: false, rect: { left: 2000, top: 100, width: 700, height: 500 } },
30
+ ];
31
+ let foreground = 101;
32
+ let cursor = { x: 960, y: 540 };
33
+ let clipboard = { text: null, formats: [] };
34
+
35
+ const find = (id) => windows.find((x) => x.hwnd === id);
36
+ export const describeWindow = (id) => { const x = find(id); return x ? { ...x, rect: { ...x.rect } } : null; };
37
+ export const listWindows = ({ includeUntitled = false } = {}) => windows.filter((x) => includeUntitled || x.title).map((x) => describeWindow(x.hwnd));
38
+ export const getRect = (id) => { const x = find(id); return x ? { ...x.rect } : null; };
39
+ export const getTitle = (id) => find(id)?.title ?? '';
40
+ export const getClass = (id) => find(id)?.className ?? '';
41
+ export const getPid = (id) => find(id)?.pid ?? 0;
42
+ export const processInfo = (pid) => { const x = windows.find((w) => w.pid === pid); return { name: x?.process ?? '', elevated: x?.elevated ?? false }; };
43
+ export const windowExists = (id) => !!find(id);
44
+ export const rootWindowAt = (x, y) => windows.find((w) => !w.minimized && x >= w.rect.left && x < w.rect.left + w.rect.width && y >= w.rect.top && y < w.rect.top + w.rect.height)?.hwnd ?? 0;
45
+ export const foregroundWindow = () => foreground;
46
+ export const bringToForeground = async (id) => { record('bringToForeground', id); if (!find(id)) return false; find(id).minimized = false; foreground = id; return true; };
47
+ export const windowDpi = () => 96;
48
+ export const selfIsElevated = () => false;
49
+ export const isScreenLocked = () => false;
50
+ export const focusIsPassword = () => false;
51
+ export const foregroundLayout = () => 0x409;
52
+ export const listMonitors = () => monitors.map((m) => ({ ...m }));
53
+ export const virtualScreen = () => {
54
+ const x = Math.min(...monitors.map((m) => m.left)), y = Math.min(...monitors.map((m) => m.top));
55
+ return { x, y, width: Math.max(...monitors.map((m) => m.left + m.width)) - x, height: Math.max(...monitors.map((m) => m.top + m.height)) - y };
56
+ };
57
+ export const cursorPos = () => ({ ...cursor });
58
+
59
+ // ---- input ----
60
+ export const mouseMoveTo = (x, y) => { cursor = { x: Math.round(x), y: Math.round(y) }; record('mouseMoveTo', x, y); };
61
+ export const mouseDown = (button) => record('mouseDown', button);
62
+ export const mouseUp = (button) => record('mouseUp', button);
63
+ export const mouseWheel = (notches) => record('mouseWheel', notches);
64
+ export const mouseHWheel = (notches) => record('mouseHWheel', notches);
65
+ export const typeUnicode = async (text, check = () => {}, delays = []) => {
66
+ const chars = [...text];
67
+ for (let i = 0; i < chars.length; i++) {
68
+ check();
69
+ record('typeUnicode', chars[i]);
70
+ const d = delays[i] ?? 0;
71
+ if (d > 0) await sleep(d);
72
+ }
73
+ };
74
+ export const keysDown = (codes) => record('keysDown', codes);
75
+ export const keysUp = (codes) => record('keysUp', codes);
76
+ export const pressVirtualKeys = (codes) => record('pressVirtualKeys', codes);
77
+
78
+ const NAMED = ['ctrl', 'shift', 'alt', 'enter', 'esc', 'tab', 'space', 'backspace', 'delete', 'home', 'end', 'pageup', 'pagedown', 'left', 'up', 'right', 'down'];
79
+ const OS_KEY = ['win', 'cmd', 'meta', 'super'];
80
+ export function parseChord(str) {
81
+ return str.trim().split('+').filter(Boolean).map((k) => {
82
+ const key = k.trim().toLowerCase();
83
+ if (OS_KEY.includes(key)) return 'meta';
84
+ if (NAMED.includes(key) || /^f([1-9]|1\d|2[0-4])$/.test(key) || /^[a-z0-9]$/.test(key) || ',.-=/;\'[]\\`'.includes(key) && key.length === 1) return key; // codes are opaque: the key name
85
+ throw new Error(`Unknown key "${k}".`);
86
+ });
87
+ }
88
+ export const parseSequence = (str) => str.trim().split(/\s+/).filter(Boolean).map(parseChord);
89
+ export const insertsContent = (codes) => (codes.includes('ctrl') && codes.includes('v')) || (codes.includes('shift') && codes.includes('insert'));
90
+
91
+ // ---- screen capture ----
92
+ function blank(rect) {
93
+ const width = Math.max(1, Math.round(rect.width)), height = Math.max(1, Math.round(rect.height));
94
+ const bits = new Uint8Array(width * height * 4);
95
+ for (let i = 3; i < bits.length; i += 4) bits[i] = 255; // opaque black
96
+ return { bits, width, height, rect: { left: rect.left, top: rect.top, width, height } };
97
+ }
98
+ export const captureScreenRect = (rect) => blank(rect);
99
+ export function captureWindow(id) {
100
+ const r = getRect(id);
101
+ if (!r) throw new Error('That window no longer exists. Call list_windows again.');
102
+ return { ...blank(r), method: 'fake' };
103
+ }
104
+
105
+ // ---- clipboard ----
106
+ export const getClipboardText = () => ({ text: clipboard.text, formats: [...clipboard.formats] });
107
+ export const setClipboardText = (text) => { clipboard = { text, formats: ['text'] }; record('setClipboardText', text); };
108
+ export const setClipboardFile = (file) => { clipboard = { text: null, formats: ['file'] }; record('setClipboardFile', file); };
109
+
110
+ // ---- stop hotkey ----
111
+ export const chordDown = () => false;
112
+ export function installStopHotkey(onStop, lingerMs = 30000) {
113
+ let until = 0;
114
+ return { touch() { until = Date.now() + lingerMs; }, get active() { return Date.now() < until; }, stop() { until = 0; }, trigger() { onStop(stopLabel); } };
115
+ }
116
+
117
+ // ---- UI ----
118
+ export class Overlay {
119
+ constructor({ enabled = true, hideAfterMs = 0, stopLabel: label = stopLabel } = {}) { this.enabled = enabled; this.hideAfterMs = hideAfterMs; this.stopLabel = label; this.active = false; this.label = ''; }
120
+ begin(label) { this.active = true; this.label = label; record('overlay.begin', label); }
121
+ done() { this.active = false; record('overlay.done'); }
122
+ stop(reason) { this.active = false; record('overlay.stop', reason); }
123
+ cancel(reason) { this.active = false; record('overlay.cancel', reason); }
124
+ async hiddenDuring(fn) { return await fn(); }
125
+ }
126
+ export const showDialog = async (opts) => { record('showDialog', opts); return { cancel: true, reason: 'closed by the user' }; };
127
+ export const ensurePump = () => {};
128
+
129
+ /** An effect window that only keeps its buffer and records what the effects did to it. */
130
+ export class Layer {
131
+ constructor(width, height) {
132
+ this.width = width;
133
+ this.height = height;
134
+ this.px = new Uint8Array(width * height * 4);
135
+ this.shown = false;
136
+ this.destroyed = false;
137
+ this.everDrawn = false;
138
+ this.presents = []; // { x, y, alpha, height, width, srcY }
139
+ layers.push(this);
140
+ }
141
+
142
+ present(x, y, alpha, height = this.height, width = this.width, srcY = 0) {
143
+ if (this.destroyed) throw new Error('Layer was destroyed');
144
+ if (!this.everDrawn && this.px.some((b) => b !== 0)) this.everDrawn = true; // some frame had pixels
145
+ this.presents.push({ x, y, alpha, height, width, srcY });
146
+ this.shown = true;
147
+ }
148
+
149
+ destroy() { this.destroyed = true; }
150
+ }
151
+
152
+ /** Deterministic mask: a solid box of 0.6 x height per character with a one pixel margin. */
153
+ export function textMask(text, heightPx = 22, weight = 600) {
154
+ const cw = Math.max(1, Math.round(heightPx * 0.6));
155
+ const width = Math.max(1, text.length * cw + 2), height = heightPx + 2;
156
+ const mask = new Uint8Array(width * height);
157
+ for (let c = 0; c < text.length; c++) {
158
+ if (text[c] === ' ') continue;
159
+ for (let y = 1; y < height - 1; y++) for (let x = 1 + c * cw; x < 1 + (c + 1) * cw - 1; x++) mask[y * width + x] = weight >= 600 ? 255 : 180;
160
+ }
161
+ return { width, height, mask };
162
+ }
@@ -0,0 +1,13 @@
1
+ // Picks the platform layer for this OS. Every platform folder exports the same API (see CONTRACT.md); the rest
2
+ // of the code imports this module and never a platform folder directly.
3
+ const IMPLEMENTATIONS = {
4
+ win32: './win32/index.js',
5
+ darwin: './darwin/index.js',
6
+ linux: './linux/index.js',
7
+ };
8
+
9
+ const target = process.env.ISPYX_PLATFORM || process.platform; // ISPYX_PLATFORM=fake runs the core without a desktop (tests)
10
+ const path = target === 'fake' ? './fake/index.js' : IMPLEMENTATIONS[target];
11
+ if (!path) throw new Error(`ispyx does not support ${process.platform} yet.`);
12
+
13
+ export default await import(path);
@@ -0,0 +1,140 @@
1
+ # Password prompt for ispyx. Prints one JSON line on stdout and exits.
2
+ # {"password":"..","confirm":"..","value":24,"unit":"saat","makeDefault":false} or {"cancel":true}
3
+ # Control names double as UI Automation ids, so tests can drive the dialog.
4
+ param(
5
+ [string]$Mode = 'unlock', # unlock | setup
6
+ [string]$Message = '',
7
+ [int]$Seconds = 30,
8
+ [int]$Value = 24,
9
+ [string]$Unit = 'hours'
10
+ )
11
+ Add-Type -AssemblyName System.Windows.Forms
12
+ Add-Type -AssemblyName System.Drawing
13
+ [System.Windows.Forms.Application]::EnableVisualStyles()
14
+
15
+ $setup = $Mode -eq 'setup'
16
+ $form = New-Object System.Windows.Forms.Form
17
+ $form.Text = 'ispyx'
18
+ $form.Name = 'authForm'
19
+ $form.StartPosition = 'CenterScreen'
20
+ $form.FormBorderStyle = 'FixedDialog'
21
+ $form.MaximizeBox = $false
22
+ $form.MinimizeBox = $false
23
+ $form.TopMost = $true
24
+ $form.ShowInTaskbar = $true
25
+ $form.ClientSize = New-Object System.Drawing.Size(380, $(if ($setup) { 250 } else { 215 }))
26
+ $font = New-Object System.Drawing.Font('Segoe UI', 9)
27
+ $form.Font = $font
28
+
29
+ function Add-Label($text, $x, $y, $w = 340, $h = 20, $name = '') {
30
+ $l = New-Object System.Windows.Forms.Label
31
+ $l.Text = $text; $l.Location = New-Object System.Drawing.Point($x, $y); $l.Size = New-Object System.Drawing.Size($w, $h)
32
+ if ($name) { $l.Name = $name }
33
+ $form.Controls.Add($l); return $l
34
+ }
35
+
36
+ $y = 12
37
+ $title = if ($setup) { 'First setup: choose a password. The tools will not run without it.' } else { 'A tool call came in. Enter the password to continue.' }
38
+ $null = Add-Label $title 20 $y 340 34 'title'
39
+ $y += 38
40
+ $msg = Add-Label $Message.Trim() 20 $y 340 18 'message'
41
+ $msg.ForeColor = [System.Drawing.Color]::Firebrick
42
+ $y += 22
43
+
44
+ $null = Add-Label 'Password' 20 $y 80 20
45
+ $pw = New-Object System.Windows.Forms.TextBox
46
+ $pw.Name = 'password'; $pw.UseSystemPasswordChar = $true
47
+ $pw.Location = New-Object System.Drawing.Point(110, ($y - 3)); $pw.Size = New-Object System.Drawing.Size(250, 24)
48
+ $form.Controls.Add($pw)
49
+ $y += 34
50
+
51
+ $cf = $null
52
+ if ($setup) {
53
+ $null = Add-Label 'Repeat' 20 $y 80 20
54
+ $cf = New-Object System.Windows.Forms.TextBox
55
+ $cf.Name = 'confirm'; $cf.UseSystemPasswordChar = $true
56
+ $cf.Location = New-Object System.Drawing.Point(110, ($y - 3)); $cf.Size = New-Object System.Drawing.Size(250, 24)
57
+ $form.Controls.Add($cf)
58
+ $y += 34
59
+ }
60
+
61
+ $null = Add-Label 'Stay unlocked for' 20 $y 130 20
62
+ $num = New-Object System.Windows.Forms.NumericUpDown
63
+ $num.Name = 'duration'; $num.Minimum = 1; $num.Maximum = 100000; $num.Value = [Math]::Max(1, $Value)
64
+ $num.Location = New-Object System.Drawing.Point(155, ($y - 3)); $num.Size = New-Object System.Drawing.Size(70, 24)
65
+ $form.Controls.Add($num)
66
+ $unitBox = New-Object System.Windows.Forms.ComboBox
67
+ $unitBox.Name = 'unit'; $unitBox.DropDownStyle = 'DropDownList'
68
+ $null = $unitBox.Items.AddRange(@('minutes', 'hours', 'days'))
69
+ $unitBox.SelectedItem = $Unit; if ($unitBox.SelectedIndex -lt 0) { $unitBox.SelectedItem = 'hours' }
70
+ $unitBox.Location = New-Object System.Drawing.Point(232, ($y - 3)); $unitBox.Size = New-Object System.Drawing.Size(80, 24)
71
+ $form.Controls.Add($unitBox)
72
+ $y += 30
73
+
74
+ $chk = New-Object System.Windows.Forms.CheckBox
75
+ $chk.Name = 'makeDefault'; $chk.Text = 'Make this the new default'
76
+ $chk.Location = New-Object System.Drawing.Point(20, $y); $chk.Size = New-Object System.Drawing.Size(340, 22)
77
+ $form.Controls.Add($chk)
78
+ $y += 30
79
+
80
+ $left = $Seconds
81
+ $count = Add-Label '' 20 ($y + 4) 170 20 'countdown'
82
+ $count.ForeColor = [System.Drawing.Color]::DimGray
83
+ $ok = New-Object System.Windows.Forms.Button
84
+ $ok.Name = 'ok'; $ok.Text = 'OK'; $ok.Location = New-Object System.Drawing.Point(200, $y); $ok.Size = New-Object System.Drawing.Size(75, 26)
85
+ $ok.DialogResult = 'OK'; $form.AcceptButton = $ok; $form.Controls.Add($ok)
86
+ $cancel = New-Object System.Windows.Forms.Button
87
+ $cancel.Name = 'cancel'; $cancel.Text = 'Cancel'; $cancel.Location = New-Object System.Drawing.Point(285, $y); $cancel.Size = New-Object System.Drawing.Size(75, 26)
88
+ $cancel.DialogResult = 'Cancel'; $form.CancelButton = $cancel; $form.Controls.Add($cancel)
89
+
90
+ $timer = New-Object System.Windows.Forms.Timer
91
+ $timer.Interval = 1000
92
+ $timer.Add_Tick({
93
+ try {
94
+ $script:left--
95
+ $count.Text = "Closes in $($script:left) s"
96
+ if ($script:left -le 0) { $script:timedOut = $true; $form.DialogResult = 'Cancel'; $form.Close() }
97
+ } catch { [Console]::Error.WriteLine("tick failed: $($_.Exception.Message)") }
98
+ })
99
+ $count.Text = "Closes in $left s"
100
+ $timer.Start()
101
+ # A background process may not take the foreground (foreground lock): attaching to the foreground thread's input lifts it.
102
+ Add-Type -Namespace Native -Name Fg -MemberDefinition @'
103
+ [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr h);
104
+ [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, int flags, System.UIntPtr extra);
105
+ [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern System.IntPtr GetForegroundWindow();
106
+ [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(System.IntPtr h, out uint pid);
107
+ [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach);
108
+ [System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
109
+ '@
110
+ $script:timedOut = $false
111
+ function Bring-ToFront {
112
+ $fg = [Native.Fg]::GetForegroundWindow()
113
+ $pid0 = [uint32]0
114
+ $fgThread = [Native.Fg]::GetWindowThreadProcessId($fg, [ref]$pid0)
115
+ $me = [Native.Fg]::GetCurrentThreadId()
116
+ # (no Alt tap here: an Alt press/release on our own window starts the window-menu mode, which freezes the buttons)
117
+ $att = $fgThread -ne 0 -and $fgThread -ne $me -and [Native.Fg]::AttachThreadInput($me, $fgThread, $true)
118
+ [void][Native.Fg]::SetForegroundWindow($form.Handle)
119
+ if ($att) { [void][Native.Fg]::AttachThreadInput($me, $fgThread, $false) }
120
+ $form.Activate(); $pw.Focus()
121
+ }
122
+ $form.Add_Shown({
123
+ try { Bring-ToFront } catch { [Console]::Error.WriteLine("bring-to-front failed: $($_.Exception.Message)") } # never let this block the dialog
124
+ })
125
+
126
+ $res = $form.ShowDialog()
127
+ $timer.Stop()
128
+ if ($res -eq 'OK') {
129
+ $num.ValidateEditText() # commit text typed into the number box
130
+ $out = [ordered]@{
131
+ password = $pw.Text
132
+ confirm = $(if ($cf) { $cf.Text } else { '' })
133
+ value = [int]$num.Value
134
+ unit = [string]$unitBox.SelectedItem
135
+ makeDefault = [bool]$chk.Checked
136
+ }
137
+ } else {
138
+ $out = [ordered]@{ cancel = $true; reason = $(if ($script:timedOut) { 'timeout' } else { 'closed' }) }
139
+ }
140
+ [Console]::Out.WriteLine(($out | ConvertTo-Json -Compress))
@@ -0,0 +1,54 @@
1
+ // "Open at Windows start": a per-user Run entry (HKCU, no admin).
2
+ //
3
+ // Design choice: the Run value does not start node.exe directly, because node.exe is a console-subsystem
4
+ // program and Windows would flash a console window at every logon. Instead the value is
5
+ // "<SystemRoot>\System32\wscript.exe" "%APPDATA%\ispyx\start.vbs"
6
+ // and start.vbs runs `"<node.exe>" "<src/server.js>"` with window style 0 (hidden, not waited for). Started with no
7
+ // arguments, server.js turns into the detached hidden resident process by itself (the launcher semantics), so
8
+ // nothing visible appears. The registry is written through reg.exe (always present, per-user, no native glue).
9
+ //
10
+ // Tests can redirect everything with ISPYX_AUTOSTART_VALUE (value name; also names the .vbs), so the real entry is
11
+ // never touched.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { execFileSync } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
18
+ const valueName = () => process.env.ISPYX_AUTOSTART_VALUE || 'Ispyx';
19
+ const roaming = () => process.env.APPDATA || path.join(process.env.USERPROFILE || '.', 'AppData', 'Roaming');
20
+ const vbsPath = () => path.join(roaming(), 'ispyx', valueName() === 'Ispyx' ? 'start.vbs' : `start-${valueName()}.vbs`);
21
+ const serverJs = () => path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'server.js');
22
+
23
+ const reg = (args) => execFileSync('reg.exe', args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
24
+
25
+ export const autostart = {
26
+ /** The command line stored in the Run value. */
27
+ command() {
28
+ const wscript = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'wscript.exe');
29
+ return `"${wscript}" "${vbsPath()}"`;
30
+ },
31
+
32
+ /** Whether the Run value exists. */
33
+ get() {
34
+ try {
35
+ return reg(['query', RUN_KEY, '/v', valueName()]).includes('REG_');
36
+ } catch { return false; }
37
+ },
38
+
39
+ /** Turns the entry on or off; returns the resulting state. Throws if the registry cannot be written. */
40
+ set(on) {
41
+ if (on) {
42
+ const vbs = vbsPath();
43
+ fs.mkdirSync(path.dirname(vbs), { recursive: true });
44
+ const q = (s) => `"${s.replace(/"/g, '""')}"`; // VBScript string literal
45
+ const cmd = `"${process.execPath}" "${serverJs()}"`;
46
+ fs.writeFileSync(vbs, `CreateObject("WScript.Shell").Run ${q(cmd)}, 0, False\r\n`, 'utf8');
47
+ reg(['add', RUN_KEY, '/v', valueName(), '/t', 'REG_SZ', '/d', this.command(), '/f']);
48
+ } else {
49
+ try { reg(['delete', RUN_KEY, '/v', valueName(), '/f']); } catch { /* not there */ }
50
+ try { fs.rmSync(vbsPath(), { force: true }); } catch { /* best effort */ }
51
+ }
52
+ return this.get();
53
+ },
54
+ };