@bivy/bivy 0.17.3-staging.5 → 0.17.3-staging.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/bivy.mjs CHANGED
@@ -2427,6 +2427,7 @@ async function cmdApp(args = []) {
2427
2427
  bivy app list [--session <id>]
2428
2428
  bivy app remove <app-id> [--session <id>]
2429
2429
  bivy app shot [app-id] [--widths 390,1280] [--themes light,dark] [--path /page] [--session <id>]
2430
+ bivy app run [--name <name>] [--restart-on-change] [--session <id>] -- <command> [args…]
2430
2431
 
2431
2432
  Manifest: {"version":1,"name":"My app","views":[
2432
2433
  {"kind":"web","name":"Website","source":{"kind":"service","port":3000}},
@@ -2435,17 +2436,36 @@ Manifest: {"version":1,"name":"My app","views":[
2435
2436
 
2436
2437
  Static web source: {"kind":"static","directory":"./dist"}
2437
2438
  Server Bivy runs: {"kind":"service","port":5173,"start":{"command":"pnpm","args":["dev"]}}
2439
+ Desktop GUI app (Linux, needs TigerVNC's Xvnc):
2440
+ {"kind":"display","name":"Editor","command":"cargo","args":["run"],"restartOnChange":true}
2441
+ It runs on a private display streamed into the preview, sized to the viewer.
2442
+ "restartOnChange" restarts it after a turn that changed files. Shortcut with
2443
+ no manifest: bivy app run -- cargo run
2438
2444
  Publishing does not start commands. Open Apps in the session menu to view or run;
2439
- a server with "start" starts on first open and is restarted if it exits.
2445
+ a server with "start" or a desktop app starts on first open and is restarted if
2446
+ it exits.
2440
2447
  Terminal commands run with the node user's permissions, not in a new sandbox.
2441
2448
  "shot" screenshots the session's web views (or one app) with a local headless
2442
- Chrome/Chromium and prints the PNG paths — look at them before saying the UI is
2449
+ Chrome/Chromium (desktop apps: straight from their display) and prints the PNG paths — look at them before saying the UI is
2443
2450
  done. It is off by default: turn it on in Bivy → Settings → this machine, or
2444
2451
  run: bivy config set sessions.appScreenshots true
2445
2452
  Web previews require operator setup; see docs/apps.md.`);
2446
2453
  return;
2447
2454
  }
2448
- if (!["publish", "list", "remove", "shot"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
2455
+ if (!["publish", "list", "remove", "shot", "run"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
2456
+ // `run -- <command> [args…]`: publish a one-window desktop app without a manifest file.
2457
+ let runManifest;
2458
+ if (action === "run") {
2459
+ const split = rest.indexOf("--");
2460
+ if (split < 0 || !rest[split + 1]) throw new Error("Usage: bivy app run [--name <name>] [--restart-on-change] -- <command> [args…]");
2461
+ const [command, ...commandArgs] = rest.splice(split).slice(1);
2462
+ const nameIndex = rest.indexOf("--name");
2463
+ const name = nameIndex >= 0 ? rest.splice(nameIndex, 2)[1] : path.basename(command);
2464
+ if (!name) throw new Error("--name requires a value.");
2465
+ const restartIndex = rest.indexOf("--restart-on-change");
2466
+ if (restartIndex >= 0) rest.splice(restartIndex, 1);
2467
+ runManifest = { version: 1, name: name.slice(0, 100), views: [{ kind: "display", name: "Window", command, args: commandArgs, ...(restartIndex >= 0 ? { restartOnChange: true } : {}) }] };
2468
+ }
2449
2469
  const shot = {};
2450
2470
  if (action === "shot") {
2451
2471
  for (const flag of ["--widths", "--themes", "--path"]) {
@@ -2464,7 +2484,7 @@ Web previews require operator setup; see docs/apps.md.`);
2464
2484
  const sessionId = resolveAttachSessionId({ sessionFlag: sessionIndex >= 0 ? rest[sessionIndex + 1] : undefined, env: process.env });
2465
2485
  if (!sessionId) throw new Error("Set --session <id> or run inside a Bivy agent session.");
2466
2486
  const positional = rest.filter((_, i) => i !== sessionIndex && (sessionIndex < 0 || i !== sessionIndex + 1));
2467
- if (positional.some((a) => a.startsWith("-")) || (action === "shot" ? positional.length > 1 : positional.length !== (action === "list" ? 0 : 1))) throw new Error("Invalid arguments. Run bivy app --help.");
2487
+ if (positional.some((a) => a.startsWith("-")) || (action === "shot" ? positional.length > 1 : positional.length !== (action === "list" || action === "run" ? 0 : 1))) throw new Error("Invalid arguments. Run bivy app --help.");
2468
2488
  const body = { sessionId, ...shot };
2469
2489
  if (action === "shot" && positional[0]) body.appId = positional[0];
2470
2490
  if (action === "publish") {
@@ -2478,11 +2498,12 @@ Web previews require operator setup; see docs/apps.md.`);
2478
2498
  }
2479
2499
  }
2480
2500
  body.manifest = manifest;
2481
- } else if (action === "remove") body.appId = positional[0];
2501
+ } else if (action === "run") body.manifest = runManifest;
2502
+ else if (action === "remove") body.appId = positional[0];
2482
2503
  const config = loadConfig();
2483
2504
  if (!(await ensureNodeRunning(config))) throw new Error("Could not reach the Bivy node.");
2484
2505
  const token = await localDeviceToken(config);
2485
- const response = await fetch(`${url(config)}/api/apps/${action}`, {
2506
+ const response = await fetch(`${url(config)}/api/apps/${action === "run" ? "publish" : action}`, {
2486
2507
  method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body),
2487
2508
  });
2488
2509
  const result = await response.json();
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ export const DISPLAY_SOCKET_PATH = "/__bivy/display";
4
+ export const DISPLAY_STATS_PATH = "/__bivy/display-stats";
5
+ export const NOVNC_PATH = "/__bivy/novnc/";
6
+ /** The page a display view serves on its own origin: noVNC's client streaming
7
+ * the app's display. It asks the display to match its size in device pixels
8
+ * (`scale` is what the display was started at), scales down when the display
9
+ * is larger, reconnects while the app starts, carries the clipboard both ways
10
+ * on a tap, gives phones a keyboard, and reports stream latency to Bivy. */
11
+ export function displayViewer(nonce, name, scale) {
12
+ const title = name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
13
+ return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content"><title>${title}</title>
14
+ <style nonce="${nonce}">:root{color-scheme:light dark;font-family:system-ui,sans-serif}html,body{margin:0;height:100%;overflow:hidden;background:Canvas;color:CanvasText}
15
+ #screen{position:fixed;inset:0}#status{position:fixed;inset:0;display:grid;place-items:center;margin:0;padding:24px;text-align:center;line-height:1.5}[hidden]{display:none!important}
16
+ #tools{position:fixed;left:12px;bottom:72px;display:flex;flex-direction:column;align-items:flex-start;gap:8px}
17
+ button,#paste{font:inherit;font-size:14px;min-height:44px;padding:0 14px;border-radius:22px;border:1px solid GrayText;background:Canvas;color:CanvasText}
18
+ #keys{display:none;min-width:44px;padding:0}@media (pointer:coarse){#keys{display:block}}
19
+ #paste{display:flex;gap:8px;align-items:center;padding:4px 4px 4px 14px}#paste input{font:inherit;min-height:36px;border:0;background:transparent;color:inherit;width:12em}
20
+ #typing{position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0}</style>
21
+ <div id="screen"></div><p id="status" role="status">Starting the app…</p>
22
+ <div id="tools"><button id="copy" type="button" hidden>Copy from app</button>
23
+ <form id="paste" hidden><input id="paste-text" aria-label="Text to paste into the app" placeholder="Paste here"><button type="submit">Send</button></form>
24
+ <button id="paste-open" type="button">Paste</button><button id="keys" type="button" aria-label="Show keyboard">⌨</button></div>
25
+ <input id="typing" aria-label="Type into the app" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false">
26
+ <script type="module" nonce="${nonce}">import RFB from "${NOVNC_PATH}core/rfb.js";
27
+ const $=id=>document.getElementById(id),screen=$("screen"),status=$("status"),typing=$("typing"),SCALE=${scale === 2 ? 2 : 1};
28
+ let rfb,wait=500,remote="";
29
+ const say=text=>{status.textContent=text;status.hidden=!text;};
30
+
31
+ // Latency: time from each input sent to the next frame back, in this browser.
32
+ const lat=[];let pending=0,bytes=0,since=performance.now();
33
+ // (Patched in place: noVNC rejects a WebSocket subclass.)
34
+ const send=WebSocket.prototype.send,probed=new WeakSet();
35
+ WebSocket.prototype.send=function(data){
36
+ if(!probed.has(this)){probed.add(this);this.addEventListener("message",e=>{bytes+=e.data.byteLength??e.data.size??0;if(pending){lat.push(performance.now()-pending);pending=0;}});}
37
+ const first=data instanceof ArrayBuffer?new Uint8Array(data)[0]:ArrayBuffer.isView(data)?new Uint8Array(data.buffer,data.byteOffset,1)[0]:-1;
38
+ if((first===4||first===5)&&!pending)pending=performance.now();
39
+ return send.call(this,data);
40
+ };
41
+ setInterval(()=>{
42
+ if(lat.length<5)return;
43
+ const sorted=lat.splice(0).sort((a,b)=>a-b),at=p=>sorted[Math.min(sorted.length-1,Math.floor(sorted.length*p))];
44
+ const secs=(performance.now()-since)/1000;since=performance.now();
45
+ fetch("${DISPLAY_STATS_PATH}",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({latencyMs:{p50:at(.5),p95:at(.95)},kBps:bytes/1024/secs,viewport:{width:innerWidth,height:innerHeight,scale:devicePixelRatio}})}).catch(()=>{});
46
+ bytes=0;
47
+ },10000);
48
+
49
+ function connect(){
50
+ rfb=new RFB(screen,(location.protocol==="https:"?"wss://":"ws://")+location.host+"${DISPLAY_SOCKET_PATH}",{shared:true});
51
+ // Ask for device pixels, so a display started at 2× stays sharp once scaled
52
+ // to fit. Only the resize request uses them; scaling still fits CSS pixels.
53
+ let ask=1;const measure=rfb._screenSize.bind(rfb),request=rfb._requestRemoteResize.bind(rfb);
54
+ rfb._screenSize=()=>{const s=measure();return{w:Math.round(s.w*ask),h:Math.round(s.h*ask)};};
55
+ rfb._requestRemoteResize=()=>{ask=SCALE;try{request();}finally{ask=1;}};
56
+ rfb.resizeSession=true;rfb.scaleViewport=true;rfb.focusOnClick=true;
57
+ rfb.addEventListener("connect",()=>{wait=500;say("");});
58
+ rfb.addEventListener("disconnect",()=>{say("Starting the app…");setTimeout(connect,wait);wait=Math.min(wait*2,5000);});
59
+ // The app copied text: offer it; the browser only lets a tap write the clipboard.
60
+ rfb.addEventListener("clipboard",e=>{remote=e.detail.text;$("copy").hidden=!remote;$("copy").textContent="Copy from app";});
61
+ }
62
+ connect();
63
+
64
+ $("copy").onclick=async()=>{try{await navigator.clipboard.writeText(remote);$("copy").textContent="Copied";setTimeout(()=>{$("copy").hidden=true;},1500);}catch{$("copy").textContent="Couldn’t copy";}};
65
+ // Paste: hand the text to the app's clipboard, then press Ctrl+V in it.
66
+ const paste=text=>{if(!text)return;rfb.clipboardPasteFrom(text);rfb.sendKey(0xffe3,"ControlLeft",true);rfb.sendKey(0x76,"KeyV");rfb.sendKey(0xffe3,"ControlLeft",false);rfb.focus();};
67
+ $("paste-open").onclick=async()=>{
68
+ try{const text=await navigator.clipboard.readText();if(text){paste(text);return;}}catch{}
69
+ // No clipboard access here: paste into a field instead.
70
+ $("paste").hidden=false;$("paste-text").focus();
71
+ };
72
+ $("paste").onsubmit=e=>{e.preventDefault();paste($("paste-text").value);$("paste-text").value="";$("paste").hidden=true;};
73
+ $("paste-text").addEventListener("keydown",e=>{if(e.key==="Escape"){$("paste").hidden=true;rfb.focus();}});
74
+
75
+ // Phones: a soft keyboard needs a text field; forward what's typed as keys.
76
+ const SPECIAL={Backspace:0xff08,Enter:0xff0d,Tab:0xff09,Escape:0xff1b,ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54};
77
+ $("keys").onclick=()=>typing.focus();
78
+ typing.addEventListener("keydown",e=>{const key=SPECIAL[e.key];if(key){e.preventDefault();rfb.sendKey(key,null);}});
79
+ typing.addEventListener("input",()=>{for(const ch of typing.value){const cp=ch.codePointAt(0);rfb.sendKey(cp<256?cp:0x01000000+cp,null);}typing.value="";});
80
+ </script></html>`;
81
+ }
@@ -0,0 +1,135 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { spawn, execFileSync } from "node:child_process";
7
+ import { FitWindowManager, writeXauthority } from "./x11.js";
8
+ /** Environment for programs on a preview display. X11 for every toolkit, so an
9
+ * app never escapes to the machine's own Wayland/X session. */
10
+ const APP_ENV = {
11
+ WAYLAND_DISPLAY: "",
12
+ GDK_BACKEND: "x11",
13
+ QT_QPA_PLATFORM: "xcb",
14
+ SDL_VIDEODRIVER: "x11",
15
+ ELECTRON_OZONE_PLATFORM_HINT: "x11",
16
+ NO_AT_BRIDGE: "1",
17
+ };
18
+ /** How each toolkit is told to draw at 2× for a high-density viewer (Chromium
19
+ * and Electron read the Xft.dpi the window manager publishes). */
20
+ const SCALE_ENV = {
21
+ 1: {},
22
+ 2: { GDK_SCALE: "2", QT_SCALE_FACTOR: "2", J2D_UISCALE: "2" },
23
+ };
24
+ export const DISPLAY_SCALES = Object.keys(SCALE_ENV).map(Number);
25
+ /** TigerVNC's X server; BIVY_XVNC overrides. */
26
+ const XVNC = ["Xtigervnc", "Xvnc"];
27
+ const START_MS = 10_000;
28
+ export function findXvnc(env = process.env) {
29
+ if (env.BIVY_XVNC)
30
+ return fs.existsSync(env.BIVY_XVNC) ? env.BIVY_XVNC : undefined;
31
+ for (const name of XVNC) {
32
+ try {
33
+ return execFileSync("which", [name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env }).trim() || undefined;
34
+ }
35
+ catch { /* next */ }
36
+ }
37
+ return undefined;
38
+ }
39
+ /** One private X display per view, started on demand: an Xvnc server that
40
+ * listens on no TCP port, an authority cookie only its programs get, and a
41
+ * window manager that fits windows to whatever size the viewer asks for. */
42
+ export class DisplayHost {
43
+ options;
44
+ displays = new Map();
45
+ constructor(options = {}) {
46
+ this.options = options;
47
+ }
48
+ /** Why desktop views can't run here, or undefined when they can. */
49
+ unavailable() {
50
+ if ((this.options.platform ?? process.platform) !== "linux")
51
+ return "Desktop app views need a Linux machine for now.";
52
+ if (!(this.options.xvnc ?? findXvnc)())
53
+ return "Desktop app views need TigerVNC's X server. Install it (Debian/Ubuntu: sudo apt install tigervnc-standalone-server), or set BIVY_XVNC to its path.";
54
+ return undefined;
55
+ }
56
+ running(id) { return this.displays.get(id)?.ready; }
57
+ /** `scale` (1 or 2) is fixed when the display starts: programs pick it up once. */
58
+ ensure(id, name, scale = 1) {
59
+ const existing = this.displays.get(id);
60
+ if (existing)
61
+ return existing.ready;
62
+ const state = { dir: fs.mkdtempSync(path.join(os.tmpdir(), "bivy-display-")) };
63
+ state.ready = this.start(state, name, SCALE_ENV[scale] ? scale : 1).then((display) => {
64
+ // If the X server dies, forget it: the next open starts a fresh one.
65
+ state.child.once("exit", () => { if (this.displays.get(id) === state)
66
+ this.stop(id); });
67
+ return display;
68
+ }, (error) => { this.stop(id); throw error; });
69
+ this.displays.set(id, state);
70
+ return state.ready;
71
+ }
72
+ async start(state, name, scale) {
73
+ const reason = this.unavailable();
74
+ if (reason)
75
+ throw new Error(reason);
76
+ const xvnc = (this.options.xvnc ?? findXvnc)();
77
+ const x11Dir = this.options.x11Dir ?? "/tmp/.X11-unix";
78
+ const authority = path.join(state.dir, "Xauthority");
79
+ const socket = path.join(state.dir, "vnc.sock");
80
+ const cookie = writeXauthority(authority);
81
+ // Another X server may take a number between our check and its lock.
82
+ for (let attempt = 0; attempt < 5; attempt++) {
83
+ const number = freeDisplay(x11Dir);
84
+ const child = spawn(xvnc, [`:${number}`, "-auth", authority, "-rfbunixpath", socket, "-rfbunixmode", "0600", "-rfbport", "-1",
85
+ "-SecurityTypes", "None", "-AlwaysShared=1", "-AcceptSetDesktopSize=1", "-SendPrimary=0", "-nolisten", "tcp", "-geometry", `${1280 * scale}x${800 * scale}`, "-depth", "24", "-dpi", String(96 * scale), "-desktop", name.slice(0, 60)], { stdio: "ignore" });
86
+ state.child = child;
87
+ const x = path.join(x11Dir, `X${number}`);
88
+ const up = await new Promise((resolve) => {
89
+ const deadline = Date.now() + START_MS;
90
+ const exited = () => resolve(false);
91
+ child.once("exit", exited);
92
+ child.once("error", exited);
93
+ const poll = () => {
94
+ if (child.exitCode !== null)
95
+ return;
96
+ if (fs.existsSync(socket) && fs.existsSync(x)) {
97
+ child.off("exit", exited);
98
+ resolve(true);
99
+ return;
100
+ }
101
+ if (Date.now() > deadline) {
102
+ child.kill("SIGKILL");
103
+ return;
104
+ }
105
+ setTimeout(poll, 50);
106
+ };
107
+ poll();
108
+ });
109
+ if (!up)
110
+ continue;
111
+ const wm = new FitWindowManager();
112
+ await wm.start(x, cookie, 96 * scale);
113
+ return { socket, wm, scale, env: { ...APP_ENV, ...SCALE_ENV[scale], DISPLAY: `:${number}`, XAUTHORITY: authority } };
114
+ }
115
+ throw new Error("Couldn't start a display for this app.");
116
+ }
117
+ stop(id) {
118
+ const state = this.displays.get(id);
119
+ if (!state)
120
+ return;
121
+ this.displays.delete(id);
122
+ void state.ready.then((display) => display.wm.stop(), () => { });
123
+ state.child?.kill("SIGTERM");
124
+ fs.rmSync(state.dir, { recursive: true, force: true });
125
+ }
126
+ stopAll() { for (const id of [...this.displays.keys()])
127
+ this.stop(id); }
128
+ }
129
+ function freeDisplay(x11Dir) {
130
+ for (let n = 100; n < 1000; n++) {
131
+ if (!fs.existsSync(path.join(x11Dir, `X${n}`)) && !fs.existsSync(`/tmp/.X${n}-lock`))
132
+ return n;
133
+ }
134
+ throw new Error("No free X display number.");
135
+ }
@@ -1,10 +1,14 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
3
  import http from "node:http";
4
+ import net from "node:net";
4
5
  import { randomBytes } from "node:crypto";
5
6
  import path from "node:path";
6
7
  import { readFileSync } from "node:fs";
8
+ import { createRequire } from "node:module";
9
+ import { WebSocketServer } from "ws";
7
10
  import { previewShell } from "./preview-shell.js";
11
+ import { displayViewer, DISPLAY_SOCKET_PATH, DISPLAY_STATS_PATH, NOVNC_PATH } from "./display-viewer.js";
8
12
  import { inspectorScript } from "./inspector.js";
9
13
  const MIME = {
10
14
  ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
@@ -47,6 +51,31 @@ function allowInspector(policy, url) {
47
51
  return directives.join("; ");
48
52
  }
49
53
  const HOUR = 60 * 60_000;
54
+ /** A viewer that stops reading doesn't make the node buffer the display. */
55
+ const DISPLAY_BUFFER_BYTES = 4 * 1024 * 1024;
56
+ /** noVNC's browser modules (core/ and its vendored zlib), served as-is. */
57
+ const novncRoot = (() => { try {
58
+ return path.dirname(path.dirname(createRequire(import.meta.url).resolve("@novnc/novnc")));
59
+ }
60
+ catch {
61
+ return undefined;
62
+ } })();
63
+ const novncFiles = new Map();
64
+ function novncFile(relative) {
65
+ if (!novncRoot || !/^(?:core|vendor)(?:\/[A-Za-z0-9_-]+)+\.js$/.test(relative))
66
+ return undefined;
67
+ let data = novncFiles.get(relative);
68
+ if (!data) {
69
+ try {
70
+ data = readFileSync(path.join(novncRoot, relative));
71
+ novncFiles.set(relative, data);
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ }
77
+ return data;
78
+ }
50
79
  /** Marks gateway-generated "server not answering" responses, so recovery
51
80
  * polling can tell them apart from an app's own 502s. */
52
81
  const UPSTREAM_DOWN = "x-bivy-upstream-down";
@@ -125,6 +154,7 @@ export class AppGateway {
125
154
  /** `reviewer`: the browser session came from a copied (shared) link. */
126
155
  sessions = new Map();
127
156
  sockets = new Map();
157
+ displays = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: false });
128
158
  /** `signIn` sends a signed-out visit to a view's stable address back
129
159
  * through Bivy, which re-opens it for a signed-in device. */
130
160
  constructor(registry, originTemplate, returnOrigins = () => [], signIn) {
@@ -186,6 +216,7 @@ export class AppGateway {
186
216
  this.revoke(id);
187
217
  this.tickets.clear();
188
218
  this.sessions.clear();
219
+ this.displays.close();
189
220
  this.server.close();
190
221
  this.server.closeAllConnections();
191
222
  }
@@ -277,7 +308,7 @@ export class AppGateway {
277
308
  return;
278
309
  }
279
310
  res.setHeader("Content-Type", "application/json");
280
- res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo }));
311
+ res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo, ...(entry.target.kind === "display" ? { inspect: false } : {}) }));
281
312
  return;
282
313
  }
