@bivy/bivy 0.17.3-staging.2 → 0.17.3-staging.21
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 +57 -10
- package/dist/agents/claude-code/runtime.js +17 -0
- package/dist/apps/display-viewer.js +81 -0
- package/dist/apps/display.js +135 -0
- package/dist/apps/gateway.js +388 -25
- package/dist/apps/inspector.js +106 -0
- package/dist/apps/listeners.js +115 -0
- package/dist/apps/preview-shell.js +206 -17
- package/dist/apps/registry.js +125 -21
- package/dist/apps/remote-preview.js +10 -2
- package/dist/apps/rfb.js +126 -0
- package/dist/apps/screenshot.js +194 -0
- package/dist/apps/service.js +244 -5
- package/dist/apps/styles.css +124 -9
- package/dist/apps/x11.js +273 -0
- package/dist/attach.js +37 -4
- package/dist/config-cli.js +2 -0
- package/dist/controllers/app-commands.js +20 -7
- package/dist/controllers/artifact-commands.js +42 -0
- package/dist/controllers/presence-commands.js +19 -0
- package/dist/node-config.js +6 -3
- package/dist/policy/session-limit.js +63 -0
- package/dist/protocol/client-command-routes.js +3 -1
- package/dist/protocol/client-command-schemas.js +11 -2
- package/dist/server.js +168 -14
- package/dist/session/presence.js +82 -0
- package/dist/session/run-terminal.js +114 -70
- package/dist/session/session-new-dedupe.js +1 -1
- package/package.json +2 -1
package/bin/bivy.mjs
CHANGED
|
@@ -974,7 +974,7 @@ function looksLikeRemote(value) {
|
|
|
974
974
|
|
|
975
975
|
function extractRunFlags(args) {
|
|
976
976
|
const rest = [];
|
|
977
|
-
let name, model, node, workspace;
|
|
977
|
+
let name, model, node, workspace, bivySession;
|
|
978
978
|
let chat = false;
|
|
979
979
|
let noOpen = false;
|
|
980
980
|
let noFollow = false;
|
|
@@ -993,11 +993,14 @@ function extractRunFlags(args) {
|
|
|
993
993
|
if (a.startsWith("--node=")) { node = a.slice("--node=".length); continue; }
|
|
994
994
|
if (a === "--workspace" && args[i + 1] !== undefined) { workspace = args[++i]; continue; }
|
|
995
995
|
if (a.startsWith("--workspace=")) { workspace = a.slice("--workspace=".length); continue; }
|
|
996
|
+
// Internal (set by `bivy resume`): the Bivy session this run continues, so
|
|
997
|
+
// the node can lock that session's chat while the terminal drives it.
|
|
998
|
+
if (a === "--bivy-session" && args[i + 1] !== undefined) { bivySession = args[++i]; continue; }
|
|
996
999
|
if (a.startsWith("--clone=")) { clone = a.slice("--clone=".length); continue; }
|
|
997
1000
|
if (a === "--clone") { clone = looksLikeRemote(args[i + 1]) ? args[++i] : true; continue; }
|
|
998
1001
|
rest.push(a);
|
|
999
1002
|
}
|
|
1000
|
-
return { name: name?.trim() || undefined, model: model?.trim() || undefined, node: node?.trim() || undefined, workspace: workspace?.trim() || undefined, chat, noOpen, noFollow, clone, rest };
|
|
1003
|
+
return { name: name?.trim() || undefined, model: model?.trim() || undefined, node: node?.trim() || undefined, workspace: workspace?.trim() || undefined, bivySession: bivySession?.trim() || undefined, chat, noOpen, noFollow, clone, rest };
|
|
1001
1004
|
}
|
|
1002
1005
|
|
|
1003
1006
|
// A safe-ish workspace dir name from a remote or path (basename minus .git).
|
|
@@ -1899,7 +1902,7 @@ function governedChatAgentId(id) {
|
|
|
1899
1902
|
|
|
1900
1903
|
async function cmdRun(args = []) {
|
|
1901
1904
|
if (!(await ensureDeps())) process.exit(1);
|
|
1902
|
-
const { name, model, node, workspace, chat, noOpen, noFollow, clone, rest } = extractRunFlags(args);
|
|
1905
|
+
const { name, model, node, workspace, bivySession, chat, noOpen, noFollow, clone, rest } = extractRunFlags(args);
|
|
1903
1906
|
// Bare `bivy` (empty rest) resolves to the configured default agent; an
|
|
1904
1907
|
// explicit `bivy run <agent>` keeps that agent verbatim.
|
|
1905
1908
|
const [agentIdArg, ...extraArgs] = rest;
|
|
@@ -1999,7 +2002,8 @@ async function cmdRun(args = []) {
|
|
|
1999
2002
|
// Pin a session id at launch when the agent's CLI supports it (and the caller
|
|
2000
2003
|
// didn't already choose one), so the on-disk session is a known, deterministic
|
|
2001
2004
|
// resume target — the anchor for later "continue as chat" adoption.
|
|
2002
|
-
const pinnedSessionId = pinRunSessionId(agentId, resolved.spec);
|
|
2005
|
+
const pinnedSessionId = bivySession ? undefined : pinRunSessionId(agentId, resolved.spec);
|
|
2006
|
+
if (bivySession) resolved.spec.sessionId = bivySession;
|
|
2003
2007
|
if (pinnedSessionId) {
|
|
2004
2008
|
console.log(c.dim(`session id ${pinnedSessionId} — resume in a terminal with '${agentId} --resume ${pinnedSessionId}'`));
|
|
2005
2009
|
}
|
|
@@ -2426,6 +2430,8 @@ async function cmdApp(args = []) {
|
|
|
2426
2430
|
console.log(`Usage: bivy app publish <manifest.json> [--session <id>]
|
|
2427
2431
|
bivy app list [--session <id>]
|
|
2428
2432
|
bivy app remove <app-id> [--session <id>]
|
|
2433
|
+
bivy app shot [app-id] [--widths 390,1280] [--themes light,dark] [--path /page] [--session <id>]
|
|
2434
|
+
bivy app run [--name <name>] [--restart-on-change] [--session <id>] -- <command> [args…]
|
|
2429
2435
|
|
|
2430
2436
|
Manifest: {"version":1,"name":"My app","views":[
|
|
2431
2437
|
{"kind":"web","name":"Website","source":{"kind":"service","port":3000}},
|
|
@@ -2433,19 +2439,58 @@ Manifest: {"version":1,"name":"My app","views":[
|
|
|
2433
2439
|
]}
|
|
2434
2440
|
|
|
2435
2441
|
Static web source: {"kind":"static","directory":"./dist"}
|
|
2436
|
-
|
|
2442
|
+
Server Bivy runs: {"kind":"service","port":5173,"start":{"command":"pnpm","args":["dev"]}}
|
|
2443
|
+
Desktop GUI app (Linux, needs TigerVNC's Xvnc):
|
|
2444
|
+
{"kind":"display","name":"Editor","command":"cargo","args":["run"],"restartOnChange":true}
|
|
2445
|
+
It runs on a private display streamed into the preview, sized to the viewer.
|
|
2446
|
+
"restartOnChange" restarts it after a turn that changed files. Shortcut with
|
|
2447
|
+
no manifest: bivy app run -- cargo run
|
|
2448
|
+
Publishing does not start commands. Open Apps in the session menu to view or run;
|
|
2449
|
+
a server with "start" or a desktop app starts on first open and is restarted if
|
|
2450
|
+
it exits.
|
|
2437
2451
|
Terminal commands run with the node user's permissions, not in a new sandbox.
|
|
2452
|
+
"shot" screenshots the session's web views (or one app) with a local headless
|
|
2453
|
+
Chrome/Chromium (desktop apps: straight from their display) and prints the PNG paths — look at them before saying the UI is
|
|
2454
|
+
done. It is off by default: turn it on in Bivy → Settings → this machine, or
|
|
2455
|
+
run: bivy config set sessions.appScreenshots true
|
|
2438
2456
|
Web previews require operator setup; see docs/apps.md.`);
|
|
2439
2457
|
return;
|
|
2440
2458
|
}
|
|
2441
|
-
if (!["publish", "list", "remove"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
|
|
2459
|
+
if (!["publish", "list", "remove", "shot", "run"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
|
|
2460
|
+
// `run -- <command> [args…]`: publish a one-window desktop app without a manifest file.
|
|
2461
|
+
let runManifest;
|
|
2462
|
+
if (action === "run") {
|
|
2463
|
+
const split = rest.indexOf("--");
|
|
2464
|
+
if (split < 0 || !rest[split + 1]) throw new Error("Usage: bivy app run [--name <name>] [--restart-on-change] -- <command> [args…]");
|
|
2465
|
+
const [command, ...commandArgs] = rest.splice(split).slice(1);
|
|
2466
|
+
const nameIndex = rest.indexOf("--name");
|
|
2467
|
+
const name = nameIndex >= 0 ? rest.splice(nameIndex, 2)[1] : path.basename(command);
|
|
2468
|
+
if (!name) throw new Error("--name requires a value.");
|
|
2469
|
+
const restartIndex = rest.indexOf("--restart-on-change");
|
|
2470
|
+
if (restartIndex >= 0) rest.splice(restartIndex, 1);
|
|
2471
|
+
runManifest = { version: 1, name: name.slice(0, 100), views: [{ kind: "display", name: "Window", command, args: commandArgs, ...(restartIndex >= 0 ? { restartOnChange: true } : {}) }] };
|
|
2472
|
+
}
|
|
2473
|
+
const shot = {};
|
|
2474
|
+
if (action === "shot") {
|
|
2475
|
+
for (const flag of ["--widths", "--themes", "--path"]) {
|
|
2476
|
+
const i = rest.indexOf(flag);
|
|
2477
|
+
if (i < 0) continue;
|
|
2478
|
+
const value = rest[i + 1];
|
|
2479
|
+
if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value.`);
|
|
2480
|
+
if (flag === "--widths") shot.widths = value.split(",").map((w) => Number(w.trim()));
|
|
2481
|
+
else if (flag === "--themes") shot.themes = value.split(",").map((t) => t.trim());
|
|
2482
|
+
else shot.path = value;
|
|
2483
|
+
rest.splice(i, 2);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2442
2486
|
const sessionIndex = rest.indexOf("--session");
|
|
2443
2487
|
if (sessionIndex >= 0 && (!rest[sessionIndex + 1] || rest[sessionIndex + 1].startsWith("--"))) throw new Error("--session requires an id.");
|
|
2444
2488
|
const sessionId = resolveAttachSessionId({ sessionFlag: sessionIndex >= 0 ? rest[sessionIndex + 1] : undefined, env: process.env });
|
|
2445
2489
|
if (!sessionId) throw new Error("Set --session <id> or run inside a Bivy agent session.");
|
|
2446
2490
|
const positional = rest.filter((_, i) => i !== sessionIndex && (sessionIndex < 0 || i !== sessionIndex + 1));
|
|
2447
|
-
if (positional.some((a) => a.startsWith("-")) || positional.length !== (action === "list" ? 0 : 1)) throw new Error("Invalid arguments. Run bivy app --help.");
|
|
2448
|
-
const body = { sessionId };
|
|
2491
|
+
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.");
|
|
2492
|
+
const body = { sessionId, ...shot };
|
|
2493
|
+
if (action === "shot" && positional[0]) body.appId = positional[0];
|
|
2449
2494
|
if (action === "publish") {
|
|
2450
2495
|
const file = path.resolve(positional[0]);
|
|
2451
2496
|
if (fs.statSync(file).size > 64 * 1024) throw new Error("App manifest exceeds 64 KiB.");
|
|
@@ -2457,11 +2502,12 @@ Web previews require operator setup; see docs/apps.md.`);
|
|
|
2457
2502
|
}
|
|
2458
2503
|
}
|
|
2459
2504
|
body.manifest = manifest;
|
|
2460
|
-
} else if (action === "
|
|
2505
|
+
} else if (action === "run") body.manifest = runManifest;
|
|
2506
|
+
else if (action === "remove") body.appId = positional[0];
|
|
2461
2507
|
const config = loadConfig();
|
|
2462
2508
|
if (!(await ensureNodeRunning(config))) throw new Error("Could not reach the Bivy node.");
|
|
2463
2509
|
const token = await localDeviceToken(config);
|
|
2464
|
-
const response = await fetch(`${url(config)}/api/apps/${action}`, {
|
|
2510
|
+
const response = await fetch(`${url(config)}/api/apps/${action === "run" ? "publish" : action}`, {
|
|
2465
2511
|
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body),
|
|
2466
2512
|
});
|
|
2467
2513
|
const result = await response.json();
|
|
@@ -2507,6 +2553,7 @@ async function resumeSessionItem(item, config, token) {
|
|
|
2507
2553
|
const resumeArgs = agentResumeArgs(agentId, resumeRef);
|
|
2508
2554
|
const runArgs = [agentId, ...resumeArgs];
|
|
2509
2555
|
if (item.workspace) runArgs.push("--workspace", item.workspace); // native resume finds the session by its original cwd
|
|
2556
|
+
if (item.id) runArgs.push("--bivy-session", item.id);
|
|
2510
2557
|
console.log(c.dim(`Resuming ${c.cyan(item.name)} with ${agentId} ${resumeArgs.join(" ")}…`));
|
|
2511
2558
|
await cmdRun(runArgs);
|
|
2512
2559
|
}
|
|
@@ -532,6 +532,9 @@ function loadClaudeTranscript(sessionId) {
|
|
|
532
532
|
// re-drive, or a session the user resumed), so it's noise — we keep only a
|
|
533
533
|
// trailing one, where the session actually ended interrupted. See below.
|
|
534
534
|
const restartNoticeIdx = [];
|
|
535
|
+
// The latest user prompt, and the index of an API-error reply just kept.
|
|
536
|
+
let lastPrompt;
|
|
537
|
+
let lastErrorIdx = -1;
|
|
535
538
|
try {
|
|
536
539
|
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
537
540
|
if (!line.trim())
|
|
@@ -567,7 +570,21 @@ function loadClaudeTranscript(sessionId) {
|
|
|
567
570
|
continue;
|
|
568
571
|
if (typeof content !== "string" && !Array.isArray(content))
|
|
569
572
|
continue;
|
|
573
|
+
// A re-driven prompt: an API-error reply (e.g. a 401 before a credential
|
|
574
|
+
// refresh) followed — past any restart notice — by the same prompt again.
|
|
575
|
+
// The turn was recovered, so drop both; otherwise history shows the error and
|
|
576
|
+
// repeats the prompt at the bottom of the chat.
|
|
577
|
+
if (role === "user" && lastErrorIdx >= 0 && JSON.stringify(content) === JSON.stringify(lastPrompt)) {
|
|
578
|
+
messages.splice(lastErrorIdx, 1);
|
|
579
|
+
restartNoticeIdx.forEach((idx, i) => { if (idx > lastErrorIdx)
|
|
580
|
+
restartNoticeIdx[i] = idx - 1; });
|
|
581
|
+
lastErrorIdx = -1;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (role === "user" && !(Array.isArray(content) && content.some((block) => block?.type === "tool_result")))
|
|
585
|
+
lastPrompt = content;
|
|
570
586
|
messages.push({ role, content, timestamp: new Date(entry?.timestamp ?? entry?.createdAt ?? Date.now()).getTime() });
|
|
587
|
+
lastErrorIdx = entry?.isApiErrorMessage === true ? messages.length - 1 : -1;
|
|
571
588
|
}
|
|
572
589
|
}
|
|
573
590
|
catch {
|
|
@@ -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
|
+
}
|