replicas-computer 0.3.26 → 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 CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/client.ts
4
- import { createConnection as createConnection2 } from "net";
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 existsSync5, mkdirSync as mkdirSync5, rmSync as rmSync4 } from "fs";
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/system.ts
3000
- import { spawnSync as spawnSync4 } from "child_process";
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(readFileSync5(join(homedir(), ".replicas", "preview-ports.json"), "utf8"));
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.26";
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 (!existsSync5(COMPUTER_USE_SOCKET_PATH)) return false;
3355
+ if (!existsSync6(COMPUTER_USE_SOCKET_PATH)) return false;
3162
3356
  return await new Promise((resolve5) => {
3163
- const socket = createConnection(COMPUTER_USE_SOCKET_PATH);
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
- mkdirSync5(dirname5(COMPUTER_USE_SOCKET_PATH), { recursive: true, mode: 448 });
3177
- rmSync4(COMPUTER_USE_SOCKET_PATH, { force: true });
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 = createServer({ allowHalfOpen: true }, (socket) => {
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
- rmSync4(COMPUTER_USE_SOCKET_PATH, { force: true });
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 = createConnection2(COMPUTER_USE_SOCKET_PATH);
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 spawn5 } from "child_process";
3507
+ import { spawn as spawn6 } from "child_process";
3314
3508
  import { createHash as createHash3 } from "crypto";
3315
- import { createReadStream, existsSync as existsSync7, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
3316
- import { createServer as createServer2 } from "http";
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 spawn3 } from "child_process";
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 = spawn3(command, args, { env: options.env, stdio: [options.controllable?.(command) ? "pipe" : "ignore", options.onOutput ? "pipe" : "ignore", "pipe"] });
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 readFileSync6 } from "fs";
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(readFileSync6(runtimeEnvFile, "utf8")) };
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 existsSync6 } from "fs";
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" && existsSync6(captureBinary);
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 spawn4 } from "child_process";
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 = spawn4("xte", [], {
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 = spawn5("bash", [SERVICES_SCRIPT, "--theme", theme], { detached: true, stdio: "ignore" });
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 = readFileSync7(`/proc/${pid}/stat`, "utf8");
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 = createServer2((request, response) => {
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 = spawn5("ffmpeg", [
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) + "/") || !existsSync7(path) || !statSync2(path).isFile()) {
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 readdirSync2(webroot).filter((file) => /\.(js|css)$/.test(file)).sort()) digest.update(readFileSync7(resolve3(webroot, name)));
5604
- response.end(readFileSync7(path, "utf8").replaceAll("__VERSION__", `${COMPUTER_USE_VERSION}-${digest.digest("hex").slice(0, 8)}`));
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 existsSync8, mkdirSync as mkdirSync6, readdirSync as readdirSync3, writeFileSync as writeFileSync5 } from "fs";
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 (!existsSync8(noVncCore) || !existsSync8(noVncVendor)) {
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 (!existsSync8(assets)) throw new Error(`Computer-use assets are missing from ${assets}`);
5835
- mkdirSync6("/usr/local/share/replicas/desktop", { recursive: true });
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
- mkdirSync6(fontRoot, { recursive: true });
5839
- for (const file of readdirSync3(assets).filter((name) => name.endsWith(".ttf"))) cpSync(resolve4(assets, file), resolve4(fontRoot, file), { force: true });
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
- const viewerRoot = "/usr/local/share/replicas/desktop/novnc";
5842
- mkdirSync6(viewerRoot, { recursive: true });
5843
- cpSync(noVncCore, resolve4(viewerRoot, "core"), { recursive: true, force: true });
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(viewerRoot, file === "novnc-index.html" ? "index.html" : file), { force: true });
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(viewerRoot, "viewer.js"), { force: true });
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
- mkdirSync6(streamRoot, { recursive: true });
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
- writeFileSync5("/etc/systemd/system/replicas-computer.service", serviceUnit, { mode: 420 });
5863
- if (existsSync8("/run/systemd/system")) {
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
- mkdirSync6("/usr/local/share/replicas-computer", { recursive: true });
5868
- writeFileSync5("/usr/local/share/replicas-computer/install.json", `${JSON.stringify({
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/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
- scheduleReconnect();
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replicas-computer",
3
- "version": "0.3.26",
3
+ "version": "0.3.27",
4
4
  "description": "Replicas workspace computer-use runtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",