replicas-computer 0.3.25 → 0.3.27
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/dist/index.js +248 -55
- package/dist/stream-viewer.js +4 -4
- package/dist/viewer.js +36 -4
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/client.ts
|
|
4
|
-
import { createConnection as
|
|
4
|
+
import { createConnection as createConnection3 } from "net";
|
|
5
5
|
|
|
6
6
|
// src/daemon.ts
|
|
7
7
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
8
|
-
import { chmodSync, existsSync as
|
|
9
|
-
import { createConnection, createServer } from "net";
|
|
8
|
+
import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync6, rmSync as rmSync5 } from "fs";
|
|
9
|
+
import { createConnection as createConnection2, createServer as createServer2 } from "net";
|
|
10
10
|
import { dirname as dirname5 } from "path";
|
|
11
11
|
|
|
12
12
|
// src/commands/desktop.ts
|
|
@@ -14,8 +14,23 @@ 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/type-guards.ts
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
// ../shared/src/engine/environment.ts
|
|
18
23
|
var DESKTOP_STREAM_PORT = 6080;
|
|
24
|
+
var DEVICES_DIR = ".ssh/replicas-devices";
|
|
25
|
+
var DEVICE_SCREEN_FILE_SUFFIX = ".screen.json";
|
|
26
|
+
function parseDeviceScreenState(value) {
|
|
27
|
+
if (!isRecord(value)) return null;
|
|
28
|
+
if (value.state === "connected" && typeof value.width === "number" && typeof value.height === "number") {
|
|
29
|
+
return { state: "connected", width: value.width, height: value.height };
|
|
30
|
+
}
|
|
31
|
+
if (value.state === "failed" && typeof value.reason === "string") return { state: "failed", reason: value.reason };
|
|
32
|
+
return value.state === "connecting" ? { state: "connecting" } : null;
|
|
33
|
+
}
|
|
19
34
|
function getDesktopViewerUrl(publicUrl) {
|
|
20
35
|
const url = new URL(`${publicUrl.replace(/\/$/, "")}/`);
|
|
21
36
|
url.searchParams.set("v", "15");
|
|
@@ -31,6 +46,7 @@ var STREAM_PORT = process.env.REPLICAS_DESKTOP_STREAM_PORT ? parseInt(process.en
|
|
|
31
46
|
var SERVICES_SCRIPT = process.env.REPLICAS_COMPUTER_SERVICES_SCRIPT || "/usr/local/bin/replicas-start-desktop-services";
|
|
32
47
|
var INPUT_LOCK_FILE = process.env.REPLICAS_DESKTOP_INPUT_LOCK_FILE || `${STATE_DIR}/input.lock`;
|
|
33
48
|
var CHROME_WRAPPER = "/usr/local/bin/replicas-chrome";
|
|
49
|
+
var NOVNC_VIEWER_ROOT = "/usr/local/share/replicas/desktop/novnc";
|
|
34
50
|
var TERMINAL_WRAPPER = "/usr/local/bin/replicas-terminal";
|
|
35
51
|
var INFO_WAIT_TIMEOUT_MS = 1e4;
|
|
36
52
|
var INFO_WAIT_INTERVAL_MS = 500;
|
|
@@ -1111,11 +1127,6 @@ async function computerRecordStopCommand() {
|
|
|
1111
1127
|
clearRecordingState();
|
|
1112
1128
|
}
|
|
1113
1129
|
|
|
1114
|
-
// ../shared/src/type-guards.ts
|
|
1115
|
-
function isRecord(value) {
|
|
1116
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
1130
|
// src/lib/chrome-session.ts
|
|
1120
1131
|
async function openChromeSession(webSocketDebuggerUrl) {
|
|
1121
1132
|
const socket = new WebSocket(webSocketDebuggerUrl);
|
|
@@ -2996,11 +3007,193 @@ async function computerDragCommand(fx, fy, tx, ty) {
|
|
|
2996
3007
|
console.log(`dragged (${fromX},${fromY}) -> (${toX},${toY})`);
|
|
2997
3008
|
}
|
|
2998
3009
|
|
|
2999
|
-
// src/commands/
|
|
3000
|
-
import {
|
|
3001
|
-
import { readFileSync as readFileSync5 } from "fs";
|
|
3010
|
+
// src/commands/screen.ts
|
|
3011
|
+
import { spawn as spawn3 } from "child_process";
|
|
3012
|
+
import { closeSync, existsSync as existsSync5, mkdirSync as mkdirSync5, openSync, readdirSync as readdirSync2, readFileSync as readFileSync5, rmSync as rmSync4, symlinkSync, writeFileSync as writeFileSync5 } from "fs";
|
|
3013
|
+
import { createConnection, createServer } from "net";
|
|
3002
3014
|
import { homedir } from "os";
|
|
3003
3015
|
import { join } from "path";
|
|
3016
|
+
import { setTimeout as sleep2 } from "timers/promises";
|
|
3017
|
+
var SCREEN_PORT = 5900;
|
|
3018
|
+
var CONNECT_TIMEOUT_MS = 3e4;
|
|
3019
|
+
var devicesDir = join(homedir(), DEVICES_DIR);
|
|
3020
|
+
function ownsProcess(session, pid) {
|
|
3021
|
+
try {
|
|
3022
|
+
return readFileSync5(`/proc/${pid}/cmdline`, "utf8").split("\0").some((arg) => arg.includes(`127.0.0.1:${session.tunnelPort}`));
|
|
3023
|
+
} catch {
|
|
3024
|
+
return false;
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
function isListening(port2) {
|
|
3028
|
+
return new Promise((resolve5) => {
|
|
3029
|
+
const socket = createConnection({ host: "127.0.0.1", port: port2 });
|
|
3030
|
+
const done = (listening) => {
|
|
3031
|
+
socket.destroy();
|
|
3032
|
+
resolve5(listening);
|
|
3033
|
+
};
|
|
3034
|
+
socket.once("connect", () => done(true));
|
|
3035
|
+
socket.once("error", () => done(false));
|
|
3036
|
+
socket.setTimeout(1e3, () => done(false));
|
|
3037
|
+
});
|
|
3038
|
+
}
|
|
3039
|
+
async function freePorts(count) {
|
|
3040
|
+
const servers = await Promise.all(Array.from({ length: count }, () => new Promise((resolve5, reject) => {
|
|
3041
|
+
const server = createServer().once("error", reject);
|
|
3042
|
+
server.listen(0, "127.0.0.1", () => resolve5(server));
|
|
3043
|
+
})));
|
|
3044
|
+
const ports = servers.map((server) => {
|
|
3045
|
+
const address = server.address();
|
|
3046
|
+
return typeof address === "object" && address ? address.port : fail("Could not reserve a local port");
|
|
3047
|
+
});
|
|
3048
|
+
await Promise.all(servers.map((server) => new Promise((resolve5) => server.close(resolve5))));
|
|
3049
|
+
return ports;
|
|
3050
|
+
}
|
|
3051
|
+
function spawnLogged(bin, args, log) {
|
|
3052
|
+
const fd = openSync(log, "a");
|
|
3053
|
+
const child = spawn3(bin, args, { detached: true, stdio: ["ignore", fd, fd] });
|
|
3054
|
+
closeSync(fd);
|
|
3055
|
+
child.unref();
|
|
3056
|
+
let exited = false;
|
|
3057
|
+
child.once("exit", () => {
|
|
3058
|
+
exited = true;
|
|
3059
|
+
});
|
|
3060
|
+
return { pid: child.pid ?? fail(`Could not start ${bin}`), exited: () => exited };
|
|
3061
|
+
}
|
|
3062
|
+
async function waitForPort(port2, child) {
|
|
3063
|
+
const deadline = Date.now() + CONNECT_TIMEOUT_MS;
|
|
3064
|
+
while (Date.now() < deadline && !child.exited()) {
|
|
3065
|
+
if (await isListening(port2)) return true;
|
|
3066
|
+
await sleep2(200);
|
|
3067
|
+
}
|
|
3068
|
+
return false;
|
|
3069
|
+
}
|
|
3070
|
+
function readBanner(port2) {
|
|
3071
|
+
return new Promise((resolve5) => {
|
|
3072
|
+
const socket = createConnection({ host: "127.0.0.1", port: port2 });
|
|
3073
|
+
let banner = "";
|
|
3074
|
+
const done = () => {
|
|
3075
|
+
socket.destroy();
|
|
3076
|
+
resolve5(banner);
|
|
3077
|
+
};
|
|
3078
|
+
socket.setTimeout(1e4, done);
|
|
3079
|
+
socket.on("data", (chunk) => {
|
|
3080
|
+
banner += chunk.toString("latin1");
|
|
3081
|
+
if (banner.length >= 12) done();
|
|
3082
|
+
});
|
|
3083
|
+
socket.once("error", done);
|
|
3084
|
+
socket.once("close", done);
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
function readSession(file) {
|
|
3088
|
+
try {
|
|
3089
|
+
const value = JSON.parse(readFileSync5(file, "utf8"));
|
|
3090
|
+
return isRecord(value) && typeof value.webPort === "number" && typeof value.tunnelPort === "number" && Array.isArray(value.pids) && value.pids.every((pid) => typeof pid === "number") ? { webPort: value.webPort, tunnelPort: value.tunnelPort, pids: value.pids } : null;
|
|
3091
|
+
} catch {
|
|
3092
|
+
return null;
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
async function closeScreen(dir, session) {
|
|
3096
|
+
if (session) {
|
|
3097
|
+
const pages = await getChromePages().catch(() => []);
|
|
3098
|
+
for (const page of pages) {
|
|
3099
|
+
if (page.webSocketDebuggerUrl && page.url?.startsWith(`http://127.0.0.1:${session.webPort}/`)) {
|
|
3100
|
+
await sendChromeCommand(page.webSocketDebuggerUrl, "Page.close", {}).catch(() => void 0);
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
for (const pid of session.pids.filter((candidate) => ownsProcess(session, candidate))) {
|
|
3104
|
+
try {
|
|
3105
|
+
process.kill(pid);
|
|
3106
|
+
} catch {
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
rmSync4(dir, { recursive: true, force: true });
|
|
3111
|
+
}
|
|
3112
|
+
async function openSession(device, dir, previous) {
|
|
3113
|
+
await closeScreen(dir, previous);
|
|
3114
|
+
const web = join(dir, "web");
|
|
3115
|
+
mkdirSync5(web, { recursive: true, mode: 448 });
|
|
3116
|
+
for (const entry of readdirSync2(NOVNC_VIEWER_ROOT)) symlinkSync(join(NOVNC_VIEWER_ROOT, entry), join(web, entry));
|
|
3117
|
+
const credentials = join(devicesDir, `${device}${DEVICE_SCREEN_FILE_SUFFIX}`);
|
|
3118
|
+
if (existsSync5(credentials)) symlinkSync(credentials, join(web, "credentials.json"));
|
|
3119
|
+
const [tunnelPort, webPort] = await freePorts(2);
|
|
3120
|
+
const tunnelLog = join(dir, "tunnel.log");
|
|
3121
|
+
const tunnel = spawnLogged("ssh", ["-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-L", `127.0.0.1:${tunnelPort}:localhost:${SCREEN_PORT}`, device], tunnelLog);
|
|
3122
|
+
const pids = [tunnel.pid];
|
|
3123
|
+
const abort = (message) => {
|
|
3124
|
+
for (const pid of pids) {
|
|
3125
|
+
try {
|
|
3126
|
+
process.kill(pid);
|
|
3127
|
+
} catch {
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
return fail(message);
|
|
3131
|
+
};
|
|
3132
|
+
if (!await waitForPort(tunnelPort, tunnel)) abort(`Could not connect to ${device} over SSH: ${readFileSync5(tunnelLog, "utf8").trim() || "timed out"}`);
|
|
3133
|
+
if (!(await readBanner(tunnelPort)).startsWith("RFB ")) {
|
|
3134
|
+
abort(`${device} isn't sharing its screen: nothing answered on port ${SCREEN_PORT}. On a Mac, turn on Screen Sharing in System Settings \u2192 General \u2192 Sharing.`);
|
|
3135
|
+
}
|
|
3136
|
+
const bridgeLog = join(dir, "bridge.log");
|
|
3137
|
+
const bridge = spawnLogged("websockify", ["--web", web, `127.0.0.1:${webPort}`, `127.0.0.1:${tunnelPort}`], bridgeLog);
|
|
3138
|
+
pids.push(bridge.pid);
|
|
3139
|
+
if (!await waitForPort(webPort, bridge)) abort(`Could not start the screen viewer: ${readFileSync5(bridgeLog, "utf8").trim() || "timed out"}`);
|
|
3140
|
+
const session = { webPort, tunnelPort, pids };
|
|
3141
|
+
writeFileSync5(join(dir, "session.json"), JSON.stringify(session));
|
|
3142
|
+
return session;
|
|
3143
|
+
}
|
|
3144
|
+
async function computerScreenCommand(device, options) {
|
|
3145
|
+
if (!/^[a-z0-9-]+$/.test(device)) fail(`Invalid device name: ${device}`);
|
|
3146
|
+
const dir = join(STATE_DIR, "screens", device);
|
|
3147
|
+
const previous = readSession(join(dir, "session.json"));
|
|
3148
|
+
if (options.close) {
|
|
3149
|
+
await closeScreen(dir, previous);
|
|
3150
|
+
console.log(`Closed ${device}'s screen`);
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
if (!existsSync5(join(devicesDir, device))) {
|
|
3154
|
+
const names = existsSync5(devicesDir) ? readdirSync2(devicesDir).filter((name) => name !== "devices.conf" && !name.endsWith(DEVICE_SCREEN_FILE_SUFFIX)) : [];
|
|
3155
|
+
fail(names.length > 0 ? `No device named ${device}. Devices: ${names.join(", ")}.` : "This workspace has no devices. Add them in the environment's Devices settings.");
|
|
3156
|
+
}
|
|
3157
|
+
ensureServicesRunning();
|
|
3158
|
+
const session = previous && previous.pids.every((pid) => ownsProcess(previous, pid)) && await isListening(previous.webPort) ? previous : await openSession(device, dir, previous);
|
|
3159
|
+
try {
|
|
3160
|
+
await showScreen(device, session);
|
|
3161
|
+
} catch (error) {
|
|
3162
|
+
await closeScreen(dir, session);
|
|
3163
|
+
throw error;
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
async function showScreen(device, session) {
|
|
3167
|
+
const origin = `http://127.0.0.1:${session.webPort}/`;
|
|
3168
|
+
const findPage = async () => (await getChromePages().catch(() => [])).find((page2) => page2.webSocketDebuggerUrl && page2.url?.startsWith(origin));
|
|
3169
|
+
let page = await findPage();
|
|
3170
|
+
if (!page) {
|
|
3171
|
+
spawn3(CHROME_WRAPPER, ["--new-window", `--app=${origin}?device=${device}&requestFps=0`], { env: withDisplay(), detached: true, stdio: "ignore" }).unref();
|
|
3172
|
+
const deadline2 = Date.now() + CONNECT_TIMEOUT_MS;
|
|
3173
|
+
while (!page && Date.now() < deadline2) {
|
|
3174
|
+
await sleep2(250);
|
|
3175
|
+
page = await findPage();
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
const target = page?.webSocketDebuggerUrl ?? fail("Chrome did not open the screen viewer");
|
|
3179
|
+
let state = { state: "connecting" };
|
|
3180
|
+
const deadline = Date.now() + CONNECT_TIMEOUT_MS;
|
|
3181
|
+
while (state.state === "connecting" && Date.now() < deadline) {
|
|
3182
|
+
state = parseDeviceScreenState(await evaluateChromeTarget(target, "globalThis.__replicasScreen ?? null").catch(() => null)) ?? state;
|
|
3183
|
+
if (state.state === "connecting") await sleep2(250);
|
|
3184
|
+
}
|
|
3185
|
+
if (state.state !== "connected") {
|
|
3186
|
+
fail(state.state === "failed" ? state.reason : `${device}'s screen didn't connect within ${CONNECT_TIMEOUT_MS / 1e3} seconds.`);
|
|
3187
|
+
}
|
|
3188
|
+
await sendChromeCommand(target, "Page.bringToFront", {});
|
|
3189
|
+
console.log(`Showing ${device}'s screen (${state.width}\xD7${state.height}) in a window on this desktop. Drive it with \`replicas computer observe\`, \`click\`, \`type\` and \`key\`; \`replicas computer screen ${device} --close\` closes it.`);
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
// src/commands/system.ts
|
|
3193
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
3194
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
3195
|
+
import { homedir as homedir2 } from "os";
|
|
3196
|
+
import { join as join2 } from "path";
|
|
3004
3197
|
import chalk from "chalk";
|
|
3005
3198
|
function bridgeStatus(details, includeBacklog = false, rootsOnly = false) {
|
|
3006
3199
|
const pids = rootsOnly ? details.rootPids ?? [] : details.listenerPids ?? [];
|
|
@@ -3014,7 +3207,7 @@ function bridgeStatus(details, includeBacklog = false, rootsOnly = false) {
|
|
|
3014
3207
|
}
|
|
3015
3208
|
async function lookupDesktopViewerUrl() {
|
|
3016
3209
|
try {
|
|
3017
|
-
const value = JSON.parse(
|
|
3210
|
+
const value = JSON.parse(readFileSync6(join2(homedir2(), ".replicas", "preview-ports.json"), "utf8"));
|
|
3018
3211
|
if (!isRecord(value) || !Array.isArray(value.previews)) return null;
|
|
3019
3212
|
const preview = value.previews.find(
|
|
3020
3213
|
(item) => isRecord(item) && item.port === STREAM_PORT && typeof item.publicUrl === "string"
|
|
@@ -3069,7 +3262,7 @@ async function computerStatusCommand() {
|
|
|
3069
3262
|
}
|
|
3070
3263
|
|
|
3071
3264
|
// src/version.ts
|
|
3072
|
-
var COMPUTER_USE_VERSION = "0.3.
|
|
3265
|
+
var COMPUTER_USE_VERSION = "0.3.27";
|
|
3073
3266
|
var COMPUTER_USE_PROTOCOL_VERSION = 1;
|
|
3074
3267
|
|
|
3075
3268
|
// src/program.ts
|
|
@@ -3107,6 +3300,7 @@ function createProgram() {
|
|
|
3107
3300
|
program.command("scroll <direction>").description("Scroll the desktop").option("--amount <n>", "Wheel ticks").option("--x <x>", "Pointer x").option("--y <y>", "Pointer y").action((direction, options) => computerScrollCommand(direction, options));
|
|
3108
3301
|
program.command("drag <fromX> <fromY> <toX> <toY>").description("Drag between desktop coordinates").action((fromX, fromY, toX, toY) => computerDragCommand(fromX, fromY, toX, toY));
|
|
3109
3302
|
program.command("launch <app> [args...]").description("Launch an application on the desktop").action((app, args) => computerLaunchCommand(app, args));
|
|
3303
|
+
program.command("screen <device>", { hidden: true }).description("Show a device's screen in a window on this desktop, where the other commands drive it").option("--close", "Close the window and its connection").action((device, options) => computerScreenCommand(device, options));
|
|
3110
3304
|
const record = program.command("record").description("Record the workspace display");
|
|
3111
3305
|
record.command("start <path>").description("Start a recording").option("--fps <n>", "Frame rate. Default 60.").action((path, options) => computerRecordStartCommand(path, options));
|
|
3112
3306
|
record.command("stop").description("Stop and finalize the active recording").action(() => computerRecordStopCommand());
|
|
@@ -3158,9 +3352,9 @@ function writeResult(socket, result) {
|
|
|
3158
3352
|
`);
|
|
3159
3353
|
}
|
|
3160
3354
|
async function hasSocketListener() {
|
|
3161
|
-
if (!
|
|
3355
|
+
if (!existsSync6(COMPUTER_USE_SOCKET_PATH)) return false;
|
|
3162
3356
|
return await new Promise((resolve5) => {
|
|
3163
|
-
const socket =
|
|
3357
|
+
const socket = createConnection2(COMPUTER_USE_SOCKET_PATH);
|
|
3164
3358
|
socket.setTimeout(500, () => socket.destroy());
|
|
3165
3359
|
socket.once("connect", () => {
|
|
3166
3360
|
socket.destroy();
|
|
@@ -3173,11 +3367,11 @@ async function hasSocketListener() {
|
|
|
3173
3367
|
async function runComputerDaemon() {
|
|
3174
3368
|
if (await hasSocketListener()) throw new Error(`Computer-use daemon is already listening on ${COMPUTER_USE_SOCKET_PATH}`);
|
|
3175
3369
|
ensureServicesRunning();
|
|
3176
|
-
|
|
3177
|
-
|
|
3370
|
+
mkdirSync6(dirname5(COMPUTER_USE_SOCKET_PATH), { recursive: true, mode: 448 });
|
|
3371
|
+
rmSync5(COMPUTER_USE_SOCKET_PATH, { force: true });
|
|
3178
3372
|
let queue = Promise.resolve();
|
|
3179
3373
|
const clients = /* @__PURE__ */ new Set();
|
|
3180
|
-
const server =
|
|
3374
|
+
const server = createServer2({ allowHalfOpen: true }, (socket) => {
|
|
3181
3375
|
if (clients.size >= MAX_CLIENTS) {
|
|
3182
3376
|
writeResult(socket, { exitCode: 75, stdout: "", stderr: "Computer-use daemon is busy; retry the request\n" });
|
|
3183
3377
|
return;
|
|
@@ -3250,7 +3444,7 @@ async function runComputerDaemon() {
|
|
|
3250
3444
|
process.once("SIGINT", close);
|
|
3251
3445
|
});
|
|
3252
3446
|
spawnSync5("bash", [SERVICES_SCRIPT, "--stop"], { stdio: "inherit" });
|
|
3253
|
-
|
|
3447
|
+
rmSync5(COMPUTER_USE_SOCKET_PATH, { force: true });
|
|
3254
3448
|
}
|
|
3255
3449
|
|
|
3256
3450
|
// src/client.ts
|
|
@@ -3259,7 +3453,7 @@ var ComputerDaemonUnavailableError = class extends Error {
|
|
|
3259
3453
|
};
|
|
3260
3454
|
function sendComputerRequest(argv) {
|
|
3261
3455
|
return new Promise((resolve5, reject) => {
|
|
3262
|
-
const socket =
|
|
3456
|
+
const socket = createConnection3(COMPUTER_USE_SOCKET_PATH);
|
|
3263
3457
|
let response = "";
|
|
3264
3458
|
let settled = false;
|
|
3265
3459
|
const finish = (error, result) => {
|
|
@@ -3310,17 +3504,17 @@ async function runComputerClient(argv) {
|
|
|
3310
3504
|
}
|
|
3311
3505
|
|
|
3312
3506
|
// src/media-server.ts
|
|
3313
|
-
import { spawn as
|
|
3507
|
+
import { spawn as spawn6 } from "child_process";
|
|
3314
3508
|
import { createHash as createHash3 } from "crypto";
|
|
3315
|
-
import { createReadStream, existsSync as
|
|
3316
|
-
import { createServer as
|
|
3509
|
+
import { createReadStream, existsSync as existsSync8, readdirSync as readdirSync3, readFileSync as readFileSync8, statSync as statSync2 } from "fs";
|
|
3510
|
+
import { createServer as createServer3 } from "http";
|
|
3317
3511
|
import { availableParallelism as availableParallelism2, loadavg } from "os";
|
|
3318
3512
|
import { dirname as dirname7, extname, resolve as resolve3 } from "path";
|
|
3319
3513
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3320
3514
|
import WebSocket2, { WebSocketServer } from "ws";
|
|
3321
3515
|
|
|
3322
3516
|
// src/encoder.ts
|
|
3323
|
-
import { spawn as
|
|
3517
|
+
import { spawn as spawn4 } from "child_process";
|
|
3324
3518
|
import { createSocket } from "dgram";
|
|
3325
3519
|
import { createInterface } from "readline";
|
|
3326
3520
|
|
|
@@ -3555,7 +3749,7 @@ function superviseEncoder(options) {
|
|
|
3555
3749
|
const warmupFrames = active ? options.warmupFrames ?? 0 : 0;
|
|
3556
3750
|
const command = options.command?.() ?? "ffmpeg";
|
|
3557
3751
|
const args = options.args(socket.address().port, warmupFrames);
|
|
3558
|
-
const child =
|
|
3752
|
+
const child = spawn4(command, args, { env: options.env, stdio: [options.controllable?.(command) ? "pipe" : "ignore", options.onOutput ? "pipe" : "ignore", "pipe"] });
|
|
3559
3753
|
child.stdin?.on("error", () => {
|
|
3560
3754
|
});
|
|
3561
3755
|
child.stderr?.on("data", (chunk) => process.stderr.write(`[desktop-stream] ${chunk}`));
|
|
@@ -3657,7 +3851,7 @@ function superviseEncoder(options) {
|
|
|
3657
3851
|
}
|
|
3658
3852
|
|
|
3659
3853
|
// src/ice-servers.ts
|
|
3660
|
-
import { readFileSync as
|
|
3854
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
3661
3855
|
|
|
3662
3856
|
// ../shared/src/desktop-stream.ts
|
|
3663
3857
|
function isStreamIceServer(value) {
|
|
@@ -3802,7 +3996,7 @@ var refreshing;
|
|
|
3802
3996
|
async function currentIceServers() {
|
|
3803
3997
|
let env = process.env;
|
|
3804
3998
|
try {
|
|
3805
|
-
env = { ...env, ...parsePosixEnvFile(
|
|
3999
|
+
env = { ...env, ...parsePosixEnvFile(readFileSync7(runtimeEnvFile, "utf8")) };
|
|
3806
4000
|
} catch {
|
|
3807
4001
|
}
|
|
3808
4002
|
const monolithUrl = readReplicasRuntimeEnv((name) => env[name], REPLICAS_RUNTIME_ENV_ALIASES.monolithUrl);
|
|
@@ -3856,7 +4050,7 @@ function toIceServers(spec = "stun:stun.l.google.com:19302", relayServers) {
|
|
|
3856
4050
|
}
|
|
3857
4051
|
|
|
3858
4052
|
// src/video-encoder.ts
|
|
3859
|
-
import { existsSync as
|
|
4053
|
+
import { existsSync as existsSync7 } from "fs";
|
|
3860
4054
|
import { availableParallelism } from "os";
|
|
3861
4055
|
import { dirname as dirname6, resolve as resolve2 } from "path";
|
|
3862
4056
|
import { fileURLToPath } from "url";
|
|
@@ -4305,7 +4499,7 @@ var captureBinary = resolve2(dirname6(fileURLToPath(import.meta.url)), "..", "di
|
|
|
4305
4499
|
function superviseVideoEncoder(options) {
|
|
4306
4500
|
const { display: display2, width: width2, height: height2, colorMode: colorMode2 } = options;
|
|
4307
4501
|
const env = { ...process.env, DISPLAY: display2 };
|
|
4308
|
-
let nativeCapture = process.env.REPLICAS_DESKTOP_STREAM_CAPTURE !== "ffmpeg" &&
|
|
4502
|
+
let nativeCapture = process.env.REPLICAS_DESKTOP_STREAM_CAPTURE !== "ffmpeg" && existsSync7(captureBinary);
|
|
4309
4503
|
let captureMetrics;
|
|
4310
4504
|
const encoder = superviseEncoder({
|
|
4311
4505
|
label: "video",
|
|
@@ -4479,7 +4673,7 @@ function superviseVideoEncoder(options) {
|
|
|
4479
4673
|
}
|
|
4480
4674
|
|
|
4481
4675
|
// src/input-bridge.ts
|
|
4482
|
-
import { spawn as
|
|
4676
|
+
import { spawn as spawn5 } from "child_process";
|
|
4483
4677
|
var scrollPixelsPerStep = 80;
|
|
4484
4678
|
var namedKeys = {
|
|
4485
4679
|
AltLeft: "Alt_L",
|
|
@@ -4660,7 +4854,7 @@ var InputBridge = class {
|
|
|
4660
4854
|
}
|
|
4661
4855
|
write(command) {
|
|
4662
4856
|
if (!this.process || this.process.exitCode !== null || this.process.stdin.destroyed) {
|
|
4663
|
-
const child =
|
|
4857
|
+
const child = spawn5("xte", [], {
|
|
4664
4858
|
env: { ...process.env, DISPLAY: this.display },
|
|
4665
4859
|
stdio: ["pipe", "pipe", "pipe"]
|
|
4666
4860
|
});
|
|
@@ -4761,7 +4955,7 @@ function drainDesktopTheme() {
|
|
|
4761
4955
|
if (applied) appliedTheme = theme;
|
|
4762
4956
|
drainDesktopTheme();
|
|
4763
4957
|
};
|
|
4764
|
-
const child =
|
|
4958
|
+
const child = spawn6("bash", [SERVICES_SCRIPT, "--theme", theme], { detached: true, stdio: "ignore" });
|
|
4765
4959
|
child.unref();
|
|
4766
4960
|
child.once("exit", (code) => finish(code === 0));
|
|
4767
4961
|
child.once("error", () => finish(false));
|
|
@@ -4769,7 +4963,7 @@ function drainDesktopTheme() {
|
|
|
4769
4963
|
function processCpuMs(pid) {
|
|
4770
4964
|
if (!pid) return 0;
|
|
4771
4965
|
try {
|
|
4772
|
-
const stat =
|
|
4966
|
+
const stat = readFileSync8(`/proc/${pid}/stat`, "utf8");
|
|
4773
4967
|
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
4774
4968
|
return (Number(fields[11]) + Number(fields[12])) * 10;
|
|
4775
4969
|
} catch {
|
|
@@ -5497,7 +5691,7 @@ async function runMediaServer() {
|
|
|
5497
5691
|
audioSampleRate: 48e3,
|
|
5498
5692
|
audioChannels: 2
|
|
5499
5693
|
};
|
|
5500
|
-
const httpServer =
|
|
5694
|
+
const httpServer = createServer3((request, response) => {
|
|
5501
5695
|
const url = new URL(request.url || "/", "http://localhost");
|
|
5502
5696
|
if (url.pathname === "/health" || url.pathname === "/telemetry") {
|
|
5503
5697
|
const all = [...viewers.values()];
|
|
@@ -5551,7 +5745,7 @@ async function runMediaServer() {
|
|
|
5551
5745
|
return;
|
|
5552
5746
|
}
|
|
5553
5747
|
if (url.pathname === "/snapshot") {
|
|
5554
|
-
const capture =
|
|
5748
|
+
const capture = spawn6("ffmpeg", [
|
|
5555
5749
|
"-hide_banner",
|
|
5556
5750
|
"-loglevel",
|
|
5557
5751
|
"error",
|
|
@@ -5588,7 +5782,7 @@ async function runMediaServer() {
|
|
|
5588
5782
|
}
|
|
5589
5783
|
const requested = url.pathname === "/" ? "stream-index.html" : url.pathname.slice(1);
|
|
5590
5784
|
const path = resolve3(webroot, requested);
|
|
5591
|
-
if (!path.startsWith(resolve3(webroot) + "/") || !
|
|
5785
|
+
if (!path.startsWith(resolve3(webroot) + "/") || !existsSync8(path) || !statSync2(path).isFile()) {
|
|
5592
5786
|
response.statusCode = 404;
|
|
5593
5787
|
response.end("Not found");
|
|
5594
5788
|
return;
|
|
@@ -5600,8 +5794,8 @@ async function runMediaServer() {
|
|
|
5600
5794
|
if (webrtc) void currentIceServers();
|
|
5601
5795
|
response.setHeader("cache-control", "no-store");
|
|
5602
5796
|
const digest = createHash3("sha1");
|
|
5603
|
-
for (const name of
|
|
5604
|
-
response.end(
|
|
5797
|
+
for (const name of readdirSync3(webroot).filter((file) => /\.(js|css)$/.test(file)).sort()) digest.update(readFileSync8(resolve3(webroot, name)));
|
|
5798
|
+
response.end(readFileSync8(path, "utf8").replaceAll("__VERSION__", `${COMPUTER_USE_VERSION}-${digest.digest("hex").slice(0, 8)}`));
|
|
5605
5799
|
return;
|
|
5606
5800
|
}
|
|
5607
5801
|
response.setHeader("cache-control", "private, max-age=31536000, immutable");
|
|
@@ -5713,7 +5907,7 @@ async function runMediaServer() {
|
|
|
5713
5907
|
|
|
5714
5908
|
// src/setup.ts
|
|
5715
5909
|
import { spawnSync as spawnSync6 } from "child_process";
|
|
5716
|
-
import { accessSync, chmodSync as chmodSync2, constants, cpSync, existsSync as
|
|
5910
|
+
import { accessSync, chmodSync as chmodSync2, constants, cpSync, existsSync as existsSync9, mkdirSync as mkdirSync7, readdirSync as readdirSync4, writeFileSync as writeFileSync6 } from "fs";
|
|
5717
5911
|
import { dirname as dirname8, resolve as resolve4 } from "path";
|
|
5718
5912
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
5719
5913
|
var dependencies = [
|
|
@@ -5814,7 +6008,7 @@ async function setupComputer(options = {}) {
|
|
|
5814
6008
|
const noVncPackageRoot = dirname8(dirname8(fileURLToPath3(import.meta.resolve("@novnc/novnc"))));
|
|
5815
6009
|
const noVncCore = resolve4(noVncPackageRoot, "core");
|
|
5816
6010
|
const noVncVendor = resolve4(noVncPackageRoot, "vendor");
|
|
5817
|
-
if (!
|
|
6011
|
+
if (!existsSync9(noVncCore) || !existsSync9(noVncVendor)) {
|
|
5818
6012
|
missing.push("bundled noVNC client");
|
|
5819
6013
|
console.error("\u2717 bundled noVNC client");
|
|
5820
6014
|
} else {
|
|
@@ -5831,23 +6025,22 @@ async function setupComputer(options = {}) {
|
|
|
5831
6025
|
if (process.getuid?.() !== 0) throw new Error("replicas-computer setup must run as root to install runtime files");
|
|
5832
6026
|
const packageRoot2 = resolve4(dirname8(fileURLToPath3(import.meta.url)), "..");
|
|
5833
6027
|
const assets = resolve4(packageRoot2, "assets", "desktop");
|
|
5834
|
-
if (!
|
|
5835
|
-
|
|
6028
|
+
if (!existsSync9(assets)) throw new Error(`Computer-use assets are missing from ${assets}`);
|
|
6029
|
+
mkdirSync7("/usr/local/share/replicas/desktop", { recursive: true });
|
|
5836
6030
|
cpSync(assets, "/usr/local/share/replicas/desktop", { recursive: true, force: true });
|
|
5837
6031
|
const fontRoot = "/usr/local/share/fonts/replicas";
|
|
5838
|
-
|
|
5839
|
-
for (const file of
|
|
6032
|
+
mkdirSync7(fontRoot, { recursive: true });
|
|
6033
|
+
for (const file of readdirSync4(assets).filter((name) => name.endsWith(".ttf"))) cpSync(resolve4(assets, file), resolve4(fontRoot, file), { force: true });
|
|
5840
6034
|
spawnSync6("fc-cache", ["-f", fontRoot], { stdio: "ignore" });
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
cpSync(
|
|
5844
|
-
cpSync(noVncVendor, resolve4(viewerRoot, "vendor"), { recursive: true, force: true });
|
|
6035
|
+
mkdirSync7(NOVNC_VIEWER_ROOT, { recursive: true });
|
|
6036
|
+
cpSync(noVncCore, resolve4(NOVNC_VIEWER_ROOT, "core"), { recursive: true, force: true });
|
|
6037
|
+
cpSync(noVncVendor, resolve4(NOVNC_VIEWER_ROOT, "vendor"), { recursive: true, force: true });
|
|
5845
6038
|
for (const file of ["novnc-index.html", "viewer.css", "viewer-theme.css", "viewer-theme.js", "viewer-icon.svg", "geist-mono-400.ttf", "geist-mono-700.ttf"]) {
|
|
5846
|
-
cpSync(resolve4(assets, file), resolve4(
|
|
6039
|
+
cpSync(resolve4(assets, file), resolve4(NOVNC_VIEWER_ROOT, file === "novnc-index.html" ? "index.html" : file), { force: true });
|
|
5847
6040
|
}
|
|
5848
|
-
cpSync(resolve4(packageRoot2, "dist", "viewer.js"), resolve4(
|
|
6041
|
+
cpSync(resolve4(packageRoot2, "dist", "viewer.js"), resolve4(NOVNC_VIEWER_ROOT, "viewer.js"), { force: true });
|
|
5849
6042
|
const streamRoot = "/usr/local/share/replicas/desktop/stream";
|
|
5850
|
-
|
|
6043
|
+
mkdirSync7(streamRoot, { recursive: true });
|
|
5851
6044
|
for (const file of ["stream-index.html", "stream-viewer.css", "viewer-theme.css", "viewer-theme.js", "viewer-icon.svg", "geist-mono-400.ttf", "geist-mono-700.ttf"]) {
|
|
5852
6045
|
cpSync(resolve4(assets, file), resolve4(streamRoot, file), { force: true });
|
|
5853
6046
|
}
|
|
@@ -5859,13 +6052,13 @@ async function setupComputer(options = {}) {
|
|
|
5859
6052
|
chmodSync2("/usr/local/bin/replicas-start-desktop-services", 493);
|
|
5860
6053
|
chmodSync2("/usr/local/bin/replicas-chrome", 493);
|
|
5861
6054
|
chmodSync2("/usr/local/bin/replicas-terminal", 493);
|
|
5862
|
-
|
|
5863
|
-
if (
|
|
6055
|
+
writeFileSync6("/etc/systemd/system/replicas-computer.service", serviceUnit, { mode: 420 });
|
|
6056
|
+
if (existsSync9("/run/systemd/system")) {
|
|
5864
6057
|
const reload = spawnSync6("systemctl", ["daemon-reload"], { stdio: "pipe" });
|
|
5865
6058
|
if (reload.status !== 0) throw new Error(`Failed to reload systemd: ${reload.stderr?.toString().trim() || `exit ${reload.status}`}`);
|
|
5866
6059
|
}
|
|
5867
|
-
|
|
5868
|
-
|
|
6060
|
+
mkdirSync7("/usr/local/share/replicas-computer", { recursive: true });
|
|
6061
|
+
writeFileSync6("/usr/local/share/replicas-computer/install.json", `${JSON.stringify({
|
|
5869
6062
|
version: COMPUTER_USE_VERSION,
|
|
5870
6063
|
protocolVersion: COMPUTER_USE_PROTOCOL_VERSION,
|
|
5871
6064
|
dependencies: found
|
package/dist/stream-viewer.js
CHANGED
|
@@ -1031,9 +1031,8 @@ var updateDiagnostics = (stats) => {
|
|
|
1031
1031
|
viewerEvents.length ? `viewer events: ${describeEvents(viewerEvents.slice(-5))}` : ""
|
|
1032
1032
|
].filter(Boolean).join("\n");
|
|
1033
1033
|
};
|
|
1034
|
-
var sampleConnection = (stats, current, now) => {
|
|
1034
|
+
var sampleConnection = (stats, current, now, shown) => {
|
|
1035
1035
|
const totals = connectionTotals;
|
|
1036
|
-
const shown = !standby && document.visibilityState === "visible" && qualityUnseen.samples < 4;
|
|
1037
1036
|
if (shown && totals.sampledAt) {
|
|
1038
1037
|
totals.shownSince ??= totals.sampledAt;
|
|
1039
1038
|
totals.shownMs += now - totals.sampledAt;
|
|
@@ -1063,8 +1062,9 @@ var reportQuality = (stats) => {
|
|
|
1063
1062
|
const current = qualityWindow ??= { at: now, presented: presentedFrames, freezes: inbound?.freezeCount ?? 0, longestGapMs: 0 };
|
|
1064
1063
|
const decoded = transport === "webrtc" ? inbound?.framesDecoded ?? 0 : socketDecodedFrames;
|
|
1065
1064
|
qualityUnseen = { decoded, presented: presentedFrames, samples: decoded > qualityUnseen.decoded && presentedFrames === qualityUnseen.presented ? qualityUnseen.samples + 1 : 0 };
|
|
1066
|
-
|
|
1067
|
-
|
|
1065
|
+
const shown = !standby && document.visibilityState === "visible" && qualityUnseen.samples < 4;
|
|
1066
|
+
if (!shown) current.unseen = true;
|
|
1067
|
+
sampleConnection(stats, telemetry, now, shown);
|
|
1068
1068
|
qualitySamples.push({ stats, telemetry });
|
|
1069
1069
|
const windowMs = now - current.at;
|
|
1070
1070
|
if (windowMs < qualityWindowMs) return;
|
package/dist/viewer.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
import RFB from "./core/rfb.js";
|
|
3
3
|
import { encodings } from "./core/encodings.js";
|
|
4
4
|
|
|
5
|
+
// ../shared/src/type-guards.ts
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
// src/wheel.ts
|
|
6
11
|
function wheelDeltaPixels(event, pageHeight) {
|
|
7
12
|
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 32 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? pageHeight : 1;
|
|
@@ -15,6 +20,17 @@ var statusText = document.querySelector("#status-text");
|
|
|
15
20
|
var hint = document.querySelector("#hint");
|
|
16
21
|
if (!screen || !status || !statusText || !hint) throw new Error("Desktop viewer markup is incomplete");
|
|
17
22
|
var params = new URLSearchParams(location.search);
|
|
23
|
+
var device = params.get("device");
|
|
24
|
+
var credentials = device ? await fetch("./credentials.json").then((response) => response.ok ? response.json() : void 0, () => void 0) : void 0;
|
|
25
|
+
var screenFailed = false;
|
|
26
|
+
var reportScreen = (state) => {
|
|
27
|
+
screenFailed = state.state === "failed";
|
|
28
|
+
globalThis.__replicasScreen = state;
|
|
29
|
+
};
|
|
30
|
+
if (device) {
|
|
31
|
+
document.title = `${device} screen`;
|
|
32
|
+
reportScreen({ state: "connecting" });
|
|
33
|
+
}
|
|
18
34
|
var intParam = (name, fallback, min = 0, max = 9) => {
|
|
19
35
|
const value = Number.parseInt(params.get(name) ?? "", 10);
|
|
20
36
|
return Number.isInteger(value) && value >= min && value <= max ? value : fallback;
|
|
@@ -93,7 +109,7 @@ var configureEncodings = (rfb) => {
|
|
|
93
109
|
};
|
|
94
110
|
};
|
|
95
111
|
var scheduleReconnect = (immediate = false) => {
|
|
96
|
-
if (reconnectTimer || document.visibilityState === "hidden" || !navigator.onLine) return;
|
|
112
|
+
if (screenFailed || reconnectTimer || document.visibilityState === "hidden" || !navigator.onLine) return;
|
|
97
113
|
const delay = immediate ? 0 : Math.min(1e4, 250 * 2 ** Math.min(reconnectAttempt, 6)) * (0.8 + Math.random() * 0.4);
|
|
98
114
|
reconnectAttempt += 1;
|
|
99
115
|
showStatus(reconnectAttempt > 1 ? "reconnecting\u2026" : "connecting\u2026");
|
|
@@ -113,7 +129,7 @@ var connect = () => {
|
|
|
113
129
|
showStatus(reconnectAttempt ? "reconnecting\u2026" : "connecting\u2026");
|
|
114
130
|
let rfb;
|
|
115
131
|
try {
|
|
116
|
-
rfb = new RFB(screen, wsUrl);
|
|
132
|
+
rfb = new RFB(screen, wsUrl, { credentials });
|
|
117
133
|
} catch {
|
|
118
134
|
scheduleReconnect();
|
|
119
135
|
return;
|
|
@@ -132,6 +148,7 @@ var connect = () => {
|
|
|
132
148
|
if (generation !== connectionGeneration) return;
|
|
133
149
|
reconnectAttempt = 0;
|
|
134
150
|
status.classList.remove("visible");
|
|
151
|
+
if (device) reportScreen({ state: "connected", width: rfb._fbWidth, height: rfb._fbHeight });
|
|
135
152
|
applyModernCursor();
|
|
136
153
|
startUpdatePump();
|
|
137
154
|
});
|
|
@@ -139,14 +156,29 @@ var connect = () => {
|
|
|
139
156
|
if (generation !== connectionGeneration) return;
|
|
140
157
|
activeRfb = null;
|
|
141
158
|
stopUpdatePump();
|
|
159
|
+
if (device && !screenFailed) reportScreen({ state: "connecting" });
|
|
142
160
|
scheduleReconnect();
|
|
143
161
|
});
|
|
144
|
-
rfb.addEventListener("securityfailure", () => {
|
|
162
|
+
rfb.addEventListener("securityfailure", (event) => {
|
|
145
163
|
if (generation !== connectionGeneration) return;
|
|
146
164
|
activeRfb = null;
|
|
147
165
|
stopUpdatePump();
|
|
148
|
-
|
|
166
|
+
if (!device) {
|
|
167
|
+
scheduleReconnect();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const reason = event instanceof CustomEvent && isRecord(event.detail) && typeof event.detail.reason === "string" ? event.detail.reason : void 0;
|
|
171
|
+
showStatus("the device rejected the screen password");
|
|
172
|
+
reportScreen({ state: "failed", reason: `${device} rejected the screen password${reason ? ` (${reason})` : ""}. Update it in the environment's Devices settings.` });
|
|
149
173
|
});
|
|
174
|
+
if (device) {
|
|
175
|
+
rfb.addEventListener("credentialsrequired", () => {
|
|
176
|
+
if (generation !== connectionGeneration) return;
|
|
177
|
+
reportScreen({ state: "failed", reason: `${device} asks for a password. Add its screen password in the environment's Devices settings.` });
|
|
178
|
+
showStatus("the device asks for a screen password");
|
|
179
|
+
rfb.disconnect();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
150
182
|
};
|
|
151
183
|
var shiftCanvasForWheel = (event) => {
|
|
152
184
|
if (!optimisticScroll || event.ctrlKey || event.metaKey || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|