@jian-ai/remote-control 0.8.1 → 0.8.3

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.
@@ -0,0 +1,516 @@
1
+ /**
2
+ * Windows computer-use: .NET screenshot + PowerShell helper (overlay + SendInput).
3
+ * English-only messages. Imported by computer.mjs on win32.
4
+ */
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { existsSync } from 'node:fs';
8
+ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
9
+ import { homedir, tmpdir } from 'node:os';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { COMPUTER_CAPABILITIES, imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
13
+
14
+ export { imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
15
+
16
+ export const COMPUTER_CAPABILITY = COMPUTER_CAPABILITIES.win32;
17
+ const IDLE_MS = 30_000;
18
+ const HELPER_NAME = 'computer-helper.ps1';
19
+
20
+ function helperPaths(home = homedir()) {
21
+ const support = join(home, '.config', 'jian-remote', 'windows');
22
+ return {
23
+ support,
24
+ script: join(support, HELPER_NAME),
25
+ stamp: join(support, `${HELPER_NAME}.sha256`),
26
+ };
27
+ }
28
+
29
+ export function packageHelperPaths(
30
+ packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..'),
31
+ ) {
32
+ return {
33
+ script: join(packageRoot, 'windows', HELPER_NAME),
34
+ };
35
+ }
36
+
37
+ function stampForBytes(bytes) {
38
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 16);
39
+ }
40
+
41
+ async function ensureHelperScript() {
42
+ const paths = helperPaths();
43
+ await mkdir(paths.support, { recursive: true });
44
+ const pkg = packageHelperPaths().script;
45
+ let sourceText;
46
+ try {
47
+ sourceText = await readFile(pkg, 'utf8');
48
+ } catch {
49
+ try {
50
+ sourceText = await readFile(paths.script, 'utf8');
51
+ } catch {
52
+ throw new Error(
53
+ 'Computer-use helper is missing from this install. Reinstall or update @jian-ai/remote-control.',
54
+ );
55
+ }
56
+ }
57
+ const hash = stampForBytes(Buffer.from(sourceText, 'utf8'));
58
+ let previous = '';
59
+ try {
60
+ previous = (await readFile(paths.stamp, 'utf8')).trim();
61
+ } catch {
62
+ previous = '';
63
+ }
64
+ if (previous !== hash || !existsSync(paths.script)) {
65
+ await writeFile(paths.script, sourceText);
66
+ await writeFile(paths.stamp, `${hash}\n`);
67
+ }
68
+ return paths.script;
69
+ }
70
+
71
+ export async function materializeComputerHelperSources(configDir) {
72
+ if (process.platform !== 'win32') return;
73
+ try {
74
+ const pkg = packageHelperPaths().script;
75
+ if (!existsSync(pkg)) return;
76
+ const support = helperPaths().support;
77
+ await mkdir(support, { recursive: true });
78
+ const localWin = join(configDir, 'windows');
79
+ await mkdir(localWin, { recursive: true });
80
+ await copyFile(pkg, join(support, HELPER_NAME));
81
+ await copyFile(pkg, join(localWin, HELPER_NAME));
82
+ } catch {
83
+ // Best-effort.
84
+ }
85
+ }
86
+
87
+ /** Primary display JPEG via PowerShell System.Drawing; scale long side ≤1600. */
88
+ export function capturePrimaryScreenshotBase64() {
89
+ const outPath = join(tmpdir(), `jian-shot-${process.pid}-${Date.now()}.jpg`);
90
+ const script = `
91
+ Add-Type -AssemblyName System.Windows.Forms
92
+ Add-Type -AssemblyName System.Drawing
93
+ $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
94
+ $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height
95
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
96
+ $g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size)
97
+ $g.Dispose()
98
+ $long = [Math]::Max($b.Width, $b.Height)
99
+ $scale = if ($long -gt 1600) { 1600.0 / $long } else { 1.0 }
100
+ $w = [Math]::Max(1, [int][Math]::Round($b.Width * $scale))
101
+ $h = [Math]::Max(1, [int][Math]::Round($b.Height * $scale))
102
+ $scaled = New-Object System.Drawing.Bitmap $w, $h
103
+ $sg = [System.Drawing.Graphics]::FromImage($scaled)
104
+ $sg.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
105
+ $sg.DrawImage($bmp, 0, 0, $w, $h)
106
+ $sg.Dispose()
107
+ $bmp.Dispose()
108
+ $encoder = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
109
+ $ep = New-Object System.Drawing.Imaging.EncoderParameters 1
110
+ $ep.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter ([System.Drawing.Imaging.Encoder]::Quality, 42L)
111
+ $scaled.Save('${outPath.replaceAll('\\', '\\\\')}', $encoder, $ep)
112
+ $scaled.Dispose()
113
+ Write-Output "$w $h"
114
+ `;
115
+ const result = spawnSync(
116
+ 'powershell.exe',
117
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
118
+ { encoding: 'utf8', maxBuffer: 600_000, windowsHide: true },
119
+ );
120
+ if (result.status !== 0) {
121
+ throw new Error((result.stderr || result.stdout || 'Screenshot failed').trim());
122
+ }
123
+ const dims = (result.stdout || '').trim().split(/\s+/);
124
+ const widthPx = Number(dims[0]);
125
+ const heightPx = Number(dims[1]);
126
+ if (!(widthPx > 0 && heightPx > 0)) throw new Error('Could not read screenshot dimensions.');
127
+ // Recompress if oversized.
128
+ const sizeScript = `
129
+ $p = '${outPath.replaceAll('\\', '\\\\')}'
130
+ $len = (Get-Item $p).Length
131
+ if ($len -gt 280000) {
132
+ Add-Type -AssemblyName System.Drawing
133
+ $img = [System.Drawing.Image]::FromFile($p)
134
+ $nw = [Math]::Max(1, [int]($img.Width * 0.8))
135
+ $nh = [Math]::Max(1, [int]($img.Height * 0.8))
136
+ $scaled = New-Object System.Drawing.Bitmap $nw, $nh
137
+ $sg = [System.Drawing.Graphics]::FromImage($scaled)
138
+ $sg.DrawImage($img, 0, 0, $nw, $nh)
139
+ $sg.Dispose(); $img.Dispose()
140
+ $encoder = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
141
+ $ep = New-Object System.Drawing.Imaging.EncoderParameters 1
142
+ $ep.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter ([System.Drawing.Imaging.Encoder]::Quality, 34L)
143
+ $tmp = $p + '.tmp'
144
+ $scaled.Save($tmp, $encoder, $ep)
145
+ $scaled.Dispose()
146
+ Move-Item -Force $tmp $p
147
+ Write-Output "$nw $nh"
148
+ } else {
149
+ Write-Output "${widthPx} ${heightPx}"
150
+ }
151
+ `;
152
+ const resized = spawnSync(
153
+ 'powershell.exe',
154
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', sizeScript],
155
+ { encoding: 'utf8', windowsHide: true },
156
+ );
157
+ let finalW = widthPx;
158
+ let finalH = heightPx;
159
+ if (resized.status === 0) {
160
+ const parts = (resized.stdout || '').trim().split(/\s+/).map(Number);
161
+ if (parts[0] > 0 && parts[1] > 0) {
162
+ finalW = parts[0];
163
+ finalH = parts[1];
164
+ }
165
+ }
166
+ const bytes = spawnSync(
167
+ 'powershell.exe',
168
+ [
169
+ '-NoProfile',
170
+ '-NonInteractive',
171
+ '-ExecutionPolicy',
172
+ 'Bypass',
173
+ '-Command',
174
+ `[Convert]::ToBase64String([IO.File]::ReadAllBytes('${outPath.replaceAll('\\', '\\\\')}'))`,
175
+ ],
176
+ { encoding: 'utf8', maxBuffer: 600_000, windowsHide: true },
177
+ );
178
+ try {
179
+ spawnSync(
180
+ 'powershell.exe',
181
+ [
182
+ '-NoProfile',
183
+ '-Command',
184
+ `Remove-Item -Force -ErrorAction SilentlyContinue '${outPath.replaceAll('\\', '\\\\')}'`,
185
+ ],
186
+ { windowsHide: true },
187
+ );
188
+ } catch {
189
+ // ignore
190
+ }
191
+ if (bytes.status !== 0 || !bytes.stdout?.trim()) {
192
+ throw new Error('Screenshot produced no image data.');
193
+ }
194
+ return { base64: bytes.stdout.replace(/\s+/g, ''), widthPx: finalW, heightPx: finalH };
195
+ }
196
+
197
+ export class WindowsComputerSession {
198
+ constructor() {
199
+ this.child = null;
200
+ this.buffer = '';
201
+ this.pending = [];
202
+ this.geometry = null;
203
+ this.shotGeometry = null;
204
+ this.idleTimer = null;
205
+ this.active = false;
206
+ }
207
+
208
+ async ensure() {
209
+ if (this.child && !this.child.killed) {
210
+ if (!this.active) {
211
+ await this.send({ cmd: 'ensureSession' });
212
+ this.active = true;
213
+ }
214
+ this.bumpIdle();
215
+ return;
216
+ }
217
+ const script = await ensureHelperScript();
218
+ this.child = spawn(
219
+ 'powershell.exe',
220
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script],
221
+ { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true },
222
+ );
223
+ this.buffer = '';
224
+ this.child.stdout.setEncoding('utf8');
225
+ this.child.stdout.on('data', (chunk) => this.onData(chunk));
226
+ this.child.stderr.on('data', (chunk) => {
227
+ const text = chunk.toString('utf8').trim();
228
+ if (text) console.error(text);
229
+ });
230
+ this.child.on('exit', () => {
231
+ this.failAll('Computer-use helper exited unexpectedly.');
232
+ this.child = null;
233
+ this.active = false;
234
+ });
235
+ const ping = await this.send({ cmd: 'ping' });
236
+ if (!ping.ok) throw new Error(ping.error || 'Computer-use helper did not respond.');
237
+ const geo = await this.send({ cmd: 'geometry' });
238
+ if (!geo.ok) throw new Error(geo.error || 'Could not read display geometry.');
239
+ this.geometry = {
240
+ originX: Number(geo.originX) || 0,
241
+ originY: Number(geo.originY) || 0,
242
+ pointWidth: Number(geo.pointWidth),
243
+ pointHeight: Number(geo.pointHeight),
244
+ pixelWidth: Number(geo.pixelWidth),
245
+ pixelHeight: Number(geo.pixelHeight),
246
+ scaleFactor: Number(geo.scaleFactor) || 1,
247
+ };
248
+ await this.send({ cmd: 'ensureSession' });
249
+ this.active = true;
250
+ this.bumpIdle();
251
+ }
252
+
253
+ bumpIdle() {
254
+ if (this.idleTimer) clearTimeout(this.idleTimer);
255
+ this.idleTimer = setTimeout(() => {
256
+ if (!this.active || !this.child) return;
257
+ void this.send({ cmd: 'setIdle' }).catch(() => {});
258
+ }, IDLE_MS);
259
+ this.idleTimer.unref?.();
260
+ }
261
+
262
+ onData(chunk) {
263
+ this.buffer += chunk;
264
+ for (;;) {
265
+ const idx = this.buffer.indexOf('\n');
266
+ if (idx < 0) break;
267
+ const line = this.buffer.slice(0, idx).trim();
268
+ this.buffer = this.buffer.slice(idx + 1);
269
+ if (!line) continue;
270
+ let parsed;
271
+ try {
272
+ parsed = JSON.parse(line);
273
+ } catch {
274
+ this.failNext('Computer-use helper returned invalid JSON.');
275
+ continue;
276
+ }
277
+ const next = this.pending.shift();
278
+ if (next) next.resolve(parsed);
279
+ }
280
+ }
281
+
282
+ failNext(message) {
283
+ const next = this.pending.shift();
284
+ if (next) next.reject(new Error(message));
285
+ }
286
+
287
+ failAll(message) {
288
+ while (this.pending.length) {
289
+ this.pending.shift().reject(new Error(message));
290
+ }
291
+ }
292
+
293
+ send(payload) {
294
+ return new Promise((resolve, reject) => {
295
+ if (!this.child?.stdin) {
296
+ reject(new Error('Computer-use helper is not running.'));
297
+ return;
298
+ }
299
+ this.pending.push({ resolve, reject });
300
+ this.child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
301
+ if (error) {
302
+ this.pending.pop();
303
+ reject(error);
304
+ }
305
+ });
306
+ });
307
+ }
308
+
309
+ requireShotGeometry() {
310
+ if (!this.shotGeometry) {
311
+ throw new Error('Take a screenshot before using screen coordinates.');
312
+ }
313
+ return this.shotGeometry;
314
+ }
315
+
316
+ mapCoordinate(coordinate) {
317
+ const [x, y] = coordinate;
318
+ const shot = this.requireShotGeometry();
319
+ if (x < 0 || y < 0 || x >= shot.widthPx || y >= shot.heightPx) {
320
+ throw new Error(
321
+ `Coordinate (${x}, ${y}) is outside the last screenshot (${shot.widthPx}×${shot.heightPx}).`,
322
+ );
323
+ }
324
+ return imagePixelToPoint(x, y, shot);
325
+ }
326
+
327
+ async end() {
328
+ if (this.idleTimer) clearTimeout(this.idleTimer);
329
+ this.idleTimer = null;
330
+ if (this.child) {
331
+ try {
332
+ await this.send({ cmd: 'endSession' });
333
+ } catch {
334
+ // ignore
335
+ }
336
+ this.child.kill();
337
+ this.child = null;
338
+ }
339
+ this.active = false;
340
+ this.shotGeometry = null;
341
+ this.geometry = null;
342
+ }
343
+
344
+ async runAction(computer) {
345
+ const { action } = computer;
346
+ if (action === 'end_session') {
347
+ await this.end();
348
+ return {
349
+ exitCode: 0,
350
+ stdout: '',
351
+ stderr: '',
352
+ timedOut: false,
353
+ computer: { ok: true },
354
+ };
355
+ }
356
+
357
+ if (action === 'wait') {
358
+ const ms = Math.round((computer.duration ?? 0.35) * 1000);
359
+ await new Promise((r) => setTimeout(r, ms));
360
+ if (this.active) this.bumpIdle();
361
+ return {
362
+ exitCode: 0,
363
+ stdout: '',
364
+ stderr: '',
365
+ timedOut: false,
366
+ computer: { ok: true },
367
+ };
368
+ }
369
+
370
+ if (action === 'screenshot') {
371
+ await this.ensure();
372
+ const shot = capturePrimaryScreenshotBase64();
373
+ if (!this.geometry) throw new Error('Display geometry unavailable.');
374
+ this.shotGeometry = {
375
+ ...this.geometry,
376
+ widthPx: shot.widthPx,
377
+ heightPx: shot.heightPx,
378
+ };
379
+ this.bumpIdle();
380
+ return {
381
+ exitCode: 0,
382
+ stdout: shot.base64,
383
+ stderr: '',
384
+ timedOut: false,
385
+ computer: {
386
+ ok: true,
387
+ widthPx: shot.widthPx,
388
+ heightPx: shot.heightPx,
389
+ scale: scaleFromGeometry(this.shotGeometry),
390
+ },
391
+ };
392
+ }
393
+
394
+ await this.ensure();
395
+
396
+ try {
397
+ if (action === 'mouse_move') {
398
+ if (!computer.coordinate) throw new Error('coordinate is required for this action.');
399
+ const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
400
+ const moved = await this.send({ cmd: 'move', pointX, pointY });
401
+ if (!moved.ok) throw new Error(moved.error || 'Move failed.');
402
+ } else if (action === 'left_click' || action === 'right_click' || action === 'double_click') {
403
+ if (!computer.coordinate) throw new Error('coordinate is required for this action.');
404
+ const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
405
+ const button = action === 'right_click' ? 'right' : 'left';
406
+ const count = action === 'double_click' ? 2 : 1;
407
+ const clicked = await this.send({ cmd: 'clickAt', pointX, pointY, button, count });
408
+ if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
409
+ } else if (action === 'left_click_drag') {
410
+ if (!computer.startCoordinate || !computer.coordinate) {
411
+ throw new Error('startCoordinate and coordinate are required for drag.');
412
+ }
413
+ const from = this.mapCoordinate(computer.startCoordinate);
414
+ const to = this.mapCoordinate(computer.coordinate);
415
+ const dragged = await this.send({
416
+ cmd: 'drag',
417
+ fromX: from.pointX,
418
+ fromY: from.pointY,
419
+ toX: to.pointX,
420
+ toY: to.pointY,
421
+ });
422
+ if (!dragged.ok) throw new Error(dragged.error || 'Drag failed.');
423
+ } else if (action === 'type') {
424
+ if (!computer.text) throw new Error('text is required for type.');
425
+ const typed = await this.send({ cmd: 'type', text: computer.text });
426
+ if (!typed.ok) throw new Error(typed.error || 'Type failed.');
427
+ } else if (action === 'key') {
428
+ if (!computer.key) throw new Error('key is required for key.');
429
+ const keyed = await this.send({ cmd: 'key', key: computer.key });
430
+ if (!keyed.ok) throw new Error(keyed.error || 'Key failed.');
431
+ } else if (action === 'scroll') {
432
+ if (!computer.coordinate) throw new Error('coordinate is required for scroll.');
433
+ if (!computer.scrollDirection) throw new Error('scrollDirection is required for scroll.');
434
+ const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
435
+ const scrolled = await this.send({
436
+ cmd: 'scroll',
437
+ pointX,
438
+ pointY,
439
+ direction: computer.scrollDirection,
440
+ amount: computer.scrollAmount ?? 3,
441
+ });
442
+ if (!scrolled.ok) throw new Error(scrolled.error || 'Scroll failed.');
443
+ } else {
444
+ throw new Error(`Unsupported computer action: ${action}`);
445
+ }
446
+ } catch (error) {
447
+ const message = error instanceof Error ? error.message : String(error);
448
+ return {
449
+ exitCode: 1,
450
+ stdout: '',
451
+ stderr: message,
452
+ timedOut: false,
453
+ computer: { ok: false, error: message },
454
+ };
455
+ }
456
+
457
+ this.bumpIdle();
458
+ return {
459
+ exitCode: 0,
460
+ stdout: '',
461
+ stderr: '',
462
+ timedOut: false,
463
+ computer: { ok: true },
464
+ };
465
+ }
466
+ }
467
+
468
+ let sharedSession = null;
469
+
470
+ export function computerSession() {
471
+ if (!sharedSession) sharedSession = new WindowsComputerSession();
472
+ return sharedSession;
473
+ }
474
+
475
+ export async function executeComputerTask(task) {
476
+ const session = computerSession();
477
+ const computer = task.computer;
478
+ if (!computer?.action) {
479
+ return {
480
+ taskId: task.id,
481
+ exitCode: 1,
482
+ stdout: '',
483
+ stderr: 'Computer task missing action.',
484
+ timedOut: false,
485
+ computer: { ok: false, error: 'Computer task missing action.' },
486
+ };
487
+ }
488
+ const timeoutMs = task.timeoutMs || 30_000;
489
+ let timedOut = false;
490
+ const result = await Promise.race([
491
+ session.runAction(computer),
492
+ new Promise((resolve) => {
493
+ setTimeout(() => {
494
+ timedOut = true;
495
+ resolve({
496
+ exitCode: null,
497
+ stdout: '',
498
+ stderr: 'Computer action timed out',
499
+ timedOut: true,
500
+ computer: { ok: false, error: 'Computer action timed out' },
501
+ });
502
+ }, timeoutMs);
503
+ }),
504
+ ]);
505
+ return { taskId: task.id, ...result, timedOut: timedOut || Boolean(result.timedOut) };
506
+ }
507
+
508
+ export async function probeComputerCapability() {
509
+ if (process.platform !== 'win32') return false;
510
+ try {
511
+ await ensureHelperScript();
512
+ return true;
513
+ } catch {
514
+ return existsSync(helperPaths().script);
515
+ }
516
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Platform router for Jian Remote computer use.
3
+ * Advertises computer-{macos,linux,windows}-v1 and dispatches to the OS backend.
4
+ */
5
+ import { capabilityForPlatform, isComputerCapability } from './computer-shared.mjs';
6
+
7
+ export {
8
+ COMPUTER_CAPABILITIES,
9
+ capabilityForPlatform,
10
+ imagePixelToPoint,
11
+ isComputerCapability,
12
+ scaleFromGeometry,
13
+ } from './computer-shared.mjs';
14
+
15
+ async function loadBackend() {
16
+ switch (process.platform) {
17
+ case 'darwin':
18
+ return import('./computer-macos.mjs');
19
+ case 'linux':
20
+ return import('./computer-linux.mjs');
21
+ case 'win32':
22
+ return import('./computer-windows.mjs');
23
+ default:
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function executeComputerTask(task) {
29
+ const backend = await loadBackend();
30
+ if (!backend) {
31
+ return {
32
+ taskId: task.id,
33
+ exitCode: 1,
34
+ stdout: '',
35
+ stderr: `Computer use is not supported on platform ${process.platform}.`,
36
+ timedOut: false,
37
+ computer: {
38
+ ok: false,
39
+ error: `Computer use is not supported on platform ${process.platform}.`,
40
+ },
41
+ };
42
+ }
43
+ return backend.executeComputerTask(task);
44
+ }
45
+
46
+ export async function probeComputerCapability() {
47
+ const backend = await loadBackend();
48
+ if (!backend) return false;
49
+ return backend.probeComputerCapability();
50
+ }
51
+
52
+ export function computerCapabilityName() {
53
+ return capabilityForPlatform(process.platform);
54
+ }
55
+
56
+ export async function materializeComputerHelperSources(configDir) {
57
+ const backend = await loadBackend();
58
+ if (backend?.materializeComputerHelperSources) {
59
+ await backend.materializeComputerHelperSources(configDir);
60
+ }
61
+ }
62
+
63
+ export { isComputerCapability as workerSupportsComputerCapability };