@bivy/bivy 0.17.3-staging.4 → 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 +27 -6
- package/dist/apps/display-viewer.js +81 -0
- package/dist/apps/display.js +135 -0
- package/dist/apps/gateway.js +131 -2
- package/dist/apps/preview-shell.js +5 -0
- package/dist/apps/registry.js +8 -2
- package/dist/apps/rfb.js +126 -0
- package/dist/apps/screenshot.js +16 -3
- package/dist/apps/service.js +71 -8
- package/dist/apps/x11.js +273 -0
- package/dist/controllers/app-commands.js +1 -1
- package/dist/protocol/client-command-schemas.js +1 -1
- package/dist/server.js +5 -1
- package/dist/session/run-terminal.js +1 -1
- package/package.json +2 -1
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
|
|
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 === "
|
|
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
|
+
}
|
package/dist/apps/gateway.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/apps/registry.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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)
|
package/dist/apps/rfb.js
ADDED
|
@@ -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
|
+
}
|
package/dist/apps/screenshot.js
CHANGED
|
@@ -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.
|
|
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")
|
package/dist/apps/service.js
CHANGED
|
@@ -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
|
|
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) {
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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;
|
package/dist/apps/x11.js
ADDED
|
@@ -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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bivy/bivy",
|
|
3
|
-
"version": "0.17.3-staging.
|
|
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",
|