@rebind.gg/mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/package.json +61 -0
- package/src/coords.ts +37 -0
- package/src/image.ts +85 -0
- package/src/index.ts +456 -0
- package/src/session.ts +188 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rebind
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @rebind.gg/mcp-server
|
|
2
|
+
|
|
3
|
+
Stdio MCP server that exposes Rebind as computer-use tools: `screenshot`,
|
|
4
|
+
`screenshot_window`, `zoom`, `click` (verified), `move_mouse`, `type`, `key`,
|
|
5
|
+
`scroll`, `run_lua`, `wait`, window control, and `calibrate`. Wraps
|
|
6
|
+
`@rebind.gg/client-ts`; requires a remote-control
|
|
7
|
+
server running in Rebind — `remote.luau` (recommended) or `remote_access.lua`
|
|
8
|
+
(protocol >= 1.1.0). The `click` tool clicks via `hid.press`, which is
|
|
9
|
+
coroutine-only; use `remote.luau` or a current `remote_access.lua` (older copies
|
|
10
|
+
called it outside a coroutine, so clicks silently failed).
|
|
11
|
+
|
|
12
|
+
## Use
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"mcpServers": {
|
|
17
|
+
"rebind": {
|
|
18
|
+
"command": "bunx",
|
|
19
|
+
"args": ["@rebind.gg/mcp-server"],
|
|
20
|
+
"env": { "REBIND_URL": "ws://127.0.0.1:19561" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`REBIND_TOKEN` is required when the server has an auth token set (the **Auth
|
|
27
|
+
token** panel field on `remote.luau`, or `AUTH_TOKEN` on `remote_access.lua`).
|
|
28
|
+
|
|
29
|
+
Check the relay is reachable before wiring it up:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
bunx @rebind.gg/mcp-server --selftest
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Set `REBIND_AGENT_LOG=<path>` to append one JSONL line per tool call
|
|
36
|
+
(`{ts, tool, ok, latency_ms, note}`) — no screenshot bytes, no user content.
|
|
37
|
+
Off unless set.
|
|
38
|
+
|
|
39
|
+
`click` and `move_mouse` reach an absolute screen point by reading the real OS
|
|
40
|
+
cursor and sending relative HID deltas (which map 1:1 to pixels), correcting
|
|
41
|
+
against the true cursor until it lands. This sidesteps the relay's own absolute
|
|
42
|
+
move, whose delta is computed from a cached cursor position that goes stale and
|
|
43
|
+
flings the pointer to a screen edge. A mouse scale probe still runs at startup;
|
|
44
|
+
if pointer ballistics are detected (enhance pointer precision, DPI mismatch) the
|
|
45
|
+
probe reports it, since acceleration would break the 1:1 assumption.
|
|
46
|
+
|
|
47
|
+
## Develop
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
bun test packages/mcp-server/tests/
|
|
51
|
+
bun run --cwd packages/mcp-server typecheck
|
|
52
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rebind.gg/mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server exposing Rebind as computer-use tools: screenshot, verified click, type, key, scroll, and window control over the Remote Access protocol.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"rebind-mcp-server": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "US Input Company",
|
|
17
|
+
"email": "inquiries@usinput.com",
|
|
18
|
+
"url": "https://usinput.com"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://rebind.gg",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/usinput/rebind-client-ts",
|
|
24
|
+
"directory": "packages/mcp-server"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"email": "support@rebind.gg"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"rebind",
|
|
31
|
+
"mcp",
|
|
32
|
+
"model-context-protocol",
|
|
33
|
+
"computer-use",
|
|
34
|
+
"openclaw",
|
|
35
|
+
"hid",
|
|
36
|
+
"automation",
|
|
37
|
+
"keyboard",
|
|
38
|
+
"mouse"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"bun": ">=1.1"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"start": "bun run src/index.ts",
|
|
45
|
+
"selftest": "bun run src/index.ts --selftest",
|
|
46
|
+
"test": "bun test tests/",
|
|
47
|
+
"typecheck": "tsc --noEmit"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
51
|
+
"@rebind.gg/client-ts": "0.2.0",
|
|
52
|
+
"sharp": "^0.34.2",
|
|
53
|
+
"zod": "^3.25.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"typescript": "^5.4.0"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/coords.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// pure image-space -> screen-space mapping for the last screenshot taken.
|
|
2
|
+
// the model only ever sees and emits coordinates in the pixel space of the
|
|
3
|
+
// screenshot in front of it; all mapping back to signed virtual-desktop
|
|
4
|
+
// screen coordinates lives here.
|
|
5
|
+
|
|
6
|
+
export interface ShotMapping {
|
|
7
|
+
/** 1-based index of the captured display, for region re-captures (zoom). */
|
|
8
|
+
displayIndex: number;
|
|
9
|
+
/** signed virtual-desktop origin of the captured display. */
|
|
10
|
+
originX: number;
|
|
11
|
+
originY: number;
|
|
12
|
+
/** image pixels per screen point (capture scale x resize factor). */
|
|
13
|
+
scale: number;
|
|
14
|
+
/** dims of the image the model saw, for bounds checking. */
|
|
15
|
+
imageWidth: number;
|
|
16
|
+
imageHeight: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Point {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function mapToScreen(shot: ShotMapping, ix: number, iy: number): Point {
|
|
25
|
+
return {
|
|
26
|
+
x: Math.round(shot.originX + ix / shot.scale),
|
|
27
|
+
y: Math.round(shot.originY + iy / shot.scale),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function inImageBounds(
|
|
32
|
+
shot: ShotMapping,
|
|
33
|
+
ix: number,
|
|
34
|
+
iy: number,
|
|
35
|
+
): boolean {
|
|
36
|
+
return ix >= 0 && ix < shot.imageWidth && iy >= 0 && iy < shot.imageHeight;
|
|
37
|
+
}
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// the model-facing image pipeline: decode the native-resolution PNG from
|
|
2
|
+
// Screen.Capture, downscale to the vision sweet spot, draw the cursor
|
|
3
|
+
// crosshair in image space, and encode webp for the MCP image block.
|
|
4
|
+
|
|
5
|
+
import sharp from "sharp";
|
|
6
|
+
|
|
7
|
+
/** long-edge target. XGA-ish is the vision-accuracy sweet spot and keeps the
|
|
8
|
+
* payload small; raise only when small text must be read (use zoom instead). */
|
|
9
|
+
export const DEFAULT_MAX_EDGE = 1280;
|
|
10
|
+
|
|
11
|
+
/** odd so the center lands on a pixel. */
|
|
12
|
+
const CROSSHAIR_SIZE = 25;
|
|
13
|
+
|
|
14
|
+
export interface RenderedShot {
|
|
15
|
+
/** webp bytes for the MCP image content block. */
|
|
16
|
+
data: Buffer;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
/** image pixels per screen point of the captured area. */
|
|
20
|
+
scale: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function crosshairSvg(): Buffer {
|
|
24
|
+
const s = CROSSHAIR_SIZE;
|
|
25
|
+
const c = (s - 1) / 2;
|
|
26
|
+
return Buffer.from(
|
|
27
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}">` +
|
|
28
|
+
`<line x1="${c}" y1="0" x2="${c}" y2="${s}" stroke="white" stroke-width="5"/>` +
|
|
29
|
+
`<line x1="0" y1="${c}" x2="${s}" y2="${c}" stroke="white" stroke-width="5"/>` +
|
|
30
|
+
`<line x1="${c}" y1="0" x2="${c}" y2="${s}" stroke="red" stroke-width="2"/>` +
|
|
31
|
+
`<line x1="0" y1="${c}" x2="${s}" y2="${c}" stroke="red" stroke-width="2"/>` +
|
|
32
|
+
`</svg>`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const clamp = (v: number, lo: number, hi: number) =>
|
|
37
|
+
Math.min(Math.max(v, lo), Math.max(lo, hi));
|
|
38
|
+
|
|
39
|
+
export async function renderScreenshot(opts: {
|
|
40
|
+
/** native-resolution lossless PNG from Screen.Capture. */
|
|
41
|
+
png: Buffer;
|
|
42
|
+
/** logical size of the captured area in screen points. */
|
|
43
|
+
logicalWidth: number;
|
|
44
|
+
logicalHeight: number;
|
|
45
|
+
/** cursor relative to the captured area, in screen points; omit to skip the marker. */
|
|
46
|
+
cursor?: { x: number; y: number };
|
|
47
|
+
maxEdge?: number;
|
|
48
|
+
}): Promise<RenderedShot> {
|
|
49
|
+
const maxEdge = opts.maxEdge ?? DEFAULT_MAX_EDGE;
|
|
50
|
+
const meta = await sharp(opts.png).metadata();
|
|
51
|
+
const nativeW = meta.width;
|
|
52
|
+
const nativeH = meta.height;
|
|
53
|
+
if (!nativeW || !nativeH) throw new Error("could not decode capture PNG");
|
|
54
|
+
|
|
55
|
+
let outW = nativeW;
|
|
56
|
+
let outH = nativeH;
|
|
57
|
+
const long = Math.max(nativeW, nativeH);
|
|
58
|
+
let img = sharp(opts.png);
|
|
59
|
+
if (long > maxEdge) {
|
|
60
|
+
const f = maxEdge / long;
|
|
61
|
+
outW = Math.max(1, Math.round(nativeW * f));
|
|
62
|
+
outH = Math.max(1, Math.round(nativeH * f));
|
|
63
|
+
img = img.resize(outW, outH);
|
|
64
|
+
}
|
|
65
|
+
const scale = outW / opts.logicalWidth;
|
|
66
|
+
|
|
67
|
+
if (opts.cursor) {
|
|
68
|
+
const px = Math.round(opts.cursor.x * scale);
|
|
69
|
+
const py = Math.round(opts.cursor.y * scale);
|
|
70
|
+
// skip the marker when the cursor sits outside the captured area
|
|
71
|
+
if (px >= 0 && px < outW && py >= 0 && py < outH) {
|
|
72
|
+
const half = (CROSSHAIR_SIZE - 1) / 2;
|
|
73
|
+
img = img.composite([
|
|
74
|
+
{
|
|
75
|
+
input: crosshairSvg(),
|
|
76
|
+
left: clamp(px - half, 0, outW - CROSSHAIR_SIZE),
|
|
77
|
+
top: clamp(py - half, 0, outH - CROSSHAIR_SIZE),
|
|
78
|
+
},
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const data = await img.webp({ quality: 80 }).toBuffer();
|
|
84
|
+
return { data, width: outW, height: outH, scale };
|
|
85
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// stdio MCP server wrapping @rebind.gg/client-ts. all model-facing features
|
|
4
|
+
// live here: image pipeline, coordinate bookkeeping, verified click,
|
|
5
|
+
// calibration. the Rust/Lua layers below ship primitives only.
|
|
6
|
+
//
|
|
7
|
+
// config: REBIND_URL (default ws://127.0.0.1:19561), REBIND_TOKEN (optional).
|
|
8
|
+
|
|
9
|
+
import { appendFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { RebindRemote } from "@rebind.gg/client-ts";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { renderScreenshot } from "./image.ts";
|
|
16
|
+
import { Session } from "./session.ts";
|
|
17
|
+
|
|
18
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
|
|
20
|
+
function text(t: string) {
|
|
21
|
+
return { content: [{ type: "text" as const, text: t }] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const url = process.env.REBIND_URL ?? "ws://127.0.0.1:19561";
|
|
26
|
+
const client = new RebindRemote(url, {
|
|
27
|
+
token: process.env.REBIND_TOKEN ?? "",
|
|
28
|
+
timeoutMs: 15000,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// --selftest: verify the relay is reachable and capturing, then exit. Used by
|
|
32
|
+
// the installer and by users to diagnose the #1 first-run failure (relay not
|
|
33
|
+
// loaded) without wiring up the MCP transport.
|
|
34
|
+
if (process.argv.includes("--selftest")) {
|
|
35
|
+
try {
|
|
36
|
+
await client.connect();
|
|
37
|
+
const cap = await client.screenCapture({});
|
|
38
|
+
console.error(
|
|
39
|
+
`selftest OK: relay ${url}, display ${cap.display.index} ${cap.display.width}x${cap.display.height}`,
|
|
40
|
+
);
|
|
41
|
+
process.exit(0);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error(
|
|
44
|
+
`selftest FAILED: Rebind relay not reachable on ${url}.\n` +
|
|
45
|
+
`Start Rebind and load the Remote Access script (remote_access.lua).\n(${e})`,
|
|
46
|
+
);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await client.connect();
|
|
52
|
+
|
|
53
|
+
const session = new Session(client);
|
|
54
|
+
try {
|
|
55
|
+
await session.calibrate();
|
|
56
|
+
} catch (e) {
|
|
57
|
+
session.calibration = { calibrated: false, reason: String(e) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const server = new McpServer({ name: "rebind", version: "0.1.0" });
|
|
61
|
+
|
|
62
|
+
// REBIND_AGENT_LOG=<path>: opt-in telemetry — one JSONL line per tool call
|
|
63
|
+
// ({ts, tool, ok, latency_ms, note}). No screenshot bytes, no user content.
|
|
64
|
+
// No-op when unset.
|
|
65
|
+
const telemetryPath = process.env.REBIND_AGENT_LOG?.replace(/^~(?=\/)/, homedir());
|
|
66
|
+
const logCall = (tool: string, ok: boolean, t0: number, note?: string) => {
|
|
67
|
+
if (!telemetryPath) return;
|
|
68
|
+
try {
|
|
69
|
+
appendFileSync(
|
|
70
|
+
telemetryPath,
|
|
71
|
+
`${JSON.stringify({ ts: new Date().toISOString(), tool, ok, latency_ms: Math.round(performance.now() - t0), ...(note ? { note } : {}) })}\n`,
|
|
72
|
+
);
|
|
73
|
+
} catch {
|
|
74
|
+
// telemetry must never break a tool call
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const register = (name: string, config: object, handler: (args: any) => Promise<any>) => {
|
|
78
|
+
server.registerTool(name, config as any, async (args: any) => {
|
|
79
|
+
const t0 = performance.now();
|
|
80
|
+
try {
|
|
81
|
+
const result = await handler(args);
|
|
82
|
+
logCall(name, true, t0);
|
|
83
|
+
return result;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
logCall(name, false, t0, String(e));
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
register(
|
|
92
|
+
"screenshot",
|
|
93
|
+
{
|
|
94
|
+
description:
|
|
95
|
+
"Capture a display and return it as an image. The cursor is marked with a red crosshair. " +
|
|
96
|
+
"All click/move coordinates you emit must be pixel coordinates in the MOST RECENT screenshot. " +
|
|
97
|
+
"After every action, take a new screenshot and verify the action had the intended effect before continuing; if not, correct and retry.",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
display: z
|
|
100
|
+
.number()
|
|
101
|
+
.int()
|
|
102
|
+
.min(1)
|
|
103
|
+
.optional()
|
|
104
|
+
.describe(
|
|
105
|
+
"1-based display index from list_displays; defaults to the display under the cursor",
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
async ({ display }) => {
|
|
110
|
+
const cap = await client.screenCapture({ display });
|
|
111
|
+
const shot = await renderScreenshot({
|
|
112
|
+
png: Buffer.from(cap.image, "base64"),
|
|
113
|
+
logicalWidth: cap.display.width,
|
|
114
|
+
logicalHeight: cap.display.height,
|
|
115
|
+
cursor: {
|
|
116
|
+
x: cap.cursor.x - cap.display.x,
|
|
117
|
+
y: cap.cursor.y - cap.display.y,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
session.lastShot = {
|
|
121
|
+
displayIndex: cap.display.index,
|
|
122
|
+
originX: cap.display.x,
|
|
123
|
+
originY: cap.display.y,
|
|
124
|
+
scale: shot.scale,
|
|
125
|
+
imageWidth: shot.width,
|
|
126
|
+
imageHeight: shot.height,
|
|
127
|
+
};
|
|
128
|
+
const win = await client.systemWindow().catch(() => null);
|
|
129
|
+
const lines = [
|
|
130
|
+
`display ${cap.display.index}: ${cap.display.width}x${cap.display.height} at (${cap.display.x}, ${cap.display.y})${cap.display.primary ? " (primary)" : ""}`,
|
|
131
|
+
`image: ${shot.width}x${shot.height} — emit click/move coordinates in this image's pixel space`,
|
|
132
|
+
`cursor: image (${Math.round((cap.cursor.x - cap.display.x) * shot.scale)}, ${Math.round((cap.cursor.y - cap.display.y) * shot.scale)}), marked with a red crosshair`,
|
|
133
|
+
win ? `active window: "${win.title}" (${win.process})` : null,
|
|
134
|
+
session.calibration && !session.calibration.calibrated
|
|
135
|
+
? `WARNING: mouse not calibrated — ${session.calibration.reason}. Clicks may land off-target; verify each one.`
|
|
136
|
+
: null,
|
|
137
|
+
].filter(Boolean);
|
|
138
|
+
return {
|
|
139
|
+
content: [
|
|
140
|
+
{ type: "text" as const, text: lines.join("\n") },
|
|
141
|
+
{
|
|
142
|
+
type: "image" as const,
|
|
143
|
+
data: shot.data.toString("base64"),
|
|
144
|
+
mimeType: "image/webp",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
register(
|
|
152
|
+
"screenshot_window",
|
|
153
|
+
{
|
|
154
|
+
description:
|
|
155
|
+
"Capture just ONE window (by title substring, or the active window if omitted) instead of a whole display — smaller, cheaper, and monitor-proof. " +
|
|
156
|
+
"Click/move coordinates you emit map into THIS capture exactly like screenshot. Prefer this over a full-screen screenshot to read or verify a specific app.",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
title: z
|
|
159
|
+
.string()
|
|
160
|
+
.optional()
|
|
161
|
+
.describe("window title substring; omit to capture the active window"),
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
async ({ title }) => {
|
|
165
|
+
const win = title
|
|
166
|
+
? (await client.windowList(title))[0]
|
|
167
|
+
: await client.systemWindow();
|
|
168
|
+
if (!win) throw new Error(`no window matches "${title}"`);
|
|
169
|
+
const cap = await client.screenCaptureWindow({ title: win.title });
|
|
170
|
+
// remote.luau captures the window rect clamped to its display; recover the
|
|
171
|
+
// capture's screen-space origin so image coords map back to the screen.
|
|
172
|
+
const originX = Math.max(cap.display.x, win.x);
|
|
173
|
+
const originY = Math.max(cap.display.y, win.y);
|
|
174
|
+
const rx = Math.max(0, win.x - cap.display.x);
|
|
175
|
+
const ry = Math.max(0, win.y - cap.display.y);
|
|
176
|
+
const logicalWidth = Math.min(win.width, cap.display.width - rx);
|
|
177
|
+
const logicalHeight = Math.min(win.height, cap.display.height - ry);
|
|
178
|
+
const curInside =
|
|
179
|
+
cap.cursor.x >= originX &&
|
|
180
|
+
cap.cursor.x < originX + logicalWidth &&
|
|
181
|
+
cap.cursor.y >= originY &&
|
|
182
|
+
cap.cursor.y < originY + logicalHeight;
|
|
183
|
+
const shot = await renderScreenshot({
|
|
184
|
+
png: Buffer.from(cap.image, "base64"),
|
|
185
|
+
logicalWidth,
|
|
186
|
+
logicalHeight,
|
|
187
|
+
cursor: curInside
|
|
188
|
+
? { x: cap.cursor.x - originX, y: cap.cursor.y - originY }
|
|
189
|
+
: undefined,
|
|
190
|
+
});
|
|
191
|
+
session.lastShot = {
|
|
192
|
+
displayIndex: cap.display.index,
|
|
193
|
+
originX,
|
|
194
|
+
originY,
|
|
195
|
+
scale: shot.scale,
|
|
196
|
+
imageWidth: shot.width,
|
|
197
|
+
imageHeight: shot.height,
|
|
198
|
+
};
|
|
199
|
+
return {
|
|
200
|
+
content: [
|
|
201
|
+
{
|
|
202
|
+
type: "text" as const,
|
|
203
|
+
text: `window "${win.title}" at screen (${win.x}, ${win.y}), ${logicalWidth}x${logicalHeight} — emit click/move coords in this image's pixel space (${shot.width}x${shot.height})`,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
type: "image" as const,
|
|
207
|
+
data: shot.data.toString("base64"),
|
|
208
|
+
mimeType: "image/webp",
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
register(
|
|
216
|
+
"zoom",
|
|
217
|
+
{
|
|
218
|
+
description:
|
|
219
|
+
"Capture a region of the last screenshot at full native resolution, for reading small text or inspecting small targets. " +
|
|
220
|
+
"READ-ONLY: click coordinates still refer to the last full screenshot, not the zoomed image.",
|
|
221
|
+
inputSchema: {
|
|
222
|
+
x: z
|
|
223
|
+
.number()
|
|
224
|
+
.describe("region top-left x, in last-screenshot image pixels"),
|
|
225
|
+
y: z
|
|
226
|
+
.number()
|
|
227
|
+
.describe("region top-left y, in last-screenshot image pixels"),
|
|
228
|
+
w: z
|
|
229
|
+
.number()
|
|
230
|
+
.min(1)
|
|
231
|
+
.describe("region width, in last-screenshot image pixels"),
|
|
232
|
+
h: z
|
|
233
|
+
.number()
|
|
234
|
+
.min(1)
|
|
235
|
+
.describe("region height, in last-screenshot image pixels"),
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
async ({ x, y, w, h }) => {
|
|
239
|
+
const shot = session.lastShot;
|
|
240
|
+
if (!shot)
|
|
241
|
+
throw new Error("no screenshot taken yet — call screenshot first");
|
|
242
|
+
const topLeft = session.mapImagePoint(x, y);
|
|
243
|
+
const cap = await client.screenCapture({
|
|
244
|
+
display: shot.displayIndex,
|
|
245
|
+
region: {
|
|
246
|
+
x: topLeft.x - shot.originX,
|
|
247
|
+
y: topLeft.y - shot.originY,
|
|
248
|
+
w: Math.ceil(w / shot.scale),
|
|
249
|
+
h: Math.ceil(h / shot.scale),
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
const rendered = await renderScreenshot({
|
|
253
|
+
png: Buffer.from(cap.image, "base64"),
|
|
254
|
+
logicalWidth: Math.ceil(w / shot.scale),
|
|
255
|
+
logicalHeight: Math.ceil(h / shot.scale),
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
content: [
|
|
259
|
+
{
|
|
260
|
+
type: "text" as const,
|
|
261
|
+
text: `zoomed region (${x}, ${y}) ${w}x${h} of the last screenshot. Coordinates for clicks still refer to the last full screenshot.`,
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
type: "image" as const,
|
|
265
|
+
data: rendered.data.toString("base64"),
|
|
266
|
+
mimeType: "image/webp",
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
register(
|
|
274
|
+
"list_displays",
|
|
275
|
+
{ description: "Enumerate displays with their virtual-desktop geometry." },
|
|
276
|
+
async () => text(JSON.stringify(await client.screenDisplays(), null, 2)),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
register(
|
|
280
|
+
"click",
|
|
281
|
+
{
|
|
282
|
+
description:
|
|
283
|
+
"Move the mouse to a point in the last screenshot and click. The landing position is verified and corrected before pressing. " +
|
|
284
|
+
"Prefer keyboard shortcuts (the key tool) for fiddly widgets like address bars and dropdowns.",
|
|
285
|
+
inputSchema: {
|
|
286
|
+
x: z.number().describe("x in last-screenshot image pixels"),
|
|
287
|
+
y: z.number().describe("y in last-screenshot image pixels"),
|
|
288
|
+
button: z.enum(["left", "right", "middle"]).default("left"),
|
|
289
|
+
double: z.boolean().default(false),
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
async ({ x, y, button, double }) => {
|
|
293
|
+
const target = session.mapImagePoint(x, y);
|
|
294
|
+
const r = await session.verifiedClick(target.x, target.y, button, double);
|
|
295
|
+
return text(
|
|
296
|
+
r.ok
|
|
297
|
+
? `clicked ${button}${double ? " (double)" : ""} at screen (${r.landed.x}, ${r.landed.y})${r.corrected ? " after one correction" : ""}`
|
|
298
|
+
: `WARNING: cursor landed at (${r.landed.x}, ${r.landed.y}) instead of (${target.x}, ${target.y}) — the click may have missed. Take a screenshot to verify.`,
|
|
299
|
+
);
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
register(
|
|
304
|
+
"move_mouse",
|
|
305
|
+
{
|
|
306
|
+
description:
|
|
307
|
+
"Move the mouse to a point in the last screenshot without clicking.",
|
|
308
|
+
inputSchema: {
|
|
309
|
+
x: z.number().describe("x in last-screenshot image pixels"),
|
|
310
|
+
y: z.number().describe("y in last-screenshot image pixels"),
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
async ({ x, y }) => {
|
|
314
|
+
const target = session.mapImagePoint(x, y);
|
|
315
|
+
const landed = await session.moveTo(target.x, target.y);
|
|
316
|
+
return text(`cursor at screen (${landed.x}, ${landed.y})`);
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
register(
|
|
321
|
+
"type",
|
|
322
|
+
{
|
|
323
|
+
description: "Type text into the focused element via the keyboard.",
|
|
324
|
+
inputSchema: { text: z.string() },
|
|
325
|
+
},
|
|
326
|
+
async ({ text: t }) => {
|
|
327
|
+
client.hidType(t);
|
|
328
|
+
// typing is paced by the relay; give it time proportional to length
|
|
329
|
+
await sleep(Math.min(50 + t.length * 15, 5000));
|
|
330
|
+
return text(`typed ${t.length} characters`);
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
register(
|
|
335
|
+
"key",
|
|
336
|
+
{
|
|
337
|
+
description:
|
|
338
|
+
'Press a key or combo. Names: "Enter", "Escape", "Tab", "Backspace", arrows ("Up", "Down", "Left", "Right"), letters/digits, modifiers "LCtrl", "LAlt", "LShift", "LMeta". ' +
|
|
339
|
+
'Combos join with "+", e.g. "LCtrl+L" (focus address bar), "LCtrl+C", "LAlt+Tab".',
|
|
340
|
+
inputSchema: { combo: z.string() },
|
|
341
|
+
},
|
|
342
|
+
async ({ combo }) => {
|
|
343
|
+
client.hidPress(combo, 30);
|
|
344
|
+
await sleep(80);
|
|
345
|
+
return text(`pressed ${combo}`);
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
register(
|
|
350
|
+
"scroll",
|
|
351
|
+
{
|
|
352
|
+
description:
|
|
353
|
+
"Scroll the mouse wheel at the current cursor position. Positive = up, negative = down.",
|
|
354
|
+
inputSchema: { clicks: z.number().int().describe("wheel clicks") },
|
|
355
|
+
},
|
|
356
|
+
async ({ clicks }) => {
|
|
357
|
+
client.hidScroll(clicks);
|
|
358
|
+
await sleep(80);
|
|
359
|
+
return text(`scrolled ${clicks} clicks`);
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
register(
|
|
364
|
+
"list_windows",
|
|
365
|
+
{
|
|
366
|
+
description: "List open windows (handle, title, process, geometry).",
|
|
367
|
+
inputSchema: {
|
|
368
|
+
filter: z.string().optional().describe("title substring filter"),
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
async ({ filter }) =>
|
|
372
|
+
text(JSON.stringify(await client.windowList(filter), null, 2)),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
register(
|
|
376
|
+
"focus_window",
|
|
377
|
+
{
|
|
378
|
+
description:
|
|
379
|
+
"Bring a window to the foreground by handle (from list_windows).",
|
|
380
|
+
inputSchema: { handle: z.number() },
|
|
381
|
+
},
|
|
382
|
+
async ({ handle }) => {
|
|
383
|
+
await client.windowActivate(handle);
|
|
384
|
+
return text(`activated window ${handle}`);
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
register(
|
|
389
|
+
"wait",
|
|
390
|
+
{
|
|
391
|
+
description: "Pause before the next action (page loads, animations).",
|
|
392
|
+
inputSchema: { ms: z.number().int().min(1).max(10000) },
|
|
393
|
+
},
|
|
394
|
+
async ({ ms }) => {
|
|
395
|
+
await sleep(ms);
|
|
396
|
+
return text(`waited ${ms}ms`);
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
register(
|
|
401
|
+
"get_cursor",
|
|
402
|
+
{ description: "Read the current cursor position in screen coordinates." },
|
|
403
|
+
async () => text(JSON.stringify(await client.systemMouse())),
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
register(
|
|
407
|
+
"active_window",
|
|
408
|
+
{ description: "Read the currently focused window." },
|
|
409
|
+
async () => text(JSON.stringify(await client.systemWindow())),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
register(
|
|
413
|
+
"calibrate",
|
|
414
|
+
{
|
|
415
|
+
description:
|
|
416
|
+
"Re-run the mouse scale probe (auto-runs at startup). Verifies relative mouse counts map 1:1 to pixels; reports pointer-ballistics misconfiguration.",
|
|
417
|
+
},
|
|
418
|
+
async () => text(JSON.stringify(await session.calibrate())),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
register(
|
|
422
|
+
"run_lua",
|
|
423
|
+
{
|
|
424
|
+
description:
|
|
425
|
+
"Run a Luau script on the connected machine through Rebind's engine and return its `return` value as JSON. " +
|
|
426
|
+
"PREFER THIS over the screenshot→click loop for any DETERMINISTIC sequence — it is far faster, cheaper, and " +
|
|
427
|
+
"reliable, and it sidesteps multi-monitor guessing. Use it to: launch apps (`System.ExecDetached(\"cmd\", {\"/c\",\"start\",\"\",url})`), " +
|
|
428
|
+
"wait for and place windows on a known display (`local h=Window.Wait(title, ms); Window.Move(h, x, y, w, h)`), " +
|
|
429
|
+
"run shell commands (`System.Exec(cmd) -> {exit,stdout,stderr}`), find UI by color (`Screen.SearchForColor(region,hex)`), " +
|
|
430
|
+
"type, click, read pixels, manage processes. Namespaces: System, HID, Input, Screen, Window, Process, File, Clipboard, Net, Macro, Env, Log. " +
|
|
431
|
+
"Fall back to `screenshot` only for genuinely visual reasoning the script cannot resolve. End the script with `return <value>` to get data back.",
|
|
432
|
+
inputSchema: {
|
|
433
|
+
source: z
|
|
434
|
+
.string()
|
|
435
|
+
.describe("Luau source. End with `return <value>` to return data."),
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
async ({ source }) => {
|
|
439
|
+
const result = await client.luaExec(source);
|
|
440
|
+
return text(
|
|
441
|
+
typeof result === "string" ? result : JSON.stringify(result, null, 2),
|
|
442
|
+
);
|
|
443
|
+
},
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
await server.connect(new StdioServerTransport());
|
|
447
|
+
// stdout carries the MCP protocol — log to stderr only
|
|
448
|
+
console.error(
|
|
449
|
+
`rebind-mcp-server: connected to ${url} (calibrated=${session.calibration?.calibrated ?? false})`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
main().catch((e) => {
|
|
454
|
+
console.error(`rebind-mcp-server: fatal: ${e}`);
|
|
455
|
+
process.exit(1);
|
|
456
|
+
});
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// per-connection state and the two composed behaviors built from existing
|
|
2
|
+
// protocol primitives: verified click (move -> read back -> correct once ->
|
|
3
|
+
// press) and calibration (relative-move scale probe at session start).
|
|
4
|
+
|
|
5
|
+
import type { RebindRemote } from "@rebind.gg/client-ts";
|
|
6
|
+
import {
|
|
7
|
+
inImageBounds,
|
|
8
|
+
mapToScreen,
|
|
9
|
+
type Point,
|
|
10
|
+
type ShotMapping,
|
|
11
|
+
} from "./coords.ts";
|
|
12
|
+
|
|
13
|
+
/** acceptable landing error. relative counts map 1:1 to pixels, but readback
|
|
14
|
+
* (GetCursorPos) lags the just-sent USB move under load, so keep a small margin. */
|
|
15
|
+
export const CLICK_EPS_PX = 5;
|
|
16
|
+
|
|
17
|
+
/** OS cursor + SystemState refresh time after a move burst, before reading back.
|
|
18
|
+
* too short and the readback is stale, the loop mis-corrects, and it diverges. */
|
|
19
|
+
const SETTLE_MS = 70;
|
|
20
|
+
|
|
21
|
+
/** correction passes: read real cursor, step the residual, repeat. */
|
|
22
|
+
const MOVE_MAX_PASSES = 6;
|
|
23
|
+
/** target pixels per glide step — smaller is smoother (more USB frames). */
|
|
24
|
+
const MOVE_STEP_PX = 40;
|
|
25
|
+
/** cap on glide steps in one pass, so a cross-screen move stays bounded. */
|
|
26
|
+
const MOVE_MAX_STEPS = 40;
|
|
27
|
+
/** delay between glide steps within a pass. */
|
|
28
|
+
const MOVE_STEP_MS = 8;
|
|
29
|
+
|
|
30
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
const dist = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
32
|
+
|
|
33
|
+
export type MouseButton = "left" | "right" | "middle";
|
|
34
|
+
|
|
35
|
+
const BUTTON_CODE: Record<MouseButton, string> = {
|
|
36
|
+
left: "Mouse1",
|
|
37
|
+
right: "Mouse2",
|
|
38
|
+
middle: "Mouse3",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface ClickResult {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
landed: Point;
|
|
44
|
+
corrected: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CalibrationResult {
|
|
48
|
+
calibrated: boolean;
|
|
49
|
+
/** input counts per screen pixel when the ratio is constant. */
|
|
50
|
+
k?: number;
|
|
51
|
+
reason?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class Session {
|
|
55
|
+
lastShot: ShotMapping | null = null;
|
|
56
|
+
calibration: CalibrationResult | null = null;
|
|
57
|
+
|
|
58
|
+
constructor(readonly client: RebindRemote) {}
|
|
59
|
+
|
|
60
|
+
/** map a point in the last screenshot's image space to screen coords. */
|
|
61
|
+
mapImagePoint(ix: number, iy: number): Point {
|
|
62
|
+
if (!this.lastShot) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"no screenshot taken yet — call the screenshot tool first",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (!inImageBounds(this.lastShot, ix, iy)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`(${ix}, ${iy}) is outside the last screenshot (${this.lastShot.imageWidth}x${this.lastShot.imageHeight}) — take a fresh screenshot`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return mapToScreen(this.lastShot, ix, iy);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Move the OS cursor to absolute screen (x, y).
|
|
77
|
+
*
|
|
78
|
+
* The relay's hid.move_smooth / hid.move_to derive their delta from a cached
|
|
79
|
+
* cursor position that goes stale and flings the pointer to a screen edge
|
|
80
|
+
* (observed: target (800,500) landing at (0,0)). Relative counts map 1:1 to
|
|
81
|
+
* pixels, so we read the REAL cursor, glide the residual in small relative
|
|
82
|
+
* steps (human-like, not an instant jump), then read back and correct against
|
|
83
|
+
* the true cursor. Converges to the exact pixel, usually in a single pass.
|
|
84
|
+
*/
|
|
85
|
+
async moveTo(x: number, y: number): Promise<Point & { passes: number }> {
|
|
86
|
+
let at = await this.client.systemMouse();
|
|
87
|
+
let passes = 0;
|
|
88
|
+
while (passes < MOVE_MAX_PASSES) {
|
|
89
|
+
const dx = x - at.x;
|
|
90
|
+
const dy = y - at.y;
|
|
91
|
+
const distance = Math.hypot(dx, dy);
|
|
92
|
+
if (distance <= CLICK_EPS_PX) break;
|
|
93
|
+
passes++;
|
|
94
|
+
const steps = Math.min(
|
|
95
|
+
MOVE_MAX_STEPS,
|
|
96
|
+
Math.max(1, Math.round(distance / MOVE_STEP_PX)),
|
|
97
|
+
);
|
|
98
|
+
let sentX = 0;
|
|
99
|
+
let sentY = 0;
|
|
100
|
+
for (let s = 1; s <= steps; s++) {
|
|
101
|
+
const nx = Math.round((dx * s) / steps) - sentX;
|
|
102
|
+
const ny = Math.round((dy * s) / steps) - sentY;
|
|
103
|
+
sentX += nx;
|
|
104
|
+
sentY += ny;
|
|
105
|
+
this.client.hidMove(nx, ny);
|
|
106
|
+
if (s < steps) await sleep(MOVE_STEP_MS);
|
|
107
|
+
}
|
|
108
|
+
await sleep(SETTLE_MS);
|
|
109
|
+
at = await this.client.systemMouse();
|
|
110
|
+
}
|
|
111
|
+
return { x: at.x, y: at.y, passes };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** move (self-correcting), then press. */
|
|
115
|
+
async verifiedClick(
|
|
116
|
+
x: number,
|
|
117
|
+
y: number,
|
|
118
|
+
button: MouseButton = "left",
|
|
119
|
+
double = false,
|
|
120
|
+
): Promise<ClickResult> {
|
|
121
|
+
const target = { x, y };
|
|
122
|
+
const at = await this.moveTo(x, y);
|
|
123
|
+
const code = BUTTON_CODE[button];
|
|
124
|
+
this.client.hidPress(code, 30);
|
|
125
|
+
if (double) {
|
|
126
|
+
await sleep(80);
|
|
127
|
+
this.client.hidPress(code, 30);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
ok: dist(at, target) <= CLICK_EPS_PX,
|
|
131
|
+
landed: { x: at.x, y: at.y },
|
|
132
|
+
corrected: at.passes > 1,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* relative-move scale probe. moves to screen center (deterministic anchor
|
|
138
|
+
* away from edge clamp), nudges by increasing counts on each axis, and
|
|
139
|
+
* checks whether counts-per-pixel is constant. a magnitude-dependent ratio
|
|
140
|
+
* means ballistics in the chain (EPP on, DPI mismatch, software fallback) —
|
|
141
|
+
* recorded and surfaced to the agent instead of failing via missed clicks.
|
|
142
|
+
*/
|
|
143
|
+
async calibrate(): Promise<CalibrationResult> {
|
|
144
|
+
const done = (r: CalibrationResult): CalibrationResult => {
|
|
145
|
+
this.calibration = r;
|
|
146
|
+
return r;
|
|
147
|
+
};
|
|
148
|
+
// 8 as the smallest probe: large enough to dodge integer quantization,
|
|
149
|
+
// small enough that ballistic acceleration curves still diverge by 256.
|
|
150
|
+
const probes = [8, 64, 256];
|
|
151
|
+
const res = await this.client.screenResolution();
|
|
152
|
+
const saved = await this.client.systemMouse();
|
|
153
|
+
const cx = Math.floor(res.width / 2);
|
|
154
|
+
const cy = Math.floor(res.height / 2);
|
|
155
|
+
|
|
156
|
+
const ratios: number[] = [];
|
|
157
|
+
try {
|
|
158
|
+
for (const axis of ["x", "y"] as const) {
|
|
159
|
+
for (const n of probes) {
|
|
160
|
+
const before = await this.moveTo(cx, cy);
|
|
161
|
+
this.client.hidMove(axis === "x" ? n : 0, axis === "y" ? n : 0);
|
|
162
|
+
await sleep(SETTLE_MS);
|
|
163
|
+
const after = await this.client.systemMouse();
|
|
164
|
+
const moved = axis === "x" ? after.x - before.x : after.y - before.y;
|
|
165
|
+
if (moved <= 0) {
|
|
166
|
+
return done({
|
|
167
|
+
calibrated: false,
|
|
168
|
+
reason: `${axis}-axis probe of ${n} counts produced no movement`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
ratios.push(n / moved);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
await this.moveTo(saved.x, saved.y);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const mean = ratios.reduce((a, b) => a + b, 0) / ratios.length;
|
|
179
|
+
const maxDev = Math.max(...ratios.map((r) => Math.abs(r - mean) / mean));
|
|
180
|
+
if (maxDev > 0.15) {
|
|
181
|
+
return done({
|
|
182
|
+
calibrated: false,
|
|
183
|
+
reason: `counts-per-pixel varies with magnitude (±${Math.round(maxDev * 100)}%) — pointer ballistics in the chain (enhance pointer precision, DPI mismatch, or software fallback)`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return done({ calibrated: true, k: mean });
|
|
187
|
+
}
|
|
188
|
+
}
|