@jian-ai/remote-control 0.8.0 → 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 -1
- package/bin/computer-linux.mjs +537 -0
- package/bin/computer-macos.mjs +255 -59
- 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/build-helper.sh +46 -0
- package/macos/computer-helper +0 -0
- package/macos/computer-helper.swift +300 -131
- 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";',
|
|
@@ -75,22 +73,77 @@ export function capturePrimaryScreenshotBase64() {
|
|
|
75
73
|
return { base64, widthPx, heightPx };
|
|
76
74
|
}
|
|
77
75
|
|
|
78
|
-
function helperPaths() {
|
|
79
|
-
const support = join(
|
|
76
|
+
function helperPaths(home = homedir()) {
|
|
77
|
+
const support = join(home, 'Library', 'Application Support', 'Jian Remote');
|
|
80
78
|
return {
|
|
81
79
|
support,
|
|
82
80
|
source: join(support, `${HELPER_NAME}.swift`),
|
|
83
81
|
binary: join(support, HELPER_NAME),
|
|
82
|
+
stamp: join(support, `${HELPER_NAME}.sha256`),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Paths inside the installed npm package (or a checkout). */
|
|
87
|
+
export function packageHelperPaths(
|
|
88
|
+
packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..'),
|
|
89
|
+
) {
|
|
90
|
+
return {
|
|
91
|
+
swift: join(packageRoot, 'macos', `${HELPER_NAME}.swift`),
|
|
92
|
+
binary: join(packageRoot, 'macos', HELPER_NAME),
|
|
84
93
|
};
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
function packageSwiftPath() {
|
|
88
|
-
return
|
|
97
|
+
return packageHelperPaths().swift;
|
|
89
98
|
}
|
|
90
99
|
|
|
100
|
+
function packageBinaryPath() {
|
|
101
|
+
return packageHelperPaths().binary;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stampForBytes(bytes) {
|
|
105
|
+
return createHash('sha256').update(bytes).digest('hex').slice(0, 16);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Drop download quarantine so a helper from npm can exec under the LaunchAgent. */
|
|
109
|
+
function clearQuarantine(path) {
|
|
110
|
+
spawnSync('xattr', ['-d', 'com.apple.quarantine', path], { encoding: 'utf8' });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Prefer a Publish-built universal binary from the npm package; otherwise compile with swiftc.
|
|
115
|
+
* Stamp is over the installed artifact bytes so updates replace a stale helper.
|
|
116
|
+
*/
|
|
91
117
|
async function ensureHelperBinary() {
|
|
92
118
|
const paths = helperPaths();
|
|
93
119
|
await mkdir(paths.support, { recursive: true, mode: 0o755 });
|
|
120
|
+
|
|
121
|
+
let previous = '';
|
|
122
|
+
try {
|
|
123
|
+
previous = (await readFile(paths.stamp, 'utf8')).trim();
|
|
124
|
+
} catch {
|
|
125
|
+
previous = '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const packagedBinary = packageBinaryPath();
|
|
129
|
+
if (existsSync(packagedBinary)) {
|
|
130
|
+
const bytes = await readFile(packagedBinary);
|
|
131
|
+
const hash = `bin:${stampForBytes(bytes)}`;
|
|
132
|
+
if (previous !== hash || !existsSync(paths.binary)) {
|
|
133
|
+
await copyFile(packagedBinary, paths.binary);
|
|
134
|
+
await chmod(paths.binary, 0o755);
|
|
135
|
+
clearQuarantine(paths.binary);
|
|
136
|
+
await writeFile(paths.stamp, `${hash}\n`, { mode: 0o644 });
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const swift = packageSwiftPath();
|
|
140
|
+
if (existsSync(swift)) await copyFile(swift, paths.source);
|
|
141
|
+
} catch {
|
|
142
|
+
// optional
|
|
143
|
+
}
|
|
144
|
+
return paths.binary;
|
|
145
|
+
}
|
|
146
|
+
|
|
94
147
|
let sourceText;
|
|
95
148
|
try {
|
|
96
149
|
sourceText = await readFile(packageSwiftPath(), 'utf8');
|
|
@@ -99,18 +152,11 @@ async function ensureHelperBinary() {
|
|
|
99
152
|
sourceText = await readFile(paths.source, 'utf8');
|
|
100
153
|
} catch {
|
|
101
154
|
throw new Error(
|
|
102
|
-
'Computer-use helper
|
|
155
|
+
'Computer-use helper is missing from this install. Reinstall or update @jian-ai/remote-control.',
|
|
103
156
|
);
|
|
104
157
|
}
|
|
105
158
|
}
|
|
106
|
-
const hash =
|
|
107
|
-
const stamp = join(paths.support, `${HELPER_NAME}.sha256`);
|
|
108
|
-
let previous = '';
|
|
109
|
-
try {
|
|
110
|
-
previous = (await readFile(stamp, 'utf8')).trim();
|
|
111
|
-
} catch {
|
|
112
|
-
previous = '';
|
|
113
|
-
}
|
|
159
|
+
const hash = `src:${stampForBytes(Buffer.from(sourceText, 'utf8'))}`;
|
|
114
160
|
const needsBuild = previous !== hash || !existsSync(paths.binary);
|
|
115
161
|
await writeFile(paths.source, sourceText, { mode: 0o644 });
|
|
116
162
|
if (needsBuild) {
|
|
@@ -122,27 +168,44 @@ async function ensureHelperBinary() {
|
|
|
122
168
|
if (compiled.status !== 0) {
|
|
123
169
|
throw new Error(
|
|
124
170
|
(compiled.stderr || compiled.stdout || 'swiftc failed').trim() ||
|
|
125
|
-
'Could not build the Jian computer-use helper.
|
|
171
|
+
'Could not build the Jian computer-use helper. Published packages ship a prebuilt helper; for source builds install Xcode Command Line Tools, or reinstall @jian-ai/remote-control.',
|
|
126
172
|
);
|
|
127
173
|
}
|
|
128
174
|
await chmod(paths.binary, 0o755);
|
|
129
|
-
|
|
175
|
+
clearQuarantine(paths.binary);
|
|
176
|
+
await writeFile(paths.stamp, `${hash}\n`, { mode: 0o644 });
|
|
130
177
|
}
|
|
131
178
|
return paths.binary;
|
|
132
179
|
}
|
|
133
180
|
|
|
134
|
-
/** Copy Swift
|
|
181
|
+
/** Copy Swift source and any prebuilt helper next to the installed worker. */
|
|
135
182
|
export async function materializeComputerHelperSources(configDir) {
|
|
136
183
|
if (process.platform !== 'darwin') return;
|
|
137
184
|
try {
|
|
138
|
-
const
|
|
139
|
-
if (!existsSync(
|
|
185
|
+
const pkg = packageHelperPaths();
|
|
186
|
+
if (!existsSync(pkg.swift) && !existsSync(pkg.binary)) return;
|
|
140
187
|
const support = join(homedir(), 'Library', 'Application Support', 'Jian Remote');
|
|
141
188
|
await mkdir(support, { recursive: true, mode: 0o755 });
|
|
142
|
-
await copyFile(from, join(support, `${HELPER_NAME}.swift`));
|
|
143
189
|
const localMacos = join(configDir, 'macos');
|
|
144
190
|
await mkdir(localMacos, { recursive: true, mode: 0o755 });
|
|
145
|
-
|
|
191
|
+
if (existsSync(pkg.swift)) {
|
|
192
|
+
await copyFile(pkg.swift, join(support, `${HELPER_NAME}.swift`));
|
|
193
|
+
await copyFile(pkg.swift, join(localMacos, `${HELPER_NAME}.swift`));
|
|
194
|
+
}
|
|
195
|
+
if (existsSync(pkg.binary)) {
|
|
196
|
+
const destSupport = join(support, HELPER_NAME);
|
|
197
|
+
const destLocal = join(localMacos, HELPER_NAME);
|
|
198
|
+
await copyFile(pkg.binary, destSupport);
|
|
199
|
+
await copyFile(pkg.binary, destLocal);
|
|
200
|
+
await chmod(destSupport, 0o755);
|
|
201
|
+
await chmod(destLocal, 0o755);
|
|
202
|
+
clearQuarantine(destSupport);
|
|
203
|
+
clearQuarantine(destLocal);
|
|
204
|
+
const bytes = await readFile(pkg.binary);
|
|
205
|
+
await writeFile(join(support, `${HELPER_NAME}.sha256`), `bin:${stampForBytes(bytes)}\n`, {
|
|
206
|
+
mode: 0o644,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
146
209
|
} catch {
|
|
147
210
|
// Best-effort on install; computer session will surface a clear error.
|
|
148
211
|
}
|
|
@@ -161,8 +224,11 @@ export class MacComputerSession {
|
|
|
161
224
|
|
|
162
225
|
async ensure() {
|
|
163
226
|
if (this.child && !this.child.killed) {
|
|
164
|
-
|
|
165
|
-
|
|
227
|
+
if (!this.active) {
|
|
228
|
+
await this.send({ cmd: 'ensureSession' });
|
|
229
|
+
this.active = true;
|
|
230
|
+
}
|
|
231
|
+
startSessionDisplayAssertion(this.child.pid || process.pid);
|
|
166
232
|
this.bumpIdle();
|
|
167
233
|
return;
|
|
168
234
|
}
|
|
@@ -181,6 +247,7 @@ export class MacComputerSession {
|
|
|
181
247
|
this.failAll('Computer-use helper exited unexpectedly.');
|
|
182
248
|
this.child = null;
|
|
183
249
|
this.active = false;
|
|
250
|
+
stopSessionDisplayAssertion();
|
|
184
251
|
});
|
|
185
252
|
const ping = await this.send({ cmd: 'ping' });
|
|
186
253
|
if (!ping.ok) throw new Error(ping.error || 'Computer-use helper did not respond.');
|
|
@@ -197,6 +264,7 @@ export class MacComputerSession {
|
|
|
197
264
|
};
|
|
198
265
|
await this.send({ cmd: 'ensureSession' });
|
|
199
266
|
this.active = true;
|
|
267
|
+
startSessionDisplayAssertion(this.child.pid || process.pid);
|
|
200
268
|
this.bumpIdle();
|
|
201
269
|
}
|
|
202
270
|
|
|
@@ -277,6 +345,7 @@ export class MacComputerSession {
|
|
|
277
345
|
async end() {
|
|
278
346
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
279
347
|
this.idleTimer = null;
|
|
348
|
+
stopSessionDisplayAssertion();
|
|
280
349
|
if (this.child) {
|
|
281
350
|
try {
|
|
282
351
|
await this.send({ cmd: 'endSession' });
|
|
@@ -305,7 +374,7 @@ export class MacComputerSession {
|
|
|
305
374
|
}
|
|
306
375
|
|
|
307
376
|
if (action === 'wait') {
|
|
308
|
-
const ms = Math.round((computer.duration ?? 0.
|
|
377
|
+
const ms = Math.round((computer.duration ?? 0.35) * 1000);
|
|
309
378
|
await new Promise((r) => setTimeout(r, ms));
|
|
310
379
|
if (this.active) this.bumpIdle();
|
|
311
380
|
return {
|
|
@@ -317,6 +386,17 @@ export class MacComputerSession {
|
|
|
317
386
|
};
|
|
318
387
|
}
|
|
319
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
|
+
|
|
320
400
|
if (action === 'screenshot') {
|
|
321
401
|
await this.ensure();
|
|
322
402
|
const shot = capturePrimaryScreenshotBase64();
|
|
@@ -344,22 +424,19 @@ export class MacComputerSession {
|
|
|
344
424
|
await this.ensure();
|
|
345
425
|
|
|
346
426
|
try {
|
|
347
|
-
if (
|
|
348
|
-
action === 'mouse_move' ||
|
|
349
|
-
action === 'left_click' ||
|
|
350
|
-
action === 'right_click' ||
|
|
351
|
-
action === 'double_click'
|
|
352
|
-
) {
|
|
427
|
+
if (action === 'mouse_move') {
|
|
353
428
|
if (!computer.coordinate) throw new Error('coordinate is required for this action.');
|
|
354
429
|
const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
|
|
355
430
|
const moved = await this.send({ cmd: 'move', pointX, pointY });
|
|
356
431
|
if (!moved.ok) throw new Error(moved.error || 'Move failed.');
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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.');
|
|
363
440
|
} else if (action === 'left_click_drag') {
|
|
364
441
|
if (!computer.startCoordinate || !computer.coordinate) {
|
|
365
442
|
throw new Error('startCoordinate and coordinate are required for drag.');
|
|
@@ -469,11 +546,130 @@ export async function probeComputerCapability() {
|
|
|
469
546
|
await ensureHelperBinary();
|
|
470
547
|
return true;
|
|
471
548
|
} catch {
|
|
472
|
-
// Still advertise: screenshot-only path can work; input fails with TCC guidance.
|
|
473
|
-
// Prefer advertising when swiftc exists so gateway can send jobs after first grant.
|
|
474
549
|
return (
|
|
475
550
|
Boolean(spawnSync('which', ['swiftc'], { encoding: 'utf8' }).stdout?.trim()) ||
|
|
476
551
|
existsSync(helperPaths().binary)
|
|
477
552
|
);
|
|
478
553
|
}
|
|
479
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
|
+
}
|