replicas-computer 0.1.0 → 0.2.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/README.md +10 -1
- package/assets/desktop/novnc-index.html +6 -269
- package/assets/desktop/start-services.sh +65 -57
- package/assets/desktop/stream-index.html +20 -0
- package/assets/desktop/stream-viewer.css +75 -0
- package/assets/desktop/viewer-icon.svg +3 -0
- package/assets/desktop/viewer.css +57 -0
- package/dist/index.js +1237 -548
- package/dist/stream-viewer.js +308 -0
- package/dist/viewer.js +199 -0
- package/package.json +6 -2
package/dist/index.js
CHANGED
|
@@ -4,21 +4,30 @@
|
|
|
4
4
|
import { createConnection as createConnection2 } from "net";
|
|
5
5
|
|
|
6
6
|
// src/daemon.ts
|
|
7
|
-
import { spawnSync as
|
|
8
|
-
import { chmodSync, existsSync as
|
|
7
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
8
|
+
import { chmodSync, existsSync as existsSync5, mkdirSync as mkdirSync5, rmSync as rmSync4 } from "fs";
|
|
9
9
|
import { createConnection, createServer } from "net";
|
|
10
|
-
import { dirname as
|
|
10
|
+
import { dirname as dirname5 } from "path";
|
|
11
11
|
|
|
12
12
|
// src/commands/desktop.ts
|
|
13
13
|
import { spawnSync } from "child_process";
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync } from "fs";
|
|
15
15
|
import { dirname, isAbsolute, resolve } from "path";
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
// ../shared/src/engine/environment.ts
|
|
18
|
+
var DESKTOP_STREAM_PORT = 6080;
|
|
19
|
+
function getDesktopViewerUrl(publicUrl) {
|
|
20
|
+
const url = new URL(`${publicUrl.replace(/\/$/, "")}/`);
|
|
21
|
+
url.searchParams.set("v", "14");
|
|
22
|
+
return url.toString();
|
|
23
|
+
}
|
|
17
24
|
var DESKTOP_VIEWER_WIDTH = 1920;
|
|
18
25
|
var DESKTOP_VIEWER_HEIGHT = 1080;
|
|
26
|
+
|
|
27
|
+
// src/commands/desktop.ts
|
|
19
28
|
var STATE_DIR = process.env.REPLICAS_DESKTOP_STATE_DIR || "/tmp/replicas-computer";
|
|
20
29
|
var DEFAULT_DISPLAY = process.env.REPLICAS_DESKTOP_DISPLAY || ":99";
|
|
21
|
-
var
|
|
30
|
+
var STREAM_PORT = process.env.REPLICAS_DESKTOP_STREAM_PORT ? parseInt(process.env.REPLICAS_DESKTOP_STREAM_PORT, 10) : DESKTOP_STREAM_PORT;
|
|
22
31
|
var SERVICES_SCRIPT = process.env.REPLICAS_COMPUTER_SERVICES_SCRIPT || "/usr/local/bin/replicas-start-desktop-services";
|
|
23
32
|
var INPUT_LOCK_FILE = process.env.REPLICAS_DESKTOP_INPUT_LOCK_FILE || `${STATE_DIR}/input.lock`;
|
|
24
33
|
var CHROME_WRAPPER = "/usr/local/bin/replicas-chrome";
|
|
@@ -28,11 +37,11 @@ var lastDesktopHealthAt = 0;
|
|
|
28
37
|
var CHROME_DEBUG_PORT = (() => {
|
|
29
38
|
const value = process.env.REPLICAS_DESKTOP_CHROME_DEBUG_PORT;
|
|
30
39
|
if (!value) return 9222;
|
|
31
|
-
const
|
|
32
|
-
if (!Number.isInteger(
|
|
40
|
+
const port2 = parseInt(value, 10);
|
|
41
|
+
if (!Number.isInteger(port2) || port2 <= 0 || port2 > 65535) {
|
|
33
42
|
fail(`REPLICAS_DESKTOP_CHROME_DEBUG_PORT must be a valid TCP port, got ${JSON.stringify(value)}`);
|
|
34
43
|
}
|
|
35
|
-
return
|
|
44
|
+
return port2;
|
|
36
45
|
})();
|
|
37
46
|
function fail(msg) {
|
|
38
47
|
throw new Error(msg);
|
|
@@ -48,7 +57,7 @@ function getDesktopBridgeStatus() {
|
|
|
48
57
|
}
|
|
49
58
|
function desktopStackHealthy() {
|
|
50
59
|
const pids = {};
|
|
51
|
-
for (const name of ["openbox", "tint2", "
|
|
60
|
+
for (const name of ["openbox", "tint2", "stream"]) {
|
|
52
61
|
try {
|
|
53
62
|
const pid = Number.parseInt(readFileSync(`${STATE_DIR}/${name}.pid`, "utf8").trim(), 10);
|
|
54
63
|
if (!Number.isFinite(pid)) return false;
|
|
@@ -59,15 +68,16 @@ function desktopStackHealthy() {
|
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
70
|
if (spawnSync("xdpyinfo", [], { env: withDisplay(), stdio: "ignore" }).status !== 0) return false;
|
|
71
|
+
if (spawnSync("pactl", ["info"], { stdio: "ignore" }).status !== 0) return false;
|
|
62
72
|
const wm = spawnSync("xprop", ["-root", "_NET_SUPPORTING_WM_CHECK"], { env: withDisplay(), stdio: "pipe" });
|
|
63
73
|
if (wm.status !== 0 || !wm.stdout?.toString().includes("window id")) return false;
|
|
64
74
|
const bridge = getDesktopBridgeStatus();
|
|
65
|
-
return !!bridge?.
|
|
75
|
+
return !!bridge?.stream.listenerPids?.includes(pids.stream) && !!bridge.stream.health;
|
|
66
76
|
}
|
|
67
77
|
function ensureServicesRunning() {
|
|
68
78
|
if (!existsSync(SERVICES_SCRIPT)) {
|
|
69
79
|
fail(
|
|
70
|
-
`Desktop services script missing at ${SERVICES_SCRIPT}. The workspace image is out of date \u2014 Xvfb / openbox /
|
|
80
|
+
`Desktop services script missing at ${SERVICES_SCRIPT}. The workspace image is out of date \u2014 Xvfb / openbox / PulseAudio / ffmpeg must be installed and \`replicas-computer setup\` must install the desktop runtime.`
|
|
71
81
|
);
|
|
72
82
|
}
|
|
73
83
|
if (Date.now() - lastDesktopHealthAt < 1e3) return;
|
|
@@ -156,37 +166,293 @@ function resolvePath(p) {
|
|
|
156
166
|
}
|
|
157
167
|
function configuredDesktopDimensions() {
|
|
158
168
|
const resolution = process.env.REPLICAS_DESKTOP_RESOLUTION?.match(/^(\d+)x(\d+)$/);
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
if (!Number.isFinite(
|
|
162
|
-
if (!Number.isFinite(
|
|
163
|
-
return { width, height };
|
|
169
|
+
const width2 = parseInt(resolution?.[1] || process.env.REPLICAS_DESKTOP_WIDTH || String(DESKTOP_VIEWER_WIDTH), 10);
|
|
170
|
+
const height2 = parseInt(resolution?.[2] || process.env.REPLICAS_DESKTOP_HEIGHT || String(DESKTOP_VIEWER_HEIGHT), 10);
|
|
171
|
+
if (!Number.isFinite(width2) || width2 <= 0) fail(`REPLICAS_DESKTOP_WIDTH must be a positive integer`);
|
|
172
|
+
if (!Number.isFinite(height2) || height2 <= 0) fail(`REPLICAS_DESKTOP_HEIGHT must be a positive integer`);
|
|
173
|
+
return { width: width2, height: height2 };
|
|
164
174
|
}
|
|
165
175
|
function clamp(n, min, max) {
|
|
166
176
|
return Math.min(max, Math.max(min, n));
|
|
167
177
|
}
|
|
168
|
-
var sleep = (ms) => new Promise((
|
|
178
|
+
var sleep = (ms) => new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
169
179
|
|
|
170
180
|
// src/program.ts
|
|
171
181
|
import { format } from "util";
|
|
172
182
|
import { Command, CommanderError } from "commander";
|
|
173
183
|
|
|
174
|
-
// src/commands/
|
|
175
|
-
import { spawn as spawn2
|
|
184
|
+
// src/commands/browser.ts
|
|
185
|
+
import { spawn as spawn2 } from "child_process";
|
|
186
|
+
import { createHash as createHash2 } from "crypto";
|
|
187
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
188
|
+
import { dirname as dirname4 } from "path";
|
|
189
|
+
|
|
190
|
+
// src/commands/capture.ts
|
|
191
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
176
192
|
import { createHash } from "crypto";
|
|
177
|
-
import { closeSync, copyFileSync
|
|
178
|
-
import {
|
|
179
|
-
|
|
180
|
-
|
|
193
|
+
import { closeSync, copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, readSync, rmSync, writeFileSync } from "fs";
|
|
194
|
+
import { dirname as dirname2 } from "path";
|
|
195
|
+
var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
196
|
+
function readPngDimensions(filePath) {
|
|
197
|
+
const fd = openSync(filePath, "r");
|
|
198
|
+
try {
|
|
199
|
+
const buf = Buffer.alloc(24);
|
|
200
|
+
const bytesRead = readSync(fd, buf, 0, 24, 0);
|
|
201
|
+
if (bytesRead < 24 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
202
|
+
fail(`${filePath} is not a valid PNG (read ${bytesRead}/24 bytes)`);
|
|
203
|
+
}
|
|
204
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
205
|
+
} finally {
|
|
206
|
+
closeSync(fd);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function brandSvgPath() {
|
|
210
|
+
const dir = process.env.REPLICAS_DESKTOP_TEMPLATES || "/usr/local/share/replicas/desktop";
|
|
211
|
+
return `${dir}/brand-wallpaper.svg`;
|
|
212
|
+
}
|
|
213
|
+
function loadBrandSvg(canvasW, canvasH) {
|
|
214
|
+
const path = brandSvgPath();
|
|
215
|
+
if (!existsSync2(path)) {
|
|
216
|
+
fail(
|
|
217
|
+
`Brand wallpaper SVG missing at ${path}. The workspace image is out of date \u2014 \`replicas-computer setup\` must install brand-wallpaper.svg at $REPLICAS_DESKTOP_TEMPLATES.`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return readFileSync2(path, "utf8").replace(/(<svg[^>]*\s)width="\d+"/, `$1width="${canvasW}"`).replace(/(<svg[^>]*\s)height="\d+"/, `$1height="${canvasH}"`);
|
|
221
|
+
}
|
|
222
|
+
var BRAND_PAD_FRACTION = 0.06;
|
|
223
|
+
var SCREENSHOT_CORNER_FRACTION = 0.022;
|
|
224
|
+
var SHADOW_SIGMA_FRACTION = 0.022;
|
|
225
|
+
var SHADOW_OFFSET_Y_FRACTION = 0.013;
|
|
226
|
+
var SHADOW_ALPHA = 0.6;
|
|
227
|
+
var SCREENSHOT_MASK_TEMPLATE = `<svg width="__W__" height="__H__" xmlns="http://www.w3.org/2000/svg"><rect width="__W__" height="__H__" rx="__R__" ry="__R__" fill="white"/></svg>`;
|
|
228
|
+
var SHADOW_MASK_TEMPLATE = `<svg width="__SW__" height="__SH__" xmlns="http://www.w3.org/2000/svg"><rect x="__M__" y="__M__" width="__W__" height="__H__" rx="__R__" ry="__R__" fill="white"/></svg>`;
|
|
229
|
+
function parseGridSize(value) {
|
|
230
|
+
if (value === void 0 || value === false) return null;
|
|
231
|
+
const size = value === true ? 100 : parseCoord(value, "--grid");
|
|
232
|
+
if (size < 25 || size > 500) fail("--grid must be between 25 and 500 pixels");
|
|
233
|
+
return size;
|
|
234
|
+
}
|
|
235
|
+
function buildGridSvg(width2, height2, size) {
|
|
236
|
+
const lines = [];
|
|
237
|
+
const labels = [];
|
|
238
|
+
for (let x = 0; x <= width2; x += size) {
|
|
239
|
+
const strokeWidth = x % (size * 5) === 0 ? 2 : 1;
|
|
240
|
+
lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${height2}" stroke="#00e5ff" stroke-opacity="0.42" stroke-width="${strokeWidth}"/>`);
|
|
241
|
+
if (x > 0 && x < width2) labels.push(`<text x="${x + 4}" y="18">${x}</text>`);
|
|
242
|
+
}
|
|
243
|
+
for (let y = 0; y <= height2; y += size) {
|
|
244
|
+
const strokeWidth = y % (size * 5) === 0 ? 2 : 1;
|
|
245
|
+
lines.push(`<line x1="0" y1="${y}" x2="${width2}" y2="${y}" stroke="#00e5ff" stroke-opacity="0.42" stroke-width="${strokeWidth}"/>`);
|
|
246
|
+
if (y > 0 && y < height2) labels.push(`<text x="4" y="${y - 4}">${y}</text>`);
|
|
247
|
+
}
|
|
248
|
+
return `<svg width="${width2}" height="${height2}" xmlns="http://www.w3.org/2000/svg">
|
|
249
|
+
<style>text{font:16px monospace;fill:white;paint-order:stroke;stroke:black;stroke-width:4px;stroke-linejoin:round}</style>
|
|
250
|
+
${lines.join("\n")}
|
|
251
|
+
${labels.join("\n")}
|
|
252
|
+
</svg>`;
|
|
253
|
+
}
|
|
254
|
+
function overlayGrid(rawPath, target, width2, height2, gridSize, gridPath) {
|
|
255
|
+
writeFileSync(gridPath, buildGridSvg(width2, height2, gridSize));
|
|
256
|
+
const r = spawnSync2(
|
|
257
|
+
"ffmpeg",
|
|
258
|
+
[
|
|
259
|
+
"-y",
|
|
260
|
+
"-hide_banner",
|
|
261
|
+
"-loglevel",
|
|
262
|
+
"error",
|
|
263
|
+
"-i",
|
|
264
|
+
rawPath,
|
|
265
|
+
"-i",
|
|
266
|
+
gridPath,
|
|
267
|
+
"-filter_complex",
|
|
268
|
+
"[0:v][1:v]overlay=0:0:format=auto",
|
|
269
|
+
"-frames:v",
|
|
270
|
+
"1",
|
|
271
|
+
"-update",
|
|
272
|
+
"1",
|
|
273
|
+
target
|
|
274
|
+
],
|
|
275
|
+
{ stdio: "pipe" }
|
|
276
|
+
);
|
|
277
|
+
if (r.status !== 0) {
|
|
278
|
+
fail(`ffmpeg grid overlay failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function computerScreenshotCommand(path, options = {}) {
|
|
282
|
+
const target = resolvePath(path);
|
|
283
|
+
mkdirSync2(dirname2(target), { recursive: true });
|
|
284
|
+
const stamp = `${process.pid}-${Date.now()}`;
|
|
285
|
+
const rawPath = `/tmp/replicas-screenshot-${stamp}.raw.png`;
|
|
286
|
+
const svgPath = `/tmp/replicas-screenshot-${stamp}.brand.svg`;
|
|
287
|
+
const maskPath = `/tmp/replicas-screenshot-${stamp}.mask.svg`;
|
|
288
|
+
const shadowPath = `/tmp/replicas-screenshot-${stamp}.shadow.svg`;
|
|
289
|
+
const gridPath = `/tmp/replicas-screenshot-${stamp}.grid.svg`;
|
|
290
|
+
try {
|
|
291
|
+
runDisplayCmd("scrot", ["-o", rawPath]);
|
|
292
|
+
const { width: width2, height: height2 } = readPngDimensions(rawPath);
|
|
293
|
+
const gridSize = parseGridSize(options.grid);
|
|
294
|
+
if (options.raw && gridSize === null) {
|
|
295
|
+
copyFileSync(rawPath, target);
|
|
296
|
+
console.log(`${target} (${width2}x${height2}, raw 1:1 desktop pixels)`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (gridSize !== null) {
|
|
300
|
+
overlayGrid(rawPath, target, width2, height2, gridSize, gridPath);
|
|
301
|
+
console.log(`${target} (${width2}x${height2}, raw 1:1 desktop pixels, ${gridSize}px grid)`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const padX = Math.round(width2 * BRAND_PAD_FRACTION);
|
|
305
|
+
const padY = Math.round(height2 * BRAND_PAD_FRACTION);
|
|
306
|
+
const canvasW = width2 + padX * 2;
|
|
307
|
+
const canvasH = height2 + padY * 2;
|
|
308
|
+
const minDim = Math.min(width2, height2);
|
|
309
|
+
const cornerR = Math.round(minDim * SCREENSHOT_CORNER_FRACTION);
|
|
310
|
+
const shadowSigma = Math.max(12, Math.round(minDim * SHADOW_SIGMA_FRACTION));
|
|
311
|
+
const shadowOffsetY = Math.round(minDim * SHADOW_OFFSET_Y_FRACTION);
|
|
312
|
+
const shadowMargin = shadowSigma * 3;
|
|
313
|
+
const shadowW = width2 + shadowMargin * 2;
|
|
314
|
+
const shadowH = height2 + shadowMargin * 2;
|
|
315
|
+
writeFileSync(svgPath, loadBrandSvg(canvasW, canvasH));
|
|
316
|
+
writeFileSync(
|
|
317
|
+
maskPath,
|
|
318
|
+
SCREENSHOT_MASK_TEMPLATE.replace(/__W__/g, String(width2)).replace(/__H__/g, String(height2)).replace(/__R__/g, String(cornerR))
|
|
319
|
+
);
|
|
320
|
+
writeFileSync(
|
|
321
|
+
shadowPath,
|
|
322
|
+
SHADOW_MASK_TEMPLATE.replace(/__SW__/g, String(shadowW)).replace(/__SH__/g, String(shadowH)).replace(/__M__/g, String(shadowMargin)).replace(/__W__/g, String(width2)).replace(/__H__/g, String(height2)).replace(/__R__/g, String(cornerR))
|
|
323
|
+
);
|
|
324
|
+
const r = spawnSync2(
|
|
325
|
+
"ffmpeg",
|
|
326
|
+
[
|
|
327
|
+
"-y",
|
|
328
|
+
"-hide_banner",
|
|
329
|
+
"-loglevel",
|
|
330
|
+
"error",
|
|
331
|
+
"-i",
|
|
332
|
+
svgPath,
|
|
333
|
+
"-i",
|
|
334
|
+
rawPath,
|
|
335
|
+
"-i",
|
|
336
|
+
maskPath,
|
|
337
|
+
"-i",
|
|
338
|
+
shadowPath,
|
|
339
|
+
"-filter_complex",
|
|
340
|
+
`[2:v]format=rgba,alphaextract[mask];[3:v]format=rgba,colorchannelmixer=rr=0:gg=0:bb=0:aa=${SHADOW_ALPHA},gblur=sigma=${shadowSigma}[shadow];[1:v]format=rgba[scr];[scr][mask]alphamerge[rounded];[0:v]format=rgba[bg];[bg][shadow]overlay=${padX - shadowMargin}:${padY - shadowMargin + shadowOffsetY}:format=auto[bg_shadow];[bg_shadow][rounded]overlay=${padX}:${padY}:format=auto`,
|
|
341
|
+
"-frames:v",
|
|
342
|
+
"1",
|
|
343
|
+
"-update",
|
|
344
|
+
"1",
|
|
345
|
+
target
|
|
346
|
+
],
|
|
347
|
+
{ stdio: "pipe" }
|
|
348
|
+
);
|
|
349
|
+
if (r.status !== 0) {
|
|
350
|
+
fail(`ffmpeg branding failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
|
|
351
|
+
}
|
|
352
|
+
} finally {
|
|
353
|
+
rmSync(rawPath, { force: true });
|
|
354
|
+
rmSync(svgPath, { force: true });
|
|
355
|
+
rmSync(maskPath, { force: true });
|
|
356
|
+
rmSync(shadowPath, { force: true });
|
|
357
|
+
rmSync(gridPath, { force: true });
|
|
358
|
+
}
|
|
359
|
+
console.log(target);
|
|
360
|
+
}
|
|
361
|
+
function hashFile(path) {
|
|
362
|
+
return createHash("sha256").update(readFileSync2(path)).digest("hex");
|
|
363
|
+
}
|
|
364
|
+
async function captureStableRawScreenshot(target, options) {
|
|
365
|
+
const start = Date.now();
|
|
366
|
+
const framePath = `${target}.frame.png`;
|
|
367
|
+
let lastHash = null;
|
|
368
|
+
let lastChangeAt = start;
|
|
369
|
+
let frames = 0;
|
|
370
|
+
let changes = 0;
|
|
371
|
+
let width2 = 0;
|
|
372
|
+
let height2 = 0;
|
|
373
|
+
try {
|
|
374
|
+
while (Date.now() - start <= options.timeoutMs) {
|
|
375
|
+
runDisplayCmd("scrot", ["-o", framePath]);
|
|
376
|
+
frames++;
|
|
377
|
+
const dimensions = readPngDimensions(framePath);
|
|
378
|
+
width2 = dimensions.width;
|
|
379
|
+
height2 = dimensions.height;
|
|
380
|
+
const hash = hashFile(framePath);
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
if (lastHash === null) {
|
|
383
|
+
lastChangeAt = now;
|
|
384
|
+
} else if (hash !== lastHash) {
|
|
385
|
+
changes++;
|
|
386
|
+
lastChangeAt = now;
|
|
387
|
+
}
|
|
388
|
+
lastHash = hash;
|
|
389
|
+
copyFileSync(framePath, target);
|
|
390
|
+
if (frames > 1 && now - lastChangeAt >= options.stableMs) {
|
|
391
|
+
return { width: width2, height: height2, stable: true, elapsedMs: now - start, frames, changes };
|
|
392
|
+
}
|
|
393
|
+
await sleep(options.pollMs);
|
|
394
|
+
}
|
|
395
|
+
return { width: width2, height: height2, stable: false, elapsedMs: Date.now() - start, frames, changes };
|
|
396
|
+
} finally {
|
|
397
|
+
rmSync(framePath, { force: true });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function getActiveWindowTitle() {
|
|
401
|
+
return tryDisplayCmd("xdotool", ["getactivewindow", "getwindowname"]);
|
|
402
|
+
}
|
|
403
|
+
function getVisibleWindowTitles() {
|
|
404
|
+
const out = tryDisplayCmd("xdotool", ["search", "--onlyvisible", "--name", ".", "getwindowname", "%@"]);
|
|
405
|
+
return out ? out.split("\n").filter(Boolean).slice(0, 20) : [];
|
|
406
|
+
}
|
|
407
|
+
async function computerObserveCommand(path, options = {}) {
|
|
408
|
+
const target = resolvePath(path);
|
|
409
|
+
mkdirSync2(dirname2(target), { recursive: true });
|
|
410
|
+
const timeoutMs = options.timeout ? parseCoord(options.timeout, "--timeout") : 3e3;
|
|
411
|
+
const stableMs = options.stableMs ? parseCoord(options.stableMs, "--stable-ms") : 600;
|
|
412
|
+
const pollMs = options.pollMs ? parseCoord(options.pollMs, "--poll-ms") : 200;
|
|
413
|
+
if (timeoutMs < 0) fail("--timeout must be >= 0");
|
|
414
|
+
if (stableMs < 0) fail("--stable-ms must be >= 0");
|
|
415
|
+
if (pollMs < 50 || pollMs > 2e3) fail("--poll-ms must be between 50 and 2000");
|
|
416
|
+
const stamp = `${process.pid}-${Date.now()}`;
|
|
417
|
+
const rawPath = `/tmp/replicas-observe-${stamp}.raw.png`;
|
|
418
|
+
const gridPath = `/tmp/replicas-observe-${stamp}.grid.svg`;
|
|
419
|
+
try {
|
|
420
|
+
ensureServicesRunning();
|
|
421
|
+
const capture = await captureStableRawScreenshot(rawPath, { timeoutMs, stableMs, pollMs });
|
|
422
|
+
const gridSize = options.raw ? null : parseGridSize(options.grid ?? true);
|
|
423
|
+
if (gridSize === null) {
|
|
424
|
+
copyFileSync(rawPath, target);
|
|
425
|
+
} else {
|
|
426
|
+
overlayGrid(rawPath, target, capture.width, capture.height, gridSize, gridPath);
|
|
427
|
+
}
|
|
428
|
+
console.log(JSON.stringify({
|
|
429
|
+
screenshot: target,
|
|
430
|
+
width: capture.width,
|
|
431
|
+
height: capture.height,
|
|
432
|
+
stable: capture.stable,
|
|
433
|
+
elapsedMs: capture.elapsedMs,
|
|
434
|
+
frames: capture.frames,
|
|
435
|
+
changes: capture.changes,
|
|
436
|
+
mouse: getMouseLocation(),
|
|
437
|
+
activeWindow: getActiveWindowTitle(),
|
|
438
|
+
visibleWindows: getVisibleWindowTitles(),
|
|
439
|
+
mode: gridSize === null ? "raw" : "grid",
|
|
440
|
+
gridSize
|
|
441
|
+
}, null, 2));
|
|
442
|
+
} finally {
|
|
443
|
+
rmSync(rawPath, { force: true });
|
|
444
|
+
rmSync(gridPath, { force: true });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
181
447
|
|
|
182
448
|
// src/commands/recording.ts
|
|
183
449
|
import { spawn } from "child_process";
|
|
184
|
-
import { appendFileSync, existsSync as
|
|
185
|
-
import { dirname as
|
|
450
|
+
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync as rmSync3, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
451
|
+
import { dirname as dirname3 } from "path";
|
|
186
452
|
|
|
187
453
|
// src/commands/recording/render.ts
|
|
188
|
-
import { spawnSync as
|
|
189
|
-
import { copyFileSync, rmSync, writeFileSync } from "fs";
|
|
454
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
455
|
+
import { copyFileSync as copyFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
190
456
|
|
|
191
457
|
// src/commands/recording/config.ts
|
|
192
458
|
var cameraMotion = {
|
|
@@ -500,7 +766,7 @@ function buildCursorOverlayFilter(actions, duration, size, assets, projectCursor
|
|
|
500
766
|
|
|
501
767
|
// src/commands/recording/render.ts
|
|
502
768
|
function videoDuration(path) {
|
|
503
|
-
const r =
|
|
769
|
+
const r = spawnSync3("ffprobe", [
|
|
504
770
|
"-v",
|
|
505
771
|
"error",
|
|
506
772
|
"-show_entries",
|
|
@@ -578,20 +844,20 @@ function projectedCursorExpression(rawCursor, axis, segments, spans, size) {
|
|
|
578
844
|
}
|
|
579
845
|
return expression;
|
|
580
846
|
}
|
|
581
|
-
function renderRecording(rawPath, target, actions,
|
|
847
|
+
function renderRecording(rawPath, target, actions, fps2, size) {
|
|
582
848
|
if (actions.length === 0) {
|
|
583
|
-
|
|
849
|
+
copyFileSync2(rawPath, target);
|
|
584
850
|
return;
|
|
585
851
|
}
|
|
586
852
|
const duration = videoDuration(rawPath);
|
|
587
853
|
const segments = buildRecordingSegments(actions, duration, size);
|
|
588
854
|
if (segments.length === 0) {
|
|
589
|
-
|
|
855
|
+
copyFileSync2(rawPath, target);
|
|
590
856
|
return;
|
|
591
857
|
}
|
|
592
858
|
const stamp = `${process.pid}-${Date.now()}`;
|
|
593
859
|
const cursor = cursorAssets(stamp);
|
|
594
|
-
|
|
860
|
+
writeFileSync2(cursor.path, cursorSvg(cursor.size));
|
|
595
861
|
const spans = renderedSegmentSpans(segments);
|
|
596
862
|
const renderedDuration = spans.length ? spans[spans.length - 1].outputEnd : duration;
|
|
597
863
|
const renderedActions = actionsOnRenderedTimeline(actions, spans);
|
|
@@ -614,9 +880,9 @@ function renderRecording(rawPath, target, actions, fps, size) {
|
|
|
614
880
|
const concatInputs = segments.map((_, index) => `[v${index}]`).join("");
|
|
615
881
|
const filter = `${screenSplitFilter};${filters.join(";")};${concatInputs}concat=n=${segments.length}:v=1:a=0[screen];${cursorFilter}`;
|
|
616
882
|
const filterPath = `/tmp/replicas-recording-filter-${stamp}.ffgraph`;
|
|
617
|
-
|
|
883
|
+
writeFileSync2(filterPath, filter);
|
|
618
884
|
try {
|
|
619
|
-
const r =
|
|
885
|
+
const r = spawnSync3("ffmpeg", [
|
|
620
886
|
"-y",
|
|
621
887
|
"-hide_banner",
|
|
622
888
|
"-loglevel",
|
|
@@ -632,7 +898,7 @@ function renderRecording(rawPath, target, actions, fps, size) {
|
|
|
632
898
|
"-map",
|
|
633
899
|
"[v]",
|
|
634
900
|
"-r",
|
|
635
|
-
String(
|
|
901
|
+
String(fps2),
|
|
636
902
|
"-c:v",
|
|
637
903
|
"libx264",
|
|
638
904
|
"-preset",
|
|
@@ -649,8 +915,8 @@ function renderRecording(rawPath, target, actions, fps, size) {
|
|
|
649
915
|
fail(`recording post-processing failed: ${r.error?.message || r.stderr?.toString().trim() || `exit ${r.status}`}`);
|
|
650
916
|
}
|
|
651
917
|
} finally {
|
|
652
|
-
|
|
653
|
-
|
|
918
|
+
rmSync2(filterPath, { force: true });
|
|
919
|
+
rmSync2(cursor.path, { force: true });
|
|
654
920
|
}
|
|
655
921
|
}
|
|
656
922
|
|
|
@@ -672,11 +938,11 @@ var RECORD_STATE_FILES = [
|
|
|
672
938
|
RECORD_ACTIONS_FILE
|
|
673
939
|
];
|
|
674
940
|
function clearRecordingState() {
|
|
675
|
-
for (const file of RECORD_STATE_FILES)
|
|
941
|
+
for (const file of RECORD_STATE_FILES) rmSync3(file, { force: true });
|
|
676
942
|
}
|
|
677
943
|
function recordingStartedAt() {
|
|
678
|
-
if (!
|
|
679
|
-
const startedAt = Number.parseInt(
|
|
944
|
+
if (!existsSync3(RECORD_STARTED_AT_FILE)) return null;
|
|
945
|
+
const startedAt = Number.parseInt(readFileSync3(RECORD_STARTED_AT_FILE, "utf8").trim(), 10);
|
|
680
946
|
return Number.isFinite(startedAt) ? startedAt : null;
|
|
681
947
|
}
|
|
682
948
|
function logRecordingAction(action) {
|
|
@@ -687,13 +953,13 @@ function logRecordingAction(action) {
|
|
|
687
953
|
`);
|
|
688
954
|
}
|
|
689
955
|
function readRecordingDimensions() {
|
|
690
|
-
if (!
|
|
956
|
+
if (!existsSync3(RECORD_DIMENSIONS_FILE)) return configuredDesktopDimensions();
|
|
691
957
|
try {
|
|
692
|
-
const dimensions = JSON.parse(
|
|
693
|
-
const
|
|
694
|
-
const
|
|
695
|
-
if (typeof
|
|
696
|
-
return { width, height };
|
|
958
|
+
const dimensions = JSON.parse(readFileSync3(RECORD_DIMENSIONS_FILE, "utf8"));
|
|
959
|
+
const width2 = dimensions?.width;
|
|
960
|
+
const height2 = dimensions?.height;
|
|
961
|
+
if (typeof width2 === "number" && Number.isFinite(width2) && width2 > 0 && typeof height2 === "number" && Number.isFinite(height2) && height2 > 0) {
|
|
962
|
+
return { width: width2, height: height2 };
|
|
697
963
|
}
|
|
698
964
|
} catch {
|
|
699
965
|
}
|
|
@@ -706,8 +972,8 @@ function isOptionalNumber(value) {
|
|
|
706
972
|
return value === void 0 || typeof value === "number";
|
|
707
973
|
}
|
|
708
974
|
function readRecordingActions() {
|
|
709
|
-
if (!
|
|
710
|
-
return
|
|
975
|
+
if (!existsSync3(RECORD_ACTIONS_FILE)) return [];
|
|
976
|
+
return readFileSync3(RECORD_ACTIONS_FILE, "utf8").split("\n").filter(Boolean).flatMap((line) => {
|
|
711
977
|
try {
|
|
712
978
|
const value = JSON.parse(line);
|
|
713
979
|
if (typeof value !== "object" || value === null) return [];
|
|
@@ -728,8 +994,8 @@ function readRecordingActions() {
|
|
|
728
994
|
}
|
|
729
995
|
async function computerRecordStartCommand(path, options) {
|
|
730
996
|
ensureServicesRunning();
|
|
731
|
-
if (
|
|
732
|
-
const pid = parseInt(
|
|
997
|
+
if (existsSync3(RECORD_PID_FILE)) {
|
|
998
|
+
const pid = parseInt(readFileSync3(RECORD_PID_FILE, "utf8").trim(), 10);
|
|
733
999
|
if (Number.isFinite(pid)) {
|
|
734
1000
|
let alive2 = false;
|
|
735
1001
|
try {
|
|
@@ -741,12 +1007,12 @@ async function computerRecordStartCommand(path, options) {
|
|
|
741
1007
|
}
|
|
742
1008
|
}
|
|
743
1009
|
const target = resolvePath(path);
|
|
744
|
-
|
|
745
|
-
const
|
|
746
|
-
const { width, height } = configuredDesktopDimensions();
|
|
747
|
-
|
|
1010
|
+
mkdirSync3(dirname3(target), { recursive: true });
|
|
1011
|
+
const fps2 = options.fps ? parseCoord(options.fps, "--fps") : 60;
|
|
1012
|
+
const { width: width2, height: height2 } = configuredDesktopDimensions();
|
|
1013
|
+
mkdirSync3(STATE_DIR, { recursive: true });
|
|
748
1014
|
const rawTarget = `${target}.raw-${Date.now()}.mp4`;
|
|
749
|
-
|
|
1015
|
+
rmSync3(RECORD_ACTIONS_FILE, { force: true });
|
|
750
1016
|
const child = spawn("ffmpeg", [
|
|
751
1017
|
"-y",
|
|
752
1018
|
"-hide_banner",
|
|
@@ -755,9 +1021,9 @@ async function computerRecordStartCommand(path, options) {
|
|
|
755
1021
|
"-f",
|
|
756
1022
|
"x11grab",
|
|
757
1023
|
"-framerate",
|
|
758
|
-
String(
|
|
1024
|
+
String(fps2),
|
|
759
1025
|
"-video_size",
|
|
760
|
-
`${
|
|
1026
|
+
`${width2}x${height2}`,
|
|
761
1027
|
"-draw_mouse",
|
|
762
1028
|
"0",
|
|
763
1029
|
"-i",
|
|
@@ -778,17 +1044,17 @@ async function computerRecordStartCommand(path, options) {
|
|
|
778
1044
|
], { detached: true, stdio: "ignore" });
|
|
779
1045
|
child.unref();
|
|
780
1046
|
if (!child.pid) fail("failed to launch ffmpeg");
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
1047
|
+
writeFileSync3(RECORD_PID_FILE, String(child.pid));
|
|
1048
|
+
writeFileSync3(RECORD_PATH_FILE, target);
|
|
1049
|
+
writeFileSync3(RECORD_RAW_PATH_FILE, rawTarget);
|
|
1050
|
+
writeFileSync3(RECORD_STARTED_AT_FILE, String(Date.now()));
|
|
1051
|
+
writeFileSync3(RECORD_FPS_FILE, String(fps2));
|
|
1052
|
+
writeFileSync3(RECORD_DIMENSIONS_FILE, JSON.stringify({ width: width2, height: height2 }));
|
|
787
1053
|
const startedAt = Date.now();
|
|
788
1054
|
while (Date.now() - startedAt < 5e3) {
|
|
789
1055
|
try {
|
|
790
1056
|
process.kill(child.pid, 0);
|
|
791
|
-
if (
|
|
1057
|
+
if (existsSync3(rawTarget) && statSync(rawTarget).size > 0) {
|
|
792
1058
|
console.log(`${target} (recording ready in ${Date.now() - startedAt}ms)`);
|
|
793
1059
|
return;
|
|
794
1060
|
}
|
|
@@ -805,13 +1071,13 @@ async function computerRecordStartCommand(path, options) {
|
|
|
805
1071
|
}
|
|
806
1072
|
if (alive) fail("ffmpeg is running but did not produce recording output within 5 seconds; run `replicas computer record stop` to finalize or retry");
|
|
807
1073
|
clearRecordingState();
|
|
808
|
-
|
|
1074
|
+
rmSync3(rawTarget, { force: true });
|
|
809
1075
|
fail("ffmpeg exited before screen recording became ready");
|
|
810
1076
|
}
|
|
811
1077
|
async function computerRecordStopCommand() {
|
|
812
|
-
if (!
|
|
813
|
-
if (
|
|
814
|
-
const pid = parseInt(
|
|
1078
|
+
if (!existsSync3(RECORD_PID_FILE) && !existsSync3(RECORD_PATH_FILE)) fail("no recording in progress");
|
|
1079
|
+
if (existsSync3(RECORD_PID_FILE)) {
|
|
1080
|
+
const pid = parseInt(readFileSync3(RECORD_PID_FILE, "utf8").trim(), 10);
|
|
815
1081
|
if (!Number.isFinite(pid)) fail("invalid recording pidfile");
|
|
816
1082
|
try {
|
|
817
1083
|
process.kill(pid, "SIGINT");
|
|
@@ -829,351 +1095,35 @@ async function computerRecordStopCommand() {
|
|
|
829
1095
|
}
|
|
830
1096
|
if (alive) fail(`ffmpeg did not finalize recording within 30 seconds (pid ${pid})`);
|
|
831
1097
|
}
|
|
832
|
-
if (
|
|
833
|
-
const target =
|
|
834
|
-
const rawPath =
|
|
835
|
-
const
|
|
1098
|
+
if (existsSync3(RECORD_PATH_FILE)) {
|
|
1099
|
+
const target = readFileSync3(RECORD_PATH_FILE, "utf8").trim();
|
|
1100
|
+
const rawPath = existsSync3(RECORD_RAW_PATH_FILE) ? readFileSync3(RECORD_RAW_PATH_FILE, "utf8").trim() : target;
|
|
1101
|
+
const fps2 = existsSync3(RECORD_FPS_FILE) ? parseInt(readFileSync3(RECORD_FPS_FILE, "utf8").trim(), 10) : 60;
|
|
836
1102
|
const size = readRecordingDimensions();
|
|
837
1103
|
const actions = readRecordingActions();
|
|
838
1104
|
if (rawPath !== target) {
|
|
839
|
-
renderRecording(rawPath, target, actions, Number.isFinite(
|
|
840
|
-
|
|
1105
|
+
renderRecording(rawPath, target, actions, Number.isFinite(fps2) ? fps2 : 60, size);
|
|
1106
|
+
rmSync3(rawPath, { force: true });
|
|
841
1107
|
}
|
|
842
1108
|
console.log(target);
|
|
843
1109
|
}
|
|
844
1110
|
clearRecordingState();
|
|
845
1111
|
}
|
|
846
1112
|
|
|
847
|
-
// src/
|
|
1113
|
+
// ../shared/src/type-guards.ts
|
|
848
1114
|
function isRecord(value) {
|
|
849
1115
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
850
1116
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
}
|
|
856
|
-
function bridgeStatus(details, includeBacklog = false, rootsOnly = false) {
|
|
857
|
-
const pids = rootsOnly ? details.rootPids ?? [] : details.listenerPids ?? [];
|
|
858
|
-
const listenerCount = rootsOnly ? details.rootListeners ?? pids.length : details.listeners ?? pids.length;
|
|
859
|
-
const listenerLabel = rootsOnly ? "root listener" : "listener";
|
|
860
|
-
const parts = [`${listenerCount} ${listenerLabel}${listenerCount === 1 ? "" : "s"} on ${details.port}`];
|
|
861
|
-
if (includeBacklog) parts.push(`backlog ${details.backlog ?? 0}`);
|
|
862
|
-
if (pids.length > 0) parts.push(`pid${pids.length === 1 ? "" : "s"} ${pids.join(",")}`);
|
|
863
|
-
return ` (${parts.join(", ")})`;
|
|
864
|
-
}
|
|
865
|
-
async function lookupDesktopViewerUrl() {
|
|
1117
|
+
|
|
1118
|
+
// src/commands/browser.ts
|
|
1119
|
+
async function fetchChromeJson(path) {
|
|
1120
|
+
let res;
|
|
866
1121
|
try {
|
|
867
|
-
|
|
868
|
-
if (!isRecord(value) || !Array.isArray(value.previews)) return null;
|
|
869
|
-
const preview = value.previews.find(
|
|
870
|
-
(item) => isRecord(item) && item.port === NOVNC_PORT && typeof item.publicUrl === "string"
|
|
871
|
-
);
|
|
872
|
-
return isRecord(preview) && typeof preview.publicUrl === "string" ? getDesktopViewerUrl(preview.publicUrl) : null;
|
|
1122
|
+
res = await fetch(`http://127.0.0.1:${CHROME_DEBUG_PORT}${path}`);
|
|
873
1123
|
} catch {
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
async function waitForDesktopViewerUrl(timeoutMs) {
|
|
878
|
-
const deadline = Date.now() + timeoutMs;
|
|
879
|
-
while (Date.now() < deadline) {
|
|
880
|
-
const url = await lookupDesktopViewerUrl();
|
|
881
|
-
if (url) return url;
|
|
882
|
-
await new Promise((resolve3) => setTimeout(resolve3, INFO_WAIT_INTERVAL_MS));
|
|
883
|
-
}
|
|
884
|
-
return await lookupDesktopViewerUrl();
|
|
885
|
-
}
|
|
886
|
-
async function computerInfoCommand() {
|
|
887
|
-
const viewerUrl = await waitForDesktopViewerUrl(INFO_WAIT_TIMEOUT_MS);
|
|
888
|
-
if (!viewerUrl) {
|
|
889
|
-
fail(
|
|
890
|
-
`Desktop preview for port ${NOVNC_PORT} is not registered. The engine registers it at startup \u2014 check /tmp/replicas-desktop-stack.log and engine logs for errors.`
|
|
891
|
-
);
|
|
892
|
-
}
|
|
893
|
-
console.log(viewerUrl);
|
|
894
|
-
console.error(chalk.dim(`Share this URL with the user to let them watch the desktop live.`));
|
|
895
|
-
}
|
|
896
|
-
async function computerStatusCommand() {
|
|
897
|
-
ensureServicesRunning();
|
|
898
|
-
const bridge = getDesktopBridgeStatus();
|
|
899
|
-
const procs = ["Xvfb", "openbox", "tint2", "x11vnc", "websockify"];
|
|
900
|
-
for (const p of procs) {
|
|
901
|
-
const r = spawnSync3("pgrep", ["-af", p], { stdio: "pipe" });
|
|
902
|
-
const running = r.status === 0 && !!r.stdout?.toString().trim();
|
|
903
|
-
const suffix = p === "x11vnc" && bridge ? bridgeStatus(bridge.x11vnc, true) : p === "websockify" && bridge ? bridgeStatus(bridge.websockify, false, true) : "";
|
|
904
|
-
console.log(` ${running ? chalk.green("\u25CF") : chalk.red("\u25CB")} ${p}${suffix}`);
|
|
905
|
-
}
|
|
906
|
-
const viewerUrl = await lookupDesktopViewerUrl();
|
|
907
|
-
if (viewerUrl) {
|
|
908
|
-
console.log(` ${chalk.cyan("preview")}: ${viewerUrl}`);
|
|
909
|
-
} else {
|
|
910
|
-
console.log(` ${chalk.dim("preview: not yet registered (engine registers it at startup)")}`);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
914
|
-
function readPngDimensions(filePath) {
|
|
915
|
-
const fd = openSync(filePath, "r");
|
|
916
|
-
try {
|
|
917
|
-
const buf = Buffer.alloc(24);
|
|
918
|
-
const bytesRead = readSync(fd, buf, 0, 24, 0);
|
|
919
|
-
if (bytesRead < 24 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
920
|
-
fail(`${filePath} is not a valid PNG (read ${bytesRead}/24 bytes)`);
|
|
921
|
-
}
|
|
922
|
-
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
923
|
-
} finally {
|
|
924
|
-
closeSync(fd);
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
function brandSvgPath() {
|
|
928
|
-
const dir = process.env.REPLICAS_DESKTOP_TEMPLATES || "/usr/local/share/replicas/desktop";
|
|
929
|
-
return `${dir}/brand-wallpaper.svg`;
|
|
930
|
-
}
|
|
931
|
-
function loadBrandSvg(canvasW, canvasH) {
|
|
932
|
-
const path = brandSvgPath();
|
|
933
|
-
if (!existsSync3(path)) {
|
|
934
|
-
fail(
|
|
935
|
-
`Brand wallpaper SVG missing at ${path}. The workspace image is out of date \u2014 \`replicas-computer setup\` must install brand-wallpaper.svg at $REPLICAS_DESKTOP_TEMPLATES.`
|
|
936
|
-
);
|
|
937
|
-
}
|
|
938
|
-
return readFileSync3(path, "utf8").replace(/(<svg[^>]*\s)width="\d+"/, `$1width="${canvasW}"`).replace(/(<svg[^>]*\s)height="\d+"/, `$1height="${canvasH}"`);
|
|
939
|
-
}
|
|
940
|
-
var BRAND_PAD_FRACTION = 0.06;
|
|
941
|
-
var SCREENSHOT_CORNER_FRACTION = 0.022;
|
|
942
|
-
var SHADOW_SIGMA_FRACTION = 0.022;
|
|
943
|
-
var SHADOW_OFFSET_Y_FRACTION = 0.013;
|
|
944
|
-
var SHADOW_ALPHA = 0.6;
|
|
945
|
-
var SCREENSHOT_MASK_TEMPLATE = `<svg width="__W__" height="__H__" xmlns="http://www.w3.org/2000/svg"><rect width="__W__" height="__H__" rx="__R__" ry="__R__" fill="white"/></svg>`;
|
|
946
|
-
var SHADOW_MASK_TEMPLATE = `<svg width="__SW__" height="__SH__" xmlns="http://www.w3.org/2000/svg"><rect x="__M__" y="__M__" width="__W__" height="__H__" rx="__R__" ry="__R__" fill="white"/></svg>`;
|
|
947
|
-
function parseGridSize(value) {
|
|
948
|
-
if (value === void 0 || value === false) return null;
|
|
949
|
-
const size = value === true ? 100 : parseCoord(value, "--grid");
|
|
950
|
-
if (size < 25 || size > 500) fail("--grid must be between 25 and 500 pixels");
|
|
951
|
-
return size;
|
|
952
|
-
}
|
|
953
|
-
function buildGridSvg(width, height, size) {
|
|
954
|
-
const lines = [];
|
|
955
|
-
const labels = [];
|
|
956
|
-
for (let x = 0; x <= width; x += size) {
|
|
957
|
-
const strokeWidth = x % (size * 5) === 0 ? 2 : 1;
|
|
958
|
-
lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${height}" stroke="#00e5ff" stroke-opacity="0.42" stroke-width="${strokeWidth}"/>`);
|
|
959
|
-
if (x > 0 && x < width) labels.push(`<text x="${x + 4}" y="18">${x}</text>`);
|
|
960
|
-
}
|
|
961
|
-
for (let y = 0; y <= height; y += size) {
|
|
962
|
-
const strokeWidth = y % (size * 5) === 0 ? 2 : 1;
|
|
963
|
-
lines.push(`<line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="#00e5ff" stroke-opacity="0.42" stroke-width="${strokeWidth}"/>`);
|
|
964
|
-
if (y > 0 && y < height) labels.push(`<text x="4" y="${y - 4}">${y}</text>`);
|
|
965
|
-
}
|
|
966
|
-
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
967
|
-
<style>text{font:16px monospace;fill:white;paint-order:stroke;stroke:black;stroke-width:4px;stroke-linejoin:round}</style>
|
|
968
|
-
${lines.join("\n")}
|
|
969
|
-
${labels.join("\n")}
|
|
970
|
-
</svg>`;
|
|
971
|
-
}
|
|
972
|
-
function overlayGrid(rawPath, target, width, height, gridSize, gridPath) {
|
|
973
|
-
writeFileSync3(gridPath, buildGridSvg(width, height, gridSize));
|
|
974
|
-
const r = spawnSync3(
|
|
975
|
-
"ffmpeg",
|
|
976
|
-
[
|
|
977
|
-
"-y",
|
|
978
|
-
"-hide_banner",
|
|
979
|
-
"-loglevel",
|
|
980
|
-
"error",
|
|
981
|
-
"-i",
|
|
982
|
-
rawPath,
|
|
983
|
-
"-i",
|
|
984
|
-
gridPath,
|
|
985
|
-
"-filter_complex",
|
|
986
|
-
"[0:v][1:v]overlay=0:0:format=auto",
|
|
987
|
-
"-frames:v",
|
|
988
|
-
"1",
|
|
989
|
-
"-update",
|
|
990
|
-
"1",
|
|
991
|
-
target
|
|
992
|
-
],
|
|
993
|
-
{ stdio: "pipe" }
|
|
994
|
-
);
|
|
995
|
-
if (r.status !== 0) {
|
|
996
|
-
fail(`ffmpeg grid overlay failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
async function computerScreenshotCommand(path, options = {}) {
|
|
1000
|
-
const target = resolvePath(path);
|
|
1001
|
-
mkdirSync3(dirname3(target), { recursive: true });
|
|
1002
|
-
const stamp = `${process.pid}-${Date.now()}`;
|
|
1003
|
-
const rawPath = `/tmp/replicas-screenshot-${stamp}.raw.png`;
|
|
1004
|
-
const svgPath = `/tmp/replicas-screenshot-${stamp}.brand.svg`;
|
|
1005
|
-
const maskPath = `/tmp/replicas-screenshot-${stamp}.mask.svg`;
|
|
1006
|
-
const shadowPath = `/tmp/replicas-screenshot-${stamp}.shadow.svg`;
|
|
1007
|
-
const gridPath = `/tmp/replicas-screenshot-${stamp}.grid.svg`;
|
|
1008
|
-
try {
|
|
1009
|
-
runDisplayCmd("scrot", ["-o", rawPath]);
|
|
1010
|
-
const { width, height } = readPngDimensions(rawPath);
|
|
1011
|
-
const gridSize = parseGridSize(options.grid);
|
|
1012
|
-
if (options.raw && gridSize === null) {
|
|
1013
|
-
copyFileSync2(rawPath, target);
|
|
1014
|
-
console.log(`${target} (${width}x${height}, raw 1:1 desktop pixels)`);
|
|
1015
|
-
return;
|
|
1016
|
-
}
|
|
1017
|
-
if (gridSize !== null) {
|
|
1018
|
-
overlayGrid(rawPath, target, width, height, gridSize, gridPath);
|
|
1019
|
-
console.log(`${target} (${width}x${height}, raw 1:1 desktop pixels, ${gridSize}px grid)`);
|
|
1020
|
-
return;
|
|
1021
|
-
}
|
|
1022
|
-
const padX = Math.round(width * BRAND_PAD_FRACTION);
|
|
1023
|
-
const padY = Math.round(height * BRAND_PAD_FRACTION);
|
|
1024
|
-
const canvasW = width + padX * 2;
|
|
1025
|
-
const canvasH = height + padY * 2;
|
|
1026
|
-
const minDim = Math.min(width, height);
|
|
1027
|
-
const cornerR = Math.round(minDim * SCREENSHOT_CORNER_FRACTION);
|
|
1028
|
-
const shadowSigma = Math.max(12, Math.round(minDim * SHADOW_SIGMA_FRACTION));
|
|
1029
|
-
const shadowOffsetY = Math.round(minDim * SHADOW_OFFSET_Y_FRACTION);
|
|
1030
|
-
const shadowMargin = shadowSigma * 3;
|
|
1031
|
-
const shadowW = width + shadowMargin * 2;
|
|
1032
|
-
const shadowH = height + shadowMargin * 2;
|
|
1033
|
-
writeFileSync3(svgPath, loadBrandSvg(canvasW, canvasH));
|
|
1034
|
-
writeFileSync3(
|
|
1035
|
-
maskPath,
|
|
1036
|
-
SCREENSHOT_MASK_TEMPLATE.replace(/__W__/g, String(width)).replace(/__H__/g, String(height)).replace(/__R__/g, String(cornerR))
|
|
1037
|
-
);
|
|
1038
|
-
writeFileSync3(
|
|
1039
|
-
shadowPath,
|
|
1040
|
-
SHADOW_MASK_TEMPLATE.replace(/__SW__/g, String(shadowW)).replace(/__SH__/g, String(shadowH)).replace(/__M__/g, String(shadowMargin)).replace(/__W__/g, String(width)).replace(/__H__/g, String(height)).replace(/__R__/g, String(cornerR))
|
|
1041
|
-
);
|
|
1042
|
-
const r = spawnSync3(
|
|
1043
|
-
"ffmpeg",
|
|
1044
|
-
[
|
|
1045
|
-
"-y",
|
|
1046
|
-
"-hide_banner",
|
|
1047
|
-
"-loglevel",
|
|
1048
|
-
"error",
|
|
1049
|
-
"-i",
|
|
1050
|
-
svgPath,
|
|
1051
|
-
"-i",
|
|
1052
|
-
rawPath,
|
|
1053
|
-
"-i",
|
|
1054
|
-
maskPath,
|
|
1055
|
-
"-i",
|
|
1056
|
-
shadowPath,
|
|
1057
|
-
"-filter_complex",
|
|
1058
|
-
`[2:v]format=rgba,alphaextract[mask];[3:v]format=rgba,colorchannelmixer=rr=0:gg=0:bb=0:aa=${SHADOW_ALPHA},gblur=sigma=${shadowSigma}[shadow];[1:v]format=rgba[scr];[scr][mask]alphamerge[rounded];[0:v]format=rgba[bg];[bg][shadow]overlay=${padX - shadowMargin}:${padY - shadowMargin + shadowOffsetY}:format=auto[bg_shadow];[bg_shadow][rounded]overlay=${padX}:${padY}:format=auto`,
|
|
1059
|
-
"-frames:v",
|
|
1060
|
-
"1",
|
|
1061
|
-
"-update",
|
|
1062
|
-
"1",
|
|
1063
|
-
target
|
|
1064
|
-
],
|
|
1065
|
-
{ stdio: "pipe" }
|
|
1066
|
-
);
|
|
1067
|
-
if (r.status !== 0) {
|
|
1068
|
-
fail(`ffmpeg branding failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
|
|
1069
|
-
}
|
|
1070
|
-
} finally {
|
|
1071
|
-
rmSync3(rawPath, { force: true });
|
|
1072
|
-
rmSync3(svgPath, { force: true });
|
|
1073
|
-
rmSync3(maskPath, { force: true });
|
|
1074
|
-
rmSync3(shadowPath, { force: true });
|
|
1075
|
-
rmSync3(gridPath, { force: true });
|
|
1076
|
-
}
|
|
1077
|
-
console.log(target);
|
|
1078
|
-
}
|
|
1079
|
-
function hashFile(path) {
|
|
1080
|
-
return createHash("sha256").update(readFileSync3(path)).digest("hex");
|
|
1081
|
-
}
|
|
1082
|
-
async function captureStableRawScreenshot(target, options) {
|
|
1083
|
-
const start = Date.now();
|
|
1084
|
-
const framePath = `${target}.frame.png`;
|
|
1085
|
-
let lastHash = null;
|
|
1086
|
-
let lastChangeAt = start;
|
|
1087
|
-
let frames = 0;
|
|
1088
|
-
let changes = 0;
|
|
1089
|
-
let width = 0;
|
|
1090
|
-
let height = 0;
|
|
1091
|
-
try {
|
|
1092
|
-
while (Date.now() - start <= options.timeoutMs) {
|
|
1093
|
-
runDisplayCmd("scrot", ["-o", framePath]);
|
|
1094
|
-
frames++;
|
|
1095
|
-
const dimensions = readPngDimensions(framePath);
|
|
1096
|
-
width = dimensions.width;
|
|
1097
|
-
height = dimensions.height;
|
|
1098
|
-
const hash = hashFile(framePath);
|
|
1099
|
-
const now = Date.now();
|
|
1100
|
-
if (lastHash === null) {
|
|
1101
|
-
lastChangeAt = now;
|
|
1102
|
-
} else if (hash !== lastHash) {
|
|
1103
|
-
changes++;
|
|
1104
|
-
lastChangeAt = now;
|
|
1105
|
-
}
|
|
1106
|
-
lastHash = hash;
|
|
1107
|
-
copyFileSync2(framePath, target);
|
|
1108
|
-
if (frames > 1 && now - lastChangeAt >= options.stableMs) {
|
|
1109
|
-
return { width, height, stable: true, elapsedMs: now - start, frames, changes };
|
|
1110
|
-
}
|
|
1111
|
-
await sleep(options.pollMs);
|
|
1112
|
-
}
|
|
1113
|
-
return { width, height, stable: false, elapsedMs: Date.now() - start, frames, changes };
|
|
1114
|
-
} finally {
|
|
1115
|
-
rmSync3(framePath, { force: true });
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
function getActiveWindowTitle() {
|
|
1119
|
-
return tryDisplayCmd("xdotool", ["getactivewindow", "getwindowname"]);
|
|
1120
|
-
}
|
|
1121
|
-
function getVisibleWindowTitles() {
|
|
1122
|
-
const out = tryDisplayCmd("xdotool", ["search", "--onlyvisible", "--name", ".", "getwindowname", "%@"]);
|
|
1123
|
-
return out ? out.split("\n").filter(Boolean).slice(0, 20) : [];
|
|
1124
|
-
}
|
|
1125
|
-
function recordingMousePosition() {
|
|
1126
|
-
const mouse = getMouseLocation();
|
|
1127
|
-
return mouse ? { x: mouse.x, y: mouse.y } : null;
|
|
1128
|
-
}
|
|
1129
|
-
async function computerObserveCommand(path, options = {}) {
|
|
1130
|
-
const target = resolvePath(path);
|
|
1131
|
-
mkdirSync3(dirname3(target), { recursive: true });
|
|
1132
|
-
const timeoutMs = options.timeout ? parseCoord(options.timeout, "--timeout") : 3e3;
|
|
1133
|
-
const stableMs = options.stableMs ? parseCoord(options.stableMs, "--stable-ms") : 600;
|
|
1134
|
-
const pollMs = options.pollMs ? parseCoord(options.pollMs, "--poll-ms") : 200;
|
|
1135
|
-
if (timeoutMs < 0) fail("--timeout must be >= 0");
|
|
1136
|
-
if (stableMs < 0) fail("--stable-ms must be >= 0");
|
|
1137
|
-
if (pollMs < 50 || pollMs > 2e3) fail("--poll-ms must be between 50 and 2000");
|
|
1138
|
-
const stamp = `${process.pid}-${Date.now()}`;
|
|
1139
|
-
const rawPath = `/tmp/replicas-observe-${stamp}.raw.png`;
|
|
1140
|
-
const gridPath = `/tmp/replicas-observe-${stamp}.grid.svg`;
|
|
1141
|
-
try {
|
|
1142
|
-
ensureServicesRunning();
|
|
1143
|
-
const capture = await captureStableRawScreenshot(rawPath, { timeoutMs, stableMs, pollMs });
|
|
1144
|
-
const gridSize = options.raw ? null : parseGridSize(options.grid ?? true);
|
|
1145
|
-
if (gridSize === null) {
|
|
1146
|
-
copyFileSync2(rawPath, target);
|
|
1147
|
-
} else {
|
|
1148
|
-
overlayGrid(rawPath, target, capture.width, capture.height, gridSize, gridPath);
|
|
1149
|
-
}
|
|
1150
|
-
console.log(JSON.stringify({
|
|
1151
|
-
screenshot: target,
|
|
1152
|
-
width: capture.width,
|
|
1153
|
-
height: capture.height,
|
|
1154
|
-
stable: capture.stable,
|
|
1155
|
-
elapsedMs: capture.elapsedMs,
|
|
1156
|
-
frames: capture.frames,
|
|
1157
|
-
changes: capture.changes,
|
|
1158
|
-
mouse: getMouseLocation(),
|
|
1159
|
-
activeWindow: getActiveWindowTitle(),
|
|
1160
|
-
visibleWindows: getVisibleWindowTitles(),
|
|
1161
|
-
mode: gridSize === null ? "raw" : "grid",
|
|
1162
|
-
gridSize
|
|
1163
|
-
}, null, 2));
|
|
1164
|
-
} finally {
|
|
1165
|
-
rmSync3(rawPath, { force: true });
|
|
1166
|
-
rmSync3(gridPath, { force: true });
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
async function fetchChromeJson(path) {
|
|
1170
|
-
let res;
|
|
1171
|
-
try {
|
|
1172
|
-
res = await fetch(`http://127.0.0.1:${CHROME_DEBUG_PORT}${path}`);
|
|
1173
|
-
} catch {
|
|
1174
|
-
fail(
|
|
1175
|
-
`Chrome DevTools is not available on localhost:${CHROME_DEBUG_PORT}. Launch Chrome with \`replicas computer launch chrome <url>\` and try again.`
|
|
1176
|
-
);
|
|
1124
|
+
fail(
|
|
1125
|
+
`Chrome DevTools is not available on localhost:${CHROME_DEBUG_PORT}. Launch Chrome with \`replicas computer launch chrome <url>\` and try again.`
|
|
1126
|
+
);
|
|
1177
1127
|
}
|
|
1178
1128
|
if (!res.ok) fail(`Chrome DevTools returned HTTP ${res.status}`);
|
|
1179
1129
|
try {
|
|
@@ -1224,7 +1174,7 @@ async function sendChromeSessionCommand(session, method, params) {
|
|
|
1224
1174
|
return chromeResult(await session.send({ method, params }), method);
|
|
1225
1175
|
}
|
|
1226
1176
|
async function withChromeSession(webSocketDebuggerUrl, callback) {
|
|
1227
|
-
return await new Promise((
|
|
1177
|
+
return await new Promise((resolve4, reject) => {
|
|
1228
1178
|
const ws = new WebSocket(webSocketDebuggerUrl);
|
|
1229
1179
|
const pending = /* @__PURE__ */ new Map();
|
|
1230
1180
|
let nextId = 1;
|
|
@@ -1246,7 +1196,7 @@ async function withChromeSession(webSocketDebuggerUrl, callback) {
|
|
|
1246
1196
|
for (const request of pending.values()) clearTimeout(request.timeout);
|
|
1247
1197
|
pending.clear();
|
|
1248
1198
|
ws.close();
|
|
1249
|
-
|
|
1199
|
+
resolve4(value);
|
|
1250
1200
|
};
|
|
1251
1201
|
const session = {
|
|
1252
1202
|
send: ({ method, params }) => new Promise((resolveCommand, rejectCommand) => {
|
|
@@ -1363,7 +1313,7 @@ function browserStateProperties(node) {
|
|
|
1363
1313
|
function readBrowserStateCache(path) {
|
|
1364
1314
|
let value;
|
|
1365
1315
|
try {
|
|
1366
|
-
value = JSON.parse(
|
|
1316
|
+
value = JSON.parse(readFileSync4(path, "utf8"));
|
|
1367
1317
|
} catch {
|
|
1368
1318
|
return null;
|
|
1369
1319
|
}
|
|
@@ -1421,9 +1371,9 @@ function browserContentQuadRects(quads, viewportRect) {
|
|
|
1421
1371
|
const ys = [quad[1], quad[3], quad[5], quad[7]];
|
|
1422
1372
|
const x = Math.min(...xs);
|
|
1423
1373
|
const y = Math.min(...ys);
|
|
1424
|
-
const
|
|
1425
|
-
const
|
|
1426
|
-
return { x, y, width, height, centerX: x +
|
|
1374
|
+
const width2 = Math.max(...xs) - x;
|
|
1375
|
+
const height2 = Math.max(...ys) - y;
|
|
1376
|
+
return { x, y, width: width2, height: height2, centerX: x + width2 / 2, centerY: y + height2 / 2 };
|
|
1427
1377
|
}).map((rect) => intersectRects(rect, viewportRect)).filter((rect) => !!rect).sort((a, b) => b.width * b.height - a.width * a.height);
|
|
1428
1378
|
}
|
|
1429
1379
|
function buildBrowserBounds(result, viewport, targetId) {
|
|
@@ -1438,14 +1388,14 @@ function buildBrowserBounds(result, viewport, targetId) {
|
|
|
1438
1388
|
nodeIndexes.forEach((nodeIndex, index) => {
|
|
1439
1389
|
const bound = bounds[index];
|
|
1440
1390
|
if (!Array.isArray(bound) || bound.length < 4 || !bound.every(Number.isFinite)) return;
|
|
1441
|
-
const [rawX, rawY,
|
|
1391
|
+
const [rawX, rawY, width2, height2] = bound;
|
|
1442
1392
|
rects.set(nodeIndex, {
|
|
1443
1393
|
x: rawX - scrollX,
|
|
1444
1394
|
y: rawY - scrollY,
|
|
1445
|
-
width,
|
|
1446
|
-
height,
|
|
1447
|
-
centerX: rawX - scrollX +
|
|
1448
|
-
centerY: rawY - scrollY +
|
|
1395
|
+
width: width2,
|
|
1396
|
+
height: height2,
|
|
1397
|
+
centerX: rawX - scrollX + width2 / 2,
|
|
1398
|
+
centerY: rawY - scrollY + height2 / 2
|
|
1449
1399
|
});
|
|
1450
1400
|
});
|
|
1451
1401
|
return rects;
|
|
@@ -1640,7 +1590,8 @@ async function captureBrowserSnapshot(page, options) {
|
|
|
1640
1590
|
node,
|
|
1641
1591
|
role: roleOf(node),
|
|
1642
1592
|
bound: typeof node.backendDOMNodeId === "number" ? bounds.get(node.backendDOMNodeId) : void 0
|
|
1643
|
-
})).filter((
|
|
1593
|
+
})).filter((entry) => {
|
|
1594
|
+
const { node, role, bound } = entry;
|
|
1644
1595
|
if (!bound || bound.rect.width <= 0 || bound.rect.height <= 0) return false;
|
|
1645
1596
|
if (role === "InlineTextBox" || role === "none" || role === "generic") return false;
|
|
1646
1597
|
if (BROWSER_INTERACTIVE_ROLES.has(role) || BROWSER_STRUCTURAL_ROLES.has(role)) return true;
|
|
@@ -1709,7 +1660,7 @@ async function captureBrowserSnapshot(page, options) {
|
|
|
1709
1660
|
visibleTextLength += Math.min(name.length, remaining) + 1;
|
|
1710
1661
|
}
|
|
1711
1662
|
}
|
|
1712
|
-
const revision =
|
|
1663
|
+
const revision = createHash2("sha256").update(JSON.stringify([page.url, entries.map(({ key, semantic }) => [key, semantic])])).digest("hex").slice(0, 16);
|
|
1713
1664
|
const snapshot = {
|
|
1714
1665
|
title: (page.title ?? "").slice(0, 1e3),
|
|
1715
1666
|
url: (page.url ?? "").slice(0, 2e3),
|
|
@@ -1889,7 +1840,7 @@ async function computerBrowserStateCommand(path, options = {}) {
|
|
|
1889
1840
|
const page = await selectChromePage(options);
|
|
1890
1841
|
const stability = await waitForBrowserStability(page, { timeoutMs, stableMs, pollMs });
|
|
1891
1842
|
const target = resolvePath(path);
|
|
1892
|
-
|
|
1843
|
+
mkdirSync4(dirname4(target), { recursive: true });
|
|
1893
1844
|
const [{ snapshot, entries }, screenshotResult] = await Promise.all([
|
|
1894
1845
|
captureBrowserSnapshot(page, { textLimit, elementLimit }),
|
|
1895
1846
|
sendChromeCommand(page.webSocketDebuggerUrl, "Page.captureScreenshot", {
|
|
@@ -1900,14 +1851,14 @@ async function computerBrowserStateCommand(path, options = {}) {
|
|
|
1900
1851
|
]);
|
|
1901
1852
|
const data = screenshotResult.data;
|
|
1902
1853
|
if (typeof data !== "string") fail("Chrome did not return screenshot data");
|
|
1903
|
-
|
|
1854
|
+
writeFileSync4(target, Buffer.from(data, "base64"));
|
|
1904
1855
|
const screenshot = readPngDimensions(target);
|
|
1905
1856
|
const targetId = page.id ?? "unknown";
|
|
1906
1857
|
const cachePath = browserStateCachePath(targetId);
|
|
1907
1858
|
let mode = "full";
|
|
1908
1859
|
let tree = snapshot.tree;
|
|
1909
1860
|
let changes = { added: snapshot.nodeCount, changed: 0, removed: 0 };
|
|
1910
|
-
if (!options.full &&
|
|
1861
|
+
if (!options.full && existsSync4(cachePath)) {
|
|
1911
1862
|
const cached = readBrowserStateCache(cachePath);
|
|
1912
1863
|
if (cached?.url === snapshot.url && cached.documentId === snapshot.documentId) {
|
|
1913
1864
|
mode = "diff";
|
|
@@ -1916,8 +1867,8 @@ async function computerBrowserStateCommand(path, options = {}) {
|
|
|
1916
1867
|
changes = { added: diff.added, changed: diff.changed, removed: diff.removed };
|
|
1917
1868
|
}
|
|
1918
1869
|
}
|
|
1919
|
-
|
|
1920
|
-
|
|
1870
|
+
mkdirSync4(STATE_DIR, { recursive: true });
|
|
1871
|
+
writeFileSync4(cachePath, JSON.stringify({ url: snapshot.url, documentId: snapshot.documentId, entries }));
|
|
1921
1872
|
const stableState = isRecord(stability.state) ? stability.state : {};
|
|
1922
1873
|
const state = {
|
|
1923
1874
|
title: snapshot.title,
|
|
@@ -1963,7 +1914,7 @@ function readBrowserRefCache(page) {
|
|
|
1963
1914
|
const targetId = page.id;
|
|
1964
1915
|
if (!targetId) fail("Chrome target has no stable ID. Capture fresh browser state and retry.");
|
|
1965
1916
|
const cachePath = browserStateCachePath(targetId);
|
|
1966
|
-
if (!
|
|
1917
|
+
if (!existsSync4(cachePath)) fail(`No browser state is cached for target ${targetId}. Run replicas computer browser-state <path> --target-id ${targetId} first.`);
|
|
1967
1918
|
const cached = readBrowserStateCache(cachePath);
|
|
1968
1919
|
if (!cached) fail(`Browser state for target ${targetId} is invalid. Capture fresh browser state and retry.`);
|
|
1969
1920
|
if (cached.url !== (page.url ?? "").slice(0, 2e3)) fail("Cached browser state belongs to a different URL. Capture fresh browser state and retry.");
|
|
@@ -2718,16 +2669,96 @@ async function computerBrowserBatchCommand(actionsJson, options = {}) {
|
|
|
2718
2669
|
}
|
|
2719
2670
|
console.log(JSON.stringify({ ok: true, targetId, elapsedMs: Date.now() - startedAt, completed }, null, 2));
|
|
2720
2671
|
}
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2672
|
+
var APP_ALIASES = {
|
|
2673
|
+
chrome: [CHROME_WRAPPER],
|
|
2674
|
+
chromium: ["chromium", "--no-first-run", "--no-default-browser-check"],
|
|
2675
|
+
firefox: ["firefox"],
|
|
2676
|
+
terminal: ["xfce4-terminal"],
|
|
2677
|
+
xterm: ["xterm"],
|
|
2678
|
+
notepad: ["mousepad"],
|
|
2679
|
+
editor: ["mousepad"],
|
|
2680
|
+
files: ["thunar"],
|
|
2681
|
+
filemanager: ["thunar"]
|
|
2682
|
+
};
|
|
2683
|
+
var LEGACY_CHROME_ALIAS = [
|
|
2684
|
+
"google-chrome",
|
|
2685
|
+
"--no-first-run",
|
|
2686
|
+
"--no-default-browser-check",
|
|
2687
|
+
"--start-maximized",
|
|
2688
|
+
"--user-data-dir=/tmp/replicas-computer/chrome-profile"
|
|
2689
|
+
];
|
|
2690
|
+
async function computerLaunchCommand(app, args) {
|
|
2691
|
+
ensureServicesRunning();
|
|
2692
|
+
const baseArgs = app === "chrome" && !existsSync4(CHROME_WRAPPER) ? LEGACY_CHROME_ALIAS : APP_ALIASES[app] ?? [app];
|
|
2693
|
+
const bin = baseArgs[0];
|
|
2694
|
+
const fullArgs = [...baseArgs.slice(1), ...args];
|
|
2695
|
+
const expectsNewChromePage = args.some((arg) => !arg.startsWith("-"));
|
|
2696
|
+
const existingChromePageIds = /* @__PURE__ */ new Set();
|
|
2697
|
+
if (app === "chrome") {
|
|
2698
|
+
try {
|
|
2699
|
+
for (const page of await getChromePages()) {
|
|
2700
|
+
if (page.id) existingChromePageIds.add(page.id);
|
|
2701
|
+
}
|
|
2702
|
+
} catch {
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
const child = spawn2(bin, fullArgs, {
|
|
2706
|
+
env: withDisplay(),
|
|
2707
|
+
detached: true,
|
|
2708
|
+
stdio: "ignore"
|
|
2709
|
+
});
|
|
2710
|
+
child.unref();
|
|
2711
|
+
if (app === "chrome") {
|
|
2712
|
+
const startedAt = Date.now();
|
|
2713
|
+
let pageCount = 0;
|
|
2714
|
+
let readyPage = null;
|
|
2715
|
+
while (Date.now() - startedAt < 15e3) {
|
|
2716
|
+
let pages = [];
|
|
2717
|
+
try {
|
|
2718
|
+
pages = await getChromePages();
|
|
2719
|
+
} catch {
|
|
2720
|
+
}
|
|
2721
|
+
pageCount = pages.length;
|
|
2722
|
+
const candidates = expectsNewChromePage && existingChromePageIds.size > 0 ? pages.filter((page) => page.id && !existingChromePageIds.has(page.id)) : pages;
|
|
2723
|
+
for (const page of candidates) {
|
|
2724
|
+
if (!page.webSocketDebuggerUrl || !page.url || page.url === "about:blank") continue;
|
|
2725
|
+
const readyState = await evaluateChromeTarget(page.webSocketDebuggerUrl, "document.readyState");
|
|
2726
|
+
if (readyState === "interactive" || readyState === "complete") {
|
|
2727
|
+
readyPage = page;
|
|
2728
|
+
break;
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
if (readyPage) break;
|
|
2732
|
+
try {
|
|
2733
|
+
if (child.pid) process.kill(child.pid, 0);
|
|
2734
|
+
} catch {
|
|
2735
|
+
fail(`Chrome exited before its desktop control channel became ready.`);
|
|
2736
|
+
}
|
|
2737
|
+
await sleep(100);
|
|
2738
|
+
}
|
|
2739
|
+
if (!readyPage?.webSocketDebuggerUrl) fail(`Chrome launched but its requested page was not controllable after 15 seconds.`);
|
|
2740
|
+
await sendChromeCommand(readyPage.webSocketDebuggerUrl, "Page.bringToFront", {});
|
|
2741
|
+
console.log(`launched ${bin} (pid ${child.pid}, ready in ${Date.now() - startedAt}ms, page ${readyPage.id}, ${pageCount} open)`);
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
console.log(`launched ${bin} (pid ${child.pid})`);
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
// src/commands/input.ts
|
|
2748
|
+
var recordingMousePosition = () => {
|
|
2749
|
+
const mouse = getMouseLocation();
|
|
2750
|
+
return mouse ? { x: mouse.x, y: mouse.y } : null;
|
|
2751
|
+
};
|
|
2752
|
+
async function computerClickCommand(xStr, yStr, options) {
|
|
2753
|
+
const dimensions = getDisplayDimensions();
|
|
2754
|
+
const x = parseScreenCoord(xStr, "x", dimensions.width);
|
|
2755
|
+
const y = parseScreenCoord(yStr, "y", dimensions.height);
|
|
2756
|
+
const button = options.button ?? "1";
|
|
2757
|
+
const args = ["mousemove", "--sync", String(x), String(y)];
|
|
2758
|
+
if (options.modifiers) {
|
|
2759
|
+
for (const mod of options.modifiers.split("+")) {
|
|
2760
|
+
args.push("keydown", mod);
|
|
2761
|
+
}
|
|
2731
2762
|
}
|
|
2732
2763
|
if (options.double) {
|
|
2733
2764
|
args.push("click", "--repeat", "2", "--delay", "50", button);
|
|
@@ -2817,83 +2848,80 @@ async function computerDragCommand(fx, fy, tx, ty) {
|
|
|
2817
2848
|
logRecordingAction({ type: "drag", x: fromX, y: fromY, toX, toY });
|
|
2818
2849
|
console.log(`dragged (${fromX},${fromY}) -> (${toX},${toY})`);
|
|
2819
2850
|
}
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
"
|
|
2835
|
-
"
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2851
|
+
|
|
2852
|
+
// src/commands/system.ts
|
|
2853
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
2854
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
2855
|
+
import { homedir } from "os";
|
|
2856
|
+
import { join } from "path";
|
|
2857
|
+
import chalk from "chalk";
|
|
2858
|
+
function bridgeStatus(details, includeBacklog = false, rootsOnly = false) {
|
|
2859
|
+
const pids = rootsOnly ? details.rootPids ?? [] : details.listenerPids ?? [];
|
|
2860
|
+
const listenerCount = rootsOnly ? details.rootListeners ?? pids.length : details.listeners ?? pids.length;
|
|
2861
|
+
const listenerLabel = rootsOnly ? "root listener" : "listener";
|
|
2862
|
+
const parts = [`${listenerCount} ${listenerLabel}${listenerCount === 1 ? "" : "s"} on ${details.port}`];
|
|
2863
|
+
parts.push(`${details.connections ?? 0} active`);
|
|
2864
|
+
if (includeBacklog) parts.push(`backlog ${details.backlog ?? 0}`);
|
|
2865
|
+
if (pids.length > 0) parts.push(`pid${pids.length === 1 ? "" : "s"} ${pids.join(",")}`);
|
|
2866
|
+
return ` (${parts.join(", ")})`;
|
|
2867
|
+
}
|
|
2868
|
+
async function lookupDesktopViewerUrl() {
|
|
2869
|
+
try {
|
|
2870
|
+
const value = JSON.parse(readFileSync5(join(homedir(), ".replicas", "preview-ports.json"), "utf8"));
|
|
2871
|
+
if (!isRecord(value) || !Array.isArray(value.previews)) return null;
|
|
2872
|
+
const preview = value.previews.find(
|
|
2873
|
+
(item) => isRecord(item) && item.port === STREAM_PORT && typeof item.publicUrl === "string"
|
|
2874
|
+
);
|
|
2875
|
+
return isRecord(preview) && typeof preview.publicUrl === "string" ? getDesktopViewerUrl(preview.publicUrl) : null;
|
|
2876
|
+
} catch {
|
|
2877
|
+
return null;
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
async function waitForDesktopViewerUrl(timeoutMs) {
|
|
2881
|
+
const deadline = Date.now() + timeoutMs;
|
|
2882
|
+
while (Date.now() < deadline) {
|
|
2883
|
+
const url = await lookupDesktopViewerUrl();
|
|
2884
|
+
if (url) return url;
|
|
2885
|
+
await new Promise((resolve4) => setTimeout(resolve4, INFO_WAIT_INTERVAL_MS));
|
|
2886
|
+
}
|
|
2887
|
+
return await lookupDesktopViewerUrl();
|
|
2888
|
+
}
|
|
2889
|
+
async function computerInfoCommand() {
|
|
2890
|
+
const viewerUrl = await waitForDesktopViewerUrl(INFO_WAIT_TIMEOUT_MS);
|
|
2891
|
+
if (!viewerUrl) {
|
|
2892
|
+
fail(
|
|
2893
|
+
`Desktop preview for port ${STREAM_PORT} is not registered. The engine registers it at startup \u2014 check /tmp/replicas-desktop-stack.log and engine logs for errors.`
|
|
2894
|
+
);
|
|
2895
|
+
}
|
|
2896
|
+
console.log(viewerUrl);
|
|
2897
|
+
console.error(chalk.dim(`Share this URL with the user to let them watch the desktop live.`));
|
|
2898
|
+
}
|
|
2899
|
+
async function computerStatusCommand() {
|
|
2839
2900
|
ensureServicesRunning();
|
|
2840
|
-
const
|
|
2841
|
-
const
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2901
|
+
const bridge = getDesktopBridgeStatus();
|
|
2902
|
+
const procs = [
|
|
2903
|
+
["Xvfb", "Xvfb"],
|
|
2904
|
+
["openbox", "openbox"],
|
|
2905
|
+
["tint2", "tint2"],
|
|
2906
|
+
["PulseAudio", "pulseaudio"],
|
|
2907
|
+
["desktop stream", "replicas-computer.*stream"]
|
|
2908
|
+
];
|
|
2909
|
+
for (const [label, pattern] of procs) {
|
|
2910
|
+
const r = spawnSync4("pgrep", ["-af", pattern], { stdio: "pipe" });
|
|
2911
|
+
const running = r.status === 0 && !!r.stdout?.toString().trim();
|
|
2912
|
+
const suffix = label === "desktop stream" && bridge ? bridgeStatus(bridge.stream) : "";
|
|
2913
|
+
console.log(` ${running ? chalk.green("\u25CF") : chalk.red("\u25CB")} ${label}${suffix}`);
|
|
2852
2914
|
}
|
|
2853
|
-
const
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
});
|
|
2858
|
-
child.unref();
|
|
2859
|
-
if (app === "chrome") {
|
|
2860
|
-
const startedAt = Date.now();
|
|
2861
|
-
let pageCount = 0;
|
|
2862
|
-
let readyPage = null;
|
|
2863
|
-
while (Date.now() - startedAt < 15e3) {
|
|
2864
|
-
let pages = [];
|
|
2865
|
-
try {
|
|
2866
|
-
pages = await getChromePages();
|
|
2867
|
-
} catch {
|
|
2868
|
-
}
|
|
2869
|
-
pageCount = pages.length;
|
|
2870
|
-
const candidates = expectsNewChromePage && existingChromePageIds.size > 0 ? pages.filter((page) => page.id && !existingChromePageIds.has(page.id)) : pages;
|
|
2871
|
-
for (const page of candidates) {
|
|
2872
|
-
if (!page.webSocketDebuggerUrl || !page.url || page.url === "about:blank") continue;
|
|
2873
|
-
const readyState = await evaluateChromeTarget(page.webSocketDebuggerUrl, "document.readyState");
|
|
2874
|
-
if (readyState === "interactive" || readyState === "complete") {
|
|
2875
|
-
readyPage = page;
|
|
2876
|
-
break;
|
|
2877
|
-
}
|
|
2878
|
-
}
|
|
2879
|
-
if (readyPage) break;
|
|
2880
|
-
try {
|
|
2881
|
-
if (child.pid) process.kill(child.pid, 0);
|
|
2882
|
-
} catch {
|
|
2883
|
-
fail(`Chrome exited before its desktop control channel became ready.`);
|
|
2884
|
-
}
|
|
2885
|
-
await sleep(100);
|
|
2886
|
-
}
|
|
2887
|
-
if (!readyPage?.webSocketDebuggerUrl) fail(`Chrome launched but its requested page was not controllable after 15 seconds.`);
|
|
2888
|
-
await sendChromeCommand(readyPage.webSocketDebuggerUrl, "Page.bringToFront", {});
|
|
2889
|
-
console.log(`launched ${bin} (pid ${child.pid}, ready in ${Date.now() - startedAt}ms, page ${readyPage.id}, ${pageCount} open)`);
|
|
2890
|
-
return;
|
|
2915
|
+
const viewerUrl = await lookupDesktopViewerUrl();
|
|
2916
|
+
if (viewerUrl) {
|
|
2917
|
+
console.log(` ${chalk.cyan("preview")}: ${viewerUrl}`);
|
|
2918
|
+
} else {
|
|
2919
|
+
console.log(` ${chalk.dim("preview: not yet registered (engine registers it at startup)")}`);
|
|
2891
2920
|
}
|
|
2892
|
-
console.log(`launched ${bin} (pid ${child.pid})`);
|
|
2893
2921
|
}
|
|
2894
2922
|
|
|
2895
2923
|
// src/version.ts
|
|
2896
|
-
var COMPUTER_USE_VERSION = "0.
|
|
2924
|
+
var COMPUTER_USE_VERSION = "0.2.0";
|
|
2897
2925
|
var COMPUTER_USE_PROTOCOL_VERSION = 1;
|
|
2898
2926
|
|
|
2899
2927
|
// src/program.ts
|
|
@@ -2971,6 +2999,8 @@ async function runDirectCommand(argv, cwd = process.cwd()) {
|
|
|
2971
2999
|
// src/daemon.ts
|
|
2972
3000
|
var COMPUTER_USE_SOCKET_PATH = process.env.REPLICAS_COMPUTER_SOCKET || `${STATE_DIR}/control.sock`;
|
|
2973
3001
|
var MAX_REQUEST_BYTES = 1024 * 1024;
|
|
3002
|
+
var MAX_CLIENTS = 256;
|
|
3003
|
+
var REQUEST_READ_TIMEOUT_MS = 1e4;
|
|
2974
3004
|
function isDaemonRequest(value) {
|
|
2975
3005
|
return typeof value === "object" && value !== null && "protocolVersion" in value && value.protocolVersion === COMPUTER_USE_PROTOCOL_VERSION && "cwd" in value && typeof value.cwd === "string" && value.cwd.startsWith("/") && "argv" in value && Array.isArray(value.argv) && value.argv.length <= 100 && value.argv.every((arg) => typeof arg === "string" && arg.length <= 1e5);
|
|
2976
3006
|
}
|
|
@@ -2979,27 +3009,38 @@ function writeResult(socket, result) {
|
|
|
2979
3009
|
`);
|
|
2980
3010
|
}
|
|
2981
3011
|
async function hasSocketListener() {
|
|
2982
|
-
if (!
|
|
2983
|
-
return await new Promise((
|
|
3012
|
+
if (!existsSync5(COMPUTER_USE_SOCKET_PATH)) return false;
|
|
3013
|
+
return await new Promise((resolve4) => {
|
|
2984
3014
|
const socket = createConnection(COMPUTER_USE_SOCKET_PATH);
|
|
2985
3015
|
socket.setTimeout(500, () => socket.destroy());
|
|
2986
3016
|
socket.once("connect", () => {
|
|
2987
3017
|
socket.destroy();
|
|
2988
|
-
|
|
3018
|
+
resolve4(true);
|
|
2989
3019
|
});
|
|
2990
|
-
socket.once("error", () =>
|
|
2991
|
-
socket.once("close", () =>
|
|
3020
|
+
socket.once("error", () => resolve4(false));
|
|
3021
|
+
socket.once("close", () => resolve4(false));
|
|
2992
3022
|
});
|
|
2993
3023
|
}
|
|
2994
3024
|
async function runComputerDaemon() {
|
|
2995
3025
|
if (await hasSocketListener()) throw new Error(`Computer-use daemon is already listening on ${COMPUTER_USE_SOCKET_PATH}`);
|
|
2996
3026
|
ensureServicesRunning();
|
|
2997
|
-
|
|
3027
|
+
mkdirSync5(dirname5(COMPUTER_USE_SOCKET_PATH), { recursive: true, mode: 448 });
|
|
2998
3028
|
rmSync4(COMPUTER_USE_SOCKET_PATH, { force: true });
|
|
2999
3029
|
let queue = Promise.resolve();
|
|
3030
|
+
const clients = /* @__PURE__ */ new Set();
|
|
3000
3031
|
const server = createServer({ allowHalfOpen: true }, (socket) => {
|
|
3032
|
+
if (clients.size >= MAX_CLIENTS) {
|
|
3033
|
+
writeResult(socket, { exitCode: 75, stdout: "", stderr: "Computer-use daemon is busy; retry the request\n" });
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3001
3036
|
let input = "";
|
|
3002
3037
|
let rejected = false;
|
|
3038
|
+
clients.add(socket);
|
|
3039
|
+
socket.once("close", () => clients.delete(socket));
|
|
3040
|
+
socket.setTimeout(REQUEST_READ_TIMEOUT_MS, () => {
|
|
3041
|
+
rejected = true;
|
|
3042
|
+
writeResult(socket, { exitCode: 2, stdout: "", stderr: "Computer-use request was not received within 10 seconds\n" });
|
|
3043
|
+
});
|
|
3003
3044
|
socket.setEncoding("utf8");
|
|
3004
3045
|
socket.on("data", (chunk) => {
|
|
3005
3046
|
if (rejected) return;
|
|
@@ -3011,6 +3052,7 @@ async function runComputerDaemon() {
|
|
|
3011
3052
|
});
|
|
3012
3053
|
socket.on("end", () => {
|
|
3013
3054
|
if (rejected) return;
|
|
3055
|
+
socket.setTimeout(0);
|
|
3014
3056
|
queue = queue.then(async () => {
|
|
3015
3057
|
let request;
|
|
3016
3058
|
try {
|
|
@@ -3030,33 +3072,35 @@ async function runComputerDaemon() {
|
|
|
3030
3072
|
});
|
|
3031
3073
|
});
|
|
3032
3074
|
});
|
|
3033
|
-
await new Promise((
|
|
3075
|
+
await new Promise((resolve4, reject) => {
|
|
3034
3076
|
server.once("error", reject);
|
|
3035
3077
|
server.listen(COMPUTER_USE_SOCKET_PATH, () => {
|
|
3036
3078
|
server.off("error", reject);
|
|
3037
3079
|
chmodSync(COMPUTER_USE_SOCKET_PATH, 384);
|
|
3038
3080
|
console.log(`replicas-computer ${COMPUTER_USE_VERSION} listening on ${COMPUTER_USE_SOCKET_PATH}`);
|
|
3039
|
-
|
|
3081
|
+
resolve4();
|
|
3040
3082
|
});
|
|
3041
3083
|
});
|
|
3042
3084
|
const healthTimer = setInterval(() => {
|
|
3043
|
-
|
|
3085
|
+
try {
|
|
3086
|
+
ensureServicesRunning();
|
|
3087
|
+
} catch (error) {
|
|
3044
3088
|
console.error(error instanceof Error ? error.message : String(error));
|
|
3045
|
-
}
|
|
3089
|
+
}
|
|
3046
3090
|
}, 5e3);
|
|
3047
3091
|
healthTimer.unref();
|
|
3048
|
-
await new Promise((
|
|
3092
|
+
await new Promise((resolve4) => {
|
|
3049
3093
|
let closing = false;
|
|
3050
3094
|
const close = () => {
|
|
3051
3095
|
if (closing) return;
|
|
3052
3096
|
closing = true;
|
|
3053
3097
|
clearInterval(healthTimer);
|
|
3054
|
-
server.close(() =>
|
|
3098
|
+
server.close(() => resolve4());
|
|
3055
3099
|
};
|
|
3056
3100
|
process.once("SIGTERM", close);
|
|
3057
3101
|
process.once("SIGINT", close);
|
|
3058
3102
|
});
|
|
3059
|
-
|
|
3103
|
+
spawnSync5("bash", [SERVICES_SCRIPT, "--stop"], { stdio: "inherit" });
|
|
3060
3104
|
rmSync4(COMPUTER_USE_SOCKET_PATH, { force: true });
|
|
3061
3105
|
}
|
|
3062
3106
|
|
|
@@ -3065,7 +3109,7 @@ var DAEMON_STARTUP_TIMEOUT_MS = 5e3;
|
|
|
3065
3109
|
var ComputerDaemonUnavailableError = class extends Error {
|
|
3066
3110
|
};
|
|
3067
3111
|
function sendComputerRequest(argv) {
|
|
3068
|
-
return new Promise((
|
|
3112
|
+
return new Promise((resolve4, reject) => {
|
|
3069
3113
|
const socket = createConnection2(COMPUTER_USE_SOCKET_PATH);
|
|
3070
3114
|
let response = "";
|
|
3071
3115
|
let settled = false;
|
|
@@ -3073,7 +3117,7 @@ function sendComputerRequest(argv) {
|
|
|
3073
3117
|
if (settled) return;
|
|
3074
3118
|
settled = true;
|
|
3075
3119
|
if (error) reject(error);
|
|
3076
|
-
else if (result)
|
|
3120
|
+
else if (result) resolve4(result);
|
|
3077
3121
|
};
|
|
3078
3122
|
socket.setEncoding("utf8");
|
|
3079
3123
|
socket.setTimeout(10 * 60 * 1e3, () => socket.destroy(new Error("Computer-use command timed out")));
|
|
@@ -3111,16 +3155,645 @@ async function runComputerClient(argv) {
|
|
|
3111
3155
|
return await sendComputerRequest(argv);
|
|
3112
3156
|
} catch (error) {
|
|
3113
3157
|
if (!(error instanceof ComputerDaemonUnavailableError) || Date.now() >= deadline) throw error;
|
|
3114
|
-
await new Promise((
|
|
3158
|
+
await new Promise((resolve4) => setTimeout(resolve4, 100));
|
|
3115
3159
|
}
|
|
3116
3160
|
}
|
|
3117
3161
|
}
|
|
3118
3162
|
|
|
3119
|
-
// src/
|
|
3120
|
-
import {
|
|
3121
|
-
import {
|
|
3122
|
-
import {
|
|
3163
|
+
// src/media-server.ts
|
|
3164
|
+
import { spawn as spawn4 } from "child_process";
|
|
3165
|
+
import { createSocket } from "dgram";
|
|
3166
|
+
import { createReadStream, existsSync as existsSync6, statSync as statSync2 } from "fs";
|
|
3167
|
+
import { createServer as createServer2 } from "http";
|
|
3168
|
+
import { dirname as dirname6, extname, resolve as resolve2 } from "path";
|
|
3123
3169
|
import { fileURLToPath } from "url";
|
|
3170
|
+
|
|
3171
|
+
// src/input-bridge.ts
|
|
3172
|
+
import { spawn as spawn3 } from "child_process";
|
|
3173
|
+
var namedKeys = {
|
|
3174
|
+
AltLeft: "Alt_L",
|
|
3175
|
+
AltRight: "Alt_R",
|
|
3176
|
+
ArrowDown: "Down",
|
|
3177
|
+
ArrowLeft: "Left",
|
|
3178
|
+
ArrowRight: "Right",
|
|
3179
|
+
ArrowUp: "Up",
|
|
3180
|
+
Backquote: "grave",
|
|
3181
|
+
Backslash: "backslash",
|
|
3182
|
+
Backspace: "BackSpace",
|
|
3183
|
+
BracketLeft: "bracketleft",
|
|
3184
|
+
BracketRight: "bracketright",
|
|
3185
|
+
Comma: "comma",
|
|
3186
|
+
ContextMenu: "Menu",
|
|
3187
|
+
ControlLeft: "Control_L",
|
|
3188
|
+
ControlRight: "Control_R",
|
|
3189
|
+
Delete: "Delete",
|
|
3190
|
+
End: "End",
|
|
3191
|
+
Enter: "Return",
|
|
3192
|
+
Equal: "equal",
|
|
3193
|
+
Escape: "Escape",
|
|
3194
|
+
Home: "Home",
|
|
3195
|
+
Insert: "Insert",
|
|
3196
|
+
MetaLeft: "Super_L",
|
|
3197
|
+
MetaRight: "Super_R",
|
|
3198
|
+
Minus: "minus",
|
|
3199
|
+
PageDown: "Page_Down",
|
|
3200
|
+
PageUp: "Page_Up",
|
|
3201
|
+
Period: "period",
|
|
3202
|
+
Quote: "apostrophe",
|
|
3203
|
+
Semicolon: "semicolon",
|
|
3204
|
+
ShiftLeft: "Shift_L",
|
|
3205
|
+
ShiftRight: "Shift_R",
|
|
3206
|
+
Slash: "slash",
|
|
3207
|
+
Space: "space",
|
|
3208
|
+
Tab: "Tab"
|
|
3209
|
+
};
|
|
3210
|
+
function xKey(code) {
|
|
3211
|
+
if (/^Key[A-Z]$/.test(code)) return code.slice(-1).toLowerCase();
|
|
3212
|
+
if (/^Digit[0-9]$/.test(code)) return code.slice(-1);
|
|
3213
|
+
if (/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(code)) return code;
|
|
3214
|
+
if (/^Numpad[0-9]$/.test(code)) return `KP_${code.slice(-1)}`;
|
|
3215
|
+
return namedKeys[code];
|
|
3216
|
+
}
|
|
3217
|
+
var InputBridge = class {
|
|
3218
|
+
constructor(display2, width2, height2) {
|
|
3219
|
+
this.display = display2;
|
|
3220
|
+
this.width = width2;
|
|
3221
|
+
this.height = height2;
|
|
3222
|
+
}
|
|
3223
|
+
display;
|
|
3224
|
+
width;
|
|
3225
|
+
height;
|
|
3226
|
+
process;
|
|
3227
|
+
pointer;
|
|
3228
|
+
pointerFlush;
|
|
3229
|
+
pressedButtons = /* @__PURE__ */ new Set();
|
|
3230
|
+
pressedKeys = /* @__PURE__ */ new Set();
|
|
3231
|
+
close() {
|
|
3232
|
+
this.release();
|
|
3233
|
+
this.process?.kill("SIGTERM");
|
|
3234
|
+
this.process = void 0;
|
|
3235
|
+
}
|
|
3236
|
+
handle(message) {
|
|
3237
|
+
if (message.type === "pointer") {
|
|
3238
|
+
this.pointer = this.coordinates(message.x, message.y);
|
|
3239
|
+
this.pointerFlush ??= setImmediate(() => this.flushPointer());
|
|
3240
|
+
return;
|
|
3241
|
+
}
|
|
3242
|
+
if (message.type === "button") {
|
|
3243
|
+
this.pointer = this.coordinates(message.x, message.y);
|
|
3244
|
+
this.flushPointer();
|
|
3245
|
+
const button = message.button === 0 ? 1 : message.button === 1 ? 2 : message.button === 2 ? 3 : 0;
|
|
3246
|
+
if (!button) return;
|
|
3247
|
+
if (message.down) {
|
|
3248
|
+
if (this.pressedButtons.has(button)) return;
|
|
3249
|
+
this.pressedButtons.add(button);
|
|
3250
|
+
this.write(`mousedown ${button}`);
|
|
3251
|
+
} else {
|
|
3252
|
+
this.pressedButtons.delete(button);
|
|
3253
|
+
this.write(`mouseup ${button}`);
|
|
3254
|
+
}
|
|
3255
|
+
return;
|
|
3256
|
+
}
|
|
3257
|
+
if (message.type === "wheel") {
|
|
3258
|
+
this.pointer = this.coordinates(message.x, message.y);
|
|
3259
|
+
this.flushPointer();
|
|
3260
|
+
const vertical = Math.min(4, Math.max(1, Math.round(Math.abs(message.deltaY) / 80)));
|
|
3261
|
+
const horizontal = Math.min(4, Math.max(1, Math.round(Math.abs(message.deltaX) / 80)));
|
|
3262
|
+
if (Math.abs(message.deltaY) >= Math.abs(message.deltaX) && message.deltaY) {
|
|
3263
|
+
for (let index = 0; index < vertical; index += 1) this.write(`mouseclick ${message.deltaY < 0 ? 4 : 5}`);
|
|
3264
|
+
} else if (message.deltaX) {
|
|
3265
|
+
for (let index = 0; index < horizontal; index += 1) this.write(`mouseclick ${message.deltaX < 0 ? 6 : 7}`);
|
|
3266
|
+
}
|
|
3267
|
+
return;
|
|
3268
|
+
}
|
|
3269
|
+
if (message.type === "key") {
|
|
3270
|
+
const key = xKey(message.code);
|
|
3271
|
+
if (!key || message.repeat) return;
|
|
3272
|
+
if (message.down) {
|
|
3273
|
+
if (this.pressedKeys.has(key)) return;
|
|
3274
|
+
this.pressedKeys.add(key);
|
|
3275
|
+
this.write(`keydown ${key}`);
|
|
3276
|
+
} else {
|
|
3277
|
+
this.pressedKeys.delete(key);
|
|
3278
|
+
this.write(`keyup ${key}`);
|
|
3279
|
+
}
|
|
3280
|
+
return;
|
|
3281
|
+
}
|
|
3282
|
+
if (message.type === "release") this.release();
|
|
3283
|
+
}
|
|
3284
|
+
release() {
|
|
3285
|
+
if (this.pointerFlush) clearImmediate(this.pointerFlush);
|
|
3286
|
+
this.pointerFlush = void 0;
|
|
3287
|
+
this.pointer = void 0;
|
|
3288
|
+
for (const button of this.pressedButtons) this.write(`mouseup ${button}`);
|
|
3289
|
+
for (const key of this.pressedKeys) this.write(`keyup ${key}`);
|
|
3290
|
+
this.pressedButtons.clear();
|
|
3291
|
+
this.pressedKeys.clear();
|
|
3292
|
+
}
|
|
3293
|
+
coordinates(x, y) {
|
|
3294
|
+
return {
|
|
3295
|
+
x: Math.round(Math.min(1, Math.max(0, x)) * (this.width - 1)),
|
|
3296
|
+
y: Math.round(Math.min(1, Math.max(0, y)) * (this.height - 1))
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
flushPointer() {
|
|
3300
|
+
if (this.pointerFlush) clearImmediate(this.pointerFlush);
|
|
3301
|
+
this.pointerFlush = void 0;
|
|
3302
|
+
if (!this.pointer) return;
|
|
3303
|
+
this.write(`mousemove ${this.pointer.x} ${this.pointer.y}`);
|
|
3304
|
+
this.pointer = void 0;
|
|
3305
|
+
}
|
|
3306
|
+
write(command) {
|
|
3307
|
+
if (!this.process || this.process.exitCode !== null || this.process.stdin.destroyed) {
|
|
3308
|
+
const child = spawn3("xte", [], {
|
|
3309
|
+
env: { ...process.env, DISPLAY: this.display },
|
|
3310
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3311
|
+
});
|
|
3312
|
+
child.stdout.resume();
|
|
3313
|
+
child.stderr.resume();
|
|
3314
|
+
child.stdin.on("error", () => {
|
|
3315
|
+
});
|
|
3316
|
+
child.once("exit", () => {
|
|
3317
|
+
if (this.process === child) this.process = void 0;
|
|
3318
|
+
});
|
|
3319
|
+
this.process = child;
|
|
3320
|
+
}
|
|
3321
|
+
this.process.stdin.write(`${command}
|
|
3322
|
+
`);
|
|
3323
|
+
}
|
|
3324
|
+
};
|
|
3325
|
+
|
|
3326
|
+
// src/media-protocol.ts
|
|
3327
|
+
var STREAM_PROTOCOL_VERSION = 1;
|
|
3328
|
+
var MEDIA_PACKET_HEADER_BYTES = 12;
|
|
3329
|
+
|
|
3330
|
+
// src/rtp.ts
|
|
3331
|
+
function parseRtpPacket(data) {
|
|
3332
|
+
if (data.length < 12 || data[0] >> 6 !== 2) return null;
|
|
3333
|
+
if (data[1] >= 192 && data[1] <= 223) return null;
|
|
3334
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
3335
|
+
const padding = Boolean(data[0] & 32);
|
|
3336
|
+
const extension = Boolean(data[0] & 16);
|
|
3337
|
+
let offset = 12 + (data[0] & 15) * 4;
|
|
3338
|
+
if (offset > data.length) return null;
|
|
3339
|
+
if (extension) {
|
|
3340
|
+
if (offset + 4 > data.length) return null;
|
|
3341
|
+
offset += 4 + view.getUint16(offset + 2) * 4;
|
|
3342
|
+
}
|
|
3343
|
+
const end = data.length - (padding ? data[data.length - 1] : 0);
|
|
3344
|
+
if (offset >= end) return null;
|
|
3345
|
+
return {
|
|
3346
|
+
marker: Boolean(data[1] & 128),
|
|
3347
|
+
payload: data.subarray(offset, end),
|
|
3348
|
+
sequence: view.getUint16(2),
|
|
3349
|
+
timestamp: view.getUint32(4)
|
|
3350
|
+
};
|
|
3351
|
+
}
|
|
3352
|
+
var RtpClock = class {
|
|
3353
|
+
constructor(rate) {
|
|
3354
|
+
this.rate = rate;
|
|
3355
|
+
}
|
|
3356
|
+
rate;
|
|
3357
|
+
base;
|
|
3358
|
+
last;
|
|
3359
|
+
wraps = 0;
|
|
3360
|
+
reset() {
|
|
3361
|
+
this.base = void 0;
|
|
3362
|
+
this.last = void 0;
|
|
3363
|
+
this.wraps = 0;
|
|
3364
|
+
}
|
|
3365
|
+
microseconds(timestamp) {
|
|
3366
|
+
if (this.last !== void 0 && timestamp < this.last && this.last - timestamp > 2147483648) this.wraps += 1;
|
|
3367
|
+
this.last = timestamp;
|
|
3368
|
+
const extended = timestamp + this.wraps * 4294967296;
|
|
3369
|
+
this.base ??= extended;
|
|
3370
|
+
return Math.round((extended - this.base) * 1e6 / this.rate);
|
|
3371
|
+
}
|
|
3372
|
+
};
|
|
3373
|
+
var startCode = new Uint8Array([0, 0, 0, 1]);
|
|
3374
|
+
var H264RtpAssembler = class {
|
|
3375
|
+
clock = new RtpClock(9e4);
|
|
3376
|
+
chunks = [];
|
|
3377
|
+
key = false;
|
|
3378
|
+
timestamp;
|
|
3379
|
+
sequence;
|
|
3380
|
+
fragmentType;
|
|
3381
|
+
damaged = false;
|
|
3382
|
+
clearFrame() {
|
|
3383
|
+
this.chunks = [];
|
|
3384
|
+
this.key = false;
|
|
3385
|
+
this.timestamp = void 0;
|
|
3386
|
+
this.sequence = void 0;
|
|
3387
|
+
this.fragmentType = void 0;
|
|
3388
|
+
this.damaged = false;
|
|
3389
|
+
}
|
|
3390
|
+
reset() {
|
|
3391
|
+
this.clock.reset();
|
|
3392
|
+
this.clearFrame();
|
|
3393
|
+
}
|
|
3394
|
+
push(packet) {
|
|
3395
|
+
if (this.timestamp !== void 0 && packet.timestamp !== this.timestamp) {
|
|
3396
|
+
this.clearFrame();
|
|
3397
|
+
}
|
|
3398
|
+
this.timestamp ??= packet.timestamp;
|
|
3399
|
+
if (this.sequence !== void 0 && packet.sequence !== (this.sequence + 1) % 65536) this.damaged = true;
|
|
3400
|
+
this.sequence = packet.sequence;
|
|
3401
|
+
const type = packet.payload[0] & 31;
|
|
3402
|
+
if (this.damaged) {
|
|
3403
|
+
} else if (type > 0 && type < 24 && this.fragmentType === void 0) {
|
|
3404
|
+
this.chunks.push(startCode, packet.payload);
|
|
3405
|
+
this.key ||= type === 5;
|
|
3406
|
+
} else if (type === 24 && this.fragmentType === void 0) {
|
|
3407
|
+
let offset2 = 1;
|
|
3408
|
+
let nals = 0;
|
|
3409
|
+
while (offset2 < packet.payload.length) {
|
|
3410
|
+
if (offset2 + 2 > packet.payload.length) {
|
|
3411
|
+
this.damaged = true;
|
|
3412
|
+
break;
|
|
3413
|
+
}
|
|
3414
|
+
const size = packet.payload[offset2] * 256 + packet.payload[offset2 + 1];
|
|
3415
|
+
offset2 += 2;
|
|
3416
|
+
if (!size || offset2 + size > packet.payload.length) {
|
|
3417
|
+
this.damaged = true;
|
|
3418
|
+
break;
|
|
3419
|
+
}
|
|
3420
|
+
const nal = packet.payload.subarray(offset2, offset2 + size);
|
|
3421
|
+
this.chunks.push(startCode, nal);
|
|
3422
|
+
this.key ||= (nal[0] & 31) === 5;
|
|
3423
|
+
offset2 += size;
|
|
3424
|
+
nals += 1;
|
|
3425
|
+
}
|
|
3426
|
+
if (!nals) this.damaged = true;
|
|
3427
|
+
} else if (type === 28 && packet.payload.length >= 3) {
|
|
3428
|
+
const fragmentType = packet.payload[1] & 31;
|
|
3429
|
+
const start = Boolean(packet.payload[1] & 128);
|
|
3430
|
+
const end = Boolean(packet.payload[1] & 64);
|
|
3431
|
+
if (start && !end && this.fragmentType === void 0) {
|
|
3432
|
+
this.fragmentType = fragmentType;
|
|
3433
|
+
this.chunks.push(startCode, new Uint8Array([packet.payload[0] & 224 | fragmentType]));
|
|
3434
|
+
this.key ||= fragmentType === 5;
|
|
3435
|
+
} else if (!start && this.fragmentType === fragmentType) {
|
|
3436
|
+
if (end) this.fragmentType = void 0;
|
|
3437
|
+
} else {
|
|
3438
|
+
this.damaged = true;
|
|
3439
|
+
}
|
|
3440
|
+
if (!this.damaged) this.chunks.push(packet.payload.subarray(2));
|
|
3441
|
+
} else this.damaged = true;
|
|
3442
|
+
if (!packet.marker) return null;
|
|
3443
|
+
if (this.damaged || this.fragmentType !== void 0 || this.chunks.length === 0) {
|
|
3444
|
+
this.clearFrame();
|
|
3445
|
+
return null;
|
|
3446
|
+
}
|
|
3447
|
+
const length = this.chunks.reduce((total, chunk) => total + chunk.length, 0);
|
|
3448
|
+
const data = new Uint8Array(length);
|
|
3449
|
+
let offset = 0;
|
|
3450
|
+
for (const chunk of this.chunks) {
|
|
3451
|
+
data.set(chunk, offset);
|
|
3452
|
+
offset += chunk.length;
|
|
3453
|
+
}
|
|
3454
|
+
const frame = { data, key: this.key, timestamp: this.clock.microseconds(packet.timestamp) };
|
|
3455
|
+
this.clearFrame();
|
|
3456
|
+
return frame;
|
|
3457
|
+
}
|
|
3458
|
+
};
|
|
3459
|
+
|
|
3460
|
+
// src/media-server.ts
|
|
3461
|
+
import WebSocket2, { WebSocketServer } from "ws";
|
|
3462
|
+
var width = Number.parseInt(process.env.REPLICAS_DESKTOP_WIDTH || String(DESKTOP_VIEWER_WIDTH), 10);
|
|
3463
|
+
var height = Number.parseInt(process.env.REPLICAS_DESKTOP_HEIGHT || String(DESKTOP_VIEWER_HEIGHT), 10);
|
|
3464
|
+
var fps = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_FPS || "60", 10);
|
|
3465
|
+
var keyframeInterval = fps;
|
|
3466
|
+
var port = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_PORT || String(DESKTOP_STREAM_PORT), 10);
|
|
3467
|
+
var display = process.env.REPLICAS_DESKTOP_DISPLAY?.replace(/^([^:])/, ":$1") || ":99";
|
|
3468
|
+
var videoCodec = "avc1.42E02A";
|
|
3469
|
+
var config = {
|
|
3470
|
+
type: "config",
|
|
3471
|
+
protocolVersion: STREAM_PROTOCOL_VERSION,
|
|
3472
|
+
width,
|
|
3473
|
+
height,
|
|
3474
|
+
fps,
|
|
3475
|
+
videoCodec,
|
|
3476
|
+
audioCodec: "opus",
|
|
3477
|
+
audioSampleRate: 48e3,
|
|
3478
|
+
audioChannels: 2
|
|
3479
|
+
};
|
|
3480
|
+
var packageRoot = resolve2(dirname6(fileURLToPath(import.meta.url)), "..");
|
|
3481
|
+
var webroot = process.env.REPLICAS_DESKTOP_STREAM_WEBROOT || resolve2(packageRoot, "assets", "desktop");
|
|
3482
|
+
var contentTypes = {
|
|
3483
|
+
".css": "text/css; charset=utf-8",
|
|
3484
|
+
".html": "text/html; charset=utf-8",
|
|
3485
|
+
".js": "text/javascript; charset=utf-8",
|
|
3486
|
+
".svg": "image/svg+xml",
|
|
3487
|
+
".woff2": "font/woff2"
|
|
3488
|
+
};
|
|
3489
|
+
function bindUdp() {
|
|
3490
|
+
return new Promise((resolveSocket, reject) => {
|
|
3491
|
+
const socket = createSocket("udp4");
|
|
3492
|
+
socket.once("error", reject);
|
|
3493
|
+
socket.bind(0, "127.0.0.1", () => {
|
|
3494
|
+
socket.off("error", reject);
|
|
3495
|
+
socket.setRecvBufferSize(4 * 1024 * 1024);
|
|
3496
|
+
resolveSocket(socket);
|
|
3497
|
+
});
|
|
3498
|
+
});
|
|
3499
|
+
}
|
|
3500
|
+
function mediaPacket(kind, timestamp, payload) {
|
|
3501
|
+
const header = Buffer.alloc(MEDIA_PACKET_HEADER_BYTES);
|
|
3502
|
+
header[0] = kind;
|
|
3503
|
+
header[1] = STREAM_PROTOCOL_VERSION;
|
|
3504
|
+
header.writeBigUInt64BE(BigInt(Math.max(0, timestamp)), 4);
|
|
3505
|
+
return Buffer.concat([header, payload]);
|
|
3506
|
+
}
|
|
3507
|
+
function validInput(value) {
|
|
3508
|
+
if (!value || typeof value !== "object" || !("type" in value) || typeof value.type !== "string") return false;
|
|
3509
|
+
if (value.type === "release") return true;
|
|
3510
|
+
if (value.type === "stats") return "videoFps" in value && typeof value.videoFps === "number" && Number.isFinite(value.videoFps) && "audioPacketsPerSecond" in value && typeof value.audioPacketsPerSecond === "number" && Number.isFinite(value.audioPacketsPerSecond) && "decodeQueue" in value && typeof value.decodeQueue === "number" && Number.isFinite(value.decodeQueue) && "rttMs" in value && typeof value.rttMs === "number" && Number.isFinite(value.rttMs);
|
|
3511
|
+
if (value.type === "ping") return "sentAt" in value && typeof value.sentAt === "number";
|
|
3512
|
+
if (value.type === "key") return "code" in value && typeof value.code === "string" && value.code.length < 40 && "down" in value && typeof value.down === "boolean";
|
|
3513
|
+
if (!("x" in value) || !("y" in value) || typeof value.x !== "number" || typeof value.y !== "number") return false;
|
|
3514
|
+
if (value.type === "pointer") return true;
|
|
3515
|
+
if (value.type === "button") return "button" in value && typeof value.button === "number" && "down" in value && typeof value.down === "boolean";
|
|
3516
|
+
return value.type === "wheel" && "deltaX" in value && typeof value.deltaX === "number" && "deltaY" in value && typeof value.deltaY === "number";
|
|
3517
|
+
}
|
|
3518
|
+
async function runMediaServer() {
|
|
3519
|
+
if (![width, height, fps, port].every((value) => Number.isInteger(value) && value > 0)) throw new Error("Invalid desktop stream dimensions, frame rate, or port");
|
|
3520
|
+
const videoSocket = await bindUdp();
|
|
3521
|
+
const audioSocket = await bindUdp();
|
|
3522
|
+
const videoPort = videoSocket.address().port;
|
|
3523
|
+
const audioPort = audioSocket.address().port;
|
|
3524
|
+
const videoAssembler = new H264RtpAssembler();
|
|
3525
|
+
const audioClock = new RtpClock(48e3);
|
|
3526
|
+
const clients = /* @__PURE__ */ new Map();
|
|
3527
|
+
let encoder;
|
|
3528
|
+
let encoderRestart;
|
|
3529
|
+
let encoderStop;
|
|
3530
|
+
let lastVideoAt = 0;
|
|
3531
|
+
let lastAudioAt = 0;
|
|
3532
|
+
let videoFrames = 0;
|
|
3533
|
+
let audioPackets = 0;
|
|
3534
|
+
let videoBytes = 0;
|
|
3535
|
+
let audioBytes = 0;
|
|
3536
|
+
const broadcast = (packet, keyframe = false) => {
|
|
3537
|
+
for (const [client, state] of clients) {
|
|
3538
|
+
if (client.readyState !== WebSocket2.OPEN) continue;
|
|
3539
|
+
if (client.bufferedAmount > 4 * 1024 * 1024) {
|
|
3540
|
+
client.close(1013, "stream client is too slow");
|
|
3541
|
+
continue;
|
|
3542
|
+
}
|
|
3543
|
+
if (!keyframe && (state.waitingForKeyframe || client.bufferedAmount > 512 * 1024)) {
|
|
3544
|
+
state.waitingForKeyframe = true;
|
|
3545
|
+
continue;
|
|
3546
|
+
}
|
|
3547
|
+
if (keyframe) state.waitingForKeyframe = false;
|
|
3548
|
+
client.send(packet, { binary: true });
|
|
3549
|
+
}
|
|
3550
|
+
};
|
|
3551
|
+
videoSocket.on("message", (data) => {
|
|
3552
|
+
const rtp = parseRtpPacket(data);
|
|
3553
|
+
if (!rtp) return;
|
|
3554
|
+
const frame = videoAssembler.push(rtp);
|
|
3555
|
+
if (!frame) return;
|
|
3556
|
+
lastVideoAt = Date.now();
|
|
3557
|
+
videoFrames += 1;
|
|
3558
|
+
videoBytes += frame.data.byteLength;
|
|
3559
|
+
const packet = mediaPacket(frame.key ? 1 /* VideoKey */ : 2 /* VideoDelta */, frame.timestamp, frame.data);
|
|
3560
|
+
broadcast(packet, frame.key);
|
|
3561
|
+
});
|
|
3562
|
+
audioSocket.on("message", (data) => {
|
|
3563
|
+
const rtp = parseRtpPacket(data);
|
|
3564
|
+
if (!rtp) return;
|
|
3565
|
+
lastAudioAt = Date.now();
|
|
3566
|
+
audioPackets += 1;
|
|
3567
|
+
audioBytes += rtp.payload.byteLength;
|
|
3568
|
+
broadcast(mediaPacket(3 /* Audio */, audioClock.microseconds(rtp.timestamp), rtp.payload));
|
|
3569
|
+
});
|
|
3570
|
+
const startEncoder = () => {
|
|
3571
|
+
clearTimeout(encoderStop);
|
|
3572
|
+
if (encoder && encoder.exitCode === null) return;
|
|
3573
|
+
clearTimeout(encoderRestart);
|
|
3574
|
+
videoAssembler.reset();
|
|
3575
|
+
audioClock.reset();
|
|
3576
|
+
const videoBitrate = process.env.REPLICAS_DESKTOP_STREAM_VIDEO_BITRATE || "12M";
|
|
3577
|
+
encoder = spawn4("ffmpeg", [
|
|
3578
|
+
"-hide_banner",
|
|
3579
|
+
"-loglevel",
|
|
3580
|
+
"warning",
|
|
3581
|
+
"-fflags",
|
|
3582
|
+
"nobuffer",
|
|
3583
|
+
"-flags",
|
|
3584
|
+
"low_delay",
|
|
3585
|
+
"-thread_queue_size",
|
|
3586
|
+
"1024",
|
|
3587
|
+
"-f",
|
|
3588
|
+
"x11grab",
|
|
3589
|
+
"-draw_mouse",
|
|
3590
|
+
"0",
|
|
3591
|
+
"-framerate",
|
|
3592
|
+
String(fps),
|
|
3593
|
+
"-video_size",
|
|
3594
|
+
`${width}x${height}`,
|
|
3595
|
+
"-use_wallclock_as_timestamps",
|
|
3596
|
+
"1",
|
|
3597
|
+
"-i",
|
|
3598
|
+
`${display}.0`,
|
|
3599
|
+
"-thread_queue_size",
|
|
3600
|
+
"1024",
|
|
3601
|
+
"-f",
|
|
3602
|
+
"pulse",
|
|
3603
|
+
"-fragment_size",
|
|
3604
|
+
"2048",
|
|
3605
|
+
"-use_wallclock_as_timestamps",
|
|
3606
|
+
"1",
|
|
3607
|
+
"-i",
|
|
3608
|
+
process.env.REPLICAS_DESKTOP_STREAM_AUDIO_SOURCE || "default",
|
|
3609
|
+
"-map",
|
|
3610
|
+
"0:v:0",
|
|
3611
|
+
"-an",
|
|
3612
|
+
"-c:v",
|
|
3613
|
+
"libx264",
|
|
3614
|
+
"-preset",
|
|
3615
|
+
"ultrafast",
|
|
3616
|
+
"-tune",
|
|
3617
|
+
"zerolatency",
|
|
3618
|
+
"-profile:v",
|
|
3619
|
+
"baseline",
|
|
3620
|
+
"-level:v",
|
|
3621
|
+
"4.2",
|
|
3622
|
+
"-pix_fmt",
|
|
3623
|
+
"yuv420p",
|
|
3624
|
+
"-bf",
|
|
3625
|
+
"0",
|
|
3626
|
+
"-g",
|
|
3627
|
+
String(keyframeInterval),
|
|
3628
|
+
"-keyint_min",
|
|
3629
|
+
String(keyframeInterval),
|
|
3630
|
+
"-sc_threshold",
|
|
3631
|
+
"0",
|
|
3632
|
+
"-crf",
|
|
3633
|
+
"18",
|
|
3634
|
+
"-maxrate",
|
|
3635
|
+
videoBitrate,
|
|
3636
|
+
"-bufsize",
|
|
3637
|
+
"1M",
|
|
3638
|
+
"-x264-params",
|
|
3639
|
+
`repeat-headers=1:sliced-threads=1:slices=4:keyint=${keyframeInterval}:min-keyint=${keyframeInterval}:scenecut=0`,
|
|
3640
|
+
"-fps_mode",
|
|
3641
|
+
"passthrough",
|
|
3642
|
+
"-flush_packets",
|
|
3643
|
+
"1",
|
|
3644
|
+
"-f",
|
|
3645
|
+
"rtp",
|
|
3646
|
+
"-payload_type",
|
|
3647
|
+
"96",
|
|
3648
|
+
`rtp://127.0.0.1:${videoPort}?pkt_size=1200&connect=1&rtcpport=${videoPort}`,
|
|
3649
|
+
"-map",
|
|
3650
|
+
"1:a:0",
|
|
3651
|
+
"-vn",
|
|
3652
|
+
"-af",
|
|
3653
|
+
"aresample=async=1:first_pts=0",
|
|
3654
|
+
"-c:a",
|
|
3655
|
+
"libopus",
|
|
3656
|
+
"-application",
|
|
3657
|
+
"lowdelay",
|
|
3658
|
+
"-frame_duration",
|
|
3659
|
+
"20",
|
|
3660
|
+
"-b:a",
|
|
3661
|
+
"128k",
|
|
3662
|
+
"-ar",
|
|
3663
|
+
"48000",
|
|
3664
|
+
"-ac",
|
|
3665
|
+
"2",
|
|
3666
|
+
"-flush_packets",
|
|
3667
|
+
"1",
|
|
3668
|
+
"-f",
|
|
3669
|
+
"rtp",
|
|
3670
|
+
"-payload_type",
|
|
3671
|
+
"111",
|
|
3672
|
+
`rtp://127.0.0.1:${audioPort}?pkt_size=1200&connect=1&rtcpport=${audioPort}`
|
|
3673
|
+
], { env: { ...process.env, DISPLAY: display }, stdio: ["ignore", "ignore", "pipe"] });
|
|
3674
|
+
encoder.stderr?.on("data", (chunk) => process.stderr.write(`[desktop-stream] ${chunk}`));
|
|
3675
|
+
encoder.once("exit", (code, signal) => {
|
|
3676
|
+
encoder = void 0;
|
|
3677
|
+
if (clients.size > 0) {
|
|
3678
|
+
process.stderr.write(`[desktop-stream] encoder exited (${signal || code}); restarting
|
|
3679
|
+
`);
|
|
3680
|
+
encoderRestart = setTimeout(startEncoder, 500);
|
|
3681
|
+
}
|
|
3682
|
+
});
|
|
3683
|
+
};
|
|
3684
|
+
const stopEncoderSoon = () => {
|
|
3685
|
+
clearTimeout(encoderStop);
|
|
3686
|
+
encoderStop = setTimeout(() => {
|
|
3687
|
+
if (clients.size > 0) return;
|
|
3688
|
+
encoder?.kill("SIGTERM");
|
|
3689
|
+
encoder = void 0;
|
|
3690
|
+
}, 5e3);
|
|
3691
|
+
};
|
|
3692
|
+
const httpServer = createServer2((request, response) => {
|
|
3693
|
+
const url = new URL(request.url || "/", "http://localhost");
|
|
3694
|
+
if (url.pathname === "/health") {
|
|
3695
|
+
response.setHeader("content-type", "application/json");
|
|
3696
|
+
response.end(JSON.stringify({
|
|
3697
|
+
clients: clients.size,
|
|
3698
|
+
encoder: Boolean(encoder && encoder.exitCode === null),
|
|
3699
|
+
lastVideoFrameMsAgo: lastVideoAt ? Date.now() - lastVideoAt : null,
|
|
3700
|
+
lastAudioPacketMsAgo: lastAudioAt ? Date.now() - lastAudioAt : null,
|
|
3701
|
+
videoFrames,
|
|
3702
|
+
audioPackets,
|
|
3703
|
+
videoBytes,
|
|
3704
|
+
audioBytes,
|
|
3705
|
+
clientStats: [...clients.values()].flatMap((client) => client.stats ? [client.stats] : []),
|
|
3706
|
+
width,
|
|
3707
|
+
height,
|
|
3708
|
+
fps
|
|
3709
|
+
}));
|
|
3710
|
+
return;
|
|
3711
|
+
}
|
|
3712
|
+
const requested = url.pathname === "/" ? "stream-index.html" : url.pathname.slice(1);
|
|
3713
|
+
const path = resolve2(webroot, requested);
|
|
3714
|
+
if (!path.startsWith(resolve2(webroot) + "/") || !existsSync6(path) || !statSync2(path).isFile()) {
|
|
3715
|
+
response.statusCode = 404;
|
|
3716
|
+
response.end("Not found");
|
|
3717
|
+
return;
|
|
3718
|
+
}
|
|
3719
|
+
response.setHeader("cache-control", "no-store");
|
|
3720
|
+
response.setHeader("content-type", contentTypes[extname(path)] || "application/octet-stream");
|
|
3721
|
+
createReadStream(path).pipe(response);
|
|
3722
|
+
});
|
|
3723
|
+
const webSocketServer = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
|
|
3724
|
+
httpServer.on("upgrade", (request, socket, head) => {
|
|
3725
|
+
if (new URL(request.url || "/", "http://localhost").pathname !== "/stream") {
|
|
3726
|
+
socket.destroy();
|
|
3727
|
+
return;
|
|
3728
|
+
}
|
|
3729
|
+
webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, request));
|
|
3730
|
+
});
|
|
3731
|
+
webSocketServer.on("connection", (client) => {
|
|
3732
|
+
clients.set(client, { waitingForKeyframe: true, input: new InputBridge(display, width, height) });
|
|
3733
|
+
client.send(JSON.stringify(config));
|
|
3734
|
+
startEncoder();
|
|
3735
|
+
client.on("message", (raw, binary) => {
|
|
3736
|
+
if (binary) return;
|
|
3737
|
+
let message;
|
|
3738
|
+
try {
|
|
3739
|
+
message = JSON.parse(raw.toString());
|
|
3740
|
+
} catch {
|
|
3741
|
+
return;
|
|
3742
|
+
}
|
|
3743
|
+
if (!validInput(message)) return;
|
|
3744
|
+
if (message.type === "ping") client.send(JSON.stringify({ type: "pong", sentAt: message.sentAt }));
|
|
3745
|
+
else if (message.type === "stats") {
|
|
3746
|
+
const state = clients.get(client);
|
|
3747
|
+
if (!state) return;
|
|
3748
|
+
state.stats = {
|
|
3749
|
+
videoFps: message.videoFps,
|
|
3750
|
+
audioPacketsPerSecond: message.audioPacketsPerSecond,
|
|
3751
|
+
decodeQueue: message.decodeQueue,
|
|
3752
|
+
rttMs: message.rttMs
|
|
3753
|
+
};
|
|
3754
|
+
} else clients.get(client)?.input.handle(message);
|
|
3755
|
+
});
|
|
3756
|
+
client.once("close", () => {
|
|
3757
|
+
clients.get(client)?.input.close();
|
|
3758
|
+
clients.delete(client);
|
|
3759
|
+
if (clients.size === 0) stopEncoderSoon();
|
|
3760
|
+
});
|
|
3761
|
+
});
|
|
3762
|
+
await new Promise((resolveServer, reject) => {
|
|
3763
|
+
httpServer.once("error", reject);
|
|
3764
|
+
httpServer.listen(port, "0.0.0.0", () => {
|
|
3765
|
+
httpServer.off("error", reject);
|
|
3766
|
+
console.log(`replicas desktop stream listening on ${port}`);
|
|
3767
|
+
resolveServer();
|
|
3768
|
+
});
|
|
3769
|
+
});
|
|
3770
|
+
await new Promise((resolveShutdown) => {
|
|
3771
|
+
let closing = false;
|
|
3772
|
+
const close = () => {
|
|
3773
|
+
if (closing) return;
|
|
3774
|
+
closing = true;
|
|
3775
|
+
clearTimeout(encoderRestart);
|
|
3776
|
+
clearTimeout(encoderStop);
|
|
3777
|
+
for (const [client, state] of clients) {
|
|
3778
|
+
state.input.close();
|
|
3779
|
+
client.close(1001, "server shutting down");
|
|
3780
|
+
}
|
|
3781
|
+
encoder?.kill("SIGTERM");
|
|
3782
|
+
videoSocket.close();
|
|
3783
|
+
audioSocket.close();
|
|
3784
|
+
webSocketServer.close();
|
|
3785
|
+
httpServer.close(() => resolveShutdown());
|
|
3786
|
+
};
|
|
3787
|
+
process.once("SIGTERM", close);
|
|
3788
|
+
process.once("SIGINT", close);
|
|
3789
|
+
});
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
// src/setup.ts
|
|
3793
|
+
import { spawnSync as spawnSync6 } from "child_process";
|
|
3794
|
+
import { accessSync, chmodSync as chmodSync2, constants, cpSync, existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
3795
|
+
import { dirname as dirname7, resolve as resolve3 } from "path";
|
|
3796
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3124
3797
|
var dependencies = [
|
|
3125
3798
|
["bash", "bash"],
|
|
3126
3799
|
["Node.js", "node"],
|
|
@@ -3133,6 +3806,9 @@ var dependencies = [
|
|
|
3133
3806
|
["tint2", "tint2"],
|
|
3134
3807
|
["picom", "picom"],
|
|
3135
3808
|
["ffmpeg", "ffmpeg"],
|
|
3809
|
+
["PulseAudio", "pulseaudio"],
|
|
3810
|
+
["PulseAudio control", "pactl"],
|
|
3811
|
+
["X automation", "xte"],
|
|
3136
3812
|
["x11vnc", "x11vnc"],
|
|
3137
3813
|
["websockify", "websockify"],
|
|
3138
3814
|
["Google Chrome", "google-chrome-stable"],
|
|
@@ -3169,13 +3845,13 @@ StandardError=inherit
|
|
|
3169
3845
|
WantedBy=multi-user.target
|
|
3170
3846
|
`;
|
|
3171
3847
|
function commandVersion(command) {
|
|
3172
|
-
const result =
|
|
3848
|
+
const result = spawnSync6(command, ["--version"], { encoding: "utf8" });
|
|
3173
3849
|
return `${result.stdout || result.stderr}`.trim().split("\n")[0]?.slice(0, 200) || "available";
|
|
3174
3850
|
}
|
|
3175
3851
|
function hasCommand(command) {
|
|
3176
3852
|
return (process.env.PATH ?? "").split(":").some((directory) => {
|
|
3177
3853
|
try {
|
|
3178
|
-
accessSync(
|
|
3854
|
+
accessSync(resolve3(directory, command), constants.X_OK);
|
|
3179
3855
|
return true;
|
|
3180
3856
|
} catch {
|
|
3181
3857
|
return false;
|
|
@@ -3195,7 +3871,7 @@ function setupComputer(options = {}) {
|
|
|
3195
3871
|
}
|
|
3196
3872
|
}
|
|
3197
3873
|
if (found.ffmpeg) {
|
|
3198
|
-
const encoders =
|
|
3874
|
+
const encoders = spawnSync6("ffmpeg", ["-hide_banner", "-encoders"], { encoding: "utf8" });
|
|
3199
3875
|
if (!`${encoders.stdout}${encoders.stderr}`.includes("libx264")) {
|
|
3200
3876
|
missing.push("ffmpeg encoder libx264");
|
|
3201
3877
|
console.error("\u2717 ffmpeg encoder libx264 is unavailable");
|
|
@@ -3203,47 +3879,55 @@ function setupComputer(options = {}) {
|
|
|
3203
3879
|
console.log("\u2713 ffmpeg encoder libx264");
|
|
3204
3880
|
}
|
|
3205
3881
|
}
|
|
3206
|
-
const
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3882
|
+
const noVncPackageRoot = dirname7(dirname7(fileURLToPath2(import.meta.resolve("@novnc/novnc"))));
|
|
3883
|
+
const noVncCore = resolve3(noVncPackageRoot, "core");
|
|
3884
|
+
const noVncVendor = resolve3(noVncPackageRoot, "vendor");
|
|
3885
|
+
if (!existsSync7(noVncCore) || !existsSync7(noVncVendor)) {
|
|
3886
|
+
missing.push("bundled noVNC client");
|
|
3887
|
+
console.error("\u2717 bundled noVNC client");
|
|
3210
3888
|
} else {
|
|
3211
|
-
console.log(
|
|
3889
|
+
console.log("\u2713 bundled noVNC client");
|
|
3212
3890
|
}
|
|
3213
|
-
if (
|
|
3891
|
+
if (spawnSync6("id", ["user"], { stdio: "ignore" }).status !== 0) {
|
|
3214
3892
|
missing.push("workspace user account");
|
|
3215
3893
|
console.error("\u2717 workspace user account \u2014 user does not exist");
|
|
3216
3894
|
} else {
|
|
3217
3895
|
console.log("\u2713 workspace user account");
|
|
3218
3896
|
}
|
|
3219
3897
|
if (missing.length > 0) throw new Error(`Missing ${missing.length} computer-use requirement${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`);
|
|
3220
|
-
if (!noVncRoot) throw new Error("noVNC web root is unavailable");
|
|
3221
3898
|
if (options.checkOnly) return;
|
|
3222
3899
|
if (process.getuid?.() !== 0) throw new Error("replicas-computer setup must run as root to install runtime files");
|
|
3223
|
-
const
|
|
3224
|
-
const assets =
|
|
3225
|
-
if (!
|
|
3226
|
-
|
|
3900
|
+
const packageRoot2 = resolve3(dirname7(fileURLToPath2(import.meta.url)), "..");
|
|
3901
|
+
const assets = resolve3(packageRoot2, "assets", "desktop");
|
|
3902
|
+
if (!existsSync7(assets)) throw new Error(`Computer-use assets are missing from ${assets}`);
|
|
3903
|
+
mkdirSync6("/usr/local/share/replicas/desktop", { recursive: true });
|
|
3227
3904
|
cpSync(assets, "/usr/local/share/replicas/desktop", { recursive: true, force: true });
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
cpSync(
|
|
3905
|
+
const viewerRoot = "/usr/local/share/replicas/desktop/novnc";
|
|
3906
|
+
mkdirSync6(viewerRoot, { recursive: true });
|
|
3907
|
+
cpSync(noVncCore, resolve3(viewerRoot, "core"), { recursive: true, force: true });
|
|
3908
|
+
cpSync(noVncVendor, resolve3(viewerRoot, "vendor"), { recursive: true, force: true });
|
|
3909
|
+
for (const file of ["novnc-index.html", "viewer.css", "viewer-icon.svg", "geist-mono.woff2"]) {
|
|
3910
|
+
cpSync(resolve3(assets, file), resolve3(viewerRoot, file === "novnc-index.html" ? "index.html" : file), { force: true });
|
|
3911
|
+
}
|
|
3912
|
+
cpSync(resolve3(packageRoot2, "dist", "viewer.js"), resolve3(viewerRoot, "viewer.js"), { force: true });
|
|
3913
|
+
const streamRoot = "/usr/local/share/replicas/desktop/stream";
|
|
3914
|
+
mkdirSync6(streamRoot, { recursive: true });
|
|
3915
|
+
for (const file of ["stream-index.html", "stream-viewer.css", "viewer-icon.svg", "geist-mono.woff2"]) {
|
|
3916
|
+
cpSync(resolve3(assets, file), resolve3(streamRoot, file), { force: true });
|
|
3917
|
+
}
|
|
3918
|
+
cpSync(resolve3(packageRoot2, "dist", "stream-viewer.js"), resolve3(streamRoot, "stream-viewer.js"), { force: true });
|
|
3919
|
+
cpSync(resolve3(assets, "start-services.sh"), "/usr/local/bin/replicas-start-desktop-services", { force: true });
|
|
3920
|
+
cpSync(resolve3(assets, "replicas-chrome"), "/usr/local/bin/replicas-chrome", { force: true });
|
|
3921
|
+
cpSync(resolve3(assets, "replicas-chrome.desktop"), "/usr/share/applications/replicas-chrome.desktop", { force: true });
|
|
3231
3922
|
chmodSync2("/usr/local/bin/replicas-start-desktop-services", 493);
|
|
3232
3923
|
chmodSync2("/usr/local/bin/replicas-chrome", 493);
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
const styleOverride = "\n#noVNC_status { display: none !important; }\n#noVNC_control_bar_anchor { display: none !important; }\n";
|
|
3237
|
-
if (existsSync5(noVncStyles) && !readFileSync4(noVncStyles, "utf8").includes("#noVNC_status { display: none !important; }")) {
|
|
3238
|
-
appendFileSync2(noVncStyles, styleOverride);
|
|
3239
|
-
}
|
|
3240
|
-
writeFileSync4("/etc/systemd/system/replicas-computer.service", serviceUnit, { mode: 420 });
|
|
3241
|
-
if (existsSync5("/run/systemd/system")) {
|
|
3242
|
-
const reload = spawnSync5("systemctl", ["daemon-reload"], { stdio: "pipe" });
|
|
3924
|
+
writeFileSync5("/etc/systemd/system/replicas-computer.service", serviceUnit, { mode: 420 });
|
|
3925
|
+
if (existsSync7("/run/systemd/system")) {
|
|
3926
|
+
const reload = spawnSync6("systemctl", ["daemon-reload"], { stdio: "pipe" });
|
|
3243
3927
|
if (reload.status !== 0) throw new Error(`Failed to reload systemd: ${reload.stderr?.toString().trim() || `exit ${reload.status}`}`);
|
|
3244
3928
|
}
|
|
3245
|
-
|
|
3246
|
-
|
|
3929
|
+
mkdirSync6("/usr/local/share/replicas-computer", { recursive: true });
|
|
3930
|
+
writeFileSync5("/usr/local/share/replicas-computer/install.json", `${JSON.stringify({
|
|
3247
3931
|
version: COMPUTER_USE_VERSION,
|
|
3248
3932
|
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
3249
3933
|
dependencies: found
|
|
@@ -3273,6 +3957,11 @@ async function main() {
|
|
|
3273
3957
|
await runComputerDaemon();
|
|
3274
3958
|
return;
|
|
3275
3959
|
}
|
|
3960
|
+
if (argv[0] === "stream") {
|
|
3961
|
+
if (argv.length > 1) throw new Error(`Unknown stream option: ${argv[1]}`);
|
|
3962
|
+
await runMediaServer();
|
|
3963
|
+
return;
|
|
3964
|
+
}
|
|
3276
3965
|
const direct = argv[0] === "--direct";
|
|
3277
3966
|
const commandArgv = direct ? argv.slice(1) : argv;
|
|
3278
3967
|
const local = commandArgv.length === 0 || ["--help", "-h", "--version", "-V", "help"].includes(commandArgv[0]);
|