@jian-ai/remote-control 0.8.1 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/bin/computer-linux.mjs +537 -0
- package/bin/computer-macos.mjs +171 -41
- package/bin/computer-shared.mjs +61 -0
- package/bin/computer-windows.mjs +516 -0
- package/bin/computer.mjs +63 -0
- package/bin/jian-remote.mjs +44 -27
- package/linux/computer-helper.py +694 -0
- package/macos/computer-helper +0 -0
- package/macos/computer-helper.swift +297 -129
- package/package.json +3 -1
- package/windows/computer-helper.ps1 +500 -0
package/bin/computer-macos.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* macOS computer-use: primary-display screenshots, coord mapping, Jian overlay helper.
|
|
3
|
-
* English-only messages. Imported by
|
|
3
|
+
* English-only messages. Imported by computer.mjs on darwin.
|
|
4
4
|
*/
|
|
5
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
@@ -9,40 +9,38 @@ import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { COMPUTER_CAPABILITIES, imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
|
|
12
13
|
|
|
13
|
-
export
|
|
14
|
+
export { imagePixelToPoint, scaleFromGeometry } from './computer-shared.mjs';
|
|
15
|
+
|
|
16
|
+
export const COMPUTER_CAPABILITY = COMPUTER_CAPABILITIES.darwin;
|
|
14
17
|
const IDLE_MS = 30_000;
|
|
15
18
|
const HELPER_NAME = 'computer-helper';
|
|
19
|
+
/** How long we cache lock / display-asleep probes between HTTPS polls. */
|
|
20
|
+
const UI_STATE_CACHE_MS = 5_000;
|
|
21
|
+
export const MAC_UI_STATES = Object.freeze(['unlocked', 'locked', 'display_asleep', 'unknown']);
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const { originX, originY, pointWidth, pointHeight, widthPx, heightPx } = geometry;
|
|
20
|
-
if (!(widthPx > 0 && heightPx > 0 && pointWidth > 0 && pointHeight > 0)) {
|
|
21
|
-
throw new Error('Invalid screenshot geometry for coordinate mapping');
|
|
22
|
-
}
|
|
23
|
-
return {
|
|
24
|
-
pointX: originX + (x + 0.5) * (pointWidth / widthPx),
|
|
25
|
-
pointY: originY + (y + 0.5) * (pointHeight / heightPx),
|
|
26
|
-
};
|
|
27
|
-
}
|
|
23
|
+
const LOCKED_COMPUTER_MESSAGE =
|
|
24
|
+
'Mac is locked — unlock the screen to use Computer Use. Shell and file tools may still work while the worker is online.';
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
imageToPointY: geometry.pointHeight / geometry.heightPx,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
26
|
+
let cachedUiState = { at: 0, state: 'unknown' };
|
|
27
|
+
let workerIdleCaffeinate = null;
|
|
28
|
+
let sessionDisplayCaffeinate = null;
|
|
35
29
|
|
|
36
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Capture primary display and compress for the worker channel.
|
|
32
|
+
* Single-pass ladder starts at 1600/42 (skips a full-res 1920@50 encode that often
|
|
33
|
+
* exceeded the budget and forced a second sips pass — common waste on Retina).
|
|
34
|
+
*/
|
|
37
35
|
export function capturePrimaryScreenshotBase64() {
|
|
38
36
|
const script = [
|
|
39
37
|
'tmp=$(mktemp /tmp/jian-shot.XXXXXX.jpg);',
|
|
40
38
|
'screencapture -x -t jpg -D 1 "$tmp" || { rm -f "$tmp"; exit 1; };',
|
|
41
|
-
'sips -Z
|
|
39
|
+
'sips -Z 1600 -s format jpeg -s formatOptions 42 "$tmp" >/dev/null 2>&1 || true;',
|
|
42
40
|
'size=$(wc -c < "$tmp" | tr -d " ");',
|
|
43
|
-
'if [ "$size" -gt 280000 ]; then sips -Z 1440 -s format jpeg -s formatOptions
|
|
41
|
+
'if [ "$size" -gt 280000 ]; then sips -Z 1440 -s format jpeg -s formatOptions 38 "$tmp" >/dev/null 2>&1 || true; fi;',
|
|
44
42
|
'size=$(wc -c < "$tmp" | tr -d " ");',
|
|
45
|
-
'if [ "$size" -gt 280000 ]; then sips -Z 1280 -s format jpeg -s formatOptions
|
|
43
|
+
'if [ "$size" -gt 280000 ]; then sips -Z 1280 -s format jpeg -s formatOptions 34 "$tmp" >/dev/null 2>&1 || true; fi;',
|
|
46
44
|
'w=$(sips -g pixelWidth "$tmp" 2>/dev/null | awk "/pixelWidth/{print \\$2}");',
|
|
47
45
|
'h=$(sips -g pixelHeight "$tmp" 2>/dev/null | awk "/pixelHeight/{print \\$2}");',
|
|
48
46
|
'echo "$w $h";',
|
|
@@ -137,7 +135,6 @@ async function ensureHelperBinary() {
|
|
|
137
135
|
clearQuarantine(paths.binary);
|
|
138
136
|
await writeFile(paths.stamp, `${hash}\n`, { mode: 0o644 });
|
|
139
137
|
}
|
|
140
|
-
// Keep source nearby for debugging / swiftc fallback after a partial install.
|
|
141
138
|
try {
|
|
142
139
|
const swift = packageSwiftPath();
|
|
143
140
|
if (existsSync(swift)) await copyFile(swift, paths.source);
|
|
@@ -227,8 +224,11 @@ export class MacComputerSession {
|
|
|
227
224
|
|
|
228
225
|
async ensure() {
|
|
229
226
|
if (this.child && !this.child.killed) {
|
|
230
|
-
|
|
231
|
-
|
|
227
|
+
if (!this.active) {
|
|
228
|
+
await this.send({ cmd: 'ensureSession' });
|
|
229
|
+
this.active = true;
|
|
230
|
+
}
|
|
231
|
+
startSessionDisplayAssertion(this.child.pid || process.pid);
|
|
232
232
|
this.bumpIdle();
|
|
233
233
|
return;
|
|
234
234
|
}
|
|
@@ -247,6 +247,7 @@ export class MacComputerSession {
|
|
|
247
247
|
this.failAll('Computer-use helper exited unexpectedly.');
|
|
248
248
|
this.child = null;
|
|
249
249
|
this.active = false;
|
|
250
|
+
stopSessionDisplayAssertion();
|
|
250
251
|
});
|
|
251
252
|
const ping = await this.send({ cmd: 'ping' });
|
|
252
253
|
if (!ping.ok) throw new Error(ping.error || 'Computer-use helper did not respond.');
|
|
@@ -263,6 +264,7 @@ export class MacComputerSession {
|
|
|
263
264
|
};
|
|
264
265
|
await this.send({ cmd: 'ensureSession' });
|
|
265
266
|
this.active = true;
|
|
267
|
+
startSessionDisplayAssertion(this.child.pid || process.pid);
|
|
266
268
|
this.bumpIdle();
|
|
267
269
|
}
|
|
268
270
|
|
|
@@ -343,6 +345,7 @@ export class MacComputerSession {
|
|
|
343
345
|
async end() {
|
|
344
346
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
345
347
|
this.idleTimer = null;
|
|
348
|
+
stopSessionDisplayAssertion();
|
|
346
349
|
if (this.child) {
|
|
347
350
|
try {
|
|
348
351
|
await this.send({ cmd: 'endSession' });
|
|
@@ -371,7 +374,7 @@ export class MacComputerSession {
|
|
|
371
374
|
}
|
|
372
375
|
|
|
373
376
|
if (action === 'wait') {
|
|
374
|
-
const ms = Math.round((computer.duration ?? 0.
|
|
377
|
+
const ms = Math.round((computer.duration ?? 0.35) * 1000);
|
|
375
378
|
await new Promise((r) => setTimeout(r, ms));
|
|
376
379
|
if (this.active) this.bumpIdle();
|
|
377
380
|
return {
|
|
@@ -383,6 +386,17 @@ export class MacComputerSession {
|
|
|
383
386
|
};
|
|
384
387
|
}
|
|
385
388
|
|
|
389
|
+
const uiBlocked = assertComputerUiAllowed();
|
|
390
|
+
if (uiBlocked) {
|
|
391
|
+
return {
|
|
392
|
+
exitCode: 1,
|
|
393
|
+
stdout: '',
|
|
394
|
+
stderr: uiBlocked,
|
|
395
|
+
timedOut: false,
|
|
396
|
+
computer: { ok: false, error: uiBlocked },
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
386
400
|
if (action === 'screenshot') {
|
|
387
401
|
await this.ensure();
|
|
388
402
|
const shot = capturePrimaryScreenshotBase64();
|
|
@@ -410,22 +424,19 @@ export class MacComputerSession {
|
|
|
410
424
|
await this.ensure();
|
|
411
425
|
|
|
412
426
|
try {
|
|
413
|
-
if (
|
|
414
|
-
action === 'mouse_move' ||
|
|
415
|
-
action === 'left_click' ||
|
|
416
|
-
action === 'right_click' ||
|
|
417
|
-
action === 'double_click'
|
|
418
|
-
) {
|
|
427
|
+
if (action === 'mouse_move') {
|
|
419
428
|
if (!computer.coordinate) throw new Error('coordinate is required for this action.');
|
|
420
429
|
const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
|
|
421
430
|
const moved = await this.send({ cmd: 'move', pointX, pointY });
|
|
422
431
|
if (!moved.ok) throw new Error(moved.error || 'Move failed.');
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
432
|
+
} else if (action === 'left_click' || action === 'right_click' || action === 'double_click') {
|
|
433
|
+
if (!computer.coordinate) throw new Error('coordinate is required for this action.');
|
|
434
|
+
const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
|
|
435
|
+
const button = action === 'right_click' ? 'right' : 'left';
|
|
436
|
+
const count = action === 'double_click' ? 2 : 1;
|
|
437
|
+
// One RPC: animate overlay, click, restore user cursor (helper-side).
|
|
438
|
+
const clicked = await this.send({ cmd: 'clickAt', pointX, pointY, button, count });
|
|
439
|
+
if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
|
|
429
440
|
} else if (action === 'left_click_drag') {
|
|
430
441
|
if (!computer.startCoordinate || !computer.coordinate) {
|
|
431
442
|
throw new Error('startCoordinate and coordinate are required for drag.');
|
|
@@ -535,11 +546,130 @@ export async function probeComputerCapability() {
|
|
|
535
546
|
await ensureHelperBinary();
|
|
536
547
|
return true;
|
|
537
548
|
} catch {
|
|
538
|
-
// Still advertise: screenshot-only path can work; input fails with TCC guidance.
|
|
539
|
-
// Prefer advertising when swiftc exists so gateway can send jobs after first grant.
|
|
540
549
|
return (
|
|
541
550
|
Boolean(spawnSync('which', ['swiftc'], { encoding: 'utf8' }).stdout?.trim()) ||
|
|
542
551
|
existsSync(helperPaths().binary)
|
|
543
552
|
);
|
|
544
553
|
}
|
|
545
554
|
}
|
|
555
|
+
|
|
556
|
+
function normalizeUiState(value) {
|
|
557
|
+
return MAC_UI_STATES.includes(value) ? value : 'unknown';
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Lock screen vs display sleep vs unlocked. Prefer the helper binary (CGSession +
|
|
562
|
+
* CGDisplayIsAsleep); fall back to unknown so we never invent "offline".
|
|
563
|
+
*/
|
|
564
|
+
export function probeMacUiState({ force = false } = {}) {
|
|
565
|
+
if (process.platform !== 'darwin') return 'unknown';
|
|
566
|
+
const now = Date.now();
|
|
567
|
+
if (!force && now - cachedUiState.at < UI_STATE_CACHE_MS) return cachedUiState.state;
|
|
568
|
+
|
|
569
|
+
let state = 'unknown';
|
|
570
|
+
const binary = helperPaths().binary;
|
|
571
|
+
if (existsSync(binary)) {
|
|
572
|
+
const probed = spawnSync(binary, ['--session-status'], {
|
|
573
|
+
encoding: 'utf8',
|
|
574
|
+
timeout: 4_000,
|
|
575
|
+
maxBuffer: 16_000,
|
|
576
|
+
});
|
|
577
|
+
if (probed.status === 0) {
|
|
578
|
+
try {
|
|
579
|
+
const line = (probed.stdout || '').trim().split('\n').pop();
|
|
580
|
+
const parsed = JSON.parse(line || '{}');
|
|
581
|
+
if (parsed?.ok) state = normalizeUiState(parsed.state);
|
|
582
|
+
} catch {
|
|
583
|
+
state = 'unknown';
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
cachedUiState = { at: now, state };
|
|
589
|
+
return state;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Prevent idle system sleep for the worker process so HTTPS polls keep firing when the
|
|
594
|
+
* screen is locked. Does not claim lid-closed / clamshell immunity (see docs/remote.md).
|
|
595
|
+
*/
|
|
596
|
+
export function startWorkerIdleAssertion(pid = process.pid) {
|
|
597
|
+
if (process.platform !== 'darwin') return null;
|
|
598
|
+
stopWorkerIdleAssertion();
|
|
599
|
+
try {
|
|
600
|
+
workerIdleCaffeinate = spawn('/usr/bin/caffeinate', ['-i', '-w', String(pid)], {
|
|
601
|
+
stdio: 'ignore',
|
|
602
|
+
detached: false,
|
|
603
|
+
});
|
|
604
|
+
workerIdleCaffeinate.on('error', () => {
|
|
605
|
+
workerIdleCaffeinate = null;
|
|
606
|
+
});
|
|
607
|
+
workerIdleCaffeinate.unref?.();
|
|
608
|
+
return workerIdleCaffeinate;
|
|
609
|
+
} catch {
|
|
610
|
+
workerIdleCaffeinate = null;
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function stopWorkerIdleAssertion() {
|
|
616
|
+
if (!workerIdleCaffeinate) return;
|
|
617
|
+
try {
|
|
618
|
+
workerIdleCaffeinate.kill('SIGTERM');
|
|
619
|
+
} catch {
|
|
620
|
+
// already gone
|
|
621
|
+
}
|
|
622
|
+
workerIdleCaffeinate = null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** While a computer-use session is open, also keep the display from idling off. */
|
|
626
|
+
function startSessionDisplayAssertion(pid = process.pid) {
|
|
627
|
+
if (process.platform !== 'darwin') return;
|
|
628
|
+
stopSessionDisplayAssertion();
|
|
629
|
+
try {
|
|
630
|
+
sessionDisplayCaffeinate = spawn('/usr/bin/caffeinate', ['-dimsu', '-w', String(pid)], {
|
|
631
|
+
stdio: 'ignore',
|
|
632
|
+
detached: false,
|
|
633
|
+
});
|
|
634
|
+
sessionDisplayCaffeinate.on('error', () => {
|
|
635
|
+
sessionDisplayCaffeinate = null;
|
|
636
|
+
});
|
|
637
|
+
sessionDisplayCaffeinate.unref?.();
|
|
638
|
+
} catch {
|
|
639
|
+
sessionDisplayCaffeinate = null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function stopSessionDisplayAssertion() {
|
|
644
|
+
if (!sessionDisplayCaffeinate) return;
|
|
645
|
+
try {
|
|
646
|
+
sessionDisplayCaffeinate.kill('SIGTERM');
|
|
647
|
+
} catch {
|
|
648
|
+
// already gone
|
|
649
|
+
}
|
|
650
|
+
sessionDisplayCaffeinate = null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** User-activity assertion: nudge a sleeping (unlocked) display awake. */
|
|
654
|
+
export function wakeMacDisplay(seconds = 3) {
|
|
655
|
+
if (process.platform !== 'darwin') return;
|
|
656
|
+
const secs = Math.max(1, Math.min(30, Math.round(seconds)));
|
|
657
|
+
spawnSync('/usr/bin/caffeinate', ['-u', '-t', String(secs)], {
|
|
658
|
+
encoding: 'utf8',
|
|
659
|
+
timeout: (secs + 2) * 1000,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Before GUI actions: wake a sleeping display; refuse clearly when the session is locked.
|
|
665
|
+
* Returns null when Computer Use may proceed, or an English error string.
|
|
666
|
+
*/
|
|
667
|
+
export function assertComputerUiAllowed() {
|
|
668
|
+
let state = probeMacUiState({ force: true });
|
|
669
|
+
if (state === 'display_asleep' || state === 'unknown') {
|
|
670
|
+
wakeMacDisplay(3);
|
|
671
|
+
state = probeMacUiState({ force: true });
|
|
672
|
+
}
|
|
673
|
+
if (state === 'locked') return LOCKED_COMPUTER_MESSAGE;
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared computer-use helpers: coords, capabilities, motion easing.
|
|
3
|
+
* Platform backends live in computer-{macos,linux,windows}.mjs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const COMPUTER_CAPABILITIES = Object.freeze({
|
|
7
|
+
darwin: 'computer-macos-v1',
|
|
8
|
+
linux: 'computer-linux-v1',
|
|
9
|
+
win32: 'computer-windows-v1',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const CAPABILITY_SET = new Set(Object.values(COMPUTER_CAPABILITIES));
|
|
13
|
+
|
|
14
|
+
/** True when a worker capability string means remote_computer is supported. */
|
|
15
|
+
export function isComputerCapability(value) {
|
|
16
|
+
return CAPABILITY_SET.has(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function capabilityForPlatform(platform = process.platform) {
|
|
20
|
+
return COMPUTER_CAPABILITIES[platform] ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Map screenshot image pixels → display points (primary). Half-pixel centers the click. */
|
|
24
|
+
export function imagePixelToPoint(x, y, geometry) {
|
|
25
|
+
const { originX, originY, pointWidth, pointHeight, widthPx, heightPx } = geometry;
|
|
26
|
+
if (!(widthPx > 0 && heightPx > 0 && pointWidth > 0 && pointHeight > 0)) {
|
|
27
|
+
throw new Error('Invalid screenshot geometry for coordinate mapping');
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
pointX: originX + (x + 0.5) * (pointWidth / widthPx),
|
|
31
|
+
pointY: originY + (y + 0.5) * (pointHeight / heightPx),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function scaleFromGeometry(geometry) {
|
|
36
|
+
return {
|
|
37
|
+
imageToPointX: geometry.pointWidth / geometry.widthPx,
|
|
38
|
+
imageToPointY: geometry.pointHeight / geometry.heightPx,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ease-out cubic — snappy agent loops, still readable as motion. */
|
|
43
|
+
export function easeOutCubic(t) {
|
|
44
|
+
const x = Math.min(1, Math.max(0, t));
|
|
45
|
+
return 1 - (1 - x) ** 3;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Duration (ms) for overlay travel. Caps keep agent loops fast on long jumps.
|
|
50
|
+
* ~0.25 ms/px with floor 28 ms / ceiling 110 ms.
|
|
51
|
+
*/
|
|
52
|
+
export function motionDurationMs(distancePx) {
|
|
53
|
+
const d = Math.max(0, distancePx);
|
|
54
|
+
return Math.round(Math.min(110, Math.max(28, d * 0.25)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function pointDistance(a, b) {
|
|
58
|
+
const dx = (b.pointX ?? b.x) - (a.pointX ?? a.x);
|
|
59
|
+
const dy = (b.pointY ?? b.y) - (a.pointY ?? a.y);
|
|
60
|
+
return Math.hypot(dx, dy);
|
|
61
|
+
}
|