283
314
  res.writeHead(404);
@@ -398,6 +429,10 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
398
429
  await this.note(req, res, entry);
399
430
  return;
400
431
  }
432
+ if (entry.target.kind === "display") {
433
+ await this.display(req, res, entry);
434
+ return;
435
+ }
401
436
  const inspect = isPageLoad(req);
402
437
  // Remember the framed page so a turn reload lands where the user was.
403
438
  if (req.method === "GET" && req.headers["sec-fetch-dest"] === "iframe" && req.url.length <= 2048)
@@ -494,6 +529,93 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
494
529
  res.on("close", () => upstream.destroy());
495
530
  req.pipe(upstream);
496
531
  }
532
+ /** A display view's origin serves only its viewer and noVNC's modules; the
533
+ * pixels come over the WebSocket (see upgrade). */
534
+ async display(req, res, entry) {
535
+ const id = entry.view.id;
536
+ // The viewer's measurements (same-origin POST, checked above). Numbers only.
537
+ if (req.url === DISPLAY_STATS_PATH && req.method === "POST") {
538
+ let body = "";
539
+ for await (const chunk of req) {
540
+ body += chunk.toString();
541
+ if (body.length > 1024) {
542
+ res.writeHead(413);
543
+ res.end();
544
+ return;
545
+ }
546
+ }
547
+ let input;
548
+ try {
549
+ input = JSON.parse(body);
550
+ }
551
+ catch {
552
+ res.writeHead(400);
553
+ res.end();
554
+ return;
555
+ }
556
+ const num = (value, max) => { const n = Number(value); return Number.isFinite(n) ? Math.min(Math.max(0, Math.round(n * 10) / 10), max) : 0; };
557
+ entry.stats = { at: Date.now(), latencyMs: { p50: num(input.latencyMs?.p50, 60_000), p95: num(input.latencyMs?.p95, 60_000) }, kBps: num(input.kBps, 1e6),
558
+ viewport: { width: num(input.viewport?.width, 10_000), height: num(input.viewport?.height, 10_000), scale: num(input.viewport?.scale, 8) } };
559
+ res.writeHead(204);
560
+ res.end();
561
+ return;
562
+ }
563
+ if (!["GET", "HEAD"].includes(req.method ?? "")) {
564
+ res.writeHead(405, { Allow: "GET, HEAD" });
565
+ res.end();
566
+ return;
567
+ }
568
+ const url = req.url.split("?")[0];
569
+ if (url.startsWith(NOVNC_PATH)) {
570
+ const data = novncFile(url.slice(NOVNC_PATH.length));
571
+ if (!data) {
572
+ res.writeHead(404);
573
+ res.end();
574
+ return;
575
+ }
576
+ res.writeHead(200, { ...responseHeaders({}, this.ancestors(id)), "content-type": "text/javascript; charset=utf-8", "content-length": data.length });
577
+ res.end(req.method === "HEAD" ? undefined : data);
578
+ return;
579
+ }
580
+ if (!isPageLoad(req)) {
581
+ res.writeHead(404);
582
+ res.end();
583
+ return;
584
+ }
585
+ const nonce = randomBytes(16).toString("hex");
586
+ const body = Buffer.from(displayViewer(nonce, entry.app.name, entry.displayScale ?? 1));
587
+ const headers = responseHeaders({}, this.ancestors(id));
588
+ headers["content-security-policy"] = `default-src 'none'; script-src 'self' 'nonce-${nonce}'; style-src 'nonce-${nonce}'; img-src data: blob:; connect-src 'self' ${this.origin(id).replace(/^https:/, "wss:")}; frame-ancestors ${this.ancestors(id)}; worker-src 'none'; base-uri 'none'; form-action 'none'`;
589
+ res.writeHead(200, { ...headers, "content-type": "text/html; charset=utf-8", "content-length": body.length });
590
+ res.end(req.method === "HEAD" ? undefined : body);
591
+ }
592
+ /** Relays a viewer's WebSocket to the display's private VNC socket. */
593
+ displaySocket(req, socket, head, entry) {
594
+ this.displays.handleUpgrade(req, socket, head, (ws) => {
595
+ // Not running yet (or any more): the viewer retries while the app starts.
596
+ if (!entry.display) {
597
+ ws.close(1013, "The display isn't running.");
598
+ return;
599
+ }
600
+ const peer = net.connect(entry.display);
601
+ const close = () => { peer.destroy(); ws.terminate(); };
602
+ peer.on("data", (data) => {
603
+ ws.send(data);
604
+ if (ws.bufferedAmount > DISPLAY_BUFFER_BYTES) {
605
+ peer.pause();
606
+ const resume = setInterval(() => { if (ws.bufferedAmount < DISPLAY_BUFFER_BYTES / 2 || ws.readyState !== ws.OPEN) {
607
+ clearInterval(resume);
608
+ peer.resume();
609
+ } }, 20);
610
+ }
611
+ });
612
+ ws.on("message", (data) => peer.write(data));
613
+ peer.on("error", close);
614
+ peer.on("close", close);
615
+ ws.on("error", close);
616
+ ws.on("close", close);
617
+ });
618
+ }
497
619
  /** A reviewer's note. Untrusted text: stored bounded, shown as text, and only
498
620
  * ever turned into a draft by the owner. Same-origin POSTs only (checked above). */
