@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.
@@ -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 jian-remote.mjs on darwin.
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,41 @@ 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 const COMPUTER_CAPABILITY = 'computer-macos-v1';
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
- /** Map screenshot image pixels → display points (primary). Half-pixel centers the click. */
18
- export function imagePixelToPoint(x, y, geometry) {
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_SCREENSHOT_NOTE =
24
+ 'Note: Mac session reports locked — image may be the lock screen (or black if the display stayed asleep). Desktop apps behind loginwindow are often not visible.';
28
25
 
29
- export function scaleFromGeometry(geometry) {
30
- return {
31
- imageToPointX: geometry.pointWidth / geometry.widthPx,
32
- imageToPointY: geometry.pointHeight / geometry.heightPx,
33
- };
34
- }
26
+ const LOCKED_ACTION_HINT =
27
+ ' Mac session reports locked; if this failed, unlock or keep the display awake (post-login lock ≠ FileVault loginwindow).';
35
28
 
36
- /** Capture primary display and compress like the gateway’s former screenshotCommand. */
29
+ let cachedUiState = { at: 0, state: 'unknown' };
30
+ let workerIdleCaffeinate = null;
31
+ let sessionDisplayCaffeinate = null;
32
+
33
+ /**
34
+ * Capture primary display and compress for the worker channel.
35
+ * Single-pass ladder starts at 1600/42 (skips a full-res 1920@50 encode that often
36
+ * exceeded the budget and forced a second sips pass — common waste on Retina).
37
+ */
37
38
  export function capturePrimaryScreenshotBase64() {
38
39
  const script = [
39
40
  'tmp=$(mktemp /tmp/jian-shot.XXXXXX.jpg);',
40
41
  'screencapture -x -t jpg -D 1 "$tmp" || { rm -f "$tmp"; exit 1; };',
41
- 'sips -Z 1920 -s format jpeg -s formatOptions 50 "$tmp" >/dev/null 2>&1 || true;',
42
+ 'sips -Z 1600 -s format jpeg -s formatOptions 42 "$tmp" >/dev/null 2>&1 || true;',
42
43
  'size=$(wc -c < "$tmp" | tr -d " ");',
43
- 'if [ "$size" -gt 280000 ]; then sips -Z 1440 -s format jpeg -s formatOptions 40 "$tmp" >/dev/null 2>&1 || true; fi;',
44
+ 'if [ "$size" -gt 280000 ]; then sips -Z 1440 -s format jpeg -s formatOptions 38 "$tmp" >/dev/null 2>&1 || true; fi;',
44
45
  'size=$(wc -c < "$tmp" | tr -d " ");',
45
- 'if [ "$size" -gt 280000 ]; then sips -Z 1280 -s format jpeg -s formatOptions 35 "$tmp" >/dev/null 2>&1 || true; fi;',
46
+ 'if [ "$size" -gt 280000 ]; then sips -Z 1280 -s format jpeg -s formatOptions 34 "$tmp" >/dev/null 2>&1 || true; fi;',
46
47
  'w=$(sips -g pixelWidth "$tmp" 2>/dev/null | awk "/pixelWidth/{print \\$2}");',
47
48
  'h=$(sips -g pixelHeight "$tmp" 2>/dev/null | awk "/pixelHeight/{print \\$2}");',
48
49
  'echo "$w $h";',
@@ -137,7 +138,6 @@ async function ensureHelperBinary() {
137
138
  clearQuarantine(paths.binary);
138
139
  await writeFile(paths.stamp, `${hash}\n`, { mode: 0o644 });
139
140
  }
140
- // Keep source nearby for debugging / swiftc fallback after a partial install.
141
141
  try {
142
142
  const swift = packageSwiftPath();
143
143
  if (existsSync(swift)) await copyFile(swift, paths.source);
@@ -227,8 +227,11 @@ export class MacComputerSession {
227
227
 
228
228
  async ensure() {
229
229
  if (this.child && !this.child.killed) {
230
- await this.send({ cmd: 'ensureSession' });
231
- this.active = true;
230
+ if (!this.active) {
231
+ await this.send({ cmd: 'ensureSession' });
232
+ this.active = true;
233
+ }
234
+ startSessionDisplayAssertion(this.child.pid || process.pid);
232
235
  this.bumpIdle();
233
236
  return;
234
237
  }
@@ -247,6 +250,7 @@ export class MacComputerSession {
247
250
  this.failAll('Computer-use helper exited unexpectedly.');
248
251
  this.child = null;
249
252
  this.active = false;
253
+ stopSessionDisplayAssertion();
250
254
  });
251
255
  const ping = await this.send({ cmd: 'ping' });
252
256
  if (!ping.ok) throw new Error(ping.error || 'Computer-use helper did not respond.');
@@ -263,6 +267,7 @@ export class MacComputerSession {
263
267
  };
264
268
  await this.send({ cmd: 'ensureSession' });
265
269
  this.active = true;
270
+ startSessionDisplayAssertion(this.child.pid || process.pid);
266
271
  this.bumpIdle();
267
272
  }
268
273
 
@@ -343,6 +348,7 @@ export class MacComputerSession {
343
348
  async end() {
344
349
  if (this.idleTimer) clearTimeout(this.idleTimer);
345
350
  this.idleTimer = null;
351
+ stopSessionDisplayAssertion();
346
352
  if (this.child) {
347
353
  try {
348
354
  await this.send({ cmd: 'endSession' });
@@ -371,7 +377,7 @@ export class MacComputerSession {
371
377
  }
372
378
 
373
379
  if (action === 'wait') {
374
- const ms = Math.round((computer.duration ?? 0.5) * 1000);
380
+ const ms = Math.round((computer.duration ?? 0.35) * 1000);
375
381
  await new Promise((r) => setTimeout(r, ms));
376
382
  if (this.active) this.bumpIdle();
377
383
  return {
@@ -383,49 +389,66 @@ export class MacComputerSession {
383
389
  };
384
390
  }
385
391
 
392
+ // Wake display when locked/asleep, then always attempt — never soft-block on uiState.
393
+ const uiPrep = prepareComputerUi();
394
+
386
395
  if (action === 'screenshot') {
387
- await this.ensure();
388
- const shot = capturePrimaryScreenshotBase64();
389
- if (!this.geometry) throw new Error('Display geometry unavailable.');
390
- this.shotGeometry = {
391
- ...this.geometry,
392
- widthPx: shot.widthPx,
393
- heightPx: shot.heightPx,
394
- };
395
- this.bumpIdle();
396
- return {
397
- exitCode: 0,
398
- stdout: shot.base64,
399
- stderr: '',
400
- timedOut: false,
401
- computer: {
402
- ok: true,
396
+ try {
397
+ await this.ensure();
398
+ const shot = capturePrimaryScreenshotBase64();
399
+ if (!this.geometry) throw new Error('Display geometry unavailable.');
400
+ this.shotGeometry = {
401
+ ...this.geometry,
403
402
  widthPx: shot.widthPx,
404
403
  heightPx: shot.heightPx,
405
- scale: scaleFromGeometry(this.shotGeometry),
406
- },
407
- };
404
+ };
405
+ this.bumpIdle();
406
+ const stderr = uiPrep.sessionLocked ? LOCKED_SCREENSHOT_NOTE : '';
407
+ return {
408
+ exitCode: 0,
409
+ stdout: shot.base64,
410
+ stderr,
411
+ timedOut: false,
412
+ computer: {
413
+ ok: true,
414
+ widthPx: shot.widthPx,
415
+ heightPx: shot.heightPx,
416
+ scale: scaleFromGeometry(this.shotGeometry),
417
+ },
418
+ };
419
+ } catch (error) {
420
+ const message = error instanceof Error ? error.message : String(error);
421
+ const hint = uiPrep.sessionLocked ? LOCKED_ACTION_HINT : '';
422
+ const tcc = /accessib|not permitted|not authorized|Screen Recording/i.test(message)
423
+ ? ' Jian Remote needs Screen Recording (and Accessibility for input) in System Settings.'
424
+ : '';
425
+ const full = `${message}${tcc}${hint}`.trim();
426
+ return {
427
+ exitCode: 1,
428
+ stdout: '',
429
+ stderr: full,
430
+ timedOut: false,
431
+ computer: { ok: false, error: full },
432
+ };
433
+ }
408
434
  }
409
435
 
410
436
  await this.ensure();
411
437
 
412
438
  try {
413
- if (
414
- action === 'mouse_move' ||
415
- action === 'left_click' ||
416
- action === 'right_click' ||
417
- action === 'double_click'
418
- ) {
439
+ if (action === 'mouse_move') {
419
440
  if (!computer.coordinate) throw new Error('coordinate is required for this action.');
420
441
  const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
421
442
  const moved = await this.send({ cmd: 'move', pointX, pointY });
422
443
  if (!moved.ok) throw new Error(moved.error || 'Move failed.');
423
- if (action !== 'mouse_move') {
424
- const button = action === 'right_click' ? 'right' : 'left';
425
- const count = action === 'double_click' ? 2 : 1;
426
- const clicked = await this.send({ cmd: 'click', button, count });
427
- if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
428
- }
444
+ } else if (action === 'left_click' || action === 'right_click' || action === 'double_click') {
445
+ if (!computer.coordinate) throw new Error('coordinate is required for this action.');
446
+ const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
447
+ const button = action === 'right_click' ? 'right' : 'left';
448
+ const count = action === 'double_click' ? 2 : 1;
449
+ // One RPC: animate overlay, click, restore user cursor (helper-side).
450
+ const clicked = await this.send({ cmd: 'clickAt', pointX, pointY, button, count });
451
+ if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
429
452
  } else if (action === 'left_click_drag') {
430
453
  if (!computer.startCoordinate || !computer.coordinate) {
431
454
  throw new Error('startCoordinate and coordinate are required for drag.');
@@ -468,12 +491,14 @@ export class MacComputerSession {
468
491
  const tcc = /accessib|not permitted|not authorized/i.test(message)
469
492
  ? ' Jian Remote needs Accessibility (and Screen Recording for screenshots) in System Settings.'
470
493
  : '';
494
+ const hint = uiPrep.sessionLocked ? LOCKED_ACTION_HINT : '';
495
+ const full = `${message}${tcc}${hint}`.trim();
471
496
  return {
472
497
  exitCode: 1,
473
498
  stdout: '',
474
- stderr: `${message}${tcc}`,
499
+ stderr: full,
475
500
  timedOut: false,
476
- computer: { ok: false, error: `${message}${tcc}`.trim() },
501
+ computer: { ok: false, error: full },
477
502
  };
478
503
  }
479
504
 
@@ -535,11 +560,129 @@ export async function probeComputerCapability() {
535
560
  await ensureHelperBinary();
536
561
  return true;
537
562
  } 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
563
  return (
541
564
  Boolean(spawnSync('which', ['swiftc'], { encoding: 'utf8' }).stdout?.trim()) ||
542
565
  existsSync(helperPaths().binary)
543
566
  );
544
567
  }
545
568
  }
569
+
570
+ function normalizeUiState(value) {
571
+ return MAC_UI_STATES.includes(value) ? value : 'unknown';
572
+ }
573
+
574
+ /**
575
+ * Lock screen vs display sleep vs unlocked. Prefer the helper binary (CGSession +
576
+ * CGDisplayIsAsleep); fall back to unknown so we never invent "offline".
577
+ */
578
+ export function probeMacUiState({ force = false } = {}) {
579
+ if (process.platform !== 'darwin') return 'unknown';
580
+ const now = Date.now();
581
+ if (!force && now - cachedUiState.at < UI_STATE_CACHE_MS) return cachedUiState.state;
582
+
583
+ let state = 'unknown';
584
+ const binary = helperPaths().binary;
585
+ if (existsSync(binary)) {
586
+ const probed = spawnSync(binary, ['--session-status'], {
587
+ encoding: 'utf8',
588
+ timeout: 4_000,
589
+ maxBuffer: 16_000,
590
+ });
591
+ if (probed.status === 0) {
592
+ try {
593
+ const line = (probed.stdout || '').trim().split('\n').pop();
594
+ const parsed = JSON.parse(line || '{}');
595
+ if (parsed?.ok) state = normalizeUiState(parsed.state);
596
+ } catch {
597
+ state = 'unknown';
598
+ }
599
+ }
600
+ }
601
+
602
+ cachedUiState = { at: now, state };
603
+ return state;
604
+ }
605
+
606
+ /**
607
+ * Prevent idle system sleep for the worker process so HTTPS polls keep firing when the
608
+ * screen is locked. Does not claim lid-closed / clamshell immunity (see docs/remote.md).
609
+ */
610
+ export function startWorkerIdleAssertion(pid = process.pid) {
611
+ if (process.platform !== 'darwin') return null;
612
+ stopWorkerIdleAssertion();
613
+ try {
614
+ workerIdleCaffeinate = spawn('/usr/bin/caffeinate', ['-i', '-w', String(pid)], {
615
+ stdio: 'ignore',
616
+ detached: false,
617
+ });
618
+ workerIdleCaffeinate.on('error', () => {
619
+ workerIdleCaffeinate = null;
620
+ });
621
+ workerIdleCaffeinate.unref?.();
622
+ return workerIdleCaffeinate;
623
+ } catch {
624
+ workerIdleCaffeinate = null;
625
+ return null;
626
+ }
627
+ }
628
+
629
+ export function stopWorkerIdleAssertion() {
630
+ if (!workerIdleCaffeinate) return;
631
+ try {
632
+ workerIdleCaffeinate.kill('SIGTERM');
633
+ } catch {
634
+ // already gone
635
+ }
636
+ workerIdleCaffeinate = null;
637
+ }
638
+
639
+ /** While a computer-use session is open, also keep the display from idling off. */
640
+ function startSessionDisplayAssertion(pid = process.pid) {
641
+ if (process.platform !== 'darwin') return;
642
+ stopSessionDisplayAssertion();
643
+ try {
644
+ sessionDisplayCaffeinate = spawn('/usr/bin/caffeinate', ['-dimsu', '-w', String(pid)], {
645
+ stdio: 'ignore',
646
+ detached: false,
647
+ });
648
+ sessionDisplayCaffeinate.on('error', () => {
649
+ sessionDisplayCaffeinate = null;
650
+ });
651
+ sessionDisplayCaffeinate.unref?.();
652
+ } catch {
653
+ sessionDisplayCaffeinate = null;
654
+ }
655
+ }
656
+
657
+ function stopSessionDisplayAssertion() {
658
+ if (!sessionDisplayCaffeinate) return;
659
+ try {
660
+ sessionDisplayCaffeinate.kill('SIGTERM');
661
+ } catch {
662
+ // already gone
663
+ }
664
+ sessionDisplayCaffeinate = null;
665
+ }
666
+
667
+ /** User-activity assertion: nudge a sleeping (unlocked) display awake. */
668
+ export function wakeMacDisplay(seconds = 3) {
669
+ if (process.platform !== 'darwin') return;
670
+ const secs = Math.max(1, Math.min(30, Math.round(seconds)));
671
+ spawnSync('/usr/bin/caffeinate', ['-u', '-t', String(secs)], {
672
+ encoding: 'utf8',
673
+ timeout: (secs + 2) * 1000,
674
+ });
675
+ }
676
+
677
+ /**
678
+ * Wake the display when locked or asleep (lock often coincides with display sleep).
679
+ * Never soft-blocks: callers always attempt the OS API next.
680
+ */
681
+ export function prepareComputerUi() {
682
+ let state = probeMacUiState({ force: true });
683
+ if (state === 'display_asleep' || state === 'locked' || state === 'unknown') {
684
+ wakeMacDisplay(3);
685
+ state = probeMacUiState({ force: true });
686
+ }
687
+ return { sessionLocked: state === 'locked', state };
688
+ }
@@ -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
+ }