@jian-ai/remote-control 0.7.2 → 0.8.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/bin/computer-macos.mjs +479 -0
- package/bin/jian-remote.mjs +55 -6
- package/macos/computer-helper.swift +735 -0
- package/package.json +2 -1
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS computer-use: primary-display screenshots, coord mapping, Jian overlay helper.
|
|
3
|
+
* English-only messages. Imported by jian-remote.mjs on darwin.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
export const COMPUTER_CAPABILITY = 'computer-macos-v1';
|
|
14
|
+
const IDLE_MS = 30_000;
|
|
15
|
+
const HELPER_NAME = 'computer-helper';
|
|
16
|
+
|
|
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
|
+
}
|
|
28
|
+
|
|
29
|
+
export function scaleFromGeometry(geometry) {
|
|
30
|
+
return {
|
|
31
|
+
imageToPointX: geometry.pointWidth / geometry.widthPx,
|
|
32
|
+
imageToPointY: geometry.pointHeight / geometry.heightPx,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Capture primary display and compress like the gateway’s former screenshotCommand. */
|
|
37
|
+
export function capturePrimaryScreenshotBase64() {
|
|
38
|
+
const script = [
|
|
39
|
+
'tmp=$(mktemp /tmp/jian-shot.XXXXXX.jpg);',
|
|
40
|
+
'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
|
+
'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
|
+
'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
|
+
'w=$(sips -g pixelWidth "$tmp" 2>/dev/null | awk "/pixelWidth/{print \\$2}");',
|
|
47
|
+
'h=$(sips -g pixelHeight "$tmp" 2>/dev/null | awk "/pixelHeight/{print \\$2}");',
|
|
48
|
+
'echo "$w $h";',
|
|
49
|
+
'base64 < "$tmp";',
|
|
50
|
+
'rm -f "$tmp"',
|
|
51
|
+
].join(' ');
|
|
52
|
+
const result = spawnSync('/bin/sh', ['-lc', script], {
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
maxBuffer: 600_000,
|
|
55
|
+
});
|
|
56
|
+
if (result.status !== 0) {
|
|
57
|
+
const err = (result.stderr || result.stdout || 'Screenshot failed').trim();
|
|
58
|
+
throw new Error(
|
|
59
|
+
/not authorized|capture/i.test(err)
|
|
60
|
+
? 'Jian Remote needs Screen Recording permission in System Settings.'
|
|
61
|
+
: err || 'Screenshot failed',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const out = result.stdout || '';
|
|
65
|
+
const nl = out.indexOf('\n');
|
|
66
|
+
if (nl < 0) throw new Error('Screenshot produced no image data.');
|
|
67
|
+
const [wRaw, hRaw] = out.slice(0, nl).trim().split(/\s+/);
|
|
68
|
+
const widthPx = Number(wRaw);
|
|
69
|
+
const heightPx = Number(hRaw);
|
|
70
|
+
const base64 = out.slice(nl + 1).replace(/\s+/g, '');
|
|
71
|
+
if (!Number.isFinite(widthPx) || !Number.isFinite(heightPx) || widthPx < 1 || heightPx < 1) {
|
|
72
|
+
throw new Error('Could not read screenshot dimensions.');
|
|
73
|
+
}
|
|
74
|
+
if (!base64) throw new Error('Screenshot produced no image data.');
|
|
75
|
+
return { base64, widthPx, heightPx };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function helperPaths() {
|
|
79
|
+
const support = join(homedir(), 'Library', 'Application Support', 'Jian Remote');
|
|
80
|
+
return {
|
|
81
|
+
support,
|
|
82
|
+
source: join(support, `${HELPER_NAME}.swift`),
|
|
83
|
+
binary: join(support, HELPER_NAME),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function packageSwiftPath() {
|
|
88
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', 'macos', `${HELPER_NAME}.swift`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function ensureHelperBinary() {
|
|
92
|
+
const paths = helperPaths();
|
|
93
|
+
await mkdir(paths.support, { recursive: true, mode: 0o755 });
|
|
94
|
+
let sourceText;
|
|
95
|
+
try {
|
|
96
|
+
sourceText = await readFile(packageSwiftPath(), 'utf8');
|
|
97
|
+
} catch {
|
|
98
|
+
try {
|
|
99
|
+
sourceText = await readFile(paths.source, 'utf8');
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error(
|
|
102
|
+
'Computer-use helper source is missing. Reinstall or update @jian-ai/remote-control.',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const hash = createHash('sha256').update(sourceText).digest('hex').slice(0, 16);
|
|
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
|
+
}
|
|
114
|
+
const needsBuild = previous !== hash || !existsSync(paths.binary);
|
|
115
|
+
await writeFile(paths.source, sourceText, { mode: 0o644 });
|
|
116
|
+
if (needsBuild) {
|
|
117
|
+
const compiled = spawnSync(
|
|
118
|
+
'swiftc',
|
|
119
|
+
['-O', '-framework', 'AppKit', '-framework', 'Carbon', '-o', paths.binary, paths.source],
|
|
120
|
+
{ encoding: 'utf8' },
|
|
121
|
+
);
|
|
122
|
+
if (compiled.status !== 0) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
(compiled.stderr || compiled.stdout || 'swiftc failed').trim() ||
|
|
125
|
+
'Could not build the Jian computer-use helper. Install Xcode Command Line Tools.',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
await chmod(paths.binary, 0o755);
|
|
129
|
+
await writeFile(stamp, `${hash}\n`, { mode: 0o644 });
|
|
130
|
+
}
|
|
131
|
+
return paths.binary;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Copy Swift sources next to the installed worker when present in the npm package. */
|
|
135
|
+
export async function materializeComputerHelperSources(configDir) {
|
|
136
|
+
if (process.platform !== 'darwin') return;
|
|
137
|
+
try {
|
|
138
|
+
const from = packageSwiftPath();
|
|
139
|
+
if (!existsSync(from)) return;
|
|
140
|
+
const support = join(homedir(), 'Library', 'Application Support', 'Jian Remote');
|
|
141
|
+
await mkdir(support, { recursive: true, mode: 0o755 });
|
|
142
|
+
await copyFile(from, join(support, `${HELPER_NAME}.swift`));
|
|
143
|
+
const localMacos = join(configDir, 'macos');
|
|
144
|
+
await mkdir(localMacos, { recursive: true, mode: 0o755 });
|
|
145
|
+
await copyFile(from, join(localMacos, `${HELPER_NAME}.swift`));
|
|
146
|
+
} catch {
|
|
147
|
+
// Best-effort on install; computer session will surface a clear error.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class MacComputerSession {
|
|
152
|
+
constructor() {
|
|
153
|
+
this.child = null;
|
|
154
|
+
this.buffer = '';
|
|
155
|
+
this.pending = [];
|
|
156
|
+
this.geometry = null;
|
|
157
|
+
this.shotGeometry = null;
|
|
158
|
+
this.idleTimer = null;
|
|
159
|
+
this.active = false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async ensure() {
|
|
163
|
+
if (this.child && !this.child.killed) {
|
|
164
|
+
await this.send({ cmd: 'ensureSession' });
|
|
165
|
+
this.active = true;
|
|
166
|
+
this.bumpIdle();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const binary = await ensureHelperBinary();
|
|
170
|
+
this.child = spawn(binary, [], {
|
|
171
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
172
|
+
});
|
|
173
|
+
this.buffer = '';
|
|
174
|
+
this.child.stdout.setEncoding('utf8');
|
|
175
|
+
this.child.stdout.on('data', (chunk) => this.onData(chunk));
|
|
176
|
+
this.child.stderr.on('data', (chunk) => {
|
|
177
|
+
const text = chunk.toString('utf8').trim();
|
|
178
|
+
if (text) console.error(text);
|
|
179
|
+
});
|
|
180
|
+
this.child.on('exit', () => {
|
|
181
|
+
this.failAll('Computer-use helper exited unexpectedly.');
|
|
182
|
+
this.child = null;
|
|
183
|
+
this.active = false;
|
|
184
|
+
});
|
|
185
|
+
const ping = await this.send({ cmd: 'ping' });
|
|
186
|
+
if (!ping.ok) throw new Error(ping.error || 'Computer-use helper did not respond.');
|
|
187
|
+
const geo = await this.send({ cmd: 'geometry' });
|
|
188
|
+
if (!geo.ok) throw new Error(geo.error || 'Could not read display geometry.');
|
|
189
|
+
this.geometry = {
|
|
190
|
+
originX: Number(geo.originX) || 0,
|
|
191
|
+
originY: Number(geo.originY) || 0,
|
|
192
|
+
pointWidth: Number(geo.pointWidth),
|
|
193
|
+
pointHeight: Number(geo.pointHeight),
|
|
194
|
+
pixelWidth: Number(geo.pixelWidth),
|
|
195
|
+
pixelHeight: Number(geo.pixelHeight),
|
|
196
|
+
scaleFactor: Number(geo.scaleFactor) || 1,
|
|
197
|
+
};
|
|
198
|
+
await this.send({ cmd: 'ensureSession' });
|
|
199
|
+
this.active = true;
|
|
200
|
+
this.bumpIdle();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
bumpIdle() {
|
|
204
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
205
|
+
this.idleTimer = setTimeout(() => {
|
|
206
|
+
if (!this.active || !this.child) return;
|
|
207
|
+
void this.send({ cmd: 'setIdle' }).catch(() => {});
|
|
208
|
+
}, IDLE_MS);
|
|
209
|
+
this.idleTimer.unref?.();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
onData(chunk) {
|
|
213
|
+
this.buffer += chunk;
|
|
214
|
+
for (;;) {
|
|
215
|
+
const idx = this.buffer.indexOf('\n');
|
|
216
|
+
if (idx < 0) break;
|
|
217
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
218
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
219
|
+
if (!line) continue;
|
|
220
|
+
let parsed;
|
|
221
|
+
try {
|
|
222
|
+
parsed = JSON.parse(line);
|
|
223
|
+
} catch {
|
|
224
|
+
this.failNext('Computer-use helper returned invalid JSON.');
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const next = this.pending.shift();
|
|
228
|
+
if (next) next.resolve(parsed);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
failNext(message) {
|
|
233
|
+
const next = this.pending.shift();
|
|
234
|
+
if (next) next.reject(new Error(message));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
failAll(message) {
|
|
238
|
+
while (this.pending.length) {
|
|
239
|
+
this.pending.shift().reject(new Error(message));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
send(payload) {
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
if (!this.child?.stdin) {
|
|
246
|
+
reject(new Error('Computer-use helper is not running.'));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this.pending.push({ resolve, reject });
|
|
250
|
+
this.child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
|
|
251
|
+
if (error) {
|
|
252
|
+
this.pending.pop();
|
|
253
|
+
reject(error);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
requireShotGeometry() {
|
|
260
|
+
if (!this.shotGeometry) {
|
|
261
|
+
throw new Error('Take a screenshot before using screen coordinates.');
|
|
262
|
+
}
|
|
263
|
+
return this.shotGeometry;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
mapCoordinate(coordinate) {
|
|
267
|
+
const [x, y] = coordinate;
|
|
268
|
+
const shot = this.requireShotGeometry();
|
|
269
|
+
if (x < 0 || y < 0 || x >= shot.widthPx || y >= shot.heightPx) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`Coordinate (${x}, ${y}) is outside the last screenshot (${shot.widthPx}×${shot.heightPx}).`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return imagePixelToPoint(x, y, shot);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async end() {
|
|
278
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
279
|
+
this.idleTimer = null;
|
|
280
|
+
if (this.child) {
|
|
281
|
+
try {
|
|
282
|
+
await this.send({ cmd: 'endSession' });
|
|
283
|
+
} catch {
|
|
284
|
+
// ignore
|
|
285
|
+
}
|
|
286
|
+
this.child.kill('SIGTERM');
|
|
287
|
+
this.child = null;
|
|
288
|
+
}
|
|
289
|
+
this.active = false;
|
|
290
|
+
this.shotGeometry = null;
|
|
291
|
+
this.geometry = null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async runAction(computer) {
|
|
295
|
+
const { action } = computer;
|
|
296
|
+
if (action === 'end_session') {
|
|
297
|
+
await this.end();
|
|
298
|
+
return {
|
|
299
|
+
exitCode: 0,
|
|
300
|
+
stdout: '',
|
|
301
|
+
stderr: '',
|
|
302
|
+
timedOut: false,
|
|
303
|
+
computer: { ok: true },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (action === 'wait') {
|
|
308
|
+
const ms = Math.round((computer.duration ?? 0.5) * 1000);
|
|
309
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
310
|
+
if (this.active) this.bumpIdle();
|
|
311
|
+
return {
|
|
312
|
+
exitCode: 0,
|
|
313
|
+
stdout: '',
|
|
314
|
+
stderr: '',
|
|
315
|
+
timedOut: false,
|
|
316
|
+
computer: { ok: true },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (action === 'screenshot') {
|
|
321
|
+
await this.ensure();
|
|
322
|
+
const shot = capturePrimaryScreenshotBase64();
|
|
323
|
+
if (!this.geometry) throw new Error('Display geometry unavailable.');
|
|
324
|
+
this.shotGeometry = {
|
|
325
|
+
...this.geometry,
|
|
326
|
+
widthPx: shot.widthPx,
|
|
327
|
+
heightPx: shot.heightPx,
|
|
328
|
+
};
|
|
329
|
+
this.bumpIdle();
|
|
330
|
+
return {
|
|
331
|
+
exitCode: 0,
|
|
332
|
+
stdout: shot.base64,
|
|
333
|
+
stderr: '',
|
|
334
|
+
timedOut: false,
|
|
335
|
+
computer: {
|
|
336
|
+
ok: true,
|
|
337
|
+
widthPx: shot.widthPx,
|
|
338
|
+
heightPx: shot.heightPx,
|
|
339
|
+
scale: scaleFromGeometry(this.shotGeometry),
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await this.ensure();
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
if (
|
|
348
|
+
action === 'mouse_move' ||
|
|
349
|
+
action === 'left_click' ||
|
|
350
|
+
action === 'right_click' ||
|
|
351
|
+
action === 'double_click'
|
|
352
|
+
) {
|
|
353
|
+
if (!computer.coordinate) throw new Error('coordinate is required for this action.');
|
|
354
|
+
const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
|
|
355
|
+
const moved = await this.send({ cmd: 'move', pointX, pointY });
|
|
356
|
+
if (!moved.ok) throw new Error(moved.error || 'Move failed.');
|
|
357
|
+
if (action !== 'mouse_move') {
|
|
358
|
+
const button = action === 'right_click' ? 'right' : 'left';
|
|
359
|
+
const count = action === 'double_click' ? 2 : 1;
|
|
360
|
+
const clicked = await this.send({ cmd: 'click', button, count });
|
|
361
|
+
if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
|
|
362
|
+
}
|
|
363
|
+
} else if (action === 'left_click_drag') {
|
|
364
|
+
if (!computer.startCoordinate || !computer.coordinate) {
|
|
365
|
+
throw new Error('startCoordinate and coordinate are required for drag.');
|
|
366
|
+
}
|
|
367
|
+
const from = this.mapCoordinate(computer.startCoordinate);
|
|
368
|
+
const to = this.mapCoordinate(computer.coordinate);
|
|
369
|
+
const dragged = await this.send({
|
|
370
|
+
cmd: 'drag',
|
|
371
|
+
fromX: from.pointX,
|
|
372
|
+
fromY: from.pointY,
|
|
373
|
+
toX: to.pointX,
|
|
374
|
+
toY: to.pointY,
|
|
375
|
+
});
|
|
376
|
+
if (!dragged.ok) throw new Error(dragged.error || 'Drag failed.');
|
|
377
|
+
} else if (action === 'type') {
|
|
378
|
+
if (!computer.text) throw new Error('text is required for type.');
|
|
379
|
+
const typed = await this.send({ cmd: 'type', text: computer.text });
|
|
380
|
+
if (!typed.ok) throw new Error(typed.error || 'Type failed.');
|
|
381
|
+
} else if (action === 'key') {
|
|
382
|
+
if (!computer.key) throw new Error('key is required for key.');
|
|
383
|
+
const keyed = await this.send({ cmd: 'key', key: computer.key });
|
|
384
|
+
if (!keyed.ok) throw new Error(keyed.error || 'Key failed.');
|
|
385
|
+
} else if (action === 'scroll') {
|
|
386
|
+
if (!computer.coordinate) throw new Error('coordinate is required for scroll.');
|
|
387
|
+
if (!computer.scrollDirection) throw new Error('scrollDirection is required for scroll.');
|
|
388
|
+
const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
|
|
389
|
+
const scrolled = await this.send({
|
|
390
|
+
cmd: 'scroll',
|
|
391
|
+
pointX,
|
|
392
|
+
pointY,
|
|
393
|
+
direction: computer.scrollDirection,
|
|
394
|
+
amount: computer.scrollAmount ?? 3,
|
|
395
|
+
});
|
|
396
|
+
if (!scrolled.ok) throw new Error(scrolled.error || 'Scroll failed.');
|
|
397
|
+
} else {
|
|
398
|
+
throw new Error(`Unsupported computer action: ${action}`);
|
|
399
|
+
}
|
|
400
|
+
} catch (error) {
|
|
401
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
402
|
+
const tcc = /accessib|not permitted|not authorized/i.test(message)
|
|
403
|
+
? ' Jian Remote needs Accessibility (and Screen Recording for screenshots) in System Settings.'
|
|
404
|
+
: '';
|
|
405
|
+
return {
|
|
406
|
+
exitCode: 1,
|
|
407
|
+
stdout: '',
|
|
408
|
+
stderr: `${message}${tcc}`,
|
|
409
|
+
timedOut: false,
|
|
410
|
+
computer: { ok: false, error: `${message}${tcc}`.trim() },
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
this.bumpIdle();
|
|
415
|
+
return {
|
|
416
|
+
exitCode: 0,
|
|
417
|
+
stdout: '',
|
|
418
|
+
stderr: '',
|
|
419
|
+
timedOut: false,
|
|
420
|
+
computer: { ok: true },
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let sharedSession = null;
|
|
426
|
+
|
|
427
|
+
export function computerSession() {
|
|
428
|
+
if (!sharedSession) sharedSession = new MacComputerSession();
|
|
429
|
+
return sharedSession;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export async function executeComputerTask(task) {
|
|
433
|
+
const session = computerSession();
|
|
434
|
+
const computer = task.computer;
|
|
435
|
+
if (!computer?.action) {
|
|
436
|
+
return {
|
|
437
|
+
taskId: task.id,
|
|
438
|
+
exitCode: 1,
|
|
439
|
+
stdout: '',
|
|
440
|
+
stderr: 'Computer task missing action.',
|
|
441
|
+
timedOut: false,
|
|
442
|
+
computer: { ok: false, error: 'Computer task missing action.' },
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const timeoutMs = task.timeoutMs || 30_000;
|
|
446
|
+
let timedOut = false;
|
|
447
|
+
const result = await Promise.race([
|
|
448
|
+
session.runAction(computer),
|
|
449
|
+
new Promise((resolve) => {
|
|
450
|
+
setTimeout(() => {
|
|
451
|
+
timedOut = true;
|
|
452
|
+
resolve({
|
|
453
|
+
exitCode: null,
|
|
454
|
+
stdout: '',
|
|
455
|
+
stderr: 'Computer action timed out',
|
|
456
|
+
timedOut: true,
|
|
457
|
+
computer: { ok: false, error: 'Computer action timed out' },
|
|
458
|
+
});
|
|
459
|
+
}, timeoutMs);
|
|
460
|
+
}),
|
|
461
|
+
]);
|
|
462
|
+
return { taskId: task.id, ...result, timedOut: timedOut || Boolean(result.timedOut) };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Probe whether this Mac can advertise computer-use (helper builds). */
|
|
466
|
+
export async function probeComputerCapability() {
|
|
467
|
+
if (process.platform !== 'darwin') return false;
|
|
468
|
+
try {
|
|
469
|
+
await ensureHelperBinary();
|
|
470
|
+
return true;
|
|
471
|
+
} 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
|
+
return (
|
|
475
|
+
Boolean(spawnSync('which', ['swiftc'], { encoding: 'utf8' }).stdout?.trim()) ||
|
|
476
|
+
existsSync(helperPaths().binary)
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
package/bin/jian-remote.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
4
|
+
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
5
5
|
import { chmod, copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { homedir, tmpdir } from 'node:os';
|
|
7
|
-
import { join, resolve } from 'node:path';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
9
|
|
|
10
10
|
const configDir = join(homedir(), '.config', 'jian-remote');
|
|
@@ -21,7 +21,7 @@ const option = (name) => {
|
|
|
21
21
|
* Stamped to the release tag on npm publish and when the gateway serves this script.
|
|
22
22
|
* Compatibility with the gateway requires the same major.minor (see versionsCompatible).
|
|
23
23
|
*/
|
|
24
|
-
export const PACKAGE_VERSION = '0.
|
|
24
|
+
export const PACKAGE_VERSION = '0.8.0';
|
|
25
25
|
|
|
26
26
|
/** Soft stdout/stderr caps while capturing a command (chars). Gateway schema matches these. */
|
|
27
27
|
const STDOUT_CAP = 400_000;
|
|
@@ -256,6 +256,7 @@ export function cappedAppend(current, chunk, cap = STDOUT_CAP) {
|
|
|
256
256
|
export function fitResult(result, budget = RESULT_BODY_BUDGET) {
|
|
257
257
|
let stdout = result.stdout ?? '';
|
|
258
258
|
let stderr = result.stderr ?? '';
|
|
259
|
+
const computer = result.computer;
|
|
259
260
|
const pack = () =>
|
|
260
261
|
JSON.stringify({
|
|
261
262
|
taskId: result.taskId,
|
|
@@ -263,10 +264,14 @@ export function fitResult(result, budget = RESULT_BODY_BUDGET) {
|
|
|
263
264
|
stdout,
|
|
264
265
|
stderr,
|
|
265
266
|
timedOut: Boolean(result.timedOut),
|
|
267
|
+
...(computer ? { computer } : {}),
|
|
266
268
|
});
|
|
267
269
|
let body = pack();
|
|
268
270
|
if (Buffer.byteLength(body) <= budget) {
|
|
269
|
-
return {
|
|
271
|
+
return {
|
|
272
|
+
result: { ...result, stdout, stderr, timedOut: Boolean(result.timedOut), computer },
|
|
273
|
+
body,
|
|
274
|
+
};
|
|
270
275
|
}
|
|
271
276
|
|
|
272
277
|
const note = '\n… truncated to fit the gateway size limit';
|
|
@@ -481,6 +486,22 @@ async function materializeWorkerScript(sourceBytes) {
|
|
|
481
486
|
if (source !== installedScript) await copyFile(source, installedScript);
|
|
482
487
|
}
|
|
483
488
|
if (process.platform !== 'win32') await chmod(installedScript, 0o700);
|
|
489
|
+
// Companion modules + macOS helper source (computer use). Best-effort beside the worker.
|
|
490
|
+
try {
|
|
491
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
492
|
+
const computerSrc = join(here, 'computer-macos.mjs');
|
|
493
|
+
const computerDest = join(configDir, 'computer-macos.mjs');
|
|
494
|
+
if (existsSync(computerSrc) && computerSrc !== computerDest) {
|
|
495
|
+
await copyFile(computerSrc, computerDest);
|
|
496
|
+
if (process.platform !== 'win32') await chmod(computerDest, 0o700);
|
|
497
|
+
}
|
|
498
|
+
if (process.platform === 'darwin') {
|
|
499
|
+
const { materializeComputerHelperSources } = await import('./computer-macos.mjs');
|
|
500
|
+
await materializeComputerHelperSources(configDir);
|
|
501
|
+
}
|
|
502
|
+
} catch {
|
|
503
|
+
// Install still succeeds; computer-use probe will explain if the helper is missing.
|
|
504
|
+
}
|
|
484
505
|
return installedScript;
|
|
485
506
|
}
|
|
486
507
|
|
|
@@ -731,7 +752,7 @@ async function update() {
|
|
|
731
752
|
printSuccess('Updated Jian Remote', facts);
|
|
732
753
|
}
|
|
733
754
|
|
|
734
|
-
async function
|
|
755
|
+
async function executeShell(task) {
|
|
735
756
|
return await new Promise((resolveResult) => {
|
|
736
757
|
const command = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
|
|
737
758
|
const commandArgs =
|
|
@@ -793,6 +814,24 @@ async function execute(task) {
|
|
|
793
814
|
});
|
|
794
815
|
}
|
|
795
816
|
|
|
817
|
+
async function execute(task) {
|
|
818
|
+
if (task.kind === 'computer') {
|
|
819
|
+
if (process.platform !== 'darwin') {
|
|
820
|
+
return {
|
|
821
|
+
taskId: task.id,
|
|
822
|
+
exitCode: 1,
|
|
823
|
+
stdout: '',
|
|
824
|
+
stderr: 'Computer use is supported on macOS Jian Remote only.',
|
|
825
|
+
timedOut: false,
|
|
826
|
+
computer: { ok: false, error: 'Computer use is supported on macOS Jian Remote only.' },
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
const { executeComputerTask } = await import('./computer-macos.mjs');
|
|
830
|
+
return executeComputerTask(task);
|
|
831
|
+
}
|
|
832
|
+
return executeShell(task);
|
|
833
|
+
}
|
|
834
|
+
|
|
796
835
|
async function updateIfNeeded(base, headers) {
|
|
797
836
|
// Prefer the installed copy under ~/.config so updates land where the service points.
|
|
798
837
|
const script = installedScript;
|
|
@@ -900,16 +939,26 @@ async function postResult(base, headers, result) {
|
|
|
900
939
|
|
|
901
940
|
async function run() {
|
|
902
941
|
const config = await loadConfig();
|
|
942
|
+
const capabilities = [];
|
|
943
|
+
if (process.platform === 'darwin') {
|
|
944
|
+
try {
|
|
945
|
+
const { probeComputerCapability, COMPUTER_CAPABILITY } = await import('./computer-macos.mjs');
|
|
946
|
+
if (await probeComputerCapability()) capabilities.push(COMPUTER_CAPABILITY);
|
|
947
|
+
} catch {
|
|
948
|
+
// Computer use unavailable until helper builds; shell tasks still work.
|
|
949
|
+
}
|
|
950
|
+
}
|
|
903
951
|
const headers = {
|
|
904
952
|
authorization: `Bearer ${config.token}`,
|
|
905
953
|
'x-jian-platform': `${process.platform}-${process.arch}`,
|
|
906
954
|
'x-jian-remote-version': PACKAGE_VERSION,
|
|
955
|
+
...(capabilities.length ? { 'x-jian-remote-capabilities': capabilities.join(',') } : {}),
|
|
907
956
|
};
|
|
908
957
|
const base = `${config.gateway}/v1/remote/worker/${config.machineId}`;
|
|
909
958
|
// Fail fast before the poll loop so an incompatible build does not look "online then stuck".
|
|
910
959
|
const gatewayVersion = await assertCompatibleWithGateway(config.gateway);
|
|
911
960
|
console.log(
|
|
912
|
-
`Connected to Jian Remote at ${config.gateway} (worker ${PACKAGE_VERSION}${gatewayVersion ? `, gateway ${gatewayVersion}` : ''})`,
|
|
961
|
+
`Connected to Jian Remote at ${config.gateway} (worker ${PACKAGE_VERSION}${gatewayVersion ? `, gateway ${gatewayVersion}` : ''}${capabilities.length ? `, capabilities ${capabilities.join(',')}` : ''})`,
|
|
913
962
|
);
|
|
914
963
|
let failures = 0;
|
|
915
964
|
let nextUpdateCheck = 0;
|
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Jian Remote Control — macOS computer-use helper
|
|
3
|
+
|
|
4
|
+
Long-lived AppKit process: overlay pointer + synthetic mouse/keyboard via CoreGraphics.
|
|
5
|
+
Protocol: newline-delimited JSON on stdin → newline-delimited JSON on stdout (flush each).
|
|
6
|
+
Errors go to stderr in English only.
|
|
7
|
+
|
|
8
|
+
Build (on a Mac with Xcode CLT):
|
|
9
|
+
swiftc -O -framework AppKit -framework Carbon -o computer-helper computer-helper.swift
|
|
10
|
+
|
|
11
|
+
Run as a child of the Node worker; keep stdin/stdout pipes open for the session.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import AppKit
|
|
15
|
+
import Carbon.HIToolbox
|
|
16
|
+
import CoreGraphics
|
|
17
|
+
import Foundation
|
|
18
|
+
|
|
19
|
+
// MARK: - JSON I/O
|
|
20
|
+
|
|
21
|
+
private func writeResponse(_ object: [String: Any]) {
|
|
22
|
+
guard JSONSerialization.isValidJSONObject(object),
|
|
23
|
+
let data = try? JSONSerialization.data(withJSONObject: object, options: []),
|
|
24
|
+
var line = String(data: data, encoding: .utf8)
|
|
25
|
+
else {
|
|
26
|
+
fputs("computer-helper: failed to encode response JSON\n", stderr)
|
|
27
|
+
fflush(stderr)
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
line.append("\n")
|
|
31
|
+
FileHandle.standardOutput.write(Data(line.utf8))
|
|
32
|
+
fflush(stdout)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private func writeOk(_ extra: [String: Any] = [:]) {
|
|
36
|
+
var body: [String: Any] = ["ok": true]
|
|
37
|
+
for (key, value) in extra {
|
|
38
|
+
body[key] = value
|
|
39
|
+
}
|
|
40
|
+
writeResponse(body)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private func writeError(_ message: String) {
|
|
44
|
+
writeResponse(["ok": false, "error": message])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private func logError(_ message: String) {
|
|
48
|
+
fputs("computer-helper: \(message)\n", stderr)
|
|
49
|
+
fflush(stderr)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private func parseJSONLine(_ line: String) -> [String: Any]? {
|
|
53
|
+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
54
|
+
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil }
|
|
55
|
+
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
56
|
+
return nil
|
|
57
|
+
}
|
|
58
|
+
return object
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private func doubleValue(_ object: [String: Any], _ key: String) -> Double? {
|
|
62
|
+
if let n = object[key] as? Double { return n }
|
|
63
|
+
if let n = object[key] as? Int { return Double(n) }
|
|
64
|
+
if let n = object[key] as? NSNumber { return n.doubleValue }
|
|
65
|
+
return nil
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private func intValue(_ object: [String: Any], _ key: String) -> Int? {
|
|
69
|
+
if let n = object[key] as? Int { return n }
|
|
70
|
+
if let n = object[key] as? Double { return Int(n) }
|
|
71
|
+
if let n = object[key] as? NSNumber { return n.intValue }
|
|
72
|
+
return nil
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// MARK: - Colors (Jian UI)
|
|
76
|
+
|
|
77
|
+
private let jianCream = NSColor(srgbRed: 0xF5 / 255, green: 0xF5 / 255, blue: 0xF3 / 255, alpha: 1)
|
|
78
|
+
private let jianInk = NSColor(srgbRed: 0x17 / 255, green: 0x17 / 255, blue: 0x16 / 255, alpha: 1)
|
|
79
|
+
|
|
80
|
+
private let sessionAlpha: CGFloat = 0.92
|
|
81
|
+
private let idleAlpha: CGFloat = 0.18
|
|
82
|
+
private let pointerSize: CGFloat = 32
|
|
83
|
+
/// Tip of the wing relative to the view's top-left (hotspot for cursor placement).
|
|
84
|
+
private let hotspot = CGPoint(x: 4, y: 4)
|
|
85
|
+
|
|
86
|
+
// MARK: - Overlay view
|
|
87
|
+
|
|
88
|
+
private final class JianPointerView: NSView {
|
|
89
|
+
private var rippleProgress: CGFloat = 0
|
|
90
|
+
private var rippleVisible = false
|
|
91
|
+
private var fillPulse: CGFloat = 0
|
|
92
|
+
private var rippleTimer: Timer?
|
|
93
|
+
|
|
94
|
+
override var isFlipped: Bool { true }
|
|
95
|
+
|
|
96
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
97
|
+
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
|
98
|
+
|
|
99
|
+
if rippleVisible {
|
|
100
|
+
let maxRadius = pointerSize * 0.95
|
|
101
|
+
let radius = 4 + maxRadius * rippleProgress
|
|
102
|
+
let alpha = max(0, 1 - rippleProgress)
|
|
103
|
+
ctx.saveGState()
|
|
104
|
+
ctx.setStrokeColor(jianInk.withAlphaComponent(0.55 * alpha).cgColor)
|
|
105
|
+
ctx.setLineWidth(1.5)
|
|
106
|
+
ctx.strokeEllipse(in: CGRect(
|
|
107
|
+
x: hotspot.x - radius,
|
|
108
|
+
y: hotspot.y - radius,
|
|
109
|
+
width: radius * 2,
|
|
110
|
+
height: radius * 2
|
|
111
|
+
))
|
|
112
|
+
ctx.restoreGState()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let path = Self.wingPath(in: bounds)
|
|
116
|
+
ctx.saveGState()
|
|
117
|
+
ctx.setShadow(offset: CGSize(width: 0, height: 1.5), blur: 4, color: NSColor.black.withAlphaComponent(0.28).cgColor)
|
|
118
|
+
let fillAlpha = 1 - fillPulse * 0.35
|
|
119
|
+
jianCream.withAlphaComponent(fillAlpha).setFill()
|
|
120
|
+
path.fill()
|
|
121
|
+
ctx.restoreGState()
|
|
122
|
+
|
|
123
|
+
jianInk.setStroke()
|
|
124
|
+
path.lineWidth = 2
|
|
125
|
+
path.lineJoinStyle = .round
|
|
126
|
+
path.lineCapStyle = .round
|
|
127
|
+
path.stroke()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Ascending wing mark inspired by the Jian brand path (favicon / mark.tsx).
|
|
131
|
+
private static func wingPath(in bounds: NSRect) -> NSBezierPath {
|
|
132
|
+
let s = min(bounds.width, bounds.height)
|
|
133
|
+
let path = NSBezierPath()
|
|
134
|
+
// Tip near top-left (hotspot), body sweeping down-right then curling back — one ascending wing.
|
|
135
|
+
path.move(to: CGPoint(x: s * 0.12, y: s * 0.10))
|
|
136
|
+
path.curve(
|
|
137
|
+
to: CGPoint(x: s * 0.72, y: s * 0.18),
|
|
138
|
+
controlPoint1: CGPoint(x: s * 0.28, y: s * 0.02),
|
|
139
|
+
controlPoint2: CGPoint(x: s * 0.52, y: s * 0.06)
|
|
140
|
+
)
|
|
141
|
+
path.curve(
|
|
142
|
+
to: CGPoint(x: s * 0.88, y: s * 0.55),
|
|
143
|
+
controlPoint1: CGPoint(x: s * 0.88, y: s * 0.28),
|
|
144
|
+
controlPoint2: CGPoint(x: s * 0.94, y: s * 0.42)
|
|
145
|
+
)
|
|
146
|
+
path.curve(
|
|
147
|
+
to: CGPoint(x: s * 0.42, y: s * 0.88),
|
|
148
|
+
controlPoint1: CGPoint(x: s * 0.80, y: s * 0.72),
|
|
149
|
+
controlPoint2: CGPoint(x: s * 0.58, y: s * 0.90)
|
|
150
|
+
)
|
|
151
|
+
path.curve(
|
|
152
|
+
to: CGPoint(x: s * 0.18, y: s * 0.48),
|
|
153
|
+
controlPoint1: CGPoint(x: s * 0.26, y: s * 0.86),
|
|
154
|
+
controlPoint2: CGPoint(x: s * 0.12, y: s * 0.68)
|
|
155
|
+
)
|
|
156
|
+
path.curve(
|
|
157
|
+
to: CGPoint(x: s * 0.12, y: s * 0.10),
|
|
158
|
+
controlPoint1: CGPoint(x: s * 0.22, y: s * 0.32),
|
|
159
|
+
controlPoint2: CGPoint(x: s * 0.08, y: s * 0.20)
|
|
160
|
+
)
|
|
161
|
+
path.close()
|
|
162
|
+
return path
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func flashClick(duration: TimeInterval = 0.2) {
|
|
166
|
+
rippleTimer?.invalidate()
|
|
167
|
+
rippleVisible = true
|
|
168
|
+
rippleProgress = 0
|
|
169
|
+
fillPulse = 1
|
|
170
|
+
needsDisplay = true
|
|
171
|
+
|
|
172
|
+
let start = Date()
|
|
173
|
+
rippleTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in
|
|
174
|
+
guard let self else {
|
|
175
|
+
timer.invalidate()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
let t = min(1, Date().timeIntervalSince(start) / duration)
|
|
179
|
+
self.rippleProgress = CGFloat(t)
|
|
180
|
+
self.fillPulse = CGFloat(1 - t)
|
|
181
|
+
self.needsDisplay = true
|
|
182
|
+
if t >= 1 {
|
|
183
|
+
timer.invalidate()
|
|
184
|
+
self.rippleVisible = false
|
|
185
|
+
self.fillPulse = 0
|
|
186
|
+
self.needsDisplay = true
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if let rippleTimer {
|
|
190
|
+
RunLoop.main.add(rippleTimer, forMode: .common)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// MARK: - Overlay window
|
|
196
|
+
|
|
197
|
+
private final class NonActivatingWindow: NSWindow {
|
|
198
|
+
override var canBecomeKey: Bool { false }
|
|
199
|
+
override var canBecomeMain: Bool { false }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private final class OverlayController {
|
|
203
|
+
static let shared = OverlayController()
|
|
204
|
+
|
|
205
|
+
private var window: NonActivatingWindow?
|
|
206
|
+
private var pointerView: JianPointerView?
|
|
207
|
+
/// Current tip/hotspot in global top-left Quartz coordinates.
|
|
208
|
+
private var tipPoint = CGPoint.zero
|
|
209
|
+
|
|
210
|
+
private init() {}
|
|
211
|
+
|
|
212
|
+
private func ensureWindow() {
|
|
213
|
+
if window != nil { return }
|
|
214
|
+
|
|
215
|
+
let view = JianPointerView(frame: NSRect(x: 0, y: 0, width: pointerSize, height: pointerSize))
|
|
216
|
+
view.wantsLayer = true
|
|
217
|
+
|
|
218
|
+
let win = NonActivatingWindow(
|
|
219
|
+
contentRect: NSRect(x: 0, y: 0, width: pointerSize, height: pointerSize),
|
|
220
|
+
styleMask: .borderless,
|
|
221
|
+
backing: .buffered,
|
|
222
|
+
defer: false
|
|
223
|
+
)
|
|
224
|
+
win.isOpaque = false
|
|
225
|
+
win.backgroundColor = .clear
|
|
226
|
+
win.hasShadow = false
|
|
227
|
+
win.level = .statusBar
|
|
228
|
+
win.ignoresMouseEvents = true
|
|
229
|
+
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
230
|
+
win.isReleasedWhenClosed = false
|
|
231
|
+
win.contentView = view
|
|
232
|
+
win.alphaValue = 0
|
|
233
|
+
win.orderOut(nil)
|
|
234
|
+
|
|
235
|
+
pointerView = view
|
|
236
|
+
window = win
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
func ensureSession() {
|
|
240
|
+
ensureWindow()
|
|
241
|
+
guard let window else { return }
|
|
242
|
+
window.alphaValue = sessionAlpha
|
|
243
|
+
positionWindow()
|
|
244
|
+
window.orderFrontRegardless()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
func setIdle() {
|
|
248
|
+
ensureWindow()
|
|
249
|
+
guard let window else { return }
|
|
250
|
+
window.alphaValue = idleAlpha
|
|
251
|
+
positionWindow()
|
|
252
|
+
window.orderFrontRegardless()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func endSession() {
|
|
256
|
+
guard let window else { return }
|
|
257
|
+
window.orderOut(nil)
|
|
258
|
+
window.alphaValue = 0
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func moveTip(to globalTopLeft: CGPoint) {
|
|
262
|
+
tipPoint = globalTopLeft
|
|
263
|
+
ensureWindow()
|
|
264
|
+
positionWindow()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
func flashClick() {
|
|
268
|
+
ensureWindow()
|
|
269
|
+
pointerView?.flashClick()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func currentTip() -> CGPoint { tipPoint }
|
|
273
|
+
|
|
274
|
+
private func positionWindow() {
|
|
275
|
+
guard let window else { return }
|
|
276
|
+
// Quartz global: origin top-left of primary, Y down. Cocoa: origin bottom-left, Y up.
|
|
277
|
+
let mainHeight = NSScreen.screens.first?.frame.height ?? NSScreen.main?.frame.height ?? 0
|
|
278
|
+
let tipCocoaY = mainHeight - tipPoint.y
|
|
279
|
+
// Window origin is bottom-left; hotspot is hotspot.y down from the top of the view.
|
|
280
|
+
let origin = NSPoint(
|
|
281
|
+
x: tipPoint.x - hotspot.x,
|
|
282
|
+
y: tipCocoaY - (pointerSize - hotspot.y)
|
|
283
|
+
)
|
|
284
|
+
window.setFrameOrigin(origin)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// MARK: - Coordinate helpers
|
|
289
|
+
|
|
290
|
+
private func cgPointFromTopLeft(_ x: Double, _ y: Double) -> CGPoint {
|
|
291
|
+
CGPoint(x: x, y: y)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// MARK: - Mouse / keyboard
|
|
295
|
+
|
|
296
|
+
private func postMouseMove(to point: CGPoint) {
|
|
297
|
+
OverlayController.shared.moveTip(to: point)
|
|
298
|
+
guard let event = CGEvent(
|
|
299
|
+
mouseEventSource: nil,
|
|
300
|
+
mouseType: .mouseMoved,
|
|
301
|
+
mouseCursorPosition: point,
|
|
302
|
+
mouseButton: .left
|
|
303
|
+
) else {
|
|
304
|
+
logError("could not create mouseMoved event")
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
event.post(tap: .cghidEventTap)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private func postMouseClick(button: CGMouseButton, count: Int64) {
|
|
311
|
+
OverlayController.shared.flashClick()
|
|
312
|
+
let point = OverlayController.shared.currentTip()
|
|
313
|
+
let downType: CGEventType = button == .right ? .rightMouseDown : .leftMouseDown
|
|
314
|
+
let upType: CGEventType = button == .right ? .rightMouseUp : .leftMouseUp
|
|
315
|
+
|
|
316
|
+
for clickIndex in 1...max(1, count) {
|
|
317
|
+
if let down = CGEvent(
|
|
318
|
+
mouseEventSource: nil,
|
|
319
|
+
mouseType: downType,
|
|
320
|
+
mouseCursorPosition: point,
|
|
321
|
+
mouseButton: button
|
|
322
|
+
) {
|
|
323
|
+
down.setIntegerValueField(.mouseEventClickState, value: clickIndex)
|
|
324
|
+
down.post(tap: .cghidEventTap)
|
|
325
|
+
}
|
|
326
|
+
if let up = CGEvent(
|
|
327
|
+
mouseEventSource: nil,
|
|
328
|
+
mouseType: upType,
|
|
329
|
+
mouseCursorPosition: point,
|
|
330
|
+
mouseButton: button
|
|
331
|
+
) {
|
|
332
|
+
up.setIntegerValueField(.mouseEventClickState, value: clickIndex)
|
|
333
|
+
up.post(tap: .cghidEventTap)
|
|
334
|
+
}
|
|
335
|
+
if clickIndex < count {
|
|
336
|
+
Thread.sleep(forTimeInterval: 0.05)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private func postDrag(from: CGPoint, to: CGPoint) {
|
|
342
|
+
postMouseMove(to: from)
|
|
343
|
+
OverlayController.shared.flashClick()
|
|
344
|
+
Thread.sleep(forTimeInterval: 0.02)
|
|
345
|
+
|
|
346
|
+
if let down = CGEvent(
|
|
347
|
+
mouseEventSource: nil,
|
|
348
|
+
mouseType: .leftMouseDown,
|
|
349
|
+
mouseCursorPosition: from,
|
|
350
|
+
mouseButton: .left
|
|
351
|
+
) {
|
|
352
|
+
down.post(tap: .cghidEventTap)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let steps = 12
|
|
356
|
+
for i in 1...steps {
|
|
357
|
+
let t = CGFloat(i) / CGFloat(steps)
|
|
358
|
+
let p = CGPoint(
|
|
359
|
+
x: from.x + (to.x - from.x) * t,
|
|
360
|
+
y: from.y + (to.y - from.y) * t
|
|
361
|
+
)
|
|
362
|
+
OverlayController.shared.moveTip(to: p)
|
|
363
|
+
if let drag = CGEvent(
|
|
364
|
+
mouseEventSource: nil,
|
|
365
|
+
mouseType: .leftMouseDragged,
|
|
366
|
+
mouseCursorPosition: p,
|
|
367
|
+
mouseButton: .left
|
|
368
|
+
) {
|
|
369
|
+
drag.post(tap: .cghidEventTap)
|
|
370
|
+
}
|
|
371
|
+
Thread.sleep(forTimeInterval: 0.008)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if let up = CGEvent(
|
|
375
|
+
mouseEventSource: nil,
|
|
376
|
+
mouseType: .leftMouseUp,
|
|
377
|
+
mouseCursorPosition: to,
|
|
378
|
+
mouseButton: .left
|
|
379
|
+
) {
|
|
380
|
+
up.post(tap: .cghidEventTap)
|
|
381
|
+
}
|
|
382
|
+
OverlayController.shared.moveTip(to: to)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private func postScroll(at point: CGPoint, direction: String, amount: Int) {
|
|
386
|
+
postMouseMove(to: point)
|
|
387
|
+
let n = max(1, amount)
|
|
388
|
+
var dx = 0
|
|
389
|
+
var dy = 0
|
|
390
|
+
switch direction {
|
|
391
|
+
case "up": dy = 1
|
|
392
|
+
case "down": dy = -1
|
|
393
|
+
case "left": dx = 1
|
|
394
|
+
case "right": dx = -1
|
|
395
|
+
default:
|
|
396
|
+
logError("unknown scroll direction: \(direction)")
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
for _ in 0..<n {
|
|
400
|
+
if let event = CGEvent(
|
|
401
|
+
scrollWheelEvent2Source: nil,
|
|
402
|
+
units: .line,
|
|
403
|
+
wheelCount: 2,
|
|
404
|
+
wheel1: Int32(dy),
|
|
405
|
+
wheel2: Int32(dx),
|
|
406
|
+
wheel3: 0
|
|
407
|
+
) {
|
|
408
|
+
event.post(tap: .cghidEventTap)
|
|
409
|
+
}
|
|
410
|
+
Thread.sleep(forTimeInterval: 0.02)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// MARK: - Keycodes (US ANSI)
|
|
415
|
+
|
|
416
|
+
private let letterKeyCodes: [Character: CGKeyCode] = [
|
|
417
|
+
"a": CGKeyCode(kVK_ANSI_A), "b": CGKeyCode(kVK_ANSI_B), "c": CGKeyCode(kVK_ANSI_C),
|
|
418
|
+
"d": CGKeyCode(kVK_ANSI_D), "e": CGKeyCode(kVK_ANSI_E), "f": CGKeyCode(kVK_ANSI_F),
|
|
419
|
+
"g": CGKeyCode(kVK_ANSI_G), "h": CGKeyCode(kVK_ANSI_H), "i": CGKeyCode(kVK_ANSI_I),
|
|
420
|
+
"j": CGKeyCode(kVK_ANSI_J), "k": CGKeyCode(kVK_ANSI_K), "l": CGKeyCode(kVK_ANSI_L),
|
|
421
|
+
"m": CGKeyCode(kVK_ANSI_M), "n": CGKeyCode(kVK_ANSI_N), "o": CGKeyCode(kVK_ANSI_O),
|
|
422
|
+
"p": CGKeyCode(kVK_ANSI_P), "q": CGKeyCode(kVK_ANSI_Q), "r": CGKeyCode(kVK_ANSI_R),
|
|
423
|
+
"s": CGKeyCode(kVK_ANSI_S), "t": CGKeyCode(kVK_ANSI_T), "u": CGKeyCode(kVK_ANSI_U),
|
|
424
|
+
"v": CGKeyCode(kVK_ANSI_V), "w": CGKeyCode(kVK_ANSI_W), "x": CGKeyCode(kVK_ANSI_X),
|
|
425
|
+
"y": CGKeyCode(kVK_ANSI_Y), "z": CGKeyCode(kVK_ANSI_Z),
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
private let digitKeyCodes: [Character: CGKeyCode] = [
|
|
429
|
+
"0": CGKeyCode(kVK_ANSI_0), "1": CGKeyCode(kVK_ANSI_1), "2": CGKeyCode(kVK_ANSI_2),
|
|
430
|
+
"3": CGKeyCode(kVK_ANSI_3), "4": CGKeyCode(kVK_ANSI_4), "5": CGKeyCode(kVK_ANSI_5),
|
|
431
|
+
"6": CGKeyCode(kVK_ANSI_6), "7": CGKeyCode(kVK_ANSI_7), "8": CGKeyCode(kVK_ANSI_8),
|
|
432
|
+
"9": CGKeyCode(kVK_ANSI_9),
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
private let namedKeyCodes: [String: CGKeyCode] = [
|
|
436
|
+
"return": CGKeyCode(kVK_Return),
|
|
437
|
+
"enter": CGKeyCode(kVK_Return),
|
|
438
|
+
"tab": CGKeyCode(kVK_Tab),
|
|
439
|
+
"escape": CGKeyCode(kVK_Escape),
|
|
440
|
+
"esc": CGKeyCode(kVK_Escape),
|
|
441
|
+
"delete": CGKeyCode(kVK_ForwardDelete),
|
|
442
|
+
"backspace": CGKeyCode(kVK_Delete),
|
|
443
|
+
"space": CGKeyCode(kVK_Space),
|
|
444
|
+
"up": CGKeyCode(kVK_UpArrow),
|
|
445
|
+
"down": CGKeyCode(kVK_DownArrow),
|
|
446
|
+
"left": CGKeyCode(kVK_LeftArrow),
|
|
447
|
+
"right": CGKeyCode(kVK_RightArrow),
|
|
448
|
+
"home": CGKeyCode(kVK_Home),
|
|
449
|
+
"end": CGKeyCode(kVK_End),
|
|
450
|
+
"pageup": CGKeyCode(kVK_PageUp),
|
|
451
|
+
"pagedown": CGKeyCode(kVK_PageDown),
|
|
452
|
+
]
|
|
453
|
+
|
|
454
|
+
private func postKeyCode(_ keyCode: CGKeyCode, flags: CGEventFlags = []) {
|
|
455
|
+
guard let down = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true),
|
|
456
|
+
let up = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false)
|
|
457
|
+
else {
|
|
458
|
+
logError("could not create keyboard event for keyCode \(keyCode)")
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
down.flags = flags
|
|
462
|
+
up.flags = flags
|
|
463
|
+
down.post(tap: .cghidEventTap)
|
|
464
|
+
up.post(tap: .cghidEventTap)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private func postUnicodeChar(_ scalar: UniChar) {
|
|
468
|
+
var chars = [scalar]
|
|
469
|
+
guard let down = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true),
|
|
470
|
+
let up = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: false)
|
|
471
|
+
else {
|
|
472
|
+
logError("could not create unicode keyboard event")
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
down.keyboardSetUnicodeString(stringLength: 1, unicodeString: &chars)
|
|
476
|
+
up.keyboardSetUnicodeString(stringLength: 1, unicodeString: &chars)
|
|
477
|
+
down.post(tap: .cghidEventTap)
|
|
478
|
+
up.post(tap: .cghidEventTap)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private func typeText(_ text: String) {
|
|
482
|
+
// Iterate UTF-16 code units; map Return/Tab to virtual keys when present as characters.
|
|
483
|
+
let utf16 = Array(text.utf16)
|
|
484
|
+
var i = 0
|
|
485
|
+
while i < utf16.count {
|
|
486
|
+
let unit = utf16[i]
|
|
487
|
+
if unit == 0x000A || unit == 0x000D {
|
|
488
|
+
postKeyCode(CGKeyCode(kVK_Return))
|
|
489
|
+
if unit == 0x000D, i + 1 < utf16.count, utf16[i + 1] == 0x000A {
|
|
490
|
+
i += 1
|
|
491
|
+
}
|
|
492
|
+
} else if unit == 0x0009 {
|
|
493
|
+
postKeyCode(CGKeyCode(kVK_Tab))
|
|
494
|
+
} else {
|
|
495
|
+
postUnicodeChar(unit)
|
|
496
|
+
}
|
|
497
|
+
i += 1
|
|
498
|
+
Thread.sleep(forTimeInterval: 0.005)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private func parseAndPostKey(_ keySpec: String) -> Bool {
|
|
503
|
+
let parts = keySpec.split(separator: "+").map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
|
504
|
+
guard !parts.isEmpty else { return false }
|
|
505
|
+
|
|
506
|
+
var flags: CGEventFlags = []
|
|
507
|
+
var keyToken: String?
|
|
508
|
+
|
|
509
|
+
for part in parts {
|
|
510
|
+
switch part {
|
|
511
|
+
case "cmd", "command", "meta", "super":
|
|
512
|
+
flags.insert(.maskCommand)
|
|
513
|
+
case "ctrl", "control":
|
|
514
|
+
flags.insert(.maskControl)
|
|
515
|
+
case "alt", "option", "opt":
|
|
516
|
+
flags.insert(.maskAlternate)
|
|
517
|
+
case "shift":
|
|
518
|
+
flags.insert(.maskShift)
|
|
519
|
+
default:
|
|
520
|
+
if keyToken != nil {
|
|
521
|
+
logError("ambiguous key chord: \(keySpec)")
|
|
522
|
+
return false
|
|
523
|
+
}
|
|
524
|
+
keyToken = part
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
guard let token = keyToken else {
|
|
529
|
+
logError("key chord has no key: \(keySpec)")
|
|
530
|
+
return false
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if let named = namedKeyCodes[token] {
|
|
534
|
+
postKeyCode(named, flags: flags)
|
|
535
|
+
return true
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if token.count == 1, let ch = token.first {
|
|
539
|
+
if let code = letterKeyCodes[ch] {
|
|
540
|
+
postKeyCode(code, flags: flags)
|
|
541
|
+
return true
|
|
542
|
+
}
|
|
543
|
+
if let code = digitKeyCodes[ch] {
|
|
544
|
+
postKeyCode(code, flags: flags)
|
|
545
|
+
return true
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Function keys F1–F12
|
|
550
|
+
if token.hasPrefix("f"), let n = Int(token.dropFirst()), (1...12).contains(n) {
|
|
551
|
+
let base = kVK_F1
|
|
552
|
+
postKeyCode(CGKeyCode(base + (n - 1)), flags: flags)
|
|
553
|
+
return true
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
logError("unknown key: \(keySpec)")
|
|
557
|
+
return false
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// MARK: - Geometry
|
|
561
|
+
|
|
562
|
+
private func geometryResponse() -> [String: Any] {
|
|
563
|
+
let screen = NSScreen.screens.first ?? NSScreen.main
|
|
564
|
+
guard let screen else {
|
|
565
|
+
return ["ok": false, "error": "no display available"]
|
|
566
|
+
}
|
|
567
|
+
let frame = screen.frame
|
|
568
|
+
let scale = screen.backingScaleFactor
|
|
569
|
+
// Primary display: Cocoa origin is bottom-left of main screen; Quartz global top-left origin is (0,0).
|
|
570
|
+
return [
|
|
571
|
+
"ok": true,
|
|
572
|
+
"originX": 0,
|
|
573
|
+
"originY": 0,
|
|
574
|
+
"pointWidth": frame.width,
|
|
575
|
+
"pointHeight": frame.height,
|
|
576
|
+
"pixelWidth": frame.width * scale,
|
|
577
|
+
"pixelHeight": frame.height * scale,
|
|
578
|
+
"scaleFactor": scale,
|
|
579
|
+
]
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// MARK: - Command dispatch
|
|
583
|
+
|
|
584
|
+
private func handleCommand(_ object: [String: Any]) {
|
|
585
|
+
guard let cmd = object["cmd"] as? String else {
|
|
586
|
+
writeError("missing cmd")
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
switch cmd {
|
|
591
|
+
case "ping":
|
|
592
|
+
writeOk()
|
|
593
|
+
|
|
594
|
+
case "geometry":
|
|
595
|
+
writeResponse(geometryResponse())
|
|
596
|
+
|
|
597
|
+
case "ensureSession":
|
|
598
|
+
OverlayController.shared.ensureSession()
|
|
599
|
+
writeOk()
|
|
600
|
+
|
|
601
|
+
case "setIdle":
|
|
602
|
+
OverlayController.shared.setIdle()
|
|
603
|
+
writeOk()
|
|
604
|
+
|
|
605
|
+
case "endSession":
|
|
606
|
+
OverlayController.shared.endSession()
|
|
607
|
+
writeOk()
|
|
608
|
+
|
|
609
|
+
case "move":
|
|
610
|
+
guard let x = doubleValue(object, "pointX"), let y = doubleValue(object, "pointY") else {
|
|
611
|
+
writeError("move requires pointX and pointY")
|
|
612
|
+
return
|
|
613
|
+
}
|
|
614
|
+
postMouseMove(to: cgPointFromTopLeft(x, y))
|
|
615
|
+
writeOk()
|
|
616
|
+
|
|
617
|
+
case "click":
|
|
618
|
+
let buttonName = (object["button"] as? String ?? "left").lowercased()
|
|
619
|
+
let count = Int64(intValue(object, "count") ?? 1)
|
|
620
|
+
let button: CGMouseButton
|
|
621
|
+
switch buttonName {
|
|
622
|
+
case "left": button = .left
|
|
623
|
+
case "right": button = .right
|
|
624
|
+
default:
|
|
625
|
+
writeError("button must be left or right")
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
if count != 1 && count != 2 {
|
|
629
|
+
writeError("count must be 1 or 2")
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
postMouseClick(button: button, count: count)
|
|
633
|
+
writeOk()
|
|
634
|
+
|
|
635
|
+
case "drag":
|
|
636
|
+
guard let fromX = doubleValue(object, "fromX"),
|
|
637
|
+
let fromY = doubleValue(object, "fromY"),
|
|
638
|
+
let toX = doubleValue(object, "toX"),
|
|
639
|
+
let toY = doubleValue(object, "toY")
|
|
640
|
+
else {
|
|
641
|
+
writeError("drag requires fromX, fromY, toX, toY")
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
postDrag(from: cgPointFromTopLeft(fromX, fromY), to: cgPointFromTopLeft(toX, toY))
|
|
645
|
+
writeOk()
|
|
646
|
+
|
|
647
|
+
case "type":
|
|
648
|
+
guard let text = object["text"] as? String else {
|
|
649
|
+
writeError("type requires text")
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
typeText(text)
|
|
653
|
+
writeOk()
|
|
654
|
+
|
|
655
|
+
case "key":
|
|
656
|
+
guard let key = object["key"] as? String else {
|
|
657
|
+
writeError("key requires key")
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
if parseAndPostKey(key) {
|
|
661
|
+
writeOk()
|
|
662
|
+
} else {
|
|
663
|
+
writeError("unrecognized key: \(key)")
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
case "scroll":
|
|
667
|
+
guard let x = doubleValue(object, "pointX"),
|
|
668
|
+
let y = doubleValue(object, "pointY"),
|
|
669
|
+
let direction = object["direction"] as? String,
|
|
670
|
+
let amount = intValue(object, "amount")
|
|
671
|
+
else {
|
|
672
|
+
writeError("scroll requires pointX, pointY, direction, amount")
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
let dir = direction.lowercased()
|
|
676
|
+
guard ["up", "down", "left", "right"].contains(dir) else {
|
|
677
|
+
writeError("direction must be up, down, left, or right")
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
postScroll(at: cgPointFromTopLeft(x, y), direction: dir, amount: amount)
|
|
681
|
+
writeOk()
|
|
682
|
+
|
|
683
|
+
default:
|
|
684
|
+
writeError("unknown cmd: \(cmd)")
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// MARK: - Stdin reader + main
|
|
689
|
+
|
|
690
|
+
private final class StdinReader {
|
|
691
|
+
private let queue = DispatchQueue(label: "com.jian.computer-helper.stdin")
|
|
692
|
+
|
|
693
|
+
func start() {
|
|
694
|
+
queue.async {
|
|
695
|
+
let handle = FileHandle.standardInput
|
|
696
|
+
var buffer = Data()
|
|
697
|
+
while true {
|
|
698
|
+
let chunk = handle.availableData
|
|
699
|
+
if chunk.isEmpty {
|
|
700
|
+
// EOF — exit the process cleanly.
|
|
701
|
+
DispatchQueue.main.async {
|
|
702
|
+
NSApp.terminate(nil)
|
|
703
|
+
}
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
buffer.append(chunk)
|
|
707
|
+
while let range = buffer.range(of: Data([0x0A])) {
|
|
708
|
+
let lineData = buffer.subdata(in: buffer.startIndex..<range.lowerBound)
|
|
709
|
+
buffer.removeSubrange(buffer.startIndex...range.lowerBound)
|
|
710
|
+
if let line = String(data: lineData, encoding: .utf8) {
|
|
711
|
+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
712
|
+
if trimmed.isEmpty { continue }
|
|
713
|
+
DispatchQueue.main.async {
|
|
714
|
+
if let object = parseJSONLine(trimmed) {
|
|
715
|
+
handleCommand(object)
|
|
716
|
+
} else {
|
|
717
|
+
logError("invalid JSON line")
|
|
718
|
+
writeError("invalid JSON")
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private let stdinReader = StdinReader()
|
|
729
|
+
|
|
730
|
+
autoreleasepool {
|
|
731
|
+
let app = NSApplication.shared
|
|
732
|
+
app.setActivationPolicy(.accessory)
|
|
733
|
+
stdinReader.start()
|
|
734
|
+
app.run()
|
|
735
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jian-ai/remote-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Jian Remote Control worker — connect a computer to a Jian gateway over HTTPS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"bin",
|
|
15
|
+
"macos",
|
|
15
16
|
"README.md"
|
|
16
17
|
],
|
|
17
18
|
"engines": {
|