ispyx 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -2,151 +2,107 @@
2
2
 
3
3
  # ispyx
4
4
 
5
- A local desktop-control [MCP](https://modelcontextprotocol.io) server. It lets an AI assistant use your computer the way a person does: look at the screen or at one window, move the mouse, click, drag, scroll, type, press hotkeys and use the clipboard.
5
+ A local desktop-control [MCP](https://modelcontextprotocol.io) server. It lets an AI assistant use your computer the way a person does: look at the screen, move the mouse, click, drag, scroll, type, press hotkeys and use the clipboard.
6
6
 
7
- - Runs on your machine, talks to the MCP client over **stdio**, opens **no network port**.
8
- - Node 22+, `koffi` for the Windows API, `@modelcontextprotocol/sdk` for MCP.
9
- - Windows and macOS (see [Platform status](#platform-status)).
7
+ - Runs on your machine, talks to the AI app over **stdio**, opens **no network port** (the settings panel only listens while it is open).
8
+ - Windows and macOS. Windows needs Node.js 22+; the macOS app carries its own.
9
+ - One resident app (tray icon on Windows, menu-bar icon on macOS) does the work. Every AI app connects through a thin `ispyx mcp` process, so there is one stop key, one banner and one set of settings.
10
10
 
11
- ## Features
11
+ ## Install
12
12
 
13
- - **Tools** for windows, monitors, screenshots (screen, monitor, region or a single window), mouse (click, move, drag, scroll, press/release), keyboard (typing, hotkeys, hold/release) and clipboard (text, image, files).
14
- - **Human-paced input.** The pointer glides along a gently curved path to each target, scrolling is smoothed, and typing goes one character at a time with natural pauses.
15
- - **On-screen feedback.** An eye opens and a scan line sweeps the captured area after a screenshot; clicks, drags, scrolls, key presses, typing and waits each get a small animation; `list_windows` sends out a sonar ping that lights up window frames. A banner shows while a tool runs. All of it is click-through and can be turned off.
16
- - **Safety.**
17
- - **Alt+Esc** (Option+Esc on macOS) stops everything: one press cancels all running and queued calls, a second press within 5 s shuts the server down.
18
- - A **denylist** of programs that are never driven (password managers by default).
19
- - Refuses to type into **password fields**.
20
- - An optional **password gate** that asks you before the first tool call.
21
- - An action log of everything the server did.
22
- - **Single resident app:** on Windows one background process (`ispyx --resident`, tray icon) does the work and every MCP client talks to it through a thin `ispyx mcp` process, so there is one stop key, one banner and one unlock state.
13
+ ### Windows
23
14
 
24
- ## Install on Windows
25
-
26
- Open PowerShell (Start menu, type "PowerShell") and paste:
15
+ Open PowerShell and paste:
27
16
 
28
17
  ```
29
18
  irm https://raw.githubusercontent.com/murat-mehmet/ispyx/master/scripts/install.ps1 | iex
30
19
  ```
31
20
 
32
- No administrator rights are needed. The script explains each step as it goes. It:
33
-
34
- - finds Node.js 22+ or installs it for your user only (winget, or the official zip with a checksum check);
35
- - installs ispyx with `npm install -g ispyx`;
36
- - puts an `Ispyx` shortcut on your Desktop, turns on start at login and starts ispyx.
21
+ No administrator rights needed. It finds or installs Node.js 22+ for your user, runs `npm install -g ispyx`, adds an `Ispyx` shortcut to your Desktop, turns on start at login and starts ispyx. Manual: install Node.js from [nodejs.org](https://nodejs.org), then `npm i -g ispyx` and run `ispyx`.
37
22
 
38
- Then it prints the commands to connect your AI app, for Claude Code: `claude mcp add ispyx --scope user -- ispyx mcp`.
23
+ Uninstall: run the installer with `-Uninstall` (add `-Purge` to also delete settings and logs). Options and troubleshooting: [docs/windows-install.md](docs/windows-install.md).
39
24
 
40
- **Opening it.** Double-click the `Ispyx` Desktop shortcut or run `ispyx`. ispyx lives in the tray (next to the clock, maybe under the `^` arrow); right-click the icon for its menu (panel, start at login, quit). `ispyx update` updates it. Alt+Esc is the emergency stop.
25
+ ### macOS
41
26
 
42
- **Uninstall.** Run the installer again with `-Uninstall` (add `-Purge` to also delete settings and logs):
27
+ Open Terminal and paste:
43
28
 
44
29
  ```
45
- & ([scriptblock]::Create((irm https://raw.githubusercontent.com/murat-mehmet/ispyx/master/scripts/install.ps1))) -Uninstall
30
+ curl -fsSL https://github.com/murat-mehmet/ispyx/releases/latest/download/install.sh | sh
46
31
  ```
47
32
 
48
- **Manual install.** Install Node.js 22+ from [nodejs.org](https://nodejs.org), then `npm i -g ispyx` and run `ispyx`.
33
+ It downloads the newest `Ispyx.app`, checks it (SHA-256, the developer's signature, notarised) and puts it in `/Applications`, then opens it; allow the two permissions its window asks for. Or download the `.dmg` from the [Releases](https://github.com/murat-mehmet/ispyx/releases) page and drag Ispyx to Applications. The app carries its own Node, nothing else is needed. Uninstall: `... | sh -s -- --uninstall` (add `--purge` for settings and logs). Details: [native/macos/README.md](native/macos/README.md).
49
34
 
50
- Details, options and troubleshooting: [docs/windows-install.md](docs/windows-install.md).
35
+ ### Updating
51
36
 
52
- ## Install and run (npx, macOS and advanced)
37
+ The panel shows when a newer version is out, with an **Update** button. Or run `ispyx update` (macOS: `/Applications/Ispyx.app/Contents/Resources/ispyx update`, or the install command above again). Windows reinstalls the npm package; macOS replaces the app, which keeps its permissions. Settings and logs stay.
53
38
 
54
- No installation is needed, the client starts it with `npx`.
39
+ ## Connect your AI app
55
40
 
56
- Claude Code:
41
+ Open the panel (tray or menu-bar icon, **Open**): it shows the exact command for your machine with a copy button. Or by hand:
57
42
 
58
43
  ```
59
- claude mcp add ispyx --scope user -- npx -y ispyx
44
+ claude mcp add ispyx --scope user -- ispyx mcp
60
45
  ```
61
46
 
62
- Claude Desktop and other MCP clients (JSON form; on Windows some clients need `cmd /c` around `npx`):
47
+ Other apps (Claude Desktop, LM Studio, ...), JSON form. On Windows some apps need `"command": "cmd", "args": ["/c", "ispyx", "mcp"]`:
63
48
 
64
49
  ```json
65
50
  {
66
51
  "mcpServers": {
67
- "ispyx": {
68
- "command": "npx",
69
- "args": ["-y", "ispyx"]
70
- }
52
+ "ispyx": { "command": "ispyx", "args": ["mcp"] }
71
53
  }
72
54
  }
73
55
  ```
74
56
 
75
- Requires Node.js 22 or newer.
76
-
77
- ### Windows: the resident app
78
-
79
- On Windows ispyx is a single resident app, like `Ispyx.app` on macOS:
80
-
81
- - `ispyx` (in a console, or from a shortcut) starts the resident app hidden in the background if it is not running, opens its panel and exits.
82
- - `ispyx --resident` is the resident itself (tray icon, effects, stop key). A second one exits at once. It writes no output; errors go to `%APPDATA%\ispyx\ispyx.log`.
83
- - `ispyx mcp` is the stdio MCP server for your client. It forwards every call to the resident and starts it first if needed. Prefer registering `npx -y ispyx mcp`; an old registration without `mcp` still works (it is detected by the missing console).
84
- - `ispyx quit` stops the resident (tray menu: Quit).
57
+ On macOS use `/Applications/Ispyx.app/Contents/Resources/ispyx` as the command, without `mcp`. The AI app starts ispyx when needed, so start at login is optional.
85
58
 
86
- Details: [src/core/RESIDENT.md](src/core/RESIDENT.md).
59
+ ## Using it
87
60
 
88
- macOS: install `Ispyx.app` from the [Releases](https://github.com/murat-mehmet/ispyx/releases) page instead, which carries its own Node and needs nothing else. See [native/macos/README.md](native/macos/README.md).
89
-
90
- ## Configuration
91
-
92
- Settings are applied in this order, later wins: built-in defaults, `config.json` (in the package folder, or the file named by `ISPYX_CONFIG`), environment variables. See `config.example.json`.
93
-
94
- | `config.json` key | Environment variable | Default | Meaning |
95
- | --- | --- | --- | --- |
96
- | (the file itself) | `ISPYX_CONFIG` | `config.json` in the package root | Path of a config file. |
97
- | `denylist` | `ISPYX_DENYLIST` (comma separated) | `keepass.exe`, `keepassxc.exe`, `1password.exe`, `bitwarden.exe`; macOS: `1Password`, `Bitwarden`, `KeePassXC`, `Keychain Access`, `Enpass` (app names, case-insensitive) | Programs that are never driven or focused. |
98
- | `logPath` | `ISPYX_LOG` | `logs/actions.jsonl` (macOS: `~/Library/Application Support/Ispyx/logs/actions.jsonl`) | Action log (JSON lines). |
99
- | `auth` | `ISPYX_AUTH` (`0`/`1`) | `false` | Ask for a password before the first tool call. |
100
- | `authFile` | `ISPYX_AUTH_FILE` | `%APPDATA%\ispyx\auth.json` (macOS: `~/Library/Application Support/Ispyx/auth.json`) | Where the password hash is stored. |
101
- | `overlay` | `ISPYX_OVERLAY` (`0`/`1`) | `true` | The "ispyx is working" banner. |
102
- | `animations` | `ISPYX_ANIMATIONS` (`0`/`1`) | `true` | The eye, scan and other effects. |
103
- | `typeSpeed` | `ISPYX_TYPE_SPEED` | `1` | Typing speed multiplier (2 = twice as fast, 0 = fixed 8 ms per character). |
104
- | `stopChord` | `ISPYX_STOP_CHORD` | `alt+esc` | Emergency stop keys (mainly for tests). |
105
- | `maxTypeLength` | | `10000` | Longest text one `type_text` call accepts. |
106
- | `moveMinMs`, `moveMaxMs` | | `300`, `1000` | Duration range of pointer glides. |
107
- | `typeMinMs`, `typeMaxMs`, `typeCapMs` | | `40`, `120`, `60000` | Pause range per typed character; a text is sped up to finish within the cap. |
108
-
109
- The older `WIN_INPUT_*` names (and `WIN_INPUT_MCP_CONFIG`) still work as fallbacks; an `ISPYX_*` variable wins when both are set.
61
+ - **Panel.** Permissions (macOS), recent activity, effects, password, denylist, start at login, the connect command. Opens in its own small window and closes when you close it.
62
+ - **Commands.** `ispyx` starts the resident app and opens the panel, `ispyx mcp` is the stdio server for an AI app, `ispyx quit` stops it, `ispyx update` installs the latest version and restarts it. On macOS they are `/Applications/Ispyx.app/Contents/Resources/ispyx update` / `quit` (the app starts the resident itself). Errors go to `%APPDATA%\ispyx\ispyx.log` (macOS: `~/Library/Application Support/Ispyx/ispyx.log`). Details: [src/core/RESIDENT.md](src/core/RESIDENT.md).
63
+ - **Stop.** **Alt+Esc** (Option+Esc on macOS): one press cancels everything running, a second press within 5 s shuts the server down.
64
+ - **Feedback.** A banner shows while a tool runs (green working, amber cancelled, red stopped). An eye and a scan line show on screenshots, clicks, scrolls and typing get small animations. All of it is click-through and can be turned off in the panel.
110
65
 
111
66
  ## Tools
112
67
 
113
68
  | Tool | What it does |
114
69
  | --- | --- |
115
- | `list_windows` | Visible top-level windows with handle, title, class, process, rectangle, whether allowed (not denylisted) and whether elevated. |
116
- | `list_monitors` | Monitor rectangles and the virtual desktop bounds. |
117
- | `cursor_position` | Current pointer position. |
118
- | `focus_window` | Restore and bring a window to the front. |
119
- | `screenshot_screen` | PNG of one monitor, all monitors, or a region. |
120
- | `screenshot_window` | PNG of one window (also GPU-rendered content), optionally cropped to a region. |
121
- | `click` | Left, right or middle click; double or triple; with held modifiers. |
122
- | `move` | Move the pointer. |
123
- | `mouse_down`, `mouse_up` | Press and release a mouse button. |
124
- | `drag` | Press at one point, glide to another, release. |
125
- | `scroll` | Smooth vertical and/or horizontal wheel, with optional modifiers. |
126
- | `type_text` | Type Unicode text into the focused control at a human pace; refused in password fields. |
127
- | `press_keys` | Key chords such as `ctrl+v`, `alt+tab`, `win+r`. |
128
- | `key_down`, `key_up` | Hold and release keys. |
70
+ | `list_windows`, `list_monitors`, `cursor_position` | What is on the desktop and where the pointer is. |
71
+ | `focus_window` | Bring a window to the front. |
72
+ | `screenshot_screen`, `screenshot_window` | PNG of a monitor, all monitors, a region or one window. |
73
+ | `click`, `move`, `drag`, `scroll` | Pointer actions. Clicks take double/triple and held modifiers. |
74
+ | `mouse_down`, `mouse_up` | Press and release a button (the pointer moves straight while one is held). |
75
+ | `type_text`, `press_keys`, `key_down`, `key_up` | Typing, hotkeys such as `ctrl+v`, hold and release. |
76
+ | `get_clipboard`, `set_clipboard` | Read text; write text, an image or a file. |
129
77
  | `wait` | Pause up to 60 s. |
130
- | `get_clipboard`, `set_clipboard` | Read clipboard text; write text, an image or a file. |
131
78
 
132
- Typical flow: `screenshot_screen`, read coordinates off the image, `click` (with `space: "shot"`) or `type_text`, then `screenshot_screen` to check. Details on coordinates and per-tool behaviour are in [docs/behavior.md](docs/behavior.md).
79
+ Typical flow: `screenshot_screen`, read coordinates off the image, `click` (with `space: "shot"`) or `type_text`, then `screenshot_screen` to check. Coordinates and per-tool behaviour: [docs/behavior.md](docs/behavior.md). Input is human-paced: the pointer glides along a curved path, scrolling is smoothed, typing has natural pauses.
133
80
 
134
- ## Safety notes
81
+ ## Safety
135
82
 
136
- - **Alt+Esc** (Option+Esc on macOS) is the emergency stop. It is caught by a low-level keyboard hook, so it does not also cycle windows.
137
- - **Elevated (administrator) windows cannot be controlled**: Windows ignores input from a normal process (UIPI). The server refuses with a plain message; it works if the server itself runs elevated. UAC prompts, the lock screen and Ctrl+Alt+Del run on the secure desktop where no input or capture is possible.
138
- - **Denylist.** Programs on the denylist are never focused, clicked or typed into, and show up as not allowed in `list_windows`. The default covers common password managers.
139
- - **No credentials.** The server never reads, stores or types saved passwords. Typing into password fields is refused.
140
- - **Action log.** Every action, refusal and error is logged locally, including typed text (except text aimed at a password field). Clipboard reads log the length only.
141
- - This tool gives an AI control of your computer. Only connect it to clients you trust, and consider turning on the password gate.
83
+ - **Alt+Esc** is caught by a low-level keyboard hook, so it does not also cycle windows.
84
+ - **Denylist** of programs that are never focused or driven (password managers by default); editable in the panel.
85
+ - **No credentials.** Typing into password fields is refused; ispyx never reads or types saved passwords.
86
+ - **Password gate** (optional, set it in the panel): asks before the first tool call.
87
+ - **Action log.** Every action, refusal and error is logged locally, including typed text (not text aimed at a password field). Clipboard reads log the length only.
88
+ - **Elevated windows** cannot be controlled on Windows (UIPI), and neither can UAC prompts or the lock screen.
89
+ - This gives an AI control of your computer. Connect it only to apps you trust.
142
90
 
143
- ## Platform status
91
+ ## Configuration
144
92
 
145
- | Platform | Status |
146
- | --- | --- |
147
- | Windows | Supported. |
148
- | macOS | Supported, macOS 13 or later (tested on Apple Silicon with macOS 26; the app is universal, Intel Macs are not tested). A menu-bar app (`native/macos`, `Ispyx.app`) does the OS work and bundles Node and this server. There is no elevation concept; the stop key is Option+Esc. |
149
- | Linux | Not planned yet. |
93
+ Most settings are in the panel. Files and variables, later wins: built-in defaults, `config.json` (package folder or the file in `ISPYX_CONFIG`), the per-user `%APPDATA%\ispyx\config.json` (macOS: `~/Library/Application Support/Ispyx/`), environment variables. See `config.example.json`.
94
+
95
+ | Key | Variable | Default |
96
+ | --- | --- | --- |
97
+ | `denylist` | `ISPYX_DENYLIST` (comma separated) | common password managers |
98
+ | `auth` | `ISPYX_AUTH` (`0`/`1`) | `false` |
99
+ | `overlay`, `animations` | `ISPYX_OVERLAY`, `ISPYX_ANIMATIONS` (`0`/`1`) | `true` |
100
+ | `typeSpeed` | `ISPYX_TYPE_SPEED` | `1` (2 = twice as fast) |
101
+ | `stopChord` | `ISPYX_STOP_CHORD` | `alt+esc` |
102
+ | `logPath` | `ISPYX_LOG` | `logs/actions.jsonl` |
103
+ | `authFile` | `ISPYX_AUTH_FILE` | `%APPDATA%\ispyx\auth.json` |
104
+
105
+ More keys (`maxTypeLength`, `moveMinMs`/`moveMaxMs`, `typeMinMs`/`typeMaxMs`/`typeCapMs`) are in `config.example.json`. The older `WIN_INPUT_*` names still work as fallbacks.
150
106
 
151
107
  ## Development
152
108
 
@@ -154,31 +110,16 @@ Typical flow: `screenshot_screen`, read coordinates off the image, `click` (with
154
110
  git clone https://github.com/murat-mehmet/ispyx
155
111
  cd ispyx
156
112
  npm install
157
- node src/server.js
113
+ npm test # unit checks; other scripts in docs/testing.md
158
114
  ```
159
115
 
160
- Layout:
161
-
162
- - `src/core/` shared, platform independent: MCP server and tools, safety guard, input motion, image encoding, password gate, single-instance IPC.
163
- - `src/platform/` the platform layer behind `src/platform/index.js`; the contract is in `src/platform/CONTRACT.md`. `win32/` is the Windows implementation (Win32 through `koffi`, PowerShell helpers), `darwin/` the macOS one (a client of `Ispyx.app`).
164
- - `src/effects/` the on-screen animations.
165
- - `native/macos/` the macOS app (Swift).
166
- - `src/server.js` is the entry point (`bin`) and only loads `src/core/server.js`.
167
- - `test/` tests; most drive the real desktop of the machine they run on.
168
-
169
- Releases (npm package and macOS DMG) are made by pushing a version tag, see [docs/releasing.md](docs/releasing.md).
170
-
171
- ```
172
- npm test # unit checks (no desktop needed for most)
173
- npm run smoke # drives the real server against Notepad
174
- npm run motion-test # pointer glide duration, curvature, exact arrival
175
- npm run typing-test # typing pace and accuracy
176
- npm run instances-test
177
- npm run shutdown-test
178
- ```
116
+ - `src/core/` shared: MCP server and tools, safety guard, input motion, resident app, panel, password gate.
117
+ - `src/platform/` the OS layer (contract in `src/platform/CONTRACT.md`): `win32/` (Win32 through `koffi`), `darwin/` (a client of `Ispyx.app`).
118
+ - `src/effects/` animations. `native/macos/` the macOS app (Swift). `scripts/install.ps1` the Windows installer.
119
+ - Releases (npm package and macOS DMG) are made by pushing a version tag: [docs/releasing.md](docs/releasing.md).
179
120
 
180
- See [docs/testing.md](docs/testing.md) for all test scripts. Tests use their own `ISPYX_*`/`WIN_INPUT_*` settings (own stop chord, own pipe) so they never interfere with a server you are using.
121
+ Tests drive the real desktop and use their own settings (own stop chord, own pipe), so they never touch a server you are using.
181
122
 
182
123
  ## License
183
124
 
184
- Not yet decided. `package.json` currently says `UNLICENSED`.
125
+ See [LICENSE](LICENSE).
@@ -11,5 +11,6 @@
11
11
  "moveMaxMs": 1000,
12
12
  "animations": true,
13
13
  "overlay": true,
14
- "auth": false
14
+ "auth": false,
15
+ "updateCheck": true
15
16
  }
package/package.json CHANGED
@@ -1,20 +1,40 @@
1
1
  {
2
2
  "name": "ispyx",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Local desktop-control MCP server: lets an AI use your keyboard, mouse and screen (windows, screenshots, clicks, typing, clipboard). stdio only, Windows and macOS.",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
7
- "bin": { "ispyx": "src/server.js" },
7
+ "bin": {
8
+ "ispyx": "src/server.js"
9
+ },
8
10
  "files": [
9
11
  "src/",
10
12
  "README.md",
11
13
  "LICENSE",
12
14
  "config.example.json"
13
15
  ],
14
- "engines": { "node": ">=22" },
15
- "keywords": ["mcp", "model-context-protocol", "computer-use", "desktop-automation", "windows", "macos", "screenshot", "keyboard", "mouse", "claude"],
16
- "repository": { "type": "git", "url": "git+https://github.com/murat-mehmet/ispyx.git" },
17
- "bugs": { "url": "https://github.com/murat-mehmet/ispyx/issues" },
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "keywords": [
20
+ "mcp",
21
+ "model-context-protocol",
22
+ "computer-use",
23
+ "desktop-automation",
24
+ "windows",
25
+ "macos",
26
+ "screenshot",
27
+ "keyboard",
28
+ "mouse",
29
+ "claude"
30
+ ],
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/murat-mehmet/ispyx.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/murat-mehmet/ispyx/issues"
37
+ },
18
38
  "homepage": "https://github.com/murat-mehmet/ispyx#readme",
19
39
  "scripts": {
20
40
  "start": "node src/server.js",
@@ -12,6 +12,7 @@ process is started with an explicit `--resident` / `mcp` argument (used by tests
12
12
  | `ispyx --resident` | The single owner of the pipe (`ipc.listenOwner`). Runs the guard, stop hotkey, effects, tool queue, tray and panel. No stdio. A second resident finds the pipe taken and exits at once (code 0). |
13
13
  | `ispyx mcp` | Thin stdio MCP server: same tool list and schemas, every call forwarded with `callOwner`. If no resident runs it starts one first (up to 10 s). If the resident dies mid-call the call returns a tool error. Keeps a watch connection so the resident's shutdown (second stop press, tray Quit) also ends it. |
14
14
  | `ispyx quit` | Ask the resident to exit. |
15
+ | `ispyx update` | Stop the resident, `npm install -g ispyx@latest`, start the resident again if it was running (Windows). |
15
16
  | `ispyx` without a console on stdin | Old MCP registration pointing at `src/server.js`: behaves as `mcp`. |
16
17
 
17
18
  Mode selection is `src/core/mode.js`. Start/reach helpers are `src/core/launcher.js`; resident extras are
package/src/core/guard.js CHANGED
@@ -29,6 +29,7 @@ const DEFAULTS = {
29
29
  maxTypeLength: 10000,
30
30
  auth: false, // ask for a password (src/auth.js) before the first tool call; env WIN_INPUT_AUTH
31
31
  authFile: '', // where the password hash and unlock time live; default %APPDATA%\ispyx\auth.json
32
+ updateCheck: true, // the resident asks GitHub once a day for a newer release (shown in the panel); env ISPYX_UPDATE_CHECK
32
33
  };
33
34
 
34
35
  /**
@@ -79,6 +80,7 @@ export function loadConfig(source = process.env) {
79
80
  if (e('TYPE_SPEED')) cfg.typeSpeed = Number(e('TYPE_SPEED'));
80
81
  if (e('OVERLAY')) cfg.overlay = !/^(0|false|no)$/i.test(e('OVERLAY'));
81
82
  if (e('ANIMATIONS')) cfg.animations = !/^(0|false|no)$/i.test(e('ANIMATIONS'));
83
+ if (e('UPDATE_CHECK')) cfg.updateCheck = !/^(0|false|no)$/i.test(e('UPDATE_CHECK'));
82
84
  cfg.denylist = cfg.denylist.map((s) => String(s).trim().toLowerCase()).filter(Boolean);
83
85
  if (e('LOG')) cfg.logPath = e('LOG');
84
86
  cfg.logPath = path.resolve(ROOT, cfg.logPath);
@@ -1,5 +1,8 @@
1
1
  // Starting and reaching the resident app (resident model). No platform import: usable before anything is loaded.
2
- import { spawn } from 'node:child_process';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
3
6
  import { fileURLToPath } from 'node:url';
4
7
  import { callOwner, OwnerGone, pipeName } from './ipc.js';
5
8
 
@@ -13,12 +16,30 @@ function macApp() {
13
16
  return m ? m[1] : null;
14
17
  }
15
18
 
19
+ const MAC_BUNDLE_ID = 'com.ispyx.app';
20
+ /** Where Ispyx.app's resident listens (macos-entry.js pins it there for the app's own processes). */
21
+ export const macAppPipe = () => path.join(os.homedir(), 'Library', 'Application Support', 'Ispyx', 'ispyx.sock');
22
+
23
+ /**
24
+ * ispyx from npm/npx on a Mac with Ispyx.app installed: forward to the app's resident instead of running a second
25
+ * server of its own (one stop key, one banner, the calls in the panel). Not for tests (ISPYX_PLATFORM) or an explicit
26
+ * ISPYX_PIPE. Returns true when this process should be the `mcp` forwarder.
27
+ */
28
+ export function useMacAppResident(env = process.env) {
29
+ if (process.platform !== 'darwin' || macApp() || env.ISPYX_PLATFORM || env.ISPYX_PIPE) return false;
30
+ const installed = ['/Applications', path.join(os.homedir(), 'Applications')].some((d) => fs.existsSync(path.join(d, 'Ispyx.app')));
31
+ if (!installed) return false;
32
+ env.ISPYX_PIPE = macAppPipe();
33
+ return true;
34
+ }
35
+
16
36
  /** Starts `ispyx --resident` detached and hidden, with this process's environment (same pipe). Inside Ispyx.app it
17
37
  * opens the app instead, which starts the resident itself. */
18
38
  export function spawnResident() {
19
39
  const app = macApp();
20
- const child = app
21
- ? spawn('open', ['-g', app, '--args', '--background'], { stdio: 'ignore', detached: true })
40
+ const forApp = app || (process.platform === 'darwin' && process.env.ISPYX_PIPE === macAppPipe());
41
+ const child = forApp
42
+ ? spawn('open', ['-g', ...(app ? [app] : ['-b', MAC_BUNDLE_ID]), '--args', '--background'], { stdio: 'ignore', detached: true })
22
43
  : spawn(process.execPath, [ENTRY, '--resident'], { detached: true, windowsHide: true, stdio: 'ignore' });
23
44
  child.on('error', () => {});
24
45
  child.unref();
@@ -63,8 +84,45 @@ export async function runLauncher() {
63
84
  process.exit(0);
64
85
  }
65
86
 
87
+ /**
88
+ * `ispyx update`. Windows: stops the resident (it holds files open), installs the latest package, starts it again if
89
+ * it ran. macOS: the app is updated as a whole (it is signed), by scripts/install.sh from the latest release.
90
+ */
91
+ export async function runUpdate() {
92
+ if (process.platform === 'darwin') {
93
+ const { MAC_INSTALL_URL } = await import('./updates.js');
94
+ const code = await new Promise((resolve) => {
95
+ const c = spawn('/bin/sh', ['-c', `curl -fsSL ${MAC_INSTALL_URL} | sh`], { stdio: 'inherit' });
96
+ c.on('error', () => resolve(1));
97
+ c.on('exit', (n) => resolve(n ?? 1));
98
+ });
99
+ process.exit(code);
100
+ }
101
+ const pipe = pipeName();
102
+ const wasRunning = await residentRunning(pipe);
103
+ if (wasRunning) {
104
+ try { await callOwner(pipe, '__quit', {}); } catch { /* gone before answering */ }
105
+ for (let i = 0; i < 40 && await residentRunning(pipe); i++) await sleep(150);
106
+ }
107
+ const code = await new Promise((resolve) => {
108
+ const c = spawn('npm', ['install', '-g', 'ispyx@latest'], { stdio: 'inherit', shell: true });
109
+ c.on('error', () => resolve(1));
110
+ c.on('exit', (n) => resolve(n ?? 1));
111
+ });
112
+ if (code !== 0) process.stderr.write('ispyx: the update failed (see the npm output above).\n');
113
+ // Start the new copy: npm replaced the files in place, so a fresh process loads them.
114
+ if (wasRunning) spawnResident();
115
+ process.exit(code);
116
+ }
117
+
66
118
  /** `ispyx quit`: asks the resident to exit; exits 0 either way. */
67
119
  export async function runQuit() {
120
+ // macOS: the app would start its resident again; quitting the app ends it and the forwarders
121
+ useMacAppResident();
122
+ if (process.platform === 'darwin' && (macApp() || process.env.ISPYX_PIPE === macAppPipe())) {
123
+ spawnSync('osascript', ['-e', `quit app id "${MAC_BUNDLE_ID}"`], { stdio: 'ignore', timeout: 10000 });
124
+ process.exit(0);
125
+ }
68
126
  try { await callOwner(pipeName(), '__quit', {}); } catch { /* not running, or gone before answering */ }
69
127
  process.exit(0);
70
128
  }
package/src/core/mode.js CHANGED
@@ -11,6 +11,7 @@ export function resolveMode(argv = process.argv.slice(2), env = process.env, pla
11
11
  if (argv.includes('--resident')) return 'resident';
12
12
  if (argv.includes('mcp')) return 'mcp';
13
13
  if (argv.includes('quit') || argv.includes('--quit')) return 'quit';
14
+ if (argv.includes('update')) return 'update';
14
15
  const target = env.ISPYX_PLATFORM || platform;
15
16
  if (target === 'win32') return isTTY ? 'launcher' : 'mcp';
16
17
  return 'classic';
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  const PAGE = fileURLToPath(new URL('./page.html', import.meta.url));
9
9
  const COOKIE = 'ispyx_panel';
10
10
  // Explicit whitelist; an action is also callable only if the host's `actions` object defines it.
11
- const ACTIONS = ['quit', 'setAutostart', 'setEffects', 'openLog', 'copy', 'close', 'requestPermission', 'openPermissionSettings', 'showConfigFile', 'clearRecent', 'addDeny', 'removeDeny', 'setPassword', 'setAuthRequired'];
11
+ const ACTIONS = ['quit', 'setAutostart', 'setEffects', 'openLog', 'copy', 'close', 'requestPermission', 'openPermissionSettings', 'showConfigFile', 'clearRecent', 'addDeny', 'removeDeny', 'setPassword', 'setAuthRequired', 'update', 'checkUpdate'];
12
12
  const BOOL_ACTIONS = new Set(['setAutostart', 'setEffects']);
13
13
  const STRING_ACTIONS = new Set(['copy', 'requestPermission', 'openPermissionSettings', 'addDeny']); // value must be a short string
14
14
  // value is an object of short strings (and a boolean for setAuthRequired.on)
@@ -17,6 +17,8 @@ header{display:flex;align-items:center;gap:14px;margin-bottom:18px}
17
17
  header img{width:44px;height:44px;flex:none;border-radius:10px}
18
18
  h1{font-size:17px;line-height:1.25;margin:0;font-weight:700}
19
19
  .sub{color:var(--mute);font-size:12px;margin-top:2px;overflow-wrap:anywhere}
20
+ .upd{display:flex;align-items:center;gap:8px;margin-top:6px;color:var(--fg)}
21
+ .upd button{padding:2px 10px;font-size:12px}
20
22
  .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--mute);margin-right:6px;vertical-align:1px}
21
23
  .run .dot{background:var(--acc);box-shadow:0 0 0 3px rgba(47,209,107,.22)}
22
24
  .warn .dot{background:var(--warn)}
@@ -93,6 +95,7 @@ button.link:hover{background:none;color:var(--fg)}
93
95
  <h1 id="head">Ispyx</h1>
94
96
  <div class="sub" id="state"><span class="dot"></span><span id="stateTxt">Connecting</span></div>
95
97
  <div class="sub" id="meta"></div>
98
+ <div class="sub upd" id="upd" hidden><span id="updTxt"></span> <button type="button" class="pri" id="updBtn">Update</button></div>
96
99
  </div>
97
100
  </header>
98
101
  <div id="off" hidden>Ispyx has quit. You can close this window.</div>
@@ -269,6 +272,9 @@ function render(s){
269
272
  if(typeof s.uptimeMs==="number")meta.push("up "+fmt(s.uptimeMs));
270
273
  if(s.pid!=null)meta.push("pid "+s.pid);
271
274
  setText("meta",meta.join(" · "));
275
+ var u=s.update;
276
+ show("upd",!!(u&&u.available));
277
+ if(u&&u.available&&!updating)setText("updTxt","Version "+u.latest+" is available.");
272
278
 
273
279
  show("sPerm",!!(perms&&perms.length));
274
280
  if(perms&&perms.length)renderPerms(perms);
@@ -421,6 +427,12 @@ $("cfgShow").addEventListener("click",function(){act("showConfigFile")});
421
427
  $("log").addEventListener("click",function(){act("openLog")});
422
428
  $("showAll").addEventListener("click",function(){expanded=!expanded;if(last&&last.recent)renderRecent(last.recent,last.recentTotal)});
423
429
  $("clearAct").addEventListener("click",function(){act("clearRecent").then(poll)});
430
+ // the update quits and restarts ispyx: this window then reports it has quit (macOS opens it again)
431
+ var updating=false;
432
+ $("updBtn").addEventListener("click",function(){
433
+ updating=true;$("updBtn").disabled=true;setText("updTxt","Updating, ispyx restarts in a moment…");
434
+ act("update").catch(function(err){updating=false;$("updBtn").disabled=false;setText("updTxt",err.message)});
435
+ });
424
436
  $("end").addEventListener("click",function(){
425
437
  if(last&&last.platform==="darwin"){act("close");return}
426
438
  if(!confirm("Quit Ispyx? AI tools lose access until it is reopened."))return;
@@ -22,6 +22,7 @@ import { spawn } from 'node:child_process';
22
22
  import { fileURLToPath } from 'node:url';
23
23
 
24
24
  import { saveUserConfig } from './guard.js';
25
+ import { startUpdateCheck, launchUpdate } from './updates.js';
25
26
  import { PasswordError } from './auth.js';
26
27
 
27
28
  /** A refusal whose message goes to the person at the panel. */
@@ -119,6 +120,8 @@ const safe = (fn, dflt) => { try { return fn(); } catch { return dflt; } };
119
120
  export function panelModel(ctx) {
120
121
  const { w, config, guard, auth, scan, clients } = ctx;
121
122
  const t0 = Date.now();
123
+ // never from tests (fake platform): they must not depend on the network
124
+ const updates = startUpdateCheck({ current: VERSION, enabled: config.updateCheck !== false && w.name !== 'fake' });
122
125
  const save = (patch) => {
123
126
  try { saveUserConfig(patch); } catch (e) { throw new PanelError(`Could not save the setting: ${e.message}`); }
124
127
  };
@@ -135,6 +138,7 @@ export function panelModel(ctx) {
135
138
  passwordSet: safe(() => auth.passwordSet(), false),
136
139
  authRequired: !!config.auth,
137
140
  denylist: config.denylist.slice(),
141
+ ...(updates.state() ? { update: updates.state() } : {}),
138
142
  });
139
143
  const actions = {
140
144
  setEffects: (on) => { scan.enabled = !!on; config.animations = !!on; save({ animations: !!on }); return !!on; },
@@ -163,8 +167,15 @@ export function panelModel(ctx) {
163
167
  save({ denylist: config.denylist.slice() });
164
168
  },
165
169
  clearRecent: () => { recent.length = 0; },
170
+ // the newer release: installs it in a process of its own, which quits and restarts ispyx (this one included)
171
+ update: () => {
172
+ if (!updates.state()?.available) throw new PanelError('This is the newest version.');
173
+ launchUpdate({ platform: w.name, dataDir: w.dataDir() });
174
+ return true;
175
+ },
176
+ checkUpdate: async () => { await updates.check(); return updates.state(); },
166
177
  };
167
- return { getState, actions };
178
+ return { getState, actions, stop: () => updates.stop() };
168
179
  }
169
180
 
170
181
  /**
@@ -13,13 +13,14 @@ import { Auth } from './auth.js';
13
13
  import { ScanAnimation, keyLabels } from '../effects/scan.js';
14
14
  import { pipeName, listenOwner, callOwner, watchOwner, OwnerGone } from './ipc.js';
15
15
  import { resolveMode } from './mode.js';
16
- import { ensureResident } from './launcher.js';
16
+ import { ensureResident, useMacAppResident } from './launcher.js';
17
17
  import { panelModel, recordCall, setupLogging, startResident } from './resident.js';
18
18
 
19
19
  const { parseSequence, insertsContent, Overlay, installStopHotkey } = w;
20
20
 
21
21
  // Windows: one resident app does the work and `ispyx mcp` forwards to it (see RESIDENT.md). Elsewhere: classic election.
22
- const mode = resolveMode();
22
+ // npm/npx on a Mac with Ispyx.app installed: forward to the app's resident rather than run a second server
23
+ const mode = resolveMode() === 'classic' && useMacAppResident() ? 'mcp' : resolveMode();
23
24
  const config = loadConfig();
24
25
  // Only the process that owns the pipe (see ipc.js) does the work; the others forward their tool calls to it.
25
26
  let guard, overlay, auth, stopHook = null;
@@ -0,0 +1,83 @@
1
+ // Newer-release check for the panel and `ispyx update`. Asks GitHub which release is the latest (an outgoing HTTPS
2
+ // request, no API key, no data sent), at most once a day; config `updateCheck: false` turns it off.
3
+ // Updating stays per platform:
4
+ // Windows `ispyx update`: stop the resident, npm install -g ispyx@latest, start it again (launcher.js)
5
+ // macOS scripts/install.sh from the latest release: checks and swaps Ispyx.app, then opens it again
6
+ import { spawn } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ export const REPO = 'murat-mehmet/ispyx';
12
+ export const MAC_INSTALL_URL = `https://github.com/${REPO}/releases/latest/download/install.sh`;
13
+ const ENTRY = fileURLToPath(new URL('../server.js', import.meta.url));
14
+ const DAY_MS = 24 * 60 * 60 * 1000;
15
+
16
+ /** 1 when a is newer than b, -1 when older, 0 when equal; "1.2.10" > "1.2.9", a release beats its pre-release. */
17
+ export function compareVersions(a, b) {
18
+ const parse = (v) => {
19
+ const [main, pre = ''] = String(v).replace(/^v/, '').split('-', 2);
20
+ return { nums: main.split('.').map((n) => Number(n) || 0), pre };
21
+ };
22
+ const x = parse(a), y = parse(b);
23
+ for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) {
24
+ const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
25
+ if (d) return Math.sign(d);
26
+ }
27
+ if (x.pre === y.pre) return 0;
28
+ if (!x.pre) return 1;
29
+ if (!y.pre) return -1;
30
+ return x.pre > y.pre ? 1 : -1;
31
+ }
32
+
33
+ /** The version of the latest release, from the redirect of /releases/latest (no API call, so no rate limit). */
34
+ export async function latestVersion({ fetchImpl = fetch, timeoutMs = 10000 } = {}) {
35
+ const res = await fetchImpl(`https://github.com/${REPO}/releases/latest`, {
36
+ method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs),
37
+ });
38
+ const m = /\/releases\/tag\/v?([^/?#]+)$/.exec(res.headers.get('location') ?? '');
39
+ if (!m) throw new Error(`no latest release found (HTTP ${res.status})`);
40
+ return decodeURIComponent(m[1]);
41
+ }
42
+
43
+ /**
44
+ * Checks shortly after start and then once a day. state() is { current, latest, available } once a check has
45
+ * answered, else null; a failed check (offline) keeps the last answer and is retried on the next round.
46
+ */
47
+ export function startUpdateCheck({ current, enabled = true, firstDelayMs = 15000, fetchImpl } = {}) {
48
+ let latest = null;
49
+ const check = async () => {
50
+ try { latest = await latestVersion({ fetchImpl }); } catch { /* offline or GitHub down: try again later */ }
51
+ };
52
+ const timers = [];
53
+ if (enabled) {
54
+ const t = setTimeout(check, firstDelayMs);
55
+ const i = setInterval(check, DAY_MS);
56
+ t.unref?.();
57
+ i.unref?.();
58
+ timers.push(t, i);
59
+ }
60
+ return {
61
+ check,
62
+ state: () => (latest ? { current, latest, available: compareVersions(latest, current) > 0 } : null),
63
+ stop: () => { for (const t of timers) clearTimeout(t); },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Starts the update in a process of its own (the update quits the running ispyx, this process included) and
69
+ * returns at once. Its output goes to update.log in the data directory.
70
+ */
71
+ export function launchUpdate({ platform = process.platform, dataDir }) {
72
+ let out = 'ignore';
73
+ try {
74
+ fs.mkdirSync(dataDir, { recursive: true });
75
+ out = fs.openSync(path.join(dataDir, 'update.log'), 'w');
76
+ } catch { /* no log then */ }
77
+ const child = platform === 'darwin'
78
+ ? spawn('/bin/sh', ['-c', `curl -fsSL ${MAC_INSTALL_URL} | sh`], { detached: true, stdio: ['ignore', out, out] })
79
+ : spawn(process.execPath, [ENTRY, 'update'], { detached: true, windowsHide: true, stdio: ['ignore', out, out] });
80
+ child.on('error', () => {});
81
+ child.unref();
82
+ if (typeof out === 'number') fs.closeSync(out);
83
+ }
@@ -1,16 +1,16 @@
1
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.
2
+ // (Contents/Resources/ispyx) as the thin forwarder (`mcp`), or for `update` / `quit`. Picks the platform
3
+ // automatically (darwin), whatever the environment of the MCP client says, then runs the normal entry point.
4
4
  import fs from 'node:fs';
5
- import os from 'node:os';
6
5
  import path from 'node:path';
6
+ import { macAppPipe } from './core/launcher.js';
7
7
 
8
8
  delete process.env.ISPYX_PLATFORM;
9
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).
10
+ // (an MCP client may give its children another temp directory). npm copies on this Mac use it too (launcher.js).
11
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');
12
+ const sock = macAppPipe();
13
+ try { fs.mkdirSync(path.dirname(sock), { recursive: true, mode: 0o700 }); } catch { /* reported when listening fails */ }
14
+ process.env.ISPYX_PIPE = sock;
15
15
  }
16
- await import('./core/server.js');
16
+ await import('./server.js'); // the entry point: picks resident / mcp / update / quit
package/src/server.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // ispyx --resident the resident app (tray, panel, tool queue)
5
5
  // ispyx mcp stdio MCP server forwarding to the resident (started on demand)
6
6
  // ispyx quit ask the resident to exit
7
+ // ispyx update stop the resident, npm install -g ispyx@latest, start it again
7
8
  // Windows without a console on stdin (an old MCP registration) behaves as `mcp`. Other platforms run the
8
9
  // classic server (core/server.js), unchanged. See src/core/RESIDENT.md.
9
10
  import { resolveMode } from "./core/mode.js";
@@ -11,4 +12,5 @@ import { resolveMode } from "./core/mode.js";
11
12
  const mode = resolveMode();
12
13
  if (mode === "launcher") await (await import("./core/launcher.js")).runLauncher();
13
14
  else if (mode === "quit") await (await import("./core/launcher.js")).runQuit();
15
+ else if (mode === "update") await (await import("./core/launcher.js")).runUpdate();
14
16
  else await import("./core/server.js");