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,16 @@
|
|
|
1
|
+
// Entry point used by Ispyx.app: the app starts it as the resident (`--resident`), and the MCP launcher
|
|
2
|
+
// (Contents/Resources/ispyx) as the thin forwarder (`mcp`). Picks the platform automatically (darwin), whatever the
|
|
3
|
+
// environment of the MCP client says, then runs the normal server.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
delete process.env.ISPYX_PLATFORM;
|
|
9
|
+
// The app's resident and every forwarder meet on one socket in the app's data folder, whatever their environment
|
|
10
|
+
// (an MCP client may give its children another temp directory).
|
|
11
|
+
if (!process.env.ISPYX_PIPE) {
|
|
12
|
+
const dir = path.join(os.homedir(), 'Library', 'Application Support', 'Ispyx');
|
|
13
|
+
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* reported when listening fails */ }
|
|
14
|
+
process.env.ISPYX_PIPE = path.join(dir, 'ispyx.sock');
|
|
15
|
+
}
|
|
16
|
+
await import('./core/server.js');
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Platform contract
|
|
2
|
+
|
|
3
|
+
`src/platform/index.js` loads one folder (`win32/`, `darwin/`, `linux/`, or `fake/` when `ISPYX_PLATFORM=fake`) and
|
|
4
|
+
exports its module namespace as the default export. Everything under `src/core/` and `src/effects/` talks to the OS
|
|
5
|
+
only through that namespace (`import platform from '../platform/index.js'`). A platform folder's `index.js` must
|
|
6
|
+
export everything below. The Windows implementation (`win32/`) is the reference: when this document is unclear,
|
|
7
|
+
read it.
|
|
8
|
+
|
|
9
|
+
## Conventions
|
|
10
|
+
|
|
11
|
+
- **Calls are synchronous** unless marked `async`. (`darwin/` reaches a native helper process, see
|
|
12
|
+
`darwin/PROTOCOL.md`; it makes the calls look synchronous.)
|
|
13
|
+
- **Coordinates** are integers in the platform's *logical desktop space*, origin top-left of the primary monitor,
|
|
14
|
+
y down; other monitors may have negative values. On Windows they are physical pixels (the process is
|
|
15
|
+
per-monitor DPI aware). On macOS they are **points** (not Retina pixels); captures are returned at that logical
|
|
16
|
+
size too (downscaled from the physical image), so `rect.width` always equals `bits` width.
|
|
17
|
+
- **Window ids** (`hwnd` in the tool API) are positive integers, opaque to the core: Windows `HWND`, macOS
|
|
18
|
+
`CGWindowID`. `0` means "none".
|
|
19
|
+
- **Key codes** are opaque to the core. `parseChord` turns `"ctrl+shift+t"` into an array of platform key codes and
|
|
20
|
+
the core only hands that array back to `keysDown`, `keysUp`, `pressVirtualKeys`, `chordDown`, `insertsContent`.
|
|
21
|
+
- **Errors**: throw `Error` with a message meant for the AI caller (plain sentence, says what to do). The core
|
|
22
|
+
turns it into a tool error.
|
|
23
|
+
- **Pixel buffers** are `Uint8Array`, BGRA, premultiplied alpha, top-down, `width * height * 4` bytes.
|
|
24
|
+
|
|
25
|
+
## Lifecycle
|
|
26
|
+
|
|
27
|
+
| Export | Signature | Notes |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `name` | `'win32' \| 'darwin' \| 'linux' \| 'fake'` | |
|
|
30
|
+
| `capabilities` | `{ elevation: boolean }` | `elevation:false` removes the "elevated window" concept (macOS). |
|
|
31
|
+
| `stopLabel` | `string` | Shown to the user: `'Alt+Esc'` on Windows, `'Option+Esc'` on macOS. |
|
|
32
|
+
| `init()` | `() => void` | Idempotent. Windows: per-monitor DPI awareness, 1 ms timer. macOS: connect to the helper and check permissions; throw a helpful error naming the missing permission. |
|
|
33
|
+
| `dataDir()` | `() => string` | Per-user writable directory (password hash). |
|
|
34
|
+
| `sleep(ms)` | `=> Promise` | |
|
|
35
|
+
|
|
36
|
+
## Windows, monitors, pointer
|
|
37
|
+
|
|
38
|
+
| Export | Returns |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `listWindows({ includeUntitled })` | `describeWindow` objects of visible, on-screen windows, front to back where the OS gives an order. |
|
|
41
|
+
| `describeWindow(id)` | `{ hwnd, title, className, pid, process, elevated, minimized, rect }`. `className` may be `''`. `process` is the executable name (Windows `notepad.exe`; macOS the app's executable name, e.g. `Notes`) and is what the denylist matches. `elevated` is `false` where the concept does not exist. |
|
|
42
|
+
| `getRect(id)` | `{ left, top, width, height } \| null` |
|
|
43
|
+
| `getTitle(id)`, `getClass(id)`, `getPid(id)` | strings / number |
|
|
44
|
+
| `processInfo(pid)` | `{ name, elevated }` |
|
|
45
|
+
| `windowExists(id)` | boolean |
|
|
46
|
+
| `rootWindowAt(x, y)` | id of the top-level window under the point, `0` if none |
|
|
47
|
+
| `foregroundWindow()` | id of the focused top-level window, `0` if none |
|
|
48
|
+
| `bringToForeground(id)` | `async`, resolves true when the window is in front (un-minimises first) |
|
|
49
|
+
| `windowDpi(id)` | number (96 = 100 %); macOS returns 96 |
|
|
50
|
+
| `selfIsElevated()` | boolean (`false` on macOS) |
|
|
51
|
+
| `isScreenLocked()` | boolean: no input possible (lock screen, secure desktop) |
|
|
52
|
+
| `focusIsPassword()` | boolean: the focused control is a password field |
|
|
53
|
+
| `foregroundLayout()` | number identifying the active keyboard layout (reported as hex; `0` if unknown) |
|
|
54
|
+
| `virtualScreen()` | `{ x, y, width, height }` bounding box of all monitors |
|
|
55
|
+
| `listMonitors()` | `[{ left, top, width, height, primary }]` |
|
|
56
|
+
| `cursorPos()` | `{ x, y }` |
|
|
57
|
+
|
|
58
|
+
## Input
|
|
59
|
+
|
|
60
|
+
| Export | Notes |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `mouseMoveTo(x, y)` | Absolute, instant (smoothing is done by the core). |
|
|
63
|
+
| `mouseDown(button)`, `mouseUp(button)` | `'left' \| 'right' \| 'middle'`. |
|
|
64
|
+
| `mouseWheel(notches)` | Positive = scroll up. Fractions allowed (smooth scrolling). |
|
|
65
|
+
| `mouseHWheel(notches)` | Positive = scroll right. |
|
|
66
|
+
| `typeUnicode(text, check, delays)` | `async`. Types `text` one character at a time as Unicode (layout independent). Before each character call `check()` (it may throw to cancel); after it wait `delays[i]` ms. Returns when done. |
|
|
67
|
+
| `keysDown(codes)`, `keysUp(codes)` | Hold / release key codes (array). |
|
|
68
|
+
| `pressVirtualKeys(codes)` | Press the chord (down in order, up in reverse). |
|
|
69
|
+
| `parseChord(str)` | `"ctrl+a"` -> codes. Names: `ctrl shift alt enter esc tab space backspace delete home end pageup pagedown left up right down f1..f24 a-z 0-9` and punctuation `, . - = / ; ' [ ] \ \``. `win`, `cmd`, `meta`, `super` mean the OS key (Windows key / Command). `alt` is Alt / Option. |
|
|
70
|
+
| `parseSequence(str)` | `"ctrl+a ctrl+c"` -> array of code arrays. |
|
|
71
|
+
| `insertsContent(codes)` | True for chords that paste (must not go into a password field). |
|
|
72
|
+
|
|
73
|
+
## Screen capture
|
|
74
|
+
|
|
75
|
+
| Export | Returns |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `captureScreenRect({ left, top, width, height })` | `{ bits, width, height, rect }`; `rect` is the area actually captured. |
|
|
78
|
+
| `captureWindow(id)` | `{ bits, width, height, rect, method }`; works for covered windows where the OS allows it, otherwise requires the window to be in front and throws a message saying so. |
|
|
79
|
+
|
|
80
|
+
## Clipboard
|
|
81
|
+
|
|
82
|
+
`getClipboardText()` -> `{ text: string \| null, formats: string[] }`; `setClipboardText(text)`;
|
|
83
|
+
`setClipboardFile(path)` (puts a file reference, as if copied in the file manager).
|
|
84
|
+
|
|
85
|
+
## Stop hotkey
|
|
86
|
+
|
|
87
|
+
| Export | Notes |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `chordDown(codes)` | True while every key of the chord is physically down (used for polling). |
|
|
90
|
+
| `installStopHotkey(onStop, lingerMs)` | Returns `{ touch(), active, stop() }`. A keyboard hook for the stop chord (`Alt+Esc`; macOS `Option+Esc`) that exists only while `touch()` was called in the last `lingerMs` (default 30 000 ms): no hook while idle. The hook swallows the chord and calls `onStop('Alt+Esc')` once per press (auto-repeat counts as one). Injected (synthetic) key events must be ignored. |
|
|
91
|
+
|
|
92
|
+
## UI
|
|
93
|
+
|
|
94
|
+
| Export | Notes |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `Overlay` | The "working / stopped / cancelled" banner. `new Overlay({ enabled, hideAfterMs, stopLabel })` with `begin(label)`, `done()`, `stop(reason)`, `cancel(reason, windowMs)`, `active`, `async hiddenDuring(fn)` (hides the banner while `fn` runs, e.g. during a screenshot). Always-on-top, click-through, never takes focus, covers no more than a thin bar. |
|
|
97
|
+
| `showDialog(opts, ms)` | `async`. The native password window for the auth gate. `opts = { mode: 'setup' \| 'unlock', message, value, unit }`; resolves to `{ password, confirm, value, unit, makeDefault }` or `{ cancel: true, reason }` where reason is `'timed out'`, `'closed by the user'` or `'dialog failed: ...'`. Must come to the front and close itself after `ms`. |
|
|
98
|
+
| `Layer` | Effect window: see below. |
|
|
99
|
+
| `textMask(text, heightPx, weight)` | `{ width, height, mask }`: anti-aliased coverage mask (0-255 per pixel) of `text` in the platform UI font. |
|
|
100
|
+
| `ensurePump()` | Windows only (message pump); other platforms export a no-op. |
|
|
101
|
+
|
|
102
|
+
### `Layer`
|
|
103
|
+
|
|
104
|
+
A borderless, click-through, never-focused, always-on-top window showing a pixel buffer with per-pixel alpha. Used by
|
|
105
|
+
all effects (`src/effects/`).
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
const layer = new Layer(width, height); // not visible yet
|
|
109
|
+
layer.px // Uint8Array(width*height*4), BGRA premultiplied; draw into it
|
|
110
|
+
layer.present(x, y, alpha, height?, width?, srcY?) // move to (x,y), show the top-left width x height part of the
|
|
111
|
+
// buffer starting at row srcY, with constant opacity alpha 0..255
|
|
112
|
+
layer.shown // true after the first present()
|
|
113
|
+
layer.destroy() // hide and free
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`present` is called up to 60 times a second per layer; layers can be as large as the whole desktop (5760 x 1080).
|
|
117
|
+
|
|
118
|
+
## Effects renderer is shared
|
|
119
|
+
|
|
120
|
+
`src/effects/` holds all drawing code (eye, scan line, rings, pills, radar) as pure JavaScript writing into
|
|
121
|
+
`layer.px`. A platform only provides `Layer` and `textMask`. Do not add effect-specific code to a platform folder.
|
|
122
|
+
|
|
123
|
+
## win32-only optional exports
|
|
124
|
+
|
|
125
|
+
Other platforms may lack these; callers must feature-test (`if (platform.Tray)`).
|
|
126
|
+
|
|
127
|
+
- `Tray({ tooltip, onOpen, onQuit, getStatus })`: notification-area eye icon on the shared message pump. Menu: status line
|
|
128
|
+
(from `getStatus()`, refreshed on each open), Open (default; also left click),
|
|
129
|
+
Quit. Methods: `setTooltip(s)`, `destroy()`. Re-adds itself when Explorer restarts. Callbacks never throw into the host.
|
|
130
|
+
- `autostart` `{ get(), set(on), command() }`: per-user HKCU Run entry `Ispyx` that starts `src/server.js` hidden
|
|
131
|
+
via `wscript.exe` + `%APPDATA%\ispyx\start.vbs`. `ISPYX_AUTOSTART_VALUE` overrides the value name (tests).
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# macOS helper protocol
|
|
2
|
+
|
|
3
|
+
On macOS the platform calls are made by **Ispyx.app** (`native/macos/`), a signed menu-bar app that holds the
|
|
4
|
+
Accessibility and Screen Recording permissions. The Node side (`src/platform/darwin/`) is a thin client. The
|
|
5
|
+
contract it implements is `../CONTRACT.md`.
|
|
6
|
+
|
|
7
|
+
## Process model
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Claude / MCP client --stdio--> `ispyx mcp` (Contents/Resources/ispyx: thin forwarder, one per client)
|
|
11
|
+
| ispyx.sock (src/core/ipc.js pipe, pinned by macos-entry.js)
|
|
12
|
+
v
|
|
13
|
+
resident Node core (`--resident`, started and watched by Ispyx.app) --helper.sock--> Ispyx.app (Swift,
|
|
14
|
+
the only process that touches the OS)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- Ispyx.app runs once per user (menu-bar agent, `LSUIElement`). At launch it starts the bundled Node core as the
|
|
18
|
+
resident (`ResidentService.swift`), the one process that does the work (like the tray app on Windows, see
|
|
19
|
+
`src/core/RESIDENT.md`), and starts it again if it dies. The panel's activity and settings come from it, so they
|
|
20
|
+
work without any AI app connected.
|
|
21
|
+
- `ispyx mcp` forwards every call to the resident; when none answers it opens the app (`open -g <the app>`), which
|
|
22
|
+
starts one, and waits.
|
|
23
|
+
- Quitting the app stops the resident with SIGTERM, which tells the forwarders to exit first. A resident whose app is
|
|
24
|
+
gone (`ISPYX_APP_PID`) leaves by itself and never opens the app again.
|
|
25
|
+
- Socket: `~/Library/Application Support/Ispyx/helper.sock` (unix domain socket, mode 0600).
|
|
26
|
+
- The app never listens on a network port.
|
|
27
|
+
|
|
28
|
+
## Wire format
|
|
29
|
+
|
|
30
|
+
UTF-8 JSON lines (`\n`-terminated), one object per line, both directions. Binary payloads (pixels, masks) are
|
|
31
|
+
**not** in JSON: they travel in shared files, see "Shared pixel buffers".
|
|
32
|
+
|
|
33
|
+
Request: `{ "id": 17, "fn": "listWindows", "args": [ { "includeUntitled": false } ] }`
|
|
34
|
+
Response: `{ "id": 17, "ok": true, "result": ... }` or `{ "id": 17, "ok": false, "error": "message for the AI" }`.
|
|
35
|
+
|
|
36
|
+
Responses may come out of order; `id` pairs them. Events from the app (no `id`):
|
|
37
|
+
`{ "event": "stop", "reason": "Option+Esc" }` (stop hotkey pressed), `{ "event": "permissions", "missing": ["accessibility"] }`.
|
|
38
|
+
|
|
39
|
+
The Ispyx window asks the server for its part of the panel (activity, effects, password, denylist):
|
|
40
|
+
`{ "event": "panelRequest", "req": 3, "kind": "state" | "action", "name": "setEffects", "value": false }`. The owner
|
|
41
|
+
answers with the call `panelReply({ "req": 3, "ok": true, "result": ... })` or `{ "req": 3, "ok": false, "error": "..." }`
|
|
42
|
+
(`panel.js`); the app takes the first answer and gives up after a timeout. Events never carry an `id` key.
|
|
43
|
+
|
|
44
|
+
### Functions
|
|
45
|
+
|
|
46
|
+
Every function of `../CONTRACT.md` that is not pure JavaScript has the same name here, with `args` = the call's
|
|
47
|
+
arguments and `result` = the return value, JSON-encoded. Differences:
|
|
48
|
+
|
|
49
|
+
- `captureScreenRect(rect)` / `captureWindow(id)`: result is `{ file, width, height, rect, method }` where `file` is
|
|
50
|
+
a path to a raw BGRA file (`width*height*4` bytes) in `$TMPDIR/ispyx/`; the client reads it, then the app
|
|
51
|
+
deletes it. (Files stay small: captured at logical size.)
|
|
52
|
+
- `typeUnicode`: the client loops, calling `typeChar(codePoint)` per character (the delays and `check()` stay in JS). `codePoint` 10 (newline) means the Return key; carriage
|
|
53
|
+
returns are dropped by the client.
|
|
54
|
+
- `parseChord`, `parseSequence`, `insertsContent`, `chordDown` (keys table): client side; `chordDown(codes)` calls
|
|
55
|
+
`keysState(codes)` -> `boolean` (all down).
|
|
56
|
+
- `installStopHotkey`: the client calls `hookTouch(lingerMs)` / `hookStop()`; the app installs a `CGEventTap`
|
|
57
|
+
(active, to swallow the chord) only while touched and emits the `stop` event.
|
|
58
|
+
- Overlay: `overlayShow({ text, kind })`, `overlayHide()`, `overlayVisible()`; the JS `Overlay` class holds the timers
|
|
59
|
+
and texts exactly like the Windows one.
|
|
60
|
+
- `showDialog(opts, ms)`: runs the native dialog in the app, result as in the contract.
|
|
61
|
+
|
|
62
|
+
### Layers (effect windows)
|
|
63
|
+
|
|
64
|
+
`layerCreate({ width, height })` -> `{ layer: <int>, file: <path> }`. The app creates a file
|
|
65
|
+
`$TMPDIR/ispyx/layer-<n>.bgra` of `width*height*4` bytes and memory-maps it; the client maps the same file
|
|
66
|
+
(`mmap` through `koffi`) and exposes it as `layer.px`. No pixel data goes over the socket.
|
|
67
|
+
`layerPresent({ layer, x, y, alpha, height, width, srcY })` -> `true`: show that part of the buffer in the layer's
|
|
68
|
+
window (`NSPanel`, `.screenSaver` level, `ignoresMouseEvents`, not key, not main, joins all spaces). Coordinates are
|
|
69
|
+
the contract's logical desktop space; convert to AppKit's bottom-left origin inside the app.
|
|
70
|
+
`layerDestroy({ layer })`. `textMask({ text, heightPx, weight })` -> `{ width, height, file }` (one byte per pixel).
|
|
71
|
+
|
|
72
|
+
## Latency budget
|
|
73
|
+
|
|
74
|
+
The client makes calls look synchronous (`Atomics.wait` on a worker that owns the socket). A round trip must stay
|
|
75
|
+
under 1 ms for `mouseMoveTo`, `cursorPos` and `layerPresent`: the core calls them 60-100 times a second.
|
|
76
|
+
|
|
77
|
+
## Permissions
|
|
78
|
+
|
|
79
|
+
On the first call the app checks `AXIsProcessTrusted()` and `CGPreflightScreenCaptureAccess()`. When one is
|
|
80
|
+
missing, every function that needs it fails with a message such as `Ispyx needs the Accessibility permission. Open
|
|
81
|
+
System Settings > Privacy & Security > Accessibility and switch Ispyx on, then try again.` and the app shows its
|
|
82
|
+
onboarding window.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Emergency stop hotkey (Option+Esc). The CGEventTap lives in Ispyx.app and exists only while touched.
|
|
2
|
+
import { callSync, post, onEvent } from './rpc.js';
|
|
3
|
+
|
|
4
|
+
export const HOOK_LINGER_MS = 30000;
|
|
5
|
+
|
|
6
|
+
/** True while every key of the chord is physically down right now. */
|
|
7
|
+
export const chordDown = (codes) => callSync('keysState', codes) === true;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Same shape as the Windows hook: touch() arms the app's event tap and pushes its removal lingerMs ahead; the
|
|
11
|
+
* app emits a `stop` event per press (auto-repeat counts as one, synthetic events are ignored there).
|
|
12
|
+
*/
|
|
13
|
+
export function installStopHotkey(onStop, lingerMs = HOOK_LINGER_MS) {
|
|
14
|
+
let armed = false;
|
|
15
|
+
let timer = null;
|
|
16
|
+
let off = null;
|
|
17
|
+
const expire = () => {
|
|
18
|
+
timer = null;
|
|
19
|
+
armed = false;
|
|
20
|
+
};
|
|
21
|
+
const stop = () => {
|
|
22
|
+
if (timer) clearTimeout(timer);
|
|
23
|
+
expire();
|
|
24
|
+
if (off) { off(); off = null; }
|
|
25
|
+
try { post('hookStop'); } catch { /* app gone */ }
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
touch() {
|
|
29
|
+
if (!armed) {
|
|
30
|
+
armed = true;
|
|
31
|
+
off ??= onEvent('stop', (ev) => { if (armed) onStop(ev?.reason ?? 'Option+Esc'); });
|
|
32
|
+
}
|
|
33
|
+
post('hookTouch', lingerMs);
|
|
34
|
+
if (timer) clearTimeout(timer);
|
|
35
|
+
timer = setTimeout(expire, lingerMs);
|
|
36
|
+
timer.unref();
|
|
37
|
+
},
|
|
38
|
+
active: () => armed,
|
|
39
|
+
stop,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// macOS platform layer: the API every platform exports (see ../CONTRACT.md), implemented by forwarding to
|
|
2
|
+
// Ispyx.app over its unix socket (see PROTOCOL.md). Nothing here touches the OS directly.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { callSync, callAsync } from './rpc.js';
|
|
7
|
+
|
|
8
|
+
export { chordDown, installStopHotkey, HOOK_LINGER_MS } from './hotkey.js';
|
|
9
|
+
export { Overlay } from './overlay.js';
|
|
10
|
+
export { parseChord, parseSequence, insertsContent } from './keys.js';
|
|
11
|
+
export { Layer, textMask } from './layer.js';
|
|
12
|
+
export { onEvent } from './rpc.js';
|
|
13
|
+
export { servePanel } from './panel.js';
|
|
14
|
+
|
|
15
|
+
export const name = 'darwin';
|
|
16
|
+
export const capabilities = { elevation: false };
|
|
17
|
+
export const stopLabel = 'Option+Esc';
|
|
18
|
+
|
|
19
|
+
export const dataDir = () => path.join(os.homedir(), 'Library', 'Application Support', 'Ispyx');
|
|
20
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
export const ensurePump = () => {};
|
|
22
|
+
export const pumpOnce = () => {};
|
|
23
|
+
|
|
24
|
+
let initialised = false;
|
|
25
|
+
/** Connects to the app (starting it if needed). foregroundWindow needs Accessibility, so a missing permission surfaces here. */
|
|
26
|
+
export function init() {
|
|
27
|
+
if (initialised) return;
|
|
28
|
+
callSync('foregroundWindow');
|
|
29
|
+
initialised = true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------- windows, monitors, pointer ----------
|
|
33
|
+
|
|
34
|
+
export const listWindows = (opts = {}) => callSync('listWindows', { includeUntitled: false, ...opts });
|
|
35
|
+
export const describeWindow = (id) => callSync('describeWindow', id);
|
|
36
|
+
export const getRect = (id) => callSync('getRect', id);
|
|
37
|
+
export const getTitle = (id) => callSync('getTitle', id);
|
|
38
|
+
export const getClass = (id) => callSync('getClass', id);
|
|
39
|
+
export const getPid = (id) => callSync('getPid', id);
|
|
40
|
+
export const processInfo = (pid) => callSync('processInfo', pid);
|
|
41
|
+
export const windowExists = (id) => !!id && callSync('windowExists', id) === true;
|
|
42
|
+
export const rootWindowAt = (x, y) => callSync('rootWindowAt', x, y);
|
|
43
|
+
export const foregroundWindow = () => callSync('foregroundWindow');
|
|
44
|
+
export const bringToForeground = (id) => callAsync('bringToForeground', id);
|
|
45
|
+
export const windowDpi = (id) => callSync('windowDpi', id);
|
|
46
|
+
export const selfIsElevated = () => false;
|
|
47
|
+
export const isScreenLocked = () => callSync('isScreenLocked') === true;
|
|
48
|
+
export const focusIsPassword = () => callSync('focusIsPassword') === true;
|
|
49
|
+
export const foregroundLayout = () => callSync('foregroundLayout');
|
|
50
|
+
export const virtualScreen = () => callSync('virtualScreen');
|
|
51
|
+
export const listMonitors = () => callSync('listMonitors');
|
|
52
|
+
export const cursorPos = () => callSync('cursorPos');
|
|
53
|
+
|
|
54
|
+
// ---------- input ----------
|
|
55
|
+
|
|
56
|
+
export const mouseMoveTo = (x, y) => { callSync('mouseMoveTo', x, y); };
|
|
57
|
+
export const mouseDown = (button = 'left') => { callSync('mouseDown', button); };
|
|
58
|
+
export const mouseUp = (button = 'left') => { callSync('mouseUp', button); };
|
|
59
|
+
export const mouseWheel = (notches) => { callSync('mouseWheel', notches); };
|
|
60
|
+
export const mouseHWheel = (notches) => { callSync('mouseHWheel', notches); };
|
|
61
|
+
export const keysDown = (codes) => { callSync('keysDown', codes); };
|
|
62
|
+
export const keysUp = (codes) => { callSync('keysUp', codes); };
|
|
63
|
+
export const pressVirtualKeys = (codes) => { callSync('pressVirtualKeys', codes); };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One character at a time with a pause after each (delays[i] ms, never under 8 ms), like a person typing; the
|
|
67
|
+
* same loop as the Windows one. The app types each code point with typeChar (10 = Return; CR is skipped).
|
|
68
|
+
*/
|
|
69
|
+
export async function typeUnicode(text, check, delays) {
|
|
70
|
+
let i = 0;
|
|
71
|
+
for (const ch of text) {
|
|
72
|
+
if (ch === '\r') { i++; continue; }
|
|
73
|
+
check?.();
|
|
74
|
+
await callAsync('typeChar', ch.codePointAt(0));
|
|
75
|
+
await sleep(Math.max(8, delays?.[i++] ?? 8));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------- capture ----------
|
|
80
|
+
|
|
81
|
+
function readCapture(r) {
|
|
82
|
+
const need = r.width * r.height * 4;
|
|
83
|
+
let data;
|
|
84
|
+
try {
|
|
85
|
+
data = fs.readFileSync(r.file);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
throw new Error(`Could not read the capture file from Ispyx (${e.code ?? e.message}).`);
|
|
88
|
+
}
|
|
89
|
+
try { fs.unlinkSync(r.file); } catch { /* already gone */ }
|
|
90
|
+
if (data.length < need) throw new Error(`The capture file is too short (${data.length} of ${need} bytes).`);
|
|
91
|
+
return new Uint8Array(data.buffer, data.byteOffset, need);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function captureScreenRect(rect) {
|
|
95
|
+
const r = callSync('captureScreenRect', rect);
|
|
96
|
+
return { bits: readCapture(r), width: r.width, height: r.height, rect: r.rect };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function captureWindow(id) {
|
|
100
|
+
const r = callSync('captureWindow', id);
|
|
101
|
+
return { bits: readCapture(r), width: r.width, height: r.height, rect: r.rect, method: r.method };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------- clipboard ----------
|
|
105
|
+
|
|
106
|
+
export const getClipboardText = () => callSync('getClipboardText');
|
|
107
|
+
export const setClipboardText = (text) => { callSync('setClipboardText', text); };
|
|
108
|
+
export const setClipboardFile = (file) => { callSync('setClipboardFile', file); };
|
|
109
|
+
|
|
110
|
+
// ---------- dialog ----------
|
|
111
|
+
|
|
112
|
+
export async function showDialog(opts, ms) {
|
|
113
|
+
try {
|
|
114
|
+
return await callAsync('showDialog', opts, ms);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return { cancel: true, reason: `dialog failed: ${e.message}` };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// "cmd+shift+t" style chords -> macOS virtual key codes (kVK_* from Carbon's HIToolbox/Events.h).
|
|
2
|
+
//
|
|
3
|
+
// The one subtle thing: `ctrl` is the literal Control key (kVK_Control, 59), NOT Command. Chords written for
|
|
4
|
+
// Windows ("ctrl+c") therefore do not copy on a Mac; use "cmd" (also spelled win / meta / super) for that.
|
|
5
|
+
// `alt` is Option. Codes are physical-key (ANSI layout) codes, as the OS expects for key events.
|
|
6
|
+
export const KVK = {
|
|
7
|
+
a: 0, s: 1, d: 2, f: 3, h: 4, g: 5, z: 6, x: 7, c: 8, v: 9, b: 11, q: 12, w: 13, e: 14, r: 15, y: 16, t: 17,
|
|
8
|
+
o: 31, u: 32, i: 34, p: 35, l: 37, j: 38, k: 40, n: 45, m: 46,
|
|
9
|
+
1: 18, 2: 19, 3: 20, 4: 21, 6: 22, 5: 23, 9: 25, 7: 26, 8: 28, 0: 29,
|
|
10
|
+
'=': 24, '-': 27, ']': 30, '[': 33, "'": 39, ';': 41, '\\': 42, ',': 43, '/': 44, '.': 47, '`': 50,
|
|
11
|
+
return: 36, tab: 48, space: 49, delete: 51 /* backspace */, escape: 53, command: 55, shift: 56, capslock: 57,
|
|
12
|
+
option: 58, control: 59, rshift: 60, roption: 61, rcontrol: 62, rcommand: 54, fn: 63,
|
|
13
|
+
forwarddelete: 117, home: 115, end: 119, pageup: 116, pagedown: 121, help: 114,
|
|
14
|
+
left: 123, right: 124, down: 125, up: 126,
|
|
15
|
+
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97, f7: 98, f8: 100, f9: 101, f10: 109, f11: 103, f12: 111,
|
|
16
|
+
f13: 105, f14: 107, f15: 113, f16: 106, f17: 64, f18: 79, f19: 80, f20: 90,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const NAMED = {
|
|
20
|
+
win: 55, windows: 55, meta: 55, cmd: 55, command: 55, super: 55, lwin: 55, rwin: 54,
|
|
21
|
+
ctrl: 59, control: 59, shift: 56, alt: 58, option: 58, opt: 58, menu: 58,
|
|
22
|
+
enter: 36, return: 36, esc: 53, escape: 53, tab: 48, space: 49,
|
|
23
|
+
backspace: 51, delete: 117, del: 117, insert: 114, ins: 114, // Mac "delete" is backspace; forward delete is the Windows Delete
|
|
24
|
+
home: 115, end: 119, pageup: 116, pgup: 116, pagedown: 121, pgdn: 121,
|
|
25
|
+
left: 123, up: 126, right: 124, down: 125,
|
|
26
|
+
',': 43, '.': 47, '-': 27, '=': 24, '/': 44, ';': 41, "'": 39, '[': 33, ']': 30, '\\': 42, '`': 50,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const CMD = new Set([55, 54]);
|
|
30
|
+
const V = 9;
|
|
31
|
+
|
|
32
|
+
export function parseChord(chord) {
|
|
33
|
+
const parts = chord.trim().toLowerCase().split('+').map((s) => s.trim()).filter(Boolean);
|
|
34
|
+
if (!parts.length) throw new Error('Empty key chord.');
|
|
35
|
+
return parts.map((p) => {
|
|
36
|
+
if (NAMED[p] !== undefined) return NAMED[p];
|
|
37
|
+
if (/^f([1-9]|1\d|20)$/.test(p)) return KVK[p];
|
|
38
|
+
if (/^[a-z0-9]$/.test(p)) return KVK[p];
|
|
39
|
+
throw new Error(`Unknown key "${p}". Use names like cmd, ctrl, shift, alt, enter, tab, esc, f5, a-z, 0-9 (macOS has no f21-f24).`);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** "cmd+a cmd+c" -> two chords. */
|
|
44
|
+
export const parseSequence = (s) => s.trim().split(/\s+/).filter(Boolean).map(parseChord);
|
|
45
|
+
|
|
46
|
+
/** Chords that paste (Cmd+V, Cmd+Shift+V, Cmd+Option+Shift+V): must not go into a password field. */
|
|
47
|
+
export function insertsContent(codes) {
|
|
48
|
+
return codes.some((k) => CMD.has(k)) && codes.includes(V) && !codes.includes(59);
|
|
49
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Effect windows and text masks on macOS. The app owns the window; the pixels live in a file that both
|
|
2
|
+
// processes map into memory, so no pixel data crosses the socket (PROTOCOL.md, "Layers").
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { callSync, post } from './rpc.js';
|
|
6
|
+
|
|
7
|
+
const PROT_READ = 1, PROT_WRITE = 2, MAP_SHARED = 1, O_RDWR = 2;
|
|
8
|
+
|
|
9
|
+
let libc = null;
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
/** koffi and libSystem load on first use (Layer's constructor is synchronous), so importing this works on every OS. */
|
|
12
|
+
function libcSync() {
|
|
13
|
+
if (libc) return libc;
|
|
14
|
+
const koffi = require('koffi');
|
|
15
|
+
const lib = koffi.load('/usr/lib/libSystem.B.dylib');
|
|
16
|
+
libc = {
|
|
17
|
+
koffi,
|
|
18
|
+
open: lib.func('int open(const char *path, int flags)'),
|
|
19
|
+
close: lib.func('int close(int fd)'),
|
|
20
|
+
mmap: lib.func('void *mmap(void *addr, size_t len, int prot, int flags, int fd, int64_t off)'),
|
|
21
|
+
munmap: lib.func('int munmap(void *addr, size_t len)'),
|
|
22
|
+
};
|
|
23
|
+
return libc;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class Layer {
|
|
27
|
+
constructor(width, height) {
|
|
28
|
+
this.width = width;
|
|
29
|
+
this.height = height;
|
|
30
|
+
this.shown = false;
|
|
31
|
+
this.id = 0;
|
|
32
|
+
this.map = null;
|
|
33
|
+
const len = width * height * 4;
|
|
34
|
+
const r = callSync('layerCreate', { width, height });
|
|
35
|
+
this.id = r.layer;
|
|
36
|
+
try {
|
|
37
|
+
const c = libcSync();
|
|
38
|
+
const fd = c.open(r.file, O_RDWR);
|
|
39
|
+
if (fd < 0) throw new Error(`cannot open ${r.file}`);
|
|
40
|
+
let ptr;
|
|
41
|
+
try {
|
|
42
|
+
ptr = c.mmap(null, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
|
43
|
+
} finally {
|
|
44
|
+
c.close(fd); // the mapping stays valid after close
|
|
45
|
+
}
|
|
46
|
+
if (!ptr) throw new Error(`mmap of ${r.file} failed`);
|
|
47
|
+
this.map = ptr;
|
|
48
|
+
this.px = new Uint8Array(c.koffi.view(ptr, len));
|
|
49
|
+
} catch (e) {
|
|
50
|
+
post('layerDestroy', { layer: this.id });
|
|
51
|
+
throw new Error(`Could not map the effect window buffer: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Shows the top-left `width` x `height` part of the buffer, starting at row srcY, at (x, y) with opacity `alpha` (0-255). */
|
|
56
|
+
present(x, y, alpha, height = this.height, width = this.width, srcY = 0) {
|
|
57
|
+
post('layerPresent', {
|
|
58
|
+
layer: this.id,
|
|
59
|
+
x: Math.round(x),
|
|
60
|
+
y: Math.round(y),
|
|
61
|
+
alpha: Math.max(0, Math.min(255, Math.round(alpha))),
|
|
62
|
+
height: Math.max(1, Math.min(this.height, Math.round(height))),
|
|
63
|
+
width: Math.max(1, Math.min(this.width, Math.round(width))),
|
|
64
|
+
srcY: Math.max(0, Math.round(srcY)),
|
|
65
|
+
});
|
|
66
|
+
this.shown = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
destroy() {
|
|
70
|
+
if (!this.id) return;
|
|
71
|
+
try { post('layerDestroy', { layer: this.id }); } catch { /* app gone */ }
|
|
72
|
+
this.id = 0;
|
|
73
|
+
if (this.map) {
|
|
74
|
+
try { libc.munmap(this.map, this.width * this.height * 4); } catch { /* already unmapped */ }
|
|
75
|
+
this.map = null;
|
|
76
|
+
}
|
|
77
|
+
this.px = new Uint8Array(0);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Anti-aliased text coverage (0-255 per pixel) in the system UI font, rendered by the app. */
|
|
82
|
+
export function textMask(text, heightPx = 22, weight = 600) {
|
|
83
|
+
const r = callSync('textMask', { text, heightPx, weight });
|
|
84
|
+
const data = fs.readFileSync(r.file);
|
|
85
|
+
if (data.length < r.width * r.height) throw new Error(`textMask file is too short (${data.length} bytes for ${r.width}x${r.height}).`);
|
|
86
|
+
return { width: r.width, height: r.height, mask: new Uint8Array(data.buffer, data.byteOffset, r.width * r.height) };
|
|
87
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The "working / stopped / cancelled" banner. Timers and texts are the same as the Windows overlay; the app
|
|
2
|
+
// draws it (overlayShow / overlayHide). Calls are fire-and-forget so a banner can never stall a tool.
|
|
3
|
+
import { post, callAsync } from './rpc.js';
|
|
4
|
+
|
|
5
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
const NAME = 'ispyx';
|
|
7
|
+
|
|
8
|
+
export class Overlay {
|
|
9
|
+
constructor({ enabled = true, hideAfterMs = 1500, stopLabel = 'Option+Esc' } = {}) {
|
|
10
|
+
this.stopLabel = stopLabel;
|
|
11
|
+
this.enabled = enabled;
|
|
12
|
+
this.hideAfterMs = hideAfterMs;
|
|
13
|
+
this.active = 0;
|
|
14
|
+
this.stopped = false;
|
|
15
|
+
this.visible = false;
|
|
16
|
+
this.text = '';
|
|
17
|
+
this.timer = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_show(text) {
|
|
21
|
+
this.text = text;
|
|
22
|
+
this.visible = true;
|
|
23
|
+
post('overlayShow', { text, kind: this.stopped ? 'stopped' : 'running' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_hide() {
|
|
27
|
+
this.visible = false;
|
|
28
|
+
post('overlayHide');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A tool started: show the banner. Call done() when it ends. */
|
|
32
|
+
begin(label) {
|
|
33
|
+
if (!this.enabled) return;
|
|
34
|
+
this.active++;
|
|
35
|
+
clearTimeout(this.timer);
|
|
36
|
+
this._show(`${NAME} is working: ${label} | To stop press ${this.stopLabel}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
done() {
|
|
40
|
+
if (!this.enabled) return;
|
|
41
|
+
this.active = Math.max(0, this.active - 1);
|
|
42
|
+
if (this.active === 0) {
|
|
43
|
+
clearTimeout(this.timer);
|
|
44
|
+
this.timer = setTimeout(() => this._hide(), this.hideAfterMs);
|
|
45
|
+
this.timer.unref();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The user stopped the server: turn the banner red and keep it for a few seconds. */
|
|
50
|
+
stop(reason) {
|
|
51
|
+
if (!this.enabled) return;
|
|
52
|
+
this.stopped = true;
|
|
53
|
+
clearTimeout(this.timer);
|
|
54
|
+
this._show(`${NAME} STOPPED (${reason})`);
|
|
55
|
+
this.timer = setTimeout(() => this._hide(), 5000);
|
|
56
|
+
this.timer.unref();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** First stop press: the running tool was cancelled; a second press within the window shuts the server down. */
|
|
60
|
+
cancel(reason, windowMs) {
|
|
61
|
+
if (!this.enabled) return;
|
|
62
|
+
this.active = 0;
|
|
63
|
+
clearTimeout(this.timer);
|
|
64
|
+
this._show(`${NAME} CANCELLED (${reason}) | press again to shut down`);
|
|
65
|
+
this.timer = setTimeout(() => this._hide(), windowMs);
|
|
66
|
+
this.timer.unref();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Hide the banner while the agent takes a screenshot, so it never covers what the agent needs to see. */
|
|
70
|
+
async hiddenDuring(fn) {
|
|
71
|
+
const visible = this.enabled && this.visible;
|
|
72
|
+
if (visible) {
|
|
73
|
+
this._hide();
|
|
74
|
+
try { await callAsync('overlayVisible'); } catch { /* ordering is what matters */ }
|
|
75
|
+
await sleep(60); // let the compositor drop it
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return await fn();
|
|
79
|
+
} finally {
|
|
80
|
+
if (visible) this._show(this.text);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The server side of the Ispyx window on macOS. The app shows the shared panel page (src/core/panel/page.html) and
|
|
2
|
+
// owns what it knows itself (permissions, start at login, connect info); activity, effects, password and denylist
|
|
3
|
+
// live in the server, so the app asks the owner for them:
|
|
4
|
+
// app -> client event { event: 'panelRequest', req, kind: 'state' | 'action', name, value }
|
|
5
|
+
// client -> app call panelReply({ req, ok, result } | { req, ok: false, error })
|
|
6
|
+
// (`req`, not `id`: a line with an id is taken for the answer to a call, see rpc-worker.js)
|
|
7
|
+
// Only the owner serves (it is the one process that runs the tools); the app takes the first reply.
|
|
8
|
+
import { onEvent, post } from './rpc.js';
|
|
9
|
+
|
|
10
|
+
/** model: { getState(), actions: { name(value) } } from core/resident.js panelModel. Returns an unsubscribe function. */
|
|
11
|
+
export function servePanel(model) {
|
|
12
|
+
return onEvent('panelRequest', async (m) => {
|
|
13
|
+
const { req, kind, name, value } = m ?? {};
|
|
14
|
+
if (req === undefined) return;
|
|
15
|
+
try {
|
|
16
|
+
let result;
|
|
17
|
+
if (kind === 'state') result = model.getState();
|
|
18
|
+
else if (kind === 'action' && Object.hasOwn(model.actions, name)) result = await model.actions[name](value ?? undefined);
|
|
19
|
+
else throw new Error(`Unknown panel action ${name}`);
|
|
20
|
+
post('panelReply', { req, ok: true, result: result ?? null });
|
|
21
|
+
} catch (e) {
|
|
22
|
+
post('panelReply', { req, ok: false, error: e.message });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|