499
621
  async note(req, res, entry) {
@@ -578,11 +700,18 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
578
700
  upgrade(req, socket, head) {
579
701
  const entry = this.entry(req);
580
702
  const expires = entry && this.authorize(req, entry.view.id);
581
- if (!entry || !expires || entry.target.kind !== "service" || req.headers.origin !== this.origin(entry.view.id) || req.headers.upgrade?.toLowerCase() !== "websocket" || !req.url?.startsWith("/") || req.url.startsWith("//")) {
703
+ const display = entry?.target.kind === "display";
704
+ if (!entry || !expires || (entry.target.kind !== "service" && !(display && req.url === DISPLAY_SOCKET_PATH)) || req.headers.origin !== this.origin(entry.view.id) || req.headers.upgrade?.toLowerCase() !== "websocket" || !req.url?.startsWith("/") || req.url.startsWith("//")) {
582
705
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
583
706
  return;
584
707
  }
585
708
  this.track(entry.view.id, socket, expires);
709
+ if (display) {
710
+ this.displaySocket(req, socket, head, entry);
711
+ return;
712
+ }
713
+ if (entry.target.kind !== "service")
714
+ return;
586
715
  const port = entry.target.port;
587
716
  const upstream = http.request({ hostname: "127.0.0.1", port, path: req.url, headers: { ...upstreamHeaders(req, this.origin(entry.view.id)), connection: "Upgrade", upgrade: "websocket" } });
588
717
  upstream.setTimeout(10_000, () => upstream.destroy());
@@ -101,6 +101,11 @@ function show(data,launch){
101
101
  back.title=data.returnTo?'Return to '+new URL(data.returnTo).host:'Close preview';
102
102
  frame.title=data.name;
103
103
  frame.hidden=false;
104
+ // Desktop apps have no page to inspect: no Point or Console. Their viewer
105
+ // (Bivy's own code) may use the clipboard; generated web apps may not.
106
+ // (Set before navigating: the policy is fixed when the frame loads.)
107
+ for(const b of [$('point'),$('errors')])b.hidden=data.inspect===false;
108
+ if(data.inspect===false)frame.allow='clipboard-read; clipboard-write';
104
109
  frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch:'/');
105
110
  back.hidden=embedded&&Boolean(data.returnTo);
106
111
  for(const b of [reload,$('point'),$('errors')])b.disabled=false;
@@ -178,8 +178,13 @@ export class AppRegistry extends EventEmitter {
178
178
  target = { kind: "terminal", ...command(spec, workspace, "Terminal view") };
179
179
  view = { ...base, kind: "terminal", command: target.command, args: target.args };
180
180
  }
181
+ else if (spec.kind === "display") {
182
+ // Shown through the web preview: Bivy's viewer streams the display.
183
+ target = { kind: "display", ...command(spec, workspace, "Display view"), restartOnChange: spec.restartOnChange === true };
184
+ view = { ...base, kind: "web", source: "display", managed: true };
185
+ }
181
186
  else
182
- throw new Error("Unsupported app view. This machine supports web and terminal views.");
187
+ throw new Error("Unsupported app view. Supported views: web, terminal and display.");
183
188
  app.views.push(view);
184
189
  entries.push({ app, view, target, revision: 0, source });
185
190
  }
@@ -202,7 +207,8 @@ export class AppRegistry extends EventEmitter {
202
207
  const bumped = [];
203
208
  let total = [...this.views.values()].reduce((sum, item) => sum + (item.target.kind === "static" ? item.target.bytes : 0), 0);
204
209
  for (const entry of this.views.values()) {
205
- if (entry.app.sessionId !== sessionId || entry.view.kind !== "web")
210
+ // A display streams live pixels: only one that restarts on changes has a new revision.
211
+ if (entry.app.sessionId !== sessionId || entry.view.kind !== "web" || (entry.target.kind === "display" && !entry.target.restartOnChange))
206
212
  continue;
207
213
  if (entry.target.kind === "static") {
208
214
  if (!entry.source)
@@ -0,0 +1,126 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import net from "node:net";
4
+ import { deflateSync } from "node:zlib";
5
+ /** Reads exact byte counts from a stream. */
6
+ class Reader {
7
+ buffer = Buffer.alloc(0);
8
+ waiting;
9
+ failure;
10
+ reject;
11
+ constructor(socket) {
12
+ socket.on("data", (chunk) => { this.buffer = Buffer.concat([this.buffer, chunk]); this.pump(); });
13
+ const fail = (error) => { this.failure = error; this.reject?.(error); };
14
+ socket.on("error", fail);
15
+ socket.on("close", () => fail(new Error("The display closed the connection.")));
16
+ }
17
+ read(n) {
18
+ return new Promise((resolve, reject) => {
19
+ if (this.failure && this.buffer.length < n) {
20
+ reject(this.failure);
21
+ return;
22
+ }
23
+ this.waiting = { n, resolve };
24
+ this.reject = reject;
25
+ this.pump();
26
+ });
27
+ }
28
+ pump() {
29
+ if (!this.waiting || this.buffer.length < this.waiting.n)
30
+ return;
31
+ const { n, resolve } = this.waiting;
32
+ this.waiting = undefined;
33
+ const out = this.buffer.subarray(0, n);
34
+ this.buffer = this.buffer.subarray(n);
35
+ resolve(out);
36
+ }
37
+ }
38
+ /** One full frame from a VNC server (security type None), as packed RGB. Joins
39
+ * as a shared client, so open viewers stay connected. */
40
+ export async function captureFrame(socketPath, timeoutMs = 10_000) {
41
+ const socket = net.connect(socketPath);
42
+ const timer = setTimeout(() => socket.destroy(new Error("The display didn't answer in time.")), timeoutMs);
43
+ try {
44
+ const reader = new Reader(socket);
45
+ const version = (await reader.read(12)).toString("latin1");
46
+ if (!/^RFB 003\.\d{3}\n$/.test(version))
47
+ throw new Error("Not a VNC display.");
48
+ socket.write("RFB 003.008\n");
49
+ const types = await reader.read((await reader.read(1))[0]);
50
+ if (!types.includes(1))
51
+ throw new Error("The display requires a password.");
52
+ socket.write(Buffer.from([1]));
53
+ if ((await reader.read(4)).readUInt32BE(0) !== 0)
54
+ throw new Error("The display refused the connection.");
55
+ socket.write(Buffer.from([1]));
56
+ const init = await reader.read(24);
57
+ const width = init.readUInt16BE(0), height = init.readUInt16BE(2);
58
+ await reader.read(init.readUInt32BE(20)); // desktop name
59
+ // 32 bpp true colour, little-endian, red at bit 16: bytes arrive B, G, R, X.
60
+ const format = Buffer.from([0, 0, 0, 0, 32, 24, 0, 1, 0, 255, 0, 255, 0, 255, 16, 8, 0, 0, 0, 0]);
61
+ const encodings = Buffer.from([2, 0, 0, 1, 0, 0, 0, 0]); // Raw only
62
+ const request = Buffer.alloc(10);
63
+ request[0] = 3;
64
+ request.writeUInt16BE(width, 6);
65
+ request.writeUInt16BE(height, 8);
66
+ socket.write(Buffer.concat([format, encodings, request]));
67
+ const rgb = Buffer.alloc(width * height * 3);
68
+ for (;;) {
69
+ const type = (await reader.read(1))[0];
70
+ if (type === 2)
71
+ continue; // bell
72
+ if (type === 3) {
73
+ const head = await reader.read(7);
74
+ await reader.read(head.readUInt32BE(3));
75
+ continue;
76
+ } // clipboard
77
+ if (type !== 0)
78
+ throw new Error("Unexpected message from the display.");
79
+ const rects = (await reader.read(3)).readUInt16BE(1);
80
+ for (let r = 0; r < rects; r++) {
81
+ const head = await reader.read(12);
82
+ const x = head.readUInt16BE(0), y = head.readUInt16BE(2), w = head.readUInt16BE(4), h = head.readUInt16BE(6);
83
+ if (head.readInt32BE(8) !== 0)
84
+ throw new Error("Unexpected encoding from the display.");
85
+ const pixels = await reader.read(w * h * 4);
86
+ for (let row = 0; row < h; row++)
87
+ for (let col = 0; col < w; col++) {
88
+ if (x + col >= width || y + row >= height)
89
+ continue;
90
+ const from = (row * w + col) * 4, to = ((y + row) * width + x + col) * 3;
91
+ rgb[to] = pixels[from + 2];
92
+ rgb[to + 1] = pixels[from + 1];
93
+ rgb[to + 2] = pixels[from];
94
+ }
95
+ }
96
+ return { width, height, rgb };
97
+ }
98
+ }
99
+ finally {
100
+ clearTimeout(timer);
101
+ socket.destroy();
102
+ }
103
+ }
104
+ const CRC = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++)
105
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
106
+ function crc32(data) { let c = 0xffffffff; for (const byte of data)
107
+ c = CRC[(c ^ byte) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }
108
+ function chunk(type, data) {
109
+ const head = Buffer.alloc(8);
110
+ head.writeUInt32BE(data.length, 0);
111
+ head.write(type, 4, "latin1");
112
+ const crc = Buffer.alloc(4);
113
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])));
114
+ return Buffer.concat([head, data, crc]);
115
+ }
116
+ export function encodePng(width, height, rgb) {
117
+ const header = Buffer.alloc(13);
118
+ header.writeUInt32BE(width, 0);
119
+ header.writeUInt32BE(height, 4);
120
+ header[8] = 8;
121
+ header[9] = 2; // 8-bit RGB
122
+ const rows = Buffer.alloc((width * 3 + 1) * height);
123
+ for (let y = 0; y < height; y++)
124
+ rgb.copy(rows, y * (width * 3 + 1) + 1, y * width * 3, (y + 1) * width * 3);
125
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", header), chunk("IDAT", deflateSync(rows)), chunk("IEND", Buffer.alloc(0))]);
126
+ }
@@ -7,6 +7,7 @@ import http from "node:http";
7
7
  import { once } from "node:events";
8
8
  import { spawn, execFileSync } from "node:child_process";
9
9
  import { WebSocket } from "ws";
10
+ import { captureFrame, encodePng } from "./rfb.js";
10
11
  /** Browsers tried in order; BIVY_CHROME overrides. Any Chromium works. */
11
12
  const CANDIDATES = [
12
13
  "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge",
@@ -95,11 +96,24 @@ class Cdp {
95
96
  }
96
97
  /** Screenshots web views with one headless browser, one shot at a time, so
97
98
  * small machines cope. Service views are loaded straight from loopback;
98
- * static views from a throwaway loopback server. Files are PNGs in `outDir`. */
99
+ * static views from a throwaway loopback server. Desktop apps are read from
100
+ * their display, at its current size, without a browser. Files are PNGs in `outDir`. */
99
101
  export async function takeShots(views, request, outDir, chrome = findChrome()) {
102
+ fs.mkdirSync(outDir, { recursive: true });
103
+ const shots = [];
104
+ for (const entry of views) {
105
+ if (entry.target.kind !== "display" || !entry.display)
106
+ continue;
107
+ const frame = await captureFrame(entry.display);
108
+ const file = path.join(outDir, `${entry.view.id.slice(0, 8)}-${frame.width}-native.png`);
109
+ fs.writeFileSync(file, encodePng(frame.width, frame.height, frame.rgb));
110
+ shots.push({ viewId: entry.view.id, view: entry.view.name, width: frame.width, theme: "native", file });
111
+ }
112
+ views = views.filter((entry) => entry.target.kind === "service" || entry.target.kind === "static");
113
+ if (!views.length)
114
+ return shots;
100
115
  if (!chrome)
101
116
  throw new Error("No Chrome or Chromium found on this machine. Install one, or set BIVY_CHROME to its path.");
102
- fs.mkdirSync(outDir, { recursive: true });
103
117
  const profile = fs.mkdtempSync(path.join(os.tmpdir(), "bivy-shot-"));
104
118
  const servers = [];
105
119
  let ws;
@@ -132,7 +146,6 @@ export async function takeShots(views, request, outDir, chrome = findChrome()) {
132
146
  const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
133
147
  const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
134
148
  await cdp.send("Page.enable", {}, sessionId);
135
- const shots = [];
136
149
  for (const entry of views) {
137
150
  let origin;
138
151
  if (entry.target.kind === "service")
@@ -3,6 +3,9 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { scanListeners } from "./listeners.js";
5
5
  import { takeShots } from "./screenshot.js";
6
+ const NO_DISPLAYS = { unavailable: () => "Desktop app views aren't available on this machine.", ensure: () => Promise.reject(new Error("Desktop app views aren't available on this machine.")), stop: () => { } };
7
+ /** How long a screenshot waits for a desktop app's first window. */
8
+ const WINDOW_WAIT_MS = 15_000;
6
9
  /** Composes view providers; registry never spawns processes and the gateway
7
10
  * never knows about terminals. New providers can extend open/remove here. */
8
11
  /** A managed server that keeps exiting is left down after this many restarts
@@ -21,6 +24,7 @@ export class AppService {
21
24
  scan;
22
25
  serverWatchMs;
23
26
  screenshots;
27
+ displays;
24
28
  shooting = Promise.resolve();
25
29
  constructor(registry, gateway, terminals, options = {}) {
26
30
  this.registry = registry;
@@ -29,6 +33,7 @@ export class AppService {
29
33
  this.scan = options.scan ?? scanListeners;
30
34
  this.serverWatchMs = options.serverWatchMs ?? 5_000;
31
35
  this.screenshots = { enabled: options.screenshots?.enabled ?? (() => false), take: options.screenshots?.take ?? takeShots };
36
+ this.displays = options.displays ?? NO_DISPLAYS;
32
37
  }
33
38
  list(sessionId) {
34
39
  const available = Boolean(this.gateway) && this.gateway?.available !== false;
@@ -39,17 +44,35 @@ export class AppService {
39
44
  continue;
40
45
  if (available)
41
46
  view.address = this.gateway?.address?.(view.id);
42
- const notes = this.registry.getView(view.id)?.notes;
43
- if (notes?.length)
44
- view.notes = structuredClone(notes);
47
+ const entry = this.registry.getView(view.id);
48
+ if (entry?.notes?.length)
49
+ view.notes = structuredClone(entry.notes);
50
+ if (entry?.stats)
51
+ view.stats = structuredClone(entry.stats);
45
52
  }
46
53
  return { apps, previewAvailable: available };
47
54
  }
48
- publish(sessionId, workspace, manifest) { return this.registry.publish(sessionId, workspace, manifest); }
55
+ publish(sessionId, workspace, manifest) {
56
+ // Tell the agent now, not on first open, when this machine can't show it.
57
+ const unavailable = manifest?.views?.some?.((view) => view?.kind === "display") && this.displays.unavailable();
58
+ if (unavailable)
59
+ throw new Error(unavailable);
60
+ return this.registry.publish(sessionId, workspace, manifest);
61
+ }
49
62
  /** An agent turn changed files in this session's workspace. With agent
50
63
  * screenshots on, Compare gets an "after" shot once servers have rebuilt. */
51
64
  turnChanged(sessionId) {
52
65
  const bumped = this.registry.touch(sessionId);
66
+ // Desktop apps that asked for it are restarted, so they run the new code.
67
+ for (const id of bumped) {
68
+ const entry = this.registry.getView(id);
69
+ const server = this.servers.get(id);
70
+ if (entry?.target.kind !== "display" || !server?.termId)
71
+ continue;
72
+ this.terminals.close(server.termId);
73
+ server.termId = undefined;
74
+ void this.ensureServer(entry).catch(() => { });
75
+ }
53
76
  if (bumped.length && this.screenshots.enabled())
54
77
  setTimeout(() => void this.capture(bumped), COMPARE_SETTLE_MS).unref?.();
55
78
  return bumped;
@@ -60,6 +83,7 @@ export class AppService {
60
83
  const entry = this.registry.getView(id);
61
84
  if (!entry || entry.view.kind !== "web")
62
85
  continue;
86
+ await this.windowShown(entry).catch(() => { });
63
87
  const outDir = path.join(os.tmpdir(), "bivy-shots", "compare", id);
64
88
  const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [390], themes: ["light"], path: entry.lastPath ?? "/" }, outDir));
65
89
  this.shooting = run;
@@ -88,8 +112,11 @@ export class AppService {
88
112
  }
89
113
  /** `direct` opens the app origin itself (no shell): a signed-in device
90
114
  * returning to a view's stable address. */
91
- async open(sessionId, appId, viewId, returnTo, direct = false) {
115
+ /** `scale`: the opening device's pixel density, used if this starts a display. */
116
+ async open(sessionId, appId, viewId, returnTo, direct = false, scale) {
92
117
  const entry = this.registry.requireView(sessionId, appId, viewId);
118
+ if (entry.target.kind === "display" && !entry.display)
119
+ entry.displayScale = scale === 2 ? 2 : 1;
93
120
  if (entry.view.kind === "web") {
94
121
  if (!this.gateway)
95
122
  throw new Error("Bivy's preview service is unavailable on this connection.");
@@ -153,6 +180,7 @@ export class AppService {
153
180
  const views = apps.flatMap((app) => app.views).filter((view) => view.kind === "web").map((view) => this.registry.getView(view.id)).filter(Boolean);
154
181
  if (!views.length)
155
182
  throw new Error("This session has no web views to screenshot. Publish one with bivy app publish, or preview a detected server.");
183
+ await Promise.all(views.map((entry) => this.windowShown(entry)));
156
184
  const outDir = path.join(os.tmpdir(), "bivy-shots", sessionId.replace(/[^A-Za-z0-9_-]/g, "_"), String(Date.now()));
157
185
  const run = this.shooting.catch(() => { }).then(() => this.screenshots.take(views, { widths, themes: [...new Set(themes)], path: page }, outDir));
158
186
  this.shooting = run;
@@ -166,10 +194,39 @@ export class AppService {
166
194
  throw new Error("Only views with a start command have server logs.");
167
195
  return { kind: "terminal", termId };
168
196
  }
197
+ /** A desktop app's display is up and showing a window (or gave up waiting),
198
+ * so a screenshot shows the app rather than an empty screen. */
199
+ async windowShown(entry) {
200
+ if (entry.target.kind !== "display")
201
+ return;
202
+ await this.ensureServer(entry);
203
+ const display = await this.displays.ensure(entry.view.id, entry.app.name, entry.displayScale);
204
+ for (const until = Date.now() + WINDOW_WAIT_MS; !display.wm.count && Date.now() < until;)
205
+ await new Promise((r) => setTimeout(r, 100));
206
+ await new Promise((r) => setTimeout(r, 800)); // first paint
207
+ }
208
+ /** The program Bivy runs for a view: a service's start command, or a desktop
209
+ * app on its display (started first). */
210
+ program(entry) {
211
+ const target = entry.target;
212
+ if (target.kind === "service" && target.start)
213
+ return { ...target.start, name: `${entry.app.name} · ${entry.view.name} server` };
214
+ if (target.kind !== "display")
215
+ throw new Error("This view has no program.");
216
+ return this.displays.ensure(entry.view.id, entry.app.name, entry.displayScale).then((display) => {
217
+ entry.display = display.socket;
218
+ return { command: target.command, args: target.args, workspace: target.workspace, env: display.env, name: `${entry.app.name} · ${entry.view.name}` };
219
+ });
220
+ }
221
+ /** Services start synchronously; a desktop app waits for its display. */
222
+ startProgram(entry) {
223
+ const program = this.program(entry);
224
+ return program instanceof Promise ? program.then((spec) => this.terminals.start(spec)) : this.terminals.start(program);
225
+ }
169
226
  ensureServer(entry) {
170
- if (entry.target.kind !== "service" || !entry.target.start)
227
+ if (!(entry.target.kind === "service" && entry.target.start) && entry.target.kind !== "display")
171
228
  return Promise.resolve(undefined);
172
- const id = entry.view.id, start = entry.target.start;
229
+ const id = entry.view.id;
173
230
  let state = this.servers.get(id);
174
231
  if (!state) {
175
232
  state = { restarts: [] };
@@ -178,7 +235,7 @@ export class AppService {
178
235
  const server = state;
179
236
  if (server.termId && this.terminals.has(server.termId))
180
237
  return Promise.resolve(server.termId);
181
- server.pending ??= this.terminals.start({ ...start, name: `${entry.app.name} · ${entry.view.name} server` })
238
+ server.pending ??= this.startProgram(entry)
182
239
  .then((termId) => {
183
240
  if (!this.registry.getView(id)) {
184
241
  this.terminals.close(termId);
@@ -209,6 +266,12 @@ export class AppService {
209
266
  return server.pending;
210
267
  }
211
268
  stopServer(id) {
269
+ this.displays.stop(id);
270
+ const entry = this.registry.getView(id);
271
+ if (entry) {
272
+ entry.display = undefined;
273
+ entry.displayScale = undefined;
274
+ }
212
275
  const server = this.servers.get(id);
213
276
  if (!server)
214
277
  return;
@@ -0,0 +1,273 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import net from "node:net";
4
+ import fs from "node:fs";
5
+ import { randomBytes } from "node:crypto";
6
+ /** An X authority file with one MIT-MAGIC-COOKIE-1 for any display number, so
7
+ * only processes Bivy hands the file to can connect. */
8
+ export function writeXauthority(file, cookie = randomBytes(16)) {
9
+ const field = (data) => { const len = Buffer.alloc(2); len.writeUInt16BE(data.length); return Buffer.concat([len, data]); };
10
+ const family = Buffer.from([0xff, 0xff]); // FamilyWild
11
+ fs.writeFileSync(file, Buffer.concat([family, field(Buffer.alloc(0)), field(Buffer.alloc(0)), field(Buffer.from("MIT-MAGIC-COOKIE-1")), field(cookie)]), { mode: 0o600 });
12
+ return cookie;
13
+ }
14
+ const pad = (n) => (4 - (n % 4)) % 4;
15
+ // Core protocol numbers used below (X11 protocol, little-endian client).
16
+ const OP = { ChangeWindowAttributes: 2, MapWindow: 8, ConfigureWindow: 12, GetGeometry: 14, ChangeProperty: 18, GetProperty: 20, SendEvent: 25, SetInputFocus: 42, QueryExtension: 98 };
17
+ /** RandR minor opcodes. */
18
+ const RR = { QueryVersion: 0, SetScreenSize: 7 };
19
+ const EV = { DestroyNotify: 17, UnmapNotify: 18, MapRequest: 20, ConfigureNotify: 22, ConfigureRequest: 23 };
20
+ const MASK = { StructureNotify: 1 << 17, SubstructureNotify: 1 << 19, SubstructureRedirect: 1 << 20 };
21
+ const CFG = { x: 1, y: 2, width: 4, height: 8, border: 16 };
22
+ const ATOM = { STRING: 31, RESOURCE_MANAGER: 23, WM_NORMAL_HINTS: 40, WM_SIZE_HINTS: 41, WINDOW: 33, WM_TRANSIENT_FOR: 68 };
23
+ /** WM_SIZE_HINTS flag: the program set a minimum size. */
24
+ const P_MIN_SIZE = 1 << 4;
25
+ /** A window manager with one rule, sized for previews: every top-level window
26
+ * fills the screen, dialogs are centered at their own size, and everything is
27
+ * refit when the viewer resizes the display. A window that can't shrink to the
28
+ * viewer's size grows the screen instead, and the viewer scales it down, so
29
+ * nothing is cut off. Pure protocol, no dependencies. */
30
+ export class FitWindowManager {
31
+ socket;
32
+ buffer = Buffer.alloc(0);
33
+ seq = 0;
34
+ root = 0;
35
+ width = 0;
36
+ height = 0;
37
+ ready = false;
38
+ /** Pending replies by sequence number: the raw reply, or undefined on error. */
39
+ replies = new Map();
40
+ /** Mapped windows we manage; `size` is what the app asked for (dialogs keep
41
+ * it), `min` the smallest it can draw. */
42
+ windows = new Map();
43
+ /** The size the viewer asked for, and the size we last set ourselves. */
44
+ viewer = [0, 0];
45
+ own;
46
+ randr = 0;
47
+ /** Resolves once the WM owns the root window; rejects if another WM does. */
48
+ /** `dpi` is published as Xft.dpi, which Chromium/Electron and others scale by. */
49
+ start(socketPath, cookie, dpi = 96) {
50
+ return new Promise((resolve, reject) => {
51
+ const socket = net.connect(socketPath);
52
+ this.socket = socket;
53
+ socket.on("error", (error) => { if (!this.ready)
54
+ reject(error); });
55
+ socket.on("close", () => { if (!this.ready)
56
+ reject(new Error("The display closed the window manager's connection.")); });
57
+ const name = Buffer.from("MIT-MAGIC-COOKIE-1");
58
+ const setup = Buffer.alloc(12);
59
+ setup.write("l", 0);
60
+ setup.writeUInt16LE(11, 2);
61
+ setup.writeUInt16LE(0, 4);
62
+ setup.writeUInt16LE(name.length, 6);
63
+ setup.writeUInt16LE(cookie.length, 8);
64
+ socket.write(Buffer.concat([setup, name, Buffer.alloc(pad(name.length)), cookie, Buffer.alloc(pad(cookie.length))]));
65
+ socket.on("data", (chunk) => {
66
+ this.buffer = Buffer.concat([this.buffer, chunk]);
67
+ try {
68
+ if (!this.ready) {
69
+ if (this.buffer.length < 8)
70
+ return;
71
+ const total = 8 + this.buffer.readUInt16LE(6) * 4;
72
+ if (this.buffer.length < total)
73
+ return;
74
+ const reply = this.buffer.subarray(0, total);
75
+ this.buffer = this.buffer.subarray(total);
76
+ if (reply[0] !== 1) {
77
+ reject(new Error(`The display refused the window manager: ${reply.subarray(8, 8 + reply[1]).toString()}`));
78
+ socket.destroy();
79
+ return;
80
+ }
81
+ this.parseSetup(reply);
82
+ this.ready = true;
83
+ this.viewer = [this.width, this.height];
84
+ const resources = Buffer.from(`Xft.dpi:\t${dpi}\n`);
85
+ this.request(OP.ChangeProperty, 0, Buffer.concat([u32(this.root, ATOM.RESOURCE_MANAGER, ATOM.STRING, 8, resources.length), resources, Buffer.alloc(pad(resources.length))]));
86
+ const name = Buffer.from("RANDR");
87
+ this.query(OP.QueryExtension, Buffer.concat([u16pair(name.length, 0), name, Buffer.alloc(pad(name.length))]), (reply) => {
88
+ if (!reply?.[8])
89
+ return;
90
+ this.randr = reply[9];
91
+ this.request(this.randr, RR.QueryVersion, u32(1, 5));
92
+ });
93
+ // Redirecting the root's substructure is what makes us the WM.
94
+ const seq = this.request(OP.ChangeWindowAttributes, 0, u32(this.root, 0x800, MASK.SubstructureRedirect | MASK.SubstructureNotify | MASK.StructureNotify));
95
+ this.replies.set(seq, () => reject(new Error("Another window manager already runs on this display.")));
96
+ // A no-reply request can't be confirmed directly; a reply to the next
97
+ // request arrives in order, so it means the first one passed.
98
+ this.query(OP.GetGeometry, u32(this.root), () => { this.replies.delete(seq); resolve(); });
99
+ }
100
+ this.drain();
101
+ }
102
+ catch {
103
+ socket.destroy();
104
+ }
105
+ });
106
+ });
107
+ }
108
+ stop() { this.socket?.destroy(); this.socket = undefined; }
109
+ get size() { return [this.width, this.height]; }
110
+ /** Top-level windows currently shown. */
111
+ get count() { return this.windows.size; }
112
+ parseSetup(reply) {
113
+ const vendorLength = reply.readUInt16LE(24);
114
+ const formats = reply[29];
115
+ const screen = 40 + vendorLength + pad(vendorLength) + formats * 8;
116
+ this.root = reply.readUInt32LE(screen);
117
+ this.width = reply.readUInt16LE(screen + 20);
118
+ this.height = reply.readUInt16LE(screen + 22);
119
+ }
120
+ request(opcode, data, body) {
121
+ const header = Buffer.alloc(4);
122
+ header[0] = opcode;
123
+ header[1] = data;
124
+ header.writeUInt16LE(1 + body.length / 4, 2);
125
+ this.socket?.write(Buffer.concat([header, body]));
126
+ this.seq = (this.seq + 1) & 0xffff;
127
+ return this.seq;
128
+ }
129
+ query(opcode, body, done) {
130
+ this.replies.set(this.request(opcode, 0, body), done);
131
+ }
132
+ drain() {
133
+ while (this.buffer.length >= 32) {
134
+ const kind = this.buffer[0];
135
+ const length = kind === 1 ? 32 + this.buffer.readUInt32LE(4) * 4 : 32;
136
+ if (this.buffer.length < length)
137
+ return;
138
+ const packet = this.buffer.subarray(0, length);
139
+ this.buffer = this.buffer.subarray(length);
140
+ const seq = packet.readUInt16LE(2);
141
+ if (kind === 0 || kind === 1) {
142
+ const done = this.replies.get(seq);
143
+ this.replies.delete(seq);
144
+ done?.(kind === 1 ? packet : undefined);
145
+ continue;
146
+ }
147
+ this.event(kind & 0x7f, packet);
148
+ }
149
+ }
150
+ event(code, e) {
151
+ if (code === EV.MapRequest) {
152
+ const window = e.readUInt32LE(8);
153
+ // The size it was created (or last configured) with, and whether it's a
154
+ // dialog: dialogs name their parent and keep their size, centered.
155
+ let size = [0, 0], min = [0, 0];
156
+ this.query(OP.GetGeometry, u32(window), (reply) => { if (reply)
157
+ size = [reply.readUInt16LE(16), reply.readUInt16LE(18)]; });
158
+ this.query(OP.GetProperty, u32(window, ATOM.WM_NORMAL_HINTS, ATOM.WM_SIZE_HINTS, 0, 18), (reply) => {
159
+ if (reply && reply.readUInt32LE(16) >= 7 && reply.readUInt32LE(32) & P_MIN_SIZE)
160
+ min = [reply.readInt32LE(32 + 20), reply.readInt32LE(32 + 24)];
161
+ });
162
+ this.query(OP.GetProperty, u32(window, ATOM.WM_TRANSIENT_FOR, ATOM.WINDOW, 0, 1), (reply) => {
163
+ if (!reply)
164
+ return; // gone already
165
+ const dialog = reply.readUInt32LE(16) > 0 && reply.readUInt32LE(32) > 0;
166
+ this.windows.set(window, { dialog, size, min: min.map((v) => Math.max(0, Math.min(v, 8192))) });
167
+ if (!this.resize())
168
+ this.fit(window);
169
+ this.request(OP.MapWindow, 0, u32(window));
170
+ this.request(OP.SetInputFocus, 2, u32(window, 0));
171
+ });
172
+ }
173
+ else if (code === EV.ConfigureRequest) {
174
+ const window = e.readUInt32LE(8);
175
+ const size = [e.readUInt16LE(20), e.readUInt16LE(22)];
176
+ const managed = this.windows.get(window);
177
+ if (managed) {
178
+ managed.size = size;
179
+ this.fit(window);
180
+ return;
181
+ }
182
+ // Not shown yet: honour it; the map request decides the final geometry.
183
+ this.configure(window, e.readInt16LE(16), e.readInt16LE(18), size[0], size[1]);
184
+ }
185
+ else if (code === EV.DestroyNotify || code === EV.UnmapNotify) {
186
+ const window = e.readUInt32LE(8);
187
+ if (e.readUInt32LE(4) === this.root && this.windows.delete(window))
188
+ this.resize();
189
+ }
190
+ else if (code === EV.ConfigureNotify && e.readUInt32LE(8) === this.root) {
191
+ // The display changed size: refit everything to the new screen. If the
192
+ // viewer did it (not us), remember what it asked for.
193
+ this.width = e.readUInt16LE(20);
194
+ this.height = e.readUInt16LE(22);
195
+ if (!this.own || this.own[0] !== this.width || this.own[1] !== this.height) {
196
+ this.viewer = [this.width, this.height];
197
+ this.own = undefined;
198
+ }
199
+ if (this.resize())
200
+ return;
201
+ for (const window of this.windows.keys())
202
+ this.fit(window);
203
+ }
204
+ }
205
+ /** Grow the screen past the viewer's size when a window can't shrink that
206
+ * far; back to the viewer's size when it can. True if a resize was sent (the
207
+ * refit follows when the display reports its new size). */
208
+ resize() {
209
+ if (!this.randr)
210
+ return false;
211
+ let [w, h] = this.viewer;
212
+ for (const { dialog, min } of this.windows.values())
213
+ if (!dialog) {
214
+ w = Math.max(w, min[0]);
215
+ h = Math.max(h, min[1]);
216
+ }
217
+ if (w === this.width && h === this.height)
218
+ return false;
219
+ this.own = [w, h];
220
+ const body = Buffer.alloc(16);
221
+ body.writeUInt32LE(this.root, 0);
222
+ body.writeUInt16LE(w, 4);
223
+ body.writeUInt16LE(h, 6);
224
+ body.writeUInt32LE(Math.round(w * 25.4 / 96), 8);
225
+ body.writeUInt32LE(Math.round(h * 25.4 / 96), 12);
226
+ const seq = this.request(this.randr, RR.SetScreenSize, body);
227
+ // Refused (e.g. too large): stop resizing and fit to the screen we have.
228
+ // A later reply means it was accepted, so drop the error handler.
229
+ this.replies.set(seq, () => { this.randr = 0; this.own = undefined; for (const window of this.windows.keys())
230
+ this.fit(window); });
231
+ this.query(OP.GetGeometry, u32(this.root), () => this.replies.delete(seq));
232
+ return true;
233
+ }
234
+ fit(window) {
235
+ const managed = this.windows.get(window);
236
+ if (!managed)
237
+ return;
238
+ if (!managed.dialog) {
239
+ this.configure(window, 0, 0, this.width, this.height);
240
+ return;
241
+ }
242
+ const w = Math.min(Math.max(managed.size[0], 1), this.width), h = Math.min(Math.max(managed.size[1], 1), this.height);
243
+ this.configure(window, Math.floor((this.width - w) / 2), Math.floor((this.height - h) / 2), w, h);
244
+ }
245
+ configure(window, x, y, width, height) {
246
+ const body = Buffer.alloc(28);
247
+ body.writeUInt32LE(window, 0);
248
+ body.writeUInt16LE(CFG.x | CFG.y | CFG.width | CFG.height | CFG.border, 4);
249
+ body.writeInt32LE(x, 8);
250
+ body.writeInt32LE(y, 12);
251
+ body.writeUInt32LE(Math.max(1, width), 16);
252
+ body.writeUInt32LE(Math.max(1, height), 20);
253
+ body.writeUInt32LE(0, 24);
254
+ this.request(OP.ConfigureWindow, 0, body);
255
+ // ICCCM: tell the client its geometry even when nothing changed, or
256
+ // toolkits that asked for another size wait for an answer and never paint.
257
+ const event = Buffer.alloc(32);
258
+ event[0] = EV.ConfigureNotify;
259
+ event.writeUInt32LE(window, 4);
260
+ event.writeUInt32LE(window, 8);
261
+ event.writeInt16LE(x, 16);
262
+ event.writeInt16LE(y, 18);
263
+ event.writeUInt16LE(Math.max(1, width), 20);
264
+ event.writeUInt16LE(Math.max(1, height), 22);
265
+ this.request(OP.SendEvent, 0, Buffer.concat([u32(window, MASK.StructureNotify), event]));
266
+ }
267
+ }
268
+ function u16pair(a, b) { const out = Buffer.alloc(4); out.writeUInt16LE(a, 0); out.writeUInt16LE(b, 2); return out; }
269
+ function u32(...values) {
270
+ const out = Buffer.alloc(values.length * 4);
271
+ values.forEach((value, i) => out.writeUInt32LE(value >>> 0, i * 4));
272
+ return out;
273
+ }
@@ -20,7 +20,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
20
20
  published(app);
21
21
  return { app };
22
22
  },
23
- "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true),
23
+ "apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true, typeof msg.scale === "number" ? msg.scale : undefined),
24
24
  "apps.shot": (msg) => service.shot(String(msg.sessionId), typeof msg.appId === "string" ? msg.appId : undefined, { widths: msg.widths, themes: msg.themes, path: typeof msg.path === "string" ? msg.path : undefined }),
25
25
  "apps.clearNotes": (msg) => service.clearNotes(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
26
26
  "apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
@@ -12,7 +12,7 @@ export const CLIENT_COMMAND_SCHEMAS = {
12
12
  "apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
13
13
  "apps.offers": Type.Object({ ...request, ...session }),
14
14
  "apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
15
- "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()) }),
15
+ "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })) }),
16
16
  "apps.shot": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()), widths: Type.Optional(Type.Array(Type.Integer(), { maxItems: 4 })), themes: Type.Optional(Type.Array(Type.Union([Type.Literal("light"), Type.Literal("dark")]), { maxItems: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
17
17
  "apps.clearNotes": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
18
18
  "apps.logs": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
package/dist/server.js CHANGED
@@ -31,6 +31,7 @@ import { AppRegistry } from "./apps/registry.js";
31
31
  import { AppGateway } from "./apps/gateway.js";
32
32
  import { RemotePreview } from "./apps/remote-preview.js";
33
33
  import { AppService } from "./apps/service.js";
34
+ import { DisplayHost } from "./apps/display.js";
34
35
  import { createAppCommands } from "./controllers/app-commands.js";
35
36
  import { bindClientCommandRoutes } from "./http/client-command-routes.js";
36
37
  import { collectDiscoveredSessions, planNativeAdoption } from "./runtime/native-session-discovery.js";
@@ -1939,6 +1940,8 @@ const appSignIn = (view, pagePath) => {
1939
1940
  };
1940
1941
  const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins, appSignIn) : undefined;
1941
1942
  const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn);
1943
+ // Desktop app views: one private display per view, started on first open.
1944
+ const appDisplays = new DisplayHost();
1942
1945
  const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
1943
1946
  start: async (spec) => {
1944
1947
  let failure = "Could not start the app terminal.";
@@ -1953,7 +1956,7 @@ const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
1953
1956
  },
1954
1957
  has: (id) => terminals.has(id),
1955
1958
  close: (id) => { terminals.close(id); },
1956
- }, { screenshots: { enabled: appScreenshotsEnabled } });
1959
+ }, { screenshots: { enabled: appScreenshotsEnabled }, displays: appDisplays });
1957
1960
  const RELAY_COMMANDS = {
1958
1961
  ...createAppCommands(appService, (id) => { const record = resolveSession(id); return record ? harnessDirFor(record) : undefined; }, (app) => {
1959
1962
  const record = resolveSession(app.sessionId);
@@ -11468,6 +11471,7 @@ function shutdown(signal) {
11468
11471
  }
11469
11472
  catch { }
11470
11473
  terminals.disposeAll();
11474
+ appDisplays.stopAll();
11471
11475
  // Drop persistent client/relay sockets so server.close() can actually drain;
11472
11476
  // otherwise a lingering WebSocket keeps the process alive until the supervisor
11473
11477
  // SIGKILLs it, orphaning children.
@@ -238,7 +238,7 @@ export function createRunTerminals(deps) {
238
238
  workspace,
239
239
  command: spec.command,
240
240
  args: spec.args,
241
- env: credentialEnv,
241
+ env: { ...credentialEnv, ...spec.env },
242
242
  cols: spec.cols,
243
243
  rows: spec.rows,
244
244
  clientId: spec.clientId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.17.3-staging.5",
3
+ "version": "0.17.3-staging.6",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",
@@ -39,6 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@modelcontextprotocol/sdk": "1.30.0",
42
+ "@novnc/novnc": "1.7.0",
42
43
  "brace-expansion": "5.0.12",
43
44
  "express": "^5.2.1",
44
45
  "express-rate-limit": "^8.7.0",