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.
- package/LICENSE +21 -0
- package/README.md +183 -2
- package/config.example.json +15 -0
- package/package.json +67 -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,832 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ispyx: local desktop-control MCP server (Windows; macOS in development). stdio only; opens no network port.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import w from '../platform/index.js';
|
|
9
|
+
import { Guard, ToolError, callContext, loadConfig, winSummary } from './guard.js';
|
|
10
|
+
import { shrinkToPng, cropCapture } from './image.js';
|
|
11
|
+
import { curvedPath, ease, randomDuration, typingDelays } from './motion.js';
|
|
12
|
+
import { Auth } from './auth.js';
|
|
13
|
+
import { ScanAnimation, keyLabels } from '../effects/scan.js';
|
|
14
|
+
import { pipeName, listenOwner, callOwner, watchOwner, OwnerGone } from './ipc.js';
|
|
15
|
+
import { resolveMode } from './mode.js';
|
|
16
|
+
import { ensureResident } from './launcher.js';
|
|
17
|
+
import { panelModel, recordCall, setupLogging, startResident } from './resident.js';
|
|
18
|
+
|
|
19
|
+
const { parseSequence, insertsContent, Overlay, installStopHotkey } = w;
|
|
20
|
+
|
|
21
|
+
// Windows: one resident app does the work and `ispyx mcp` forwards to it (see RESIDENT.md). Elsewhere: classic election.
|
|
22
|
+
const mode = resolveMode();
|
|
23
|
+
const config = loadConfig();
|
|
24
|
+
// Only the process that owns the pipe (see ipc.js) does the work; the others forward their tool calls to it.
|
|
25
|
+
let guard, overlay, auth, stopHook = null;
|
|
26
|
+
const scan = new ScanAnimation({ enabled: config.animations });
|
|
27
|
+
let pendingAnim = null; // the screenshot animation in progress: the next queued call waits for it
|
|
28
|
+
let owner = null;
|
|
29
|
+
let residentUi = null;
|
|
30
|
+
let stdioOpen = false; // classic: this process still serves its own MCP client
|
|
31
|
+
const pipe = pipeName();
|
|
32
|
+
|
|
33
|
+
function initCore() {
|
|
34
|
+
w.init();
|
|
35
|
+
guard = new Guard(config);
|
|
36
|
+
overlay = new Overlay({ enabled: config.overlay, stopLabel: guard.stopLabel });
|
|
37
|
+
guard.overlay = overlay;
|
|
38
|
+
// second stop press: order every other ispyx server to exit, then leave
|
|
39
|
+
guard.onShutdown = shutdownAll;
|
|
40
|
+
auth = new Auth({ enabled: config.auth, ...(config.authFile ? { file: config.authFile } : {}) });
|
|
41
|
+
guard.startWatching();
|
|
42
|
+
if (guard.stopIsDefault) stopHook = installStopHotkey((reason) => guard.trigger(reason)); // default stop chord only (Alt+Esc / Option+Esc); installed while working
|
|
43
|
+
guard.log({ tool: 'server_start', auth: config.auth, denylist: config.denylist, pid: process.pid });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Orders every connected server to exit, then leaves (second stop press, tray Quit, `ispyx quit`). */
|
|
47
|
+
function shutdownAll() {
|
|
48
|
+
residentUi?.stop();
|
|
49
|
+
owner?.broadcastShutdown();
|
|
50
|
+
setTimeout(() => process.exit(0), 300);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function tryBecomeOwner() {
|
|
54
|
+
if (owner) return true;
|
|
55
|
+
owner = await listenOwner(pipe, runTool);
|
|
56
|
+
if (owner) {
|
|
57
|
+
initCore();
|
|
58
|
+
// macOS: Ispyx.app shows the panel and asks the owner for activity, effects, password and denylist
|
|
59
|
+
if (mode === 'classic' && w.servePanel) w.servePanel(panelModel({ w, config, owner, guard, auth, scan, clients: () => owner.clients() + (stdioOpen ? 1 : 0) }));
|
|
60
|
+
}
|
|
61
|
+
return !!owner;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** After a capture: eye and scan line over the captured region. Returns at once; later calls queue behind it. */
|
|
65
|
+
function playScan(rect) {
|
|
66
|
+
const epoch = guard.epoch;
|
|
67
|
+
// one eye and one scan line, even when the region spans several monitors (the line crosses all of them)
|
|
68
|
+
pendingAnim = scan.play(rect, () => guard.epoch !== epoch);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** focus_window: green frame around the window; later calls queue behind it. */
|
|
72
|
+
function playFlash(rect) {
|
|
73
|
+
const epoch = guard.epoch;
|
|
74
|
+
pendingAnim = scan.flash(rect, () => guard.epoch !== epoch);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** click: rings at the point. Does not hold up later calls, only screenshots (see waitForEffects). */
|
|
78
|
+
const effects = new Set();
|
|
79
|
+
function playRipple(p, button, rings) {
|
|
80
|
+
const epoch = guard.epoch;
|
|
81
|
+
const run = scan.ripple({ x: p.sx, y: p.sy }, button, rings, () => guard.epoch !== epoch).finally(() => effects.delete(run));
|
|
82
|
+
effects.add(run);
|
|
83
|
+
}
|
|
84
|
+
/** drag: ring at the start and a trail behind the pointer; the returned function ends it (button released). */
|
|
85
|
+
function playTrail(a, z, button, opts) {
|
|
86
|
+
const epoch = guard.epoch;
|
|
87
|
+
let stop;
|
|
88
|
+
const stopped = new Promise((r) => { stop = r; });
|
|
89
|
+
const run = scan.trail({ x: a.sx, y: a.sy }, { left: Math.min(a.sx, z.sx), top: Math.min(a.sy, z.sy), right: Math.max(a.sx, z.sx), bottom: Math.max(a.sy, z.sy) }, stopped, () => guard.epoch !== epoch, button, opts).finally(() => effects.delete(run));
|
|
90
|
+
effects.add(run);
|
|
91
|
+
return stop;
|
|
92
|
+
}
|
|
93
|
+
/** scroll: chevrons beside the pointer while the wheel turns; the returned function ends it. */
|
|
94
|
+
function playWheelCue(p, vert, horiz) {
|
|
95
|
+
const epoch = guard.epoch;
|
|
96
|
+
let stop;
|
|
97
|
+
const stopped = new Promise((r) => { stop = r; });
|
|
98
|
+
const speed = Math.max(Math.abs(vert), Math.abs(horiz)) / 3;
|
|
99
|
+
const run = scan.wheelCue({ x: p.sx, y: p.sy }, Math.sign(vert), Math.sign(horiz), speed, stopped, () => guard.epoch !== epoch).finally(() => effects.delete(run));
|
|
100
|
+
effects.add(run);
|
|
101
|
+
return stop;
|
|
102
|
+
}
|
|
103
|
+
/** press_keys: key caps near the bottom of the monitor holding the foreground window. */
|
|
104
|
+
function keyboardArea() {
|
|
105
|
+
const fg = w.foregroundWindow();
|
|
106
|
+
const r = fg ? w.getRect(fg) : null;
|
|
107
|
+
const cx = r ? r.left + r.width / 2 : 0, cy = r ? r.top + r.height / 2 : 0;
|
|
108
|
+
const mons = w.listMonitors();
|
|
109
|
+
const m = mons.find((q) => cx >= q.left && cx < q.left + q.width && cy >= q.top && cy < q.top + q.height) ?? mons.find((q) => q.primary) ?? mons[0];
|
|
110
|
+
return m ? { left: m.left, top: m.top, width: m.width, height: m.height } : null;
|
|
111
|
+
}
|
|
112
|
+
function playKeycaps(keys) {
|
|
113
|
+
const epoch = guard.epoch;
|
|
114
|
+
const area = keyboardArea();
|
|
115
|
+
if (!area) return;
|
|
116
|
+
const run = scan.keycaps(keyLabels(keys), area, () => guard.epoch !== epoch).finally(() => effects.delete(run));
|
|
117
|
+
effects.add(run);
|
|
118
|
+
}
|
|
119
|
+
/** list_windows: a scan line passes over the monitor and lights each window frame (at most every 8 s). */
|
|
120
|
+
let lastRadar = 0;
|
|
121
|
+
function playRadar(list) {
|
|
122
|
+
if (!config.animations || Date.now() - lastRadar < 8000) return;
|
|
123
|
+
const areas = w.listMonitors().map((m) => ({ left: m.left, top: m.top, width: m.width, height: m.height }));
|
|
124
|
+
const home = keyboardArea();
|
|
125
|
+
if (!areas.length || !home) return;
|
|
126
|
+
lastRadar = Date.now();
|
|
127
|
+
const epoch = guard.epoch;
|
|
128
|
+
const wins = list.filter((x) => !x.minimized && x.rect && x.rect.width > 0).map((x) => ({ rect: x.rect, allowed: x.allowed, elevated: x.elevated }));
|
|
129
|
+
// one eye on the monitor the keys go to; its ping carries on across the other monitors, lighting their windows
|
|
130
|
+
const run = scan.radarAll(wins, areas, home, () => guard.epoch !== epoch).finally(() => effects.delete(run));
|
|
131
|
+
effects.add(run);
|
|
132
|
+
}
|
|
133
|
+
/** wait (1 s or longer): a pill with the time left. Ends with the wait; a cancelled call removes it. */
|
|
134
|
+
function playCountdown(ms) {
|
|
135
|
+
const epoch = guard.epoch;
|
|
136
|
+
const area = keyboardArea();
|
|
137
|
+
if (!area) return;
|
|
138
|
+
const run = scan.countdown(ms, area, () => guard.epoch !== epoch).finally(() => effects.delete(run));
|
|
139
|
+
effects.add(run);
|
|
140
|
+
}
|
|
141
|
+
/** type_text: progress pill (never the text). Returns { progress, stop }. */
|
|
142
|
+
function playTyping(total) {
|
|
143
|
+
const epoch = guard.epoch;
|
|
144
|
+
const area = keyboardArea();
|
|
145
|
+
const progress = { n: 0 };
|
|
146
|
+
let stop = () => {};
|
|
147
|
+
if (!area || total < 6) return { progress, stop };
|
|
148
|
+
const stopped = new Promise((r) => { stop = r; });
|
|
149
|
+
const run = scan.typing(total, progress, area, stopped, () => guard.epoch !== epoch).finally(() => effects.delete(run));
|
|
150
|
+
effects.add(run);
|
|
151
|
+
return { progress, stop };
|
|
152
|
+
}
|
|
153
|
+
/** A screenshot must not catch a running effect. */
|
|
154
|
+
async function waitForEffects() {
|
|
155
|
+
while (effects.size) await Promise.all([...effects]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const handlers = new Map();
|
|
159
|
+
let chain = Promise.resolve();
|
|
160
|
+
const UNQUEUED = new Set(['wait']);
|
|
161
|
+
/** Runs a tool here. Calls from all Claude instances take turns, so two of them never fight over the pointer. */
|
|
162
|
+
function runTool(name, args) {
|
|
163
|
+
if (mode === 'resident' && name.startsWith('__')) return control(name);
|
|
164
|
+
const h = handlers.get(name);
|
|
165
|
+
if (!h) throw new Error(`Unknown tool ${name}`);
|
|
166
|
+
const ctx = { epoch: guard.epoch }; // a stop press after this point cancels the call, queued or running
|
|
167
|
+
recordCall(name, args); // the panel's activity list
|
|
168
|
+
residentUi?.touch();
|
|
169
|
+
const call = () => callContext.run(ctx, () => h(args));
|
|
170
|
+
if (UNQUEUED.has(name)) return call();
|
|
171
|
+
const p = chain.then(call);
|
|
172
|
+
chain = p.catch(() => {}).then(() => { const a = pendingAnim; pendingAnim = null; return a; });
|
|
173
|
+
return p;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Resident control calls (see resident.js header). */
|
|
177
|
+
function control(name) {
|
|
178
|
+
if (name === '__ping') return 'pong';
|
|
179
|
+
if (name === '__panel') return Promise.resolve(residentUi?.openPanel?.()).then(() => '');
|
|
180
|
+
if (name === '__quit') { setTimeout(shutdownAll, 50); return 'bye'; }
|
|
181
|
+
throw new Error(`Unknown control call ${name}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const toolError = (text) => ({ isError: true, content: [{ type: 'text', text }] });
|
|
185
|
+
|
|
186
|
+
/** `ispyx mcp`: every call goes to the resident, which is started first when it is not running. */
|
|
187
|
+
async function forwardToResident(name, args) {
|
|
188
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
189
|
+
try {
|
|
190
|
+
return await callOwner(pipe, name, args);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
if (!(e instanceof OwnerGone)) return toolError(`${e.message} (The resident ispyx app was closed or crashed; the next call starts it again.)`);
|
|
193
|
+
if (attempt === 0 && !(await ensureResident(pipe))) break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return toolError(process.platform === 'darwin'
|
|
197
|
+
? 'Ispyx.app is not running and could not be started within 10 s. Open Ispyx and allow the permissions its window asks for, then try again (log: ~/Library/Application Support/Ispyx/ispyx.log).'
|
|
198
|
+
: 'The ispyx app is not running and could not be started within 10 s. Start it from the Start menu or run "ispyx", then try again (log: %APPDATA%\\ispyx\\ispyx.log).');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function dispatch(name, args) {
|
|
202
|
+
if (mode === 'mcp') return forwardToResident(name, args);
|
|
203
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
204
|
+
if (owner) return runTool(name, args);
|
|
205
|
+
try {
|
|
206
|
+
return await callOwner(pipe, name, args);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
if (!(e instanceof OwnerGone)) return { isError: true, content: [{ type: 'text', text: e.message }] };
|
|
209
|
+
await tryBecomeOwner(); // the owner is gone: take over
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { isError: true, content: [{ type: 'text', text: 'Could not reach or become the ispyx server. Try again.' }] };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const target = {
|
|
216
|
+
hwnd: z.number().int().optional().describe('Window handle from list_windows. Window-relative mode and focusing use it.'),
|
|
217
|
+
title: z.string().optional().describe('Part of a window title (case-insensitive). Ignored when hwnd is given.'),
|
|
218
|
+
};
|
|
219
|
+
const space = z.enum(['window', 'screen', 'shot']).optional().describe(
|
|
220
|
+
'Coordinate space. "screen": absolute desktop pixels (virtual-desktop space, may be negative on monitors left of / above the primary). ' +
|
|
221
|
+
'"window": screenshot pixels inside the window given by hwnd/title (see screenshot_window). ' +
|
|
222
|
+
'"shot": pixels of the last screenshot_screen image. Default: "window" when hwnd/title is given, otherwise "screen".');
|
|
223
|
+
const xy = {
|
|
224
|
+
x: z.number().describe('X in the chosen coordinate space.'),
|
|
225
|
+
y: z.number().describe('Y in the chosen coordinate space.'),
|
|
226
|
+
};
|
|
227
|
+
const buttons = z.enum(['left', 'right', 'middle']).default('left');
|
|
228
|
+
const modifiers = z.array(z.enum(['ctrl', 'shift', 'alt', 'win'])).optional().describe('Keys held down during the action, e.g. ["ctrl"] for ctrl+click.');
|
|
229
|
+
const MIN_GLIDE_MS = 120; // the pointer never teleports, whatever durationMs says
|
|
230
|
+
const motion = {
|
|
231
|
+
durationMs: z.number().int().min(0).max(5000).optional().describe(`Glide time, at least ${MIN_GLIDE_MS} ms; default random 300-1000 ms. The pointer never jumps.`),
|
|
232
|
+
};
|
|
233
|
+
const MOD_KEY = (m) => w.parseChord(m)[0]; // platform key code of a modifier name (win = Windows key / Command)
|
|
234
|
+
|
|
235
|
+
const text = (s) => ({ content: [{ type: 'text', text: s }] });
|
|
236
|
+
const json = (o) => text(JSON.stringify(o));
|
|
237
|
+
|
|
238
|
+
const server = new McpServer({ name: 'ispyx', version: '2.0.0' });
|
|
239
|
+
{
|
|
240
|
+
const register = server.registerTool.bind(server);
|
|
241
|
+
server.registerTool = (name, cfg, handler) => {
|
|
242
|
+
handlers.set(name, handler);
|
|
243
|
+
return register(name, cfg, (...a) => dispatch(name, cfg.inputSchema ? a[0] : {}));
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Wraps a tool: kill switch + lock check before, JSONL log after, plain errors out. */
|
|
248
|
+
function action(name, handler, { session = true } = {}) {
|
|
249
|
+
return async (args) => {
|
|
250
|
+
const entry = { tool: name, args: redact(name, args) };
|
|
251
|
+
const shown = !QUIET.has(name);
|
|
252
|
+
stopHook?.touch(); // keeps the stop-chord hook installed while working and for 30 s after the last call
|
|
253
|
+
const keepHook = stopHook && setInterval(() => stopHook.touch(), 10000);
|
|
254
|
+
try {
|
|
255
|
+
guard.checkKill();
|
|
256
|
+
await auth.ensure();
|
|
257
|
+
guard.checkKill();
|
|
258
|
+
if (session) guard.checkSession();
|
|
259
|
+
if (shown) overlay.begin(name);
|
|
260
|
+
const res = await handler(args, entry);
|
|
261
|
+
guard.checkKill(); // stopped while the tool was finishing: report it, do not claim success
|
|
262
|
+
entry.ok = true;
|
|
263
|
+
guard.log(entry);
|
|
264
|
+
return res;
|
|
265
|
+
} catch (e) {
|
|
266
|
+
entry.ok = false;
|
|
267
|
+
entry.error = e.message;
|
|
268
|
+
guard.log(entry);
|
|
269
|
+
const msg = e instanceof ToolError ? e.message : `Unexpected error: ${e.message}`;
|
|
270
|
+
return { isError: true, content: [{ type: 'text', text: msg }] };
|
|
271
|
+
} finally {
|
|
272
|
+
if (keepHook) { clearInterval(keepHook); stopHook.touch(); }
|
|
273
|
+
if (shown && overlay.active) overlay.done();
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const QUIET = new Set(['list_windows', 'list_monitors', 'cursor_position', 'get_clipboard']);
|
|
278
|
+
|
|
279
|
+
// The log keeps the typed text, except where it could be a secret.
|
|
280
|
+
function redact(name, args) {
|
|
281
|
+
const a = { ...args };
|
|
282
|
+
if (name === 'type_text' && typeof a.text === 'string') a.text = a.text.slice(0, 2000);
|
|
283
|
+
if (name === 'set_clipboard' && typeof a.text === 'string') {
|
|
284
|
+
a.textLength = a.text.length;
|
|
285
|
+
a.text = a.text.slice(0, 200);
|
|
286
|
+
}
|
|
287
|
+
return a;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Returns map(x, y) -> { sx, sy } in desktop pixels for the chosen space. In window space the window is
|
|
292
|
+
* focused first and each point must belong to it; otherwise the window under the point must be usable.
|
|
293
|
+
*/
|
|
294
|
+
async function pointMapper(args, entry) {
|
|
295
|
+
const sp = args.space ?? (args.hwnd || args.title ? 'window' : 'screen');
|
|
296
|
+
entry.space = sp;
|
|
297
|
+
if (sp === 'window') {
|
|
298
|
+
if (!args.hwnd && !args.title) throw new ToolError('Window space needs hwnd or title.');
|
|
299
|
+
const win = guard.resolve(args);
|
|
300
|
+
guard.assertUsable(win, { needVisible: false });
|
|
301
|
+
await guard.focus(win);
|
|
302
|
+
guard.assertUsable(w.describeWindow(win.hwnd));
|
|
303
|
+
entry.window = winSummary(win);
|
|
304
|
+
return (x, y) => {
|
|
305
|
+
const p = guard.toScreen(w.describeWindow(win.hwnd), x, y);
|
|
306
|
+
guard.assertPointOwned(win, p.sx, p.sy);
|
|
307
|
+
return p;
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
return (x, y) => {
|
|
311
|
+
const p = guard.toScreenGlobal(x, y, sp);
|
|
312
|
+
const under = guard.assertPointUsable(p.sx, p.sy);
|
|
313
|
+
if (under) entry.window = winSummary(under);
|
|
314
|
+
return p;
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Move the pointer to p like a hand would: a curved path with a smooth speed profile, taking a random
|
|
321
|
+
* 300-1000 ms (config moveMinMs/moveMaxMs, or durationMs). While a mouse button is held the path is straight.
|
|
322
|
+
*/
|
|
323
|
+
async function glide(p, args = {}, { trail = true, ring = false } = {}) {
|
|
324
|
+
const from = w.cursorPos();
|
|
325
|
+
const dist = Math.hypot(p.sx - from.x, p.sy - from.y);
|
|
326
|
+
if (dist < 4) {
|
|
327
|
+
w.mouseMoveTo(p.sx, p.sy);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const stopTrail = trail ? playTrail({ sx: from.x, sy: from.y }, p, 'left', { marker: false, life: 520, ring }) : () => {}; // a ring where it lands only for the move tool (click draws its own)
|
|
331
|
+
try {
|
|
332
|
+
const ms = Math.max(MIN_GLIDE_MS, args.durationMs ?? randomDuration(config.moveMinMs, config.moveMaxMs));
|
|
333
|
+
// with a button held (drag, or mouse_down then move) the pointer draws, so the line must be straight
|
|
334
|
+
const path = guard.heldButtons.size > 0
|
|
335
|
+
? (t) => ({ x: from.x + (p.sx - from.x) * t, y: from.y + (p.sy - from.y) * t })
|
|
336
|
+
: curvedPath(from, { x: p.sx, y: p.sy });
|
|
337
|
+
const v = w.virtualScreen();
|
|
338
|
+
const t0 = performance.now();
|
|
339
|
+
for (let n = 0; ; n++) {
|
|
340
|
+
const t = Math.min(1, (performance.now() - t0) / ms);
|
|
341
|
+
const q = path(ease(t));
|
|
342
|
+
// a bend must never drag the pointer off the desktop
|
|
343
|
+
const x = Math.min(v.x + v.width - 1, Math.max(v.x, Math.round(q.x)));
|
|
344
|
+
const y = Math.min(v.y + v.height - 1, Math.max(v.y, Math.round(q.y)));
|
|
345
|
+
w.mouseMoveTo(x, y);
|
|
346
|
+
if (t >= 1) break;
|
|
347
|
+
if (n % 4 === 0) guard.checkKill();
|
|
348
|
+
await w.sleep(8);
|
|
349
|
+
}
|
|
350
|
+
w.mouseMoveTo(p.sx, p.sy);
|
|
351
|
+
} finally {
|
|
352
|
+
stopTrail();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Wheel like a hand on a smooth-scrolling mouse: the total is spread over many small wheel events
|
|
358
|
+
* (a fraction of a notch each) following the same smooth speed curve as the pointer glide, so pages
|
|
359
|
+
* scroll continuously instead of jumping. Duration: random 300-1000 ms, longer for big scrolls
|
|
360
|
+
* (or durationMs).
|
|
361
|
+
*/
|
|
362
|
+
async function smoothWheel(vert, horiz, args) {
|
|
363
|
+
const big = Math.max(Math.abs(vert), Math.abs(horiz));
|
|
364
|
+
const ms = Math.max(MIN_GLIDE_MS, args.durationMs ?? Math.min(2500, randomDuration(config.moveMinMs, config.moveMaxMs) + Math.round(big * 40)));
|
|
365
|
+
const totalV = Math.round(vert * 120);
|
|
366
|
+
const totalH = Math.round(horiz * 120);
|
|
367
|
+
let sentV = 0, sentH = 0, events = 0;
|
|
368
|
+
const t0 = performance.now();
|
|
369
|
+
for (let n = 0; ; n++) {
|
|
370
|
+
const t = Math.min(1, (performance.now() - t0) / ms);
|
|
371
|
+
const e = ease(t);
|
|
372
|
+
const dv = Math.round(totalV * e) - sentV;
|
|
373
|
+
const dh = Math.round(totalH * e) - sentH;
|
|
374
|
+
if (dv) { w.mouseWheel(dv / 120); sentV += dv; events++; }
|
|
375
|
+
if (dh) { w.mouseHWheel(dh / 120); sentH += dh; events++; }
|
|
376
|
+
if (t >= 1) break;
|
|
377
|
+
if (n % 4 === 0) guard.checkKill();
|
|
378
|
+
await w.sleep(8);
|
|
379
|
+
}
|
|
380
|
+
return { events, ms: Math.round(performance.now() - t0) };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Hold modifier keys for the duration of fn. */
|
|
384
|
+
async function withModifiers(mods, fn) {
|
|
385
|
+
const vks = (mods ?? []).map(MOD_KEY);
|
|
386
|
+
if (vks.length) w.keysDown(vks);
|
|
387
|
+
try {
|
|
388
|
+
return await fn();
|
|
389
|
+
} finally {
|
|
390
|
+
if (vks.length) w.keysUp(vks);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------- tools
|
|
395
|
+
|
|
396
|
+
server.registerTool('list_windows', {
|
|
397
|
+
description: `List visible top-level windows with handle, title, process and screen rectangle, ${w.name !== 'darwin' ? 'including the taskbar (Shell_TrayWnd). ' : ''}Use includeUntitled for every visible top-level window.`,
|
|
398
|
+
inputSchema: { includeUntitled: z.boolean().default(false) },
|
|
399
|
+
}, action('list_windows', async (args) => {
|
|
400
|
+
const list = w.listWindows({ includeUntitled: !!args.includeUntitled }).map((x) => ({
|
|
401
|
+
hwnd: x.hwnd, title: x.title, className: x.className, process: x.process, rect: x.rect,
|
|
402
|
+
minimized: x.minimized, allowed: !guard.denied(x), elevated: x.elevated,
|
|
403
|
+
}));
|
|
404
|
+
playRadar(list);
|
|
405
|
+
return json({ denylist: config.denylist, foreground: w.foregroundWindow(), windows: list });
|
|
406
|
+
}, { session: false }));
|
|
407
|
+
|
|
408
|
+
server.registerTool('list_monitors', {
|
|
409
|
+
description: 'Monitors with their desktop rectangles and the virtual desktop bounds (physical pixels).',
|
|
410
|
+
}, action('list_monitors', async () => {
|
|
411
|
+
const mons = w.listMonitors().sort((a, b) => Number(b.primary) - Number(a.primary)).map((m, index) => ({ index, ...m }));
|
|
412
|
+
return json({ virtualScreen: w.virtualScreen(), monitors: mons });
|
|
413
|
+
}, { session: false }));
|
|
414
|
+
|
|
415
|
+
server.registerTool('cursor_position', {
|
|
416
|
+
description: 'Current mouse pointer position in desktop pixels.',
|
|
417
|
+
}, action('cursor_position', async () => json(w.cursorPos()), { session: false }));
|
|
418
|
+
|
|
419
|
+
server.registerTool('focus_window', {
|
|
420
|
+
description: 'Restore the window if minimized and bring it to the foreground. Works for any window outside the denylist.',
|
|
421
|
+
inputSchema: target,
|
|
422
|
+
}, action('focus_window', async (args, entry) => {
|
|
423
|
+
const win = guard.resolve(args);
|
|
424
|
+
entry.window = winSummary(win);
|
|
425
|
+
await guard.focus(win);
|
|
426
|
+
const now = w.describeWindow(win.hwnd);
|
|
427
|
+
playFlash(now.rect);
|
|
428
|
+
return json({ ok: true, window: winSummary(now) });
|
|
429
|
+
}));
|
|
430
|
+
|
|
431
|
+
server.registerTool('screenshot_window', {
|
|
432
|
+
description: 'PNG of one window only (works for Chrome and GPU-rendered content). Returns the image plus `scale` and `rect` (and the clamped `region` when one was given). With space "window", click/move/drag/scroll take coordinates in the pixels of the LAST screenshot_window of that hwnd, also when it was cropped: the server remembers the crop origin and scale per window, so a point in the cropped image maps back correctly. Optional `region` {x, y, width, height} crops inside the window (window pixels from its top-left, before maxWidth scaling; clamped to the window, error if fully outside); maxWidth then applies to the crop. Without `region` nothing changes.',
|
|
433
|
+
inputSchema: { ...target, region: z.object({ x: z.number(), y: z.number(), width: z.number().positive(), height: z.number().positive() }).optional().describe('Crop inside the window, window pixels from its top-left.'), maxWidth: z.number().int().min(100).max(8000).default(1280).describe('Maximum image width in pixels.') },
|
|
434
|
+
}, action('screenshot_window', async (args, entry) => {
|
|
435
|
+
if (!args.hwnd && !args.title) throw new ToolError('Give hwnd or title, or use screenshot_screen for the whole desktop.');
|
|
436
|
+
const win = guard.resolve(args);
|
|
437
|
+
guard.assertUsable(win, { needVisible: false });
|
|
438
|
+
entry.window = winSummary(win);
|
|
439
|
+
if (win.minimized) throw new ToolError(`"${win.title}" is minimized and cannot be captured. Use focus_window first.`);
|
|
440
|
+
await waitForEffects();
|
|
441
|
+
let cap = await overlay.hiddenDuring(() => w.captureWindow(win.hwnd));
|
|
442
|
+
if (args.region) {
|
|
443
|
+
const cropped = cropCapture(cap, args.region);
|
|
444
|
+
if (!cropped) throw new ToolError(`Region is outside the window (the window is ${cap.width}x${cap.height} px, region starts at ${args.region.x},${args.region.y}).`);
|
|
445
|
+
cap = cropped;
|
|
446
|
+
}
|
|
447
|
+
playScan({ left: cap.rect.left + (cap.region?.x ?? 0), top: cap.rect.top + (cap.region?.y ?? 0), width: cap.width, height: cap.height });
|
|
448
|
+
const img = shrinkToPng(cap, args.maxWidth ?? 1280);
|
|
449
|
+
guard.shots.set(win.hwnd, { scale: img.scale, ox: cap.region?.x ?? 0, oy: cap.region?.y ?? 0 });
|
|
450
|
+
guard.lastWindow = win.hwnd;
|
|
451
|
+
return {
|
|
452
|
+
content: [
|
|
453
|
+
{ type: 'image', data: img.png.toString('base64'), mimeType: 'image/png' },
|
|
454
|
+
{ type: 'text', text: JSON.stringify({ hwnd: win.hwnd, scale: img.scale, rect: cap.rect, ...(cap.region ? { region: cap.region } : {}), width: img.width, height: img.height, method: cap.method }) },
|
|
455
|
+
],
|
|
456
|
+
};
|
|
457
|
+
}));
|
|
458
|
+
|
|
459
|
+
server.registerTool('screenshot_screen', {
|
|
460
|
+
description: 'PNG of the desktop: one monitor (default: primary), all monitors together (all: true), or a rectangle (region, in desktop pixels). Returns `rect` (desktop pixels) and `scale`; a point (x, y) in the image is desktop pixel (rect.left + x/scale, rect.top + y/scale), or use space "shot" in the pointer tools.',
|
|
461
|
+
inputSchema: {
|
|
462
|
+
monitor: z.number().int().min(0).optional().describe('Monitor index from list_monitors (0 = primary).'),
|
|
463
|
+
all: z.boolean().optional().describe('Capture the whole virtual desktop (all monitors).'),
|
|
464
|
+
region: z.object({ x: z.number().int(), y: z.number().int(), width: z.number().int().min(1), height: z.number().int().min(1) }).optional(),
|
|
465
|
+
maxWidth: z.number().int().min(100).max(8000).default(1280),
|
|
466
|
+
},
|
|
467
|
+
}, action('screenshot_screen', async (args, entry) => {
|
|
468
|
+
const v = w.virtualScreen();
|
|
469
|
+
let rect;
|
|
470
|
+
if (args.region) {
|
|
471
|
+
const r = args.region;
|
|
472
|
+
const left = Math.max(r.x, v.x), top = Math.max(r.y, v.y);
|
|
473
|
+
const right = Math.min(r.x + r.width, v.x + v.width), bottom = Math.min(r.y + r.height, v.y + v.height);
|
|
474
|
+
if (right <= left || bottom <= top) throw new ToolError('That region is outside the desktop.');
|
|
475
|
+
rect = { left, top, width: right - left, height: bottom - top };
|
|
476
|
+
} else if (args.all) {
|
|
477
|
+
rect = { left: v.x, top: v.y, width: v.width, height: v.height };
|
|
478
|
+
} else {
|
|
479
|
+
const mons = w.listMonitors().sort((a, b) => Number(b.primary) - Number(a.primary));
|
|
480
|
+
const m = mons[args.monitor ?? 0];
|
|
481
|
+
if (!m) throw new ToolError(`No monitor ${args.monitor}; there are ${mons.length}.`);
|
|
482
|
+
rect = { left: m.left, top: m.top, width: m.width, height: m.height };
|
|
483
|
+
}
|
|
484
|
+
entry.rect = rect;
|
|
485
|
+
await waitForEffects();
|
|
486
|
+
const cap = await overlay.hiddenDuring(() => w.captureScreenRect(rect));
|
|
487
|
+
playScan(rect);
|
|
488
|
+
const img = shrinkToPng(cap, args.maxWidth ?? 1280);
|
|
489
|
+
guard.lastShot = { rect, scale: img.scale };
|
|
490
|
+
return {
|
|
491
|
+
content: [
|
|
492
|
+
{ type: 'image', data: img.png.toString('base64'), mimeType: 'image/png' },
|
|
493
|
+
{ type: 'text', text: JSON.stringify({ scale: img.scale, rect, width: img.width, height: img.height, cursor: w.cursorPos() }) },
|
|
494
|
+
],
|
|
495
|
+
};
|
|
496
|
+
}));
|
|
497
|
+
|
|
498
|
+
server.registerTool('click', {
|
|
499
|
+
description: 'Click at a point: left/right/middle button, double or triple (clicks), optional held modifiers (ctrl/shift/alt/win). In window space the window is focused first.',
|
|
500
|
+
inputSchema: { ...motion, ...target, space, ...xy, button: buttons, double: z.boolean().default(false), clicks: z.number().int().min(1).max(3).optional(), modifiers },
|
|
501
|
+
}, action('click', async (args, entry) => {
|
|
502
|
+
const map = await pointMapper(args, entry);
|
|
503
|
+
const p = map(args.x, args.y);
|
|
504
|
+
entry.coords = { x: args.x, y: args.y, screen: { x: p.sx, y: p.sy } };
|
|
505
|
+
const b = args.button ?? 'left';
|
|
506
|
+
const n = args.clicks ?? (args.double ? 2 : 1);
|
|
507
|
+
await glide(p, args);
|
|
508
|
+
await w.sleep(30);
|
|
509
|
+
guard.checkKill();
|
|
510
|
+
playRipple(p, b, n);
|
|
511
|
+
await withModifiers(args.modifiers, async () => {
|
|
512
|
+
for (let i = 0; i < n; i++) {
|
|
513
|
+
w.mouseDown(b);
|
|
514
|
+
w.mouseUp(b);
|
|
515
|
+
if (i < n - 1) await w.sleep(40);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
return json({ ok: true, screen: { x: p.sx, y: p.sy } });
|
|
519
|
+
}));
|
|
520
|
+
|
|
521
|
+
server.registerTool('move', {
|
|
522
|
+
description: 'Move the mouse pointer.',
|
|
523
|
+
inputSchema: { ...motion, ...target, space, ...xy },
|
|
524
|
+
}, action('move', async (args, entry) => {
|
|
525
|
+
const map = await pointMapper(args, entry);
|
|
526
|
+
const p = map(args.x, args.y);
|
|
527
|
+
entry.coords = { x: args.x, y: args.y, screen: { x: p.sx, y: p.sy } };
|
|
528
|
+
await glide(p, args, { ring: true });
|
|
529
|
+
return json({ ok: true, screen: { x: p.sx, y: p.sy } });
|
|
530
|
+
}));
|
|
531
|
+
|
|
532
|
+
server.registerTool('mouse_down', {
|
|
533
|
+
description: 'Press and keep holding a mouse button (optionally after moving to x, y). Release with mouse_up. Held buttons are released automatically on the kill switch or shutdown.',
|
|
534
|
+
inputSchema: { ...motion, ...target, space, x: z.number().optional(), y: z.number().optional(), button: buttons },
|
|
535
|
+
}, action('mouse_down', async (args, entry) => {
|
|
536
|
+
const map = await pointMapper(args, entry);
|
|
537
|
+
if (args.x !== undefined && args.y !== undefined) {
|
|
538
|
+
const p = map(args.x, args.y);
|
|
539
|
+
entry.coords = { x: args.x, y: args.y, screen: { x: p.sx, y: p.sy } };
|
|
540
|
+
await glide(p, args);
|
|
541
|
+
await w.sleep(30);
|
|
542
|
+
}
|
|
543
|
+
const b = args.button ?? 'left';
|
|
544
|
+
w.mouseDown(b);
|
|
545
|
+
guard.heldButtons.add(b);
|
|
546
|
+
return json({ ok: true, held: [...guard.heldButtons] });
|
|
547
|
+
}));
|
|
548
|
+
|
|
549
|
+
server.registerTool('mouse_up', {
|
|
550
|
+
description: 'Release a mouse button held with mouse_down (optionally after moving to x, y).',
|
|
551
|
+
inputSchema: { ...motion, ...target, space, x: z.number().optional(), y: z.number().optional(), button: buttons },
|
|
552
|
+
}, action('mouse_up', async (args, entry) => {
|
|
553
|
+
const map = await pointMapper(args, entry);
|
|
554
|
+
if (args.x !== undefined && args.y !== undefined) {
|
|
555
|
+
const p = map(args.x, args.y);
|
|
556
|
+
entry.coords = { x: args.x, y: args.y, screen: { x: p.sx, y: p.sy } };
|
|
557
|
+
await glide(p, args);
|
|
558
|
+
await w.sleep(30);
|
|
559
|
+
}
|
|
560
|
+
const b = args.button ?? 'left';
|
|
561
|
+
w.mouseUp(b);
|
|
562
|
+
guard.heldButtons.delete(b);
|
|
563
|
+
return json({ ok: true, held: [...guard.heldButtons] });
|
|
564
|
+
}));
|
|
565
|
+
|
|
566
|
+
server.registerTool('drag', {
|
|
567
|
+
description: 'Glide to the start point, press the button, glide to the end point (many small moves, as HTML5 drag-and-drop needs), release. Optional held modifiers.',
|
|
568
|
+
inputSchema: { ...motion,
|
|
569
|
+
...target, space,
|
|
570
|
+
fromX: z.number(), fromY: z.number(), toX: z.number(), toY: z.number(),
|
|
571
|
+
button: buttons,
|
|
572
|
+
modifiers,
|
|
573
|
+
},
|
|
574
|
+
}, action('drag', async (args, entry) => {
|
|
575
|
+
const map = await pointMapper(args, entry);
|
|
576
|
+
const a = map(args.fromX, args.fromY);
|
|
577
|
+
const z2 = map(args.toX, args.toY);
|
|
578
|
+
entry.coords = { from: { x: args.fromX, y: args.fromY, screen: { x: a.sx, y: a.sy } }, to: { x: args.toX, y: args.toY, screen: { x: z2.sx, y: z2.sy } } };
|
|
579
|
+
const b = args.button ?? 'left';
|
|
580
|
+
await withModifiers(args.modifiers, async () => {
|
|
581
|
+
await glide(a, args);
|
|
582
|
+
await w.sleep(40);
|
|
583
|
+
w.mouseDown(b);
|
|
584
|
+
guard.heldButtons.add(b);
|
|
585
|
+
const stopTrail = playTrail(a, z2, b);
|
|
586
|
+
try {
|
|
587
|
+
await w.sleep(40);
|
|
588
|
+
await glide(z2, args, { trail: false });
|
|
589
|
+
await w.sleep(40);
|
|
590
|
+
} finally {
|
|
591
|
+
w.mouseUp(b);
|
|
592
|
+
guard.heldButtons.delete(b);
|
|
593
|
+
stopTrail();
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
return json({ ok: true, from: { x: a.sx, y: a.sy }, to: { x: z2.sx, y: z2.sy } });
|
|
597
|
+
}));
|
|
598
|
+
|
|
599
|
+
server.registerTool('scroll', {
|
|
600
|
+
description: 'Smooth mouse wheel at a point (spread over 300-1000+ ms). `amount`: vertical notches, positive up, negative down. `horizontal`: horizontal notches, positive right, negative left. Either or both.',
|
|
601
|
+
inputSchema: { ...motion,
|
|
602
|
+
...target, space, ...xy,
|
|
603
|
+
amount: z.number().min(-100).max(100).optional(),
|
|
604
|
+
horizontal: z.number().min(-100).max(100).optional(),
|
|
605
|
+
modifiers,
|
|
606
|
+
},
|
|
607
|
+
}, action('scroll', async (args, entry) => {
|
|
608
|
+
if (!args.amount && !args.horizontal) throw new ToolError('Give amount (vertical) and/or horizontal.');
|
|
609
|
+
const map = await pointMapper(args, entry);
|
|
610
|
+
const p = map(args.x, args.y);
|
|
611
|
+
entry.coords = { x: args.x, y: args.y, screen: { x: p.sx, y: p.sy }, amount: args.amount, horizontal: args.horizontal };
|
|
612
|
+
await glide(p, args);
|
|
613
|
+
await w.sleep(30);
|
|
614
|
+
guard.checkKill();
|
|
615
|
+
let stats = { events: 1, ms: 0 };
|
|
616
|
+
const stopCue = playWheelCue(p, args.amount ?? 0, args.horizontal ?? 0);
|
|
617
|
+
try {
|
|
618
|
+
await withModifiers(args.modifiers, async () => {
|
|
619
|
+
stats = await smoothWheel(args.amount ?? 0, args.horizontal ?? 0, args);
|
|
620
|
+
});
|
|
621
|
+
} finally {
|
|
622
|
+
stopCue();
|
|
623
|
+
}
|
|
624
|
+
entry.events = stats.events;
|
|
625
|
+
return json({ ok: true, screen: { x: p.sx, y: p.sy }, wheelEvents: stats.events, ms: stats.ms });
|
|
626
|
+
}));
|
|
627
|
+
|
|
628
|
+
function assertNotPassword() {
|
|
629
|
+
let isPw;
|
|
630
|
+
try {
|
|
631
|
+
isPw = w.focusIsPassword();
|
|
632
|
+
} catch (e) {
|
|
633
|
+
throw new ToolError(`Refused: could not verify that the focused field is not a password field (${e.message}).`);
|
|
634
|
+
}
|
|
635
|
+
if (isPw) throw new ToolError('Refused: the focused field is a password field. Log in yourself; this server never types into password fields.');
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Keyboard target: focus hwnd/title when given, otherwise whatever is in front. Verifies the foreground window. */
|
|
639
|
+
async function keyboardTarget(args, entry) {
|
|
640
|
+
if (args.hwnd || args.title) {
|
|
641
|
+
const win = guard.resolve(args);
|
|
642
|
+
entry.window = winSummary(win);
|
|
643
|
+
await guard.prepareKeyboard(win);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const fg = w.foregroundWindow();
|
|
647
|
+
if (!fg) throw new ToolError('No window has the keyboard focus. Use focus_window first.');
|
|
648
|
+
const d = w.describeWindow(fg);
|
|
649
|
+
entry.window = winSummary(d);
|
|
650
|
+
guard.assertUsable(d);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
server.registerTool('type_text', {
|
|
654
|
+
description: 'Type text into the focused control using Unicode key events, one character at a time at a human pace (random 60-200 ms per character; very long text is sped up to finish within 60 s, use set_clipboard + ctrl+v for bulk text). `speed` divides the waits: 1 = normal, 2 = twice as fast, 0.5 = half as fast, 0 = a fixed 8 ms after every character (the fastest allowed; no wait is ever shorter, and text is never sent in one batch). The 60 s cap applies after `speed`. (Turkish and other non-ASCII characters work). Refused when the focused control is a password field. Pass hwnd/title to focus a window first; otherwise the current foreground window gets the text.',
|
|
655
|
+
inputSchema: { ...target, text: z.string().min(1), speed: z.number().min(0).max(100).optional().describe('Typing speed multiplier for the waits between characters. Default 1; 2 halves the waits, 0.5 doubles them, 0 = fixed 8 ms per character (fastest).') },
|
|
656
|
+
}, action('type_text', async (args, entry) => {
|
|
657
|
+
if (args.text.length > config.maxTypeLength) throw new ToolError(`Text is longer than ${config.maxTypeLength} characters. Use set_clipboard and ctrl+v for long text.`);
|
|
658
|
+
await keyboardTarget(args, entry);
|
|
659
|
+
try {
|
|
660
|
+
assertNotPassword();
|
|
661
|
+
} catch (e) {
|
|
662
|
+
delete entry.args.text; // never keep text aimed at a password field
|
|
663
|
+
entry.args.refusedChars = args.text.length;
|
|
664
|
+
throw e;
|
|
665
|
+
}
|
|
666
|
+
guard.checkKill();
|
|
667
|
+
const speed = args.speed ?? config.typeSpeed;
|
|
668
|
+
const delays = typingDelays(args.text, { min: config.typeMinMs, max: config.typeMaxMs, capMs: config.typeCapMs, speed });
|
|
669
|
+
const t0 = performance.now();
|
|
670
|
+
const fx = playTyping([...args.text].filter((c) => c !== '\r').length);
|
|
671
|
+
try {
|
|
672
|
+
await w.typeUnicode(args.text, () => { fx.progress.n++; guard.checkKill(); }, delays);
|
|
673
|
+
} finally {
|
|
674
|
+
fx.stop();
|
|
675
|
+
}
|
|
676
|
+
return json({ ok: true, typed: args.text.length, ms: Math.round(performance.now() - t0), speed, keyboardLayout: '0x' + w.foregroundLayout().toString(16) });
|
|
677
|
+
}));
|
|
678
|
+
|
|
679
|
+
function parseKeys(keys) {
|
|
680
|
+
try {
|
|
681
|
+
return parseSequence(keys);
|
|
682
|
+
} catch (e) {
|
|
683
|
+
throw new ToolError(e.message);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
server.registerTool('press_keys', {
|
|
688
|
+
description: 'Press key chords, e.g. "ctrl+v", "enter", "alt+tab", "win+r", "win+d". Separate several chords with spaces ("ctrl+a ctrl+c"). Names: ctrl shift alt win enter tab esc space backspace delete home end pageup pagedown arrows f1-f24 a-z 0-9. Pass hwnd/title to focus a window first; otherwise the foreground window gets the keys.',
|
|
689
|
+
inputSchema: { ...target, keys: z.string().min(1) },
|
|
690
|
+
}, action('press_keys', async (args, entry) => {
|
|
691
|
+
const chords = parseKeys(args.keys);
|
|
692
|
+
await keyboardTarget(args, entry);
|
|
693
|
+
if (chords.some(insertsContent)) assertNotPassword();
|
|
694
|
+
playKeycaps(args.keys);
|
|
695
|
+
for (const c of chords) {
|
|
696
|
+
guard.checkKill();
|
|
697
|
+
w.pressVirtualKeys(c);
|
|
698
|
+
await w.sleep(30);
|
|
699
|
+
}
|
|
700
|
+
return json({ ok: true, chords: chords.length });
|
|
701
|
+
}));
|
|
702
|
+
|
|
703
|
+
server.registerTool('key_down', {
|
|
704
|
+
description: 'Press and hold keys (e.g. "shift" or "ctrl+shift") until key_up. Held keys are released automatically on the kill switch or shutdown.',
|
|
705
|
+
inputSchema: { ...target, keys: z.string().min(1) },
|
|
706
|
+
}, action('key_down', async (args, entry) => {
|
|
707
|
+
const vks = parseKeys(args.keys).flat();
|
|
708
|
+
await keyboardTarget(args, entry);
|
|
709
|
+
w.keysDown(vks);
|
|
710
|
+
vks.forEach((k) => guard.heldKeys.add(k));
|
|
711
|
+
return json({ ok: true, held: guard.heldKeys.size });
|
|
712
|
+
}));
|
|
713
|
+
|
|
714
|
+
server.registerTool('key_up', {
|
|
715
|
+
description: 'Release keys held with key_down.',
|
|
716
|
+
inputSchema: { keys: z.string().min(1) },
|
|
717
|
+
}, action('key_up', async (args) => {
|
|
718
|
+
const vks = parseKeys(args.keys).flat();
|
|
719
|
+
w.keysUp(vks);
|
|
720
|
+
vks.forEach((k) => guard.heldKeys.delete(k));
|
|
721
|
+
return json({ ok: true, held: guard.heldKeys.size });
|
|
722
|
+
}));
|
|
723
|
+
|
|
724
|
+
server.registerTool('wait', {
|
|
725
|
+
description: 'Pause (milliseconds, up to 60000), for page loads and animations. The kill switch is checked while waiting.',
|
|
726
|
+
inputSchema: { ms: z.number().int().min(0).max(60000) },
|
|
727
|
+
}, action('wait', async (args) => {
|
|
728
|
+
let left = args.ms;
|
|
729
|
+
if (args.ms >= 1000) playCountdown(args.ms); // shorter waits are too frequent to be worth a cue
|
|
730
|
+
while (left > 0) {
|
|
731
|
+
const step = Math.min(left, 250);
|
|
732
|
+
await w.sleep(step);
|
|
733
|
+
left -= step;
|
|
734
|
+
guard.checkKill();
|
|
735
|
+
}
|
|
736
|
+
return json({ ok: true, waited: args.ms });
|
|
737
|
+
}, { session: false }));
|
|
738
|
+
|
|
739
|
+
server.registerTool('get_clipboard', {
|
|
740
|
+
description: 'Read the clipboard text. Also reports whether it holds an image or files. The text is returned to the caller but not written to the action log (only its length).',
|
|
741
|
+
}, action('get_clipboard', async (args, entry) => {
|
|
742
|
+
const c = w.getClipboardText();
|
|
743
|
+
entry.length = c.text === null ? null : c.text.length;
|
|
744
|
+
entry.formats = c.formats;
|
|
745
|
+
return json(c);
|
|
746
|
+
}, { session: false }));
|
|
747
|
+
|
|
748
|
+
function setImageClipboard(imagePath) {
|
|
749
|
+
return new Promise((resolve, reject) => {
|
|
750
|
+
if (w.name === 'darwin') {
|
|
751
|
+
const ext = imagePath.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
|
|
752
|
+
const cls = { png: 'PNGf', jpg: 'JPEG', jpeg: 'JPEG', gif: 'GIFf', tif: 'TIFF', tiff: 'TIFF' }[ext];
|
|
753
|
+
if (!cls) return reject(new ToolError('Could not load that image onto the clipboard: on macOS use a PNG, JPEG, GIF or TIFF file.'));
|
|
754
|
+
const osa = spawn('osascript', ['-e', 'on run argv', '-e', `set the clipboard to (read (POSIX file (item 1 of argv)) as «class ${cls}»)`, '-e', 'end run', imagePath], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
755
|
+
let err = '';
|
|
756
|
+
osa.stderr.on('data', (d) => (err += d));
|
|
757
|
+
osa.on('error', reject);
|
|
758
|
+
osa.on('exit', (code) => (code === 0 ? resolve() : reject(new ToolError(`Could not load that image onto the clipboard: ${err.trim().split('\n')[0] || 'unsupported file'}`))));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const script = "Add-Type -AssemblyName System.Windows.Forms,System.Drawing;$i=[System.Drawing.Image]::FromFile($env:ISPYX_IMG);[System.Windows.Forms.Clipboard]::SetImage($i);$i.Dispose()";
|
|
762
|
+
const ps = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-STA', '-Command', script], {
|
|
763
|
+
env: { ...process.env, ISPYX_IMG: imagePath }, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'],
|
|
764
|
+
});
|
|
765
|
+
let err = '';
|
|
766
|
+
ps.stderr.on('data', (d) => (err += d));
|
|
767
|
+
ps.on('error', reject);
|
|
768
|
+
ps.on('exit', (code) => (code === 0 ? resolve() : reject(new ToolError(`Could not load that image onto the clipboard: ${err.trim().split('\n')[0] || 'unsupported file'}`))));
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
server.registerTool('set_clipboard', {
|
|
773
|
+
description: 'Put text, an image, or a file on the clipboard so it can be pasted with ctrl+v (long captions, uploads). Give exactly one of text, imagePath (pastes as a picture) or filePath (pastes as a file, as copied in Explorer).',
|
|
774
|
+
inputSchema: { text: z.string().optional(), imagePath: z.string().optional(), filePath: z.string().optional() },
|
|
775
|
+
}, action('set_clipboard', async (args, entry) => {
|
|
776
|
+
const given = ['text', 'imagePath', 'filePath'].filter((k) => args[k] !== undefined);
|
|
777
|
+
if (given.length !== 1) throw new ToolError('Give exactly one of text, imagePath or filePath.');
|
|
778
|
+
if (args.text !== undefined) {
|
|
779
|
+
w.setClipboardText(args.text);
|
|
780
|
+
return json({ ok: true, kind: 'text', length: args.text.length });
|
|
781
|
+
}
|
|
782
|
+
const p = args.imagePath ?? args.filePath;
|
|
783
|
+
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) throw new ToolError(`File not found: ${p}`);
|
|
784
|
+
if (args.imagePath) {
|
|
785
|
+
await setImageClipboard(p);
|
|
786
|
+
return json({ ok: true, kind: 'image', path: p });
|
|
787
|
+
}
|
|
788
|
+
w.setClipboardFile(p);
|
|
789
|
+
return json({ ok: true, kind: 'file', path: p });
|
|
790
|
+
}, { session: false }));
|
|
791
|
+
|
|
792
|
+
// ---------------------------------------------------------------- run
|
|
793
|
+
|
|
794
|
+
process.on('exit', () => guard?.releaseAll());
|
|
795
|
+
if (mode === 'resident') {
|
|
796
|
+
// the single owner: no stdio, errors go to %APPDATA%\ispyx\ispyx.log; a second resident leaves at once
|
|
797
|
+
const log = setupLogging(w.dataDir());
|
|
798
|
+
try {
|
|
799
|
+
await tryBecomeOwner();
|
|
800
|
+
} catch (e) {
|
|
801
|
+
log?.write('fatal', ['start failed:', e]);
|
|
802
|
+
process.exit(1);
|
|
803
|
+
}
|
|
804
|
+
if (!owner) process.exit(0);
|
|
805
|
+
residentUi = await startResident({ w, config, owner, guard, auth, scan, shutdown: shutdownAll, log });
|
|
806
|
+
// Started by Ispyx.app (macOS): the app stops it with SIGTERM when it quits, and quitting the app ends the
|
|
807
|
+
// forwarders too, so tell them first. If the app is gone without that (crash, kill), leave as well.
|
|
808
|
+
const appPid = Number(process.env.ISPYX_APP_PID) || 0;
|
|
809
|
+
if (appPid) {
|
|
810
|
+
process.on('SIGTERM', shutdownAll);
|
|
811
|
+
const t = setInterval(() => { try { process.kill(appPid, 0); } catch { shutdownAll(); } }, 1000);
|
|
812
|
+
t.unref?.();
|
|
813
|
+
}
|
|
814
|
+
} else if (mode === 'mcp') {
|
|
815
|
+
// thin stdio server: the resident does the work; it orders us to exit on its shutdown (second stop press, Quit)
|
|
816
|
+
watchOwner(pipe, () => process.exit(0), () => false);
|
|
817
|
+
await server.connect(new StdioServerTransport());
|
|
818
|
+
process.stdin.on('close', () => process.exit(0));
|
|
819
|
+
} else {
|
|
820
|
+
await tryBecomeOwner();
|
|
821
|
+
if (!owner) watchOwner(pipe, () => process.exit(0), () => !!owner);
|
|
822
|
+
await server.connect(new StdioServerTransport());
|
|
823
|
+
stdioOpen = true;
|
|
824
|
+
// When this Claude goes away, an owner that still serves other instances keeps running until the last one is gone.
|
|
825
|
+
process.stdin.on('close', () => {
|
|
826
|
+
stdioOpen = false;
|
|
827
|
+
if (!owner || owner.clients() === 0) process.exit(0);
|
|
828
|
+
const t = setInterval(() => { if (owner.clients() === 0) process.exit(0); }, 2000);
|
|
829
|
+
t.unref?.();
|
|
830
|
+
guard.releaseAll();
|
|
831
|
+
});
|
|
832
|
+
}
|