claude-kvm-native 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/hid.js ADDED
@@ -0,0 +1,248 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * █████╗ ██████╗ █████╗ ███████╗
4
+ * ██╔══██╗██╔══██╗██╔══██╗██╔════╝
5
+ * ███████║██████╔╝███████║███████╗
6
+ * ██╔══██║██╔══██╗██╔══██║╚════██║
7
+ * ██║ ██║██║ ██║██║ ██║███████║
8
+ * ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝
9
+ *
10
+ * Copyright (c) 2025 Rıza Emre ARAS <r.emrearas@proton.me>
11
+ *
12
+ * This file is part of Claude KVM.
13
+ * Released under the MIT License — see LICENSE for details.
14
+ */
15
+
16
+ import { charToKeysym, namedKeyToKeysym } from '../utils/keysym.js';
17
+
18
+ /**
19
+ * HID Controller — KVM-style input via VNC.
20
+ *
21
+ * Tracks cursor position. Uses direct teleport for move.
22
+ * Smooth interpolation only for drag operations.
23
+ * Click operations use the current tracked position.
24
+ */
25
+
26
+ // VNC button masks
27
+ const BUTTON = {
28
+ LEFT: 1,
29
+ MIDDLE: 2,
30
+ RIGHT: 4,
31
+ SCROLL_UP: 8,
32
+ SCROLL_DOWN: 16,
33
+ SCROLL_LEFT: 32,
34
+ SCROLL_RIGHT: 64,
35
+ };
36
+
37
+ export class HIDController {
38
+ /**
39
+ * @param {import('./types.js').ClaudeKVMConfig} config
40
+ * @param {import('./vnc.js').VNCClient} vncClient
41
+ */
42
+ constructor(config, vncClient) {
43
+ this.vnc = vncClient;
44
+ this.clickHoldMs = config.hid.click_hold_ms;
45
+ this.keyHoldMs = config.hid.key_hold_ms;
46
+ this.typingDelay = config.hid.typing_delay_ms;
47
+ this.scrollEventsPerStep = config.hid.scroll_events_per_step ?? 5;
48
+
49
+ /** @type {number} Current cursor X (native resolution) */
50
+ this.cursorX = 0;
51
+ /** @type {number} Current cursor Y (native resolution) */
52
+ this.cursorY = 0;
53
+ }
54
+
55
+ // ── Cursor Position ──────────────────────────────────────
56
+
57
+ /**
58
+ * Get current cursor position in native coordinates.
59
+ * @returns {import('./types.js').CursorPosition}
60
+ */
61
+ getCursorPosition() {
62
+ return { x: this.cursorX, y: this.cursorY };
63
+ }
64
+
65
+ // ── Mouse: Move ──────────────────────────────────────────
66
+
67
+ /**
68
+ * Teleport cursor to target position (single pointer event).
69
+ * @param {number} x - Target X (native resolution)
70
+ * @param {number} y - Target Y (native resolution)
71
+ */
72
+ async mouseMove(x, y) {
73
+ this.vnc.pointerEvent(x, y, 0);
74
+ this.cursorX = x;
75
+ this.cursorY = y;
76
+ }
77
+
78
+ // ── Mouse: Click (at current position) ───────────────────
79
+
80
+ /**
81
+ * Click at current cursor position.
82
+ * @param {'left' | 'middle' | 'right'} [button='left']
83
+ */
84
+ async mouseClick(button = 'left') {
85
+ const mask = button === 'right' ? BUTTON.RIGHT :
86
+ button === 'middle' ? BUTTON.MIDDLE : BUTTON.LEFT;
87
+
88
+ this.vnc.pointerEvent(this.cursorX, this.cursorY, mask);
89
+ await sleep(this.clickHoldMs);
90
+ this.vnc.pointerEvent(this.cursorX, this.cursorY, 0);
91
+ }
92
+
93
+ /** Double-click at current cursor position. */
94
+ async mouseDoubleClick() {
95
+ await this.mouseClick('left');
96
+ await sleep(50);
97
+ await this.mouseClick('left');
98
+ }
99
+
100
+ // ── Mouse: Drag ──────────────────────────────────────────
101
+
102
+ /**
103
+ * Drag from current position to target (smooth).
104
+ * @param {number} endX - Target X (native resolution)
105
+ * @param {number} endY - Target Y (native resolution)
106
+ */
107
+ async mouseDrag(endX, endY) {
108
+ // Press at current position
109
+ this.vnc.pointerEvent(this.cursorX, this.cursorY, BUTTON.LEFT);
110
+ await sleep(100);
111
+
112
+ // Smooth drag to target
113
+ const steps = Math.max(5, Math.ceil(
114
+ Math.hypot(endX - this.cursorX, endY - this.cursorY) / 30
115
+ ));
116
+ const startX = this.cursorX;
117
+ const startY = this.cursorY;
118
+
119
+ for (let i = 1; i <= steps; i++) {
120
+ const t = i / steps;
121
+ const ix = Math.round(startX + (endX - startX) * t);
122
+ const iy = Math.round(startY + (endY - startY) * t);
123
+ this.vnc.pointerEvent(ix, iy, BUTTON.LEFT);
124
+ await sleep(12);
125
+ }
126
+
127
+ // Release
128
+ this.vnc.pointerEvent(endX, endY, 0);
129
+ this.cursorX = endX;
130
+ this.cursorY = endY;
131
+ }
132
+
133
+ // ── Mouse: Scroll ────────────────────────────────────────
134
+
135
+ /**
136
+ * Scroll at current cursor position.
137
+ * @param {'up' | 'down' | 'left' | 'right'} direction
138
+ * @param {number} [amount=3]
139
+ */
140
+ async scroll(direction, amount = 3) {
141
+ const buttonMask = direction === 'up' ? BUTTON.SCROLL_UP :
142
+ direction === 'down' ? BUTTON.SCROLL_DOWN :
143
+ direction === 'left' ? BUTTON.SCROLL_LEFT :
144
+ BUTTON.SCROLL_RIGHT;
145
+
146
+ for (let i = 0; i < amount; i++) {
147
+ for (let j = 0; j < this.scrollEventsPerStep; j++) {
148
+ this.vnc.pointerEvent(this.cursorX, this.cursorY, buttonMask);
149
+ await sleep(10);
150
+ this.vnc.pointerEvent(this.cursorX, this.cursorY, 0);
151
+ await sleep(10);
152
+ }
153
+ await sleep(30);
154
+ }
155
+ }
156
+
157
+ // ── Keyboard ─────────────────────────────────────────────
158
+
159
+ /** @param {string} key */
160
+ async keyPress(key) {
161
+ const keysym = namedKeyToKeysym(key);
162
+ if (!keysym) {
163
+ console.warn(`HID: unknown key "${key}"`);
164
+ return;
165
+ }
166
+ this.vnc.keyEvent(keysym, true);
167
+ await sleep(this.keyHoldMs);
168
+ this.vnc.keyEvent(keysym, false);
169
+ }
170
+
171
+ /** @param {string} combo - Keys separated by '+' */
172
+ async keyCombo(combo) {
173
+ const keys = combo.split('+').map(k => k.trim().toLowerCase());
174
+ /** @type {number[]} */
175
+ const keysyms = [];
176
+
177
+ for (const k of keys) {
178
+ const ks = namedKeyToKeysym(k);
179
+ if (ks) {
180
+ keysyms.push(ks);
181
+ } else {
182
+ const charKs = charToKeysym(k);
183
+ if (charKs) keysyms.push(charKs.keysym);
184
+ else { console.warn(`HID: unknown key in combo "${k}"`); return; }
185
+ }
186
+ }
187
+
188
+ for (const ks of keysyms) {
189
+ this.vnc.keyEvent(ks, true);
190
+ await sleep(50);
191
+ }
192
+ await sleep(80);
193
+ for (let i = keysyms.length - 1; i >= 0; i--) {
194
+ this.vnc.keyEvent(keysyms[i], false);
195
+ await sleep(50);
196
+ }
197
+ }
198
+
199
+ /** @param {string} text */
200
+ async typeText(text) {
201
+ for (const ch of text) {
202
+ const mapping = charToKeysym(ch);
203
+ if (!mapping) {
204
+ console.warn(`HID: unmapped char '${ch}' (${ch.charCodeAt(0)})`);
205
+ continue;
206
+ }
207
+
208
+ const { keysym, shift } = mapping;
209
+ const delay = this.typingDelay.min +
210
+ Math.random() * (this.typingDelay.max - this.typingDelay.min);
211
+
212
+ if (shift) {
213
+ this.vnc.keyEvent(0xFFE1, true);
214
+ await sleep(20);
215
+ }
216
+
217
+ this.vnc.keyEvent(keysym, true);
218
+ await sleep(this.keyHoldMs);
219
+ this.vnc.keyEvent(keysym, false);
220
+
221
+ if (shift) {
222
+ await sleep(20);
223
+ this.vnc.keyEvent(0xFFE1, false);
224
+ }
225
+
226
+ await sleep(delay);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Paste text via clipboard, or typeText fallback on macOS.
232
+ * Apple VNC doesn't bridge ClientCutText to the system pasteboard.
233
+ * @param {string} text
234
+ */
235
+ async pasteText(text) {
236
+ if (this.vnc.isMacOS) {
237
+ await this.typeText(text);
238
+ return;
239
+ }
240
+ this.vnc.setClipboard(text);
241
+ await sleep(100);
242
+ await this.keyCombo('ctrl+v');
243
+ }
244
+ }
245
+
246
+ function sleep(ms) {
247
+ return new Promise(resolve => setTimeout(resolve, ms));
248
+ }
package/lib/ssh.js ADDED
@@ -0,0 +1,162 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * █████╗ ██████╗ █████╗ ███████╗
4
+ * ██╔══██╗██╔══██╗██╔══██╗██╔════╝
5
+ * ███████║██████╔╝███████║███████╗
6
+ * ██╔══██║██╔══██╗██╔══██║╚════██║
7
+ * ██║ ██║██║ ██║██║ ██║███████║
8
+ * ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝
9
+ *
10
+ * Copyright (c) 2025 Rıza Emre ARAS <r.emrearas@proton.me>
11
+ *
12
+ * This file is part of Claude KVM.
13
+ * Released under the MIT License — see LICENSE for details.
14
+ */
15
+
16
+ import { Client } from 'ssh2';
17
+ import { readFileSync } from 'node:fs';
18
+
19
+ function dbg(label, ...args) {
20
+ const ts = new Date().toISOString().slice(11, 23);
21
+ console.error(`[SSH ${ts}] ${label}`, ...args);
22
+ }
23
+
24
+ export class SSHClient {
25
+ /**
26
+ * @param {import('./types.js').SSHConnectionConfig} config
27
+ */
28
+ constructor(config) {
29
+ this.host = config.host;
30
+ this.port = config.port || 22;
31
+ this.username = config.username;
32
+ this.password = config.password || null;
33
+ this.privateKeyPath = config.privateKeyPath || null;
34
+
35
+ /** @type {import('ssh2').Client | null} */
36
+ this._client = null;
37
+ /** @type {boolean} */
38
+ this.connected = false;
39
+ /** @type {boolean} */
40
+ this.connecting = false;
41
+ /** @type {number} */
42
+ this.commandCount = 0;
43
+
44
+ dbg('INIT', `target=${this.host}:${this.port} user=${this.username} auth=${this.password ? 'password' : this.privateKeyPath ? 'key' : 'none'}`);
45
+ }
46
+
47
+ /**
48
+ * Connect to the SSH server. Resolves when ready.
49
+ * @returns {Promise<void>}
50
+ */
51
+ connect() {
52
+ if (this.connected) return Promise.resolve();
53
+ if (this.connecting) return this._connectPromise;
54
+
55
+ this.connecting = true;
56
+ this._connectPromise = new Promise((resolve, reject) => {
57
+ this._client = new Client();
58
+
59
+ const connectConfig = {
60
+ host: this.host,
61
+ port: this.port,
62
+ username: this.username,
63
+ readyTimeout: 10000,
64
+ };
65
+
66
+ if (this.privateKeyPath) {
67
+ try {
68
+ connectConfig.privateKey = readFileSync(this.privateKeyPath);
69
+ dbg('AUTH', `using key: ${this.privateKeyPath}`);
70
+ } catch (err) {
71
+ this.connecting = false;
72
+ reject(new Error(`Failed to read SSH key: ${err.message}`));
73
+ return;
74
+ }
75
+ } else if (this.password) {
76
+ connectConfig.password = this.password;
77
+ dbg('AUTH', 'using password');
78
+ }
79
+
80
+ this._client.on('ready', () => {
81
+ dbg('READY', `connected to ${this.host}:${this.port}`);
82
+ this.connected = true;
83
+ this.connecting = false;
84
+ resolve();
85
+ });
86
+
87
+ this._client.on('error', (err) => {
88
+ dbg('ERROR', err.message);
89
+ if (this.connecting) {
90
+ this.connecting = false;
91
+ reject(new Error(`SSH connection failed: ${err.message}`));
92
+ }
93
+ });
94
+
95
+ this._client.on('close', () => {
96
+ dbg('CLOSE', 'connection closed');
97
+ this.connected = false;
98
+ this.connecting = false;
99
+ this._client = null;
100
+ });
101
+
102
+ dbg('CONNECT', `opening SSH to ${this.host}:${this.port}...`);
103
+ this._client.connect(connectConfig);
104
+ });
105
+
106
+ return this._connectPromise;
107
+ }
108
+
109
+ /**
110
+ * Execute a command over SSH.
111
+ * @param {string} command
112
+ * @param {number} [timeoutMs=30000]
113
+ * @returns {Promise<{stdout: string, stderr: string, code: number}>}
114
+ */
115
+ async exec(command, timeoutMs = 30000) {
116
+ if (!this.connected) {
117
+ await this.connect();
118
+ }
119
+
120
+ this.commandCount++;
121
+ dbg('EXEC', `[#${this.commandCount}] ${command}`);
122
+
123
+ return new Promise((resolve, reject) => {
124
+ const timer = setTimeout(() => {
125
+ reject(new Error(`SSH command timeout after ${timeoutMs}ms`));
126
+ }, timeoutMs);
127
+
128
+ this._client.exec(command, (err, stream) => {
129
+ if (err) {
130
+ clearTimeout(timer);
131
+ reject(new Error(`SSH exec error: ${err.message}`));
132
+ return;
133
+ }
134
+
135
+ let stdout = '';
136
+ let stderr = '';
137
+
138
+ stream.on('data', (data) => { stdout += data.toString(); });
139
+ stream.stderr.on('data', (data) => { stderr += data.toString(); });
140
+
141
+ stream.on('close', (code) => {
142
+ clearTimeout(timer);
143
+ dbg('EXEC', `[#${this.commandCount}] exit=${code} stdout=${stdout.length}B stderr=${stderr.length}B`);
144
+ resolve({ stdout, stderr, code: code ?? 0 });
145
+ });
146
+ });
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Disconnect from the SSH server.
152
+ */
153
+ disconnect() {
154
+ dbg('DISCONNECT', 'closing connection');
155
+ if (this._client) {
156
+ this._client.end();
157
+ this._client = null;
158
+ }
159
+ this.connected = false;
160
+ this.connecting = false;
161
+ }
162
+ }
package/lib/types.js ADDED
@@ -0,0 +1,138 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * █████╗ ██████╗ █████╗ ███████╗
4
+ * ██╔══██╗██╔══██╗██╔══██╗██╔════╝
5
+ * ███████║██████╔╝███████║███████╗
6
+ * ██╔══██║██╔══██╗██╔══██║╚════██║
7
+ * ██║ ██║██║ ██║██║ ██║███████║
8
+ * ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝
9
+ *
10
+ * Copyright (c) 2025 Rıza Emre ARAS <r.emrearas@proton.me>
11
+ *
12
+ * This file is part of Claude KVM.
13
+ * Released under the MIT License — see LICENSE for details.
14
+ */
15
+
16
+ /**
17
+ * @typedef {object} VNCConnectionConfig
18
+ * @property {string} host
19
+ * @property {number} port
20
+ * @property {'auto' | 'none'} auth
21
+ * @property {string} [username]
22
+ * @property {string} [password]
23
+ */
24
+
25
+ /**
26
+ * @typedef {object} TypingDelayConfig
27
+ * @property {number} min
28
+ * @property {number} max
29
+ */
30
+
31
+ /**
32
+ * @typedef {object} HIDConfig
33
+ * @property {number} click_hold_ms
34
+ * @property {number} key_hold_ms
35
+ * @property {TypingDelayConfig} typing_delay_ms
36
+ * @property {number} scroll_events_per_step
37
+ */
38
+
39
+ /**
40
+ * @typedef {object} CaptureConfig
41
+ */
42
+
43
+ /**
44
+ * @typedef {object} DiffConfig
45
+ * @property {number} pixel_threshold
46
+ */
47
+
48
+ /**
49
+ * @typedef {object} DisplayConfig
50
+ * @property {number} max_dimension
51
+ */
52
+
53
+ /**
54
+ * @typedef {object} VNCTimeoutConfig
55
+ * @property {number} connect_timeout_ms
56
+ * @property {number} screenshot_timeout_ms
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} ClaudeKVMConfig
61
+ * @property {DisplayConfig} [display]
62
+ * @property {HIDConfig} hid
63
+ * @property {CaptureConfig} capture
64
+ * @property {DiffConfig} diff
65
+ * @property {VNCTimeoutConfig} [vnc_timeouts]
66
+ */
67
+
68
+ /**
69
+ * @typedef {object} PixelFormat
70
+ * @property {number} bitsPerPixel
71
+ * @property {number} depth
72
+ * @property {number} bigEndian
73
+ * @property {number} trueColour
74
+ * @property {number} redMax
75
+ * @property {number} greenMax
76
+ * @property {number} blueMax
77
+ * @property {number} redShift
78
+ * @property {number} greenShift
79
+ * @property {number} blueShift
80
+ */
81
+
82
+ /**
83
+ * @typedef {object} VNCServerInfo
84
+ * @property {number} width
85
+ * @property {number} height
86
+ * @property {string} name
87
+ */
88
+
89
+ /**
90
+ * @typedef {object} ScreenshotResult
91
+ * @property {Buffer} buffer
92
+ * @property {string} base64
93
+ * @property {number} width
94
+ * @property {number} height
95
+ */
96
+
97
+
98
+ /**
99
+ * @typedef {object} QuickDiffResult
100
+ * @property {boolean} changeDetected
101
+ */
102
+
103
+ /**
104
+ * @typedef {object} ScaledDisplay
105
+ * @property {number} width
106
+ * @property {number} height
107
+ */
108
+
109
+ /**
110
+ * @typedef {object} ToolExecResult
111
+ * @property {string} text
112
+ * @property {string} [imageBase64]
113
+ * @property {boolean} [done]
114
+ * @property {'success' | 'failed'} [status]
115
+ */
116
+
117
+ /**
118
+ * @typedef {object} KeysymMapping
119
+ * @property {number} keysym
120
+ * @property {boolean} shift
121
+ */
122
+
123
+ /**
124
+ * @typedef {object} CursorPosition
125
+ * @property {number} x
126
+ * @property {number} y
127
+ */
128
+
129
+ /**
130
+ * @typedef {object} SSHConnectionConfig
131
+ * @property {string} host
132
+ * @property {number} [port]
133
+ * @property {string} username
134
+ * @property {string} [password]
135
+ * @property {string} [privateKeyPath]
136
+ */
137
+
138
+ export {};