@bivy/bivy 0.17.3-staging.3 → 0.17.3-staging.31

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.
Files changed (39) hide show
  1. package/bin/bivy.mjs +175 -10
  2. package/dist/agents/claude-code/runtime.js +17 -0
  3. package/dist/apps/annotate.js +94 -0
  4. package/dist/apps/display-viewer.js +154 -0
  5. package/dist/apps/display.js +145 -0
  6. package/dist/apps/gateway.js +410 -26
  7. package/dist/apps/input.js +96 -0
  8. package/dist/apps/inspector.js +167 -0
  9. package/dist/apps/macos-display.js +170 -0
  10. package/dist/apps/macos-display.swift +873 -0
  11. package/dist/apps/menu.js +76 -0
  12. package/dist/apps/preview-shell.js +402 -37
  13. package/dist/apps/preview-stream.js +17 -3
  14. package/dist/apps/registry.js +92 -18
  15. package/dist/apps/remote-preview.js +10 -2
  16. package/dist/apps/review.js +109 -0
  17. package/dist/apps/rfb.js +175 -0
  18. package/dist/apps/screenshot.js +200 -0
  19. package/dist/apps/service.js +519 -9
  20. package/dist/apps/styles.css +166 -9
  21. package/dist/apps/tokens.css +6 -0
  22. package/dist/apps/types.js +16 -0
  23. package/dist/apps/x11.js +273 -0
  24. package/dist/attach.js +37 -4
  25. package/dist/config-cli.js +2 -0
  26. package/dist/controllers/app-commands.js +17 -4
  27. package/dist/controllers/artifact-commands.js +42 -0
  28. package/dist/controllers/presence-commands.js +19 -0
  29. package/dist/node-config.js +6 -3
  30. package/dist/policy/session-limit.js +63 -0
  31. package/dist/protocol/client-command-routes.js +3 -1
  32. package/dist/protocol/client-command-schemas.js +21 -3
  33. package/dist/server.js +246 -23
  34. package/dist/session/attachment-store.js +15 -0
  35. package/dist/session/event-log.js +25 -3
  36. package/dist/session/presence.js +82 -0
  37. package/dist/session/run-terminal.js +114 -70
  38. package/dist/session/session-new-dedupe.js +1 -1
  39. 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,17 @@ 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 present [view] [--path /page] [--note "…"] [--session <id>]
2435
+ bivy app share [app-id] [--view <view>] [--session <id>]
2436
+ bivy app run [--name <name>] [--restart-on-change] [--session <id>] -- <command> [args…]
2437
+ bivy app click <x> <y> [--right|--middle] [--double] [--app <app>]
2438
+ bivy app type <text> [--app <app>]
2439
+ bivy app key <keys> [--app <app>] e.g. enter, cmd+s, ctrl+shift+t
2440
+ bivy app scroll <x> <y> [--up|--down|--left|--right] [--steps <n>] [--app <app>]
2441
+ bivy app drag <x> <y> <to-x> <to-y> [--app <app>]
2442
+ bivy app move <x> <y> [--app <app>]
2443
+ bivy app menu ["File > Save"] [--app <app>] macOS: list the menu bar, or choose an item
2429
2444
 
2430
2445
  Manifest: {"version":1,"name":"My app","views":[
2431
2446
  {"kind":"web","name":"Website","source":{"kind":"service","port":3000}},
@@ -2433,19 +2448,92 @@ Manifest: {"version":1,"name":"My app","views":[
2433
2448
  ]}
2434
2449
 
2435
2450
  Static web source: {"kind":"static","directory":"./dist"}
2436
- Publishing does not start commands. Open Apps in the session menu to view or run.
2451
+ Server Bivy runs: {"kind":"service","port":5173,"start":{"command":"pnpm","args":["dev"]}}
2452
+ Desktop GUI app (Linux with TigerVNC's Xvnc, or macOS 13+):
2453
+ {"kind":"display","name":"Editor","command":"cargo","args":["run"],"restartOnChange":true}
2454
+ It runs on a private display (Linux) or shows its own windows (macOS),
2455
+ streamed into the preview and sized to the viewer.
2456
+ "restartOnChange" restarts it after a turn that changed files. Shortcut with
2457
+ no manifest: bivy app run -- cargo run
2458
+ Publishing does not start commands. Open Apps in the session menu to view or run;
2459
+ a server with "start" or a desktop app starts on first open and is restarted if
2460
+ it exits.
2437
2461
  Terminal commands run with the node user's permissions, not in a new sandbox.
2462
+ "shot" screenshots the session's web views (or one app) with a local headless
2463
+ Chrome/Chromium (desktop apps: straight from their display) and prints the PNG paths — look at them before saying the UI is
2464
+ done. It is off by default: turn it on in Bivy → Settings → this machine, or
2465
+ run: bivy config set sessions.appScreenshots true
2466
+ click/type/key/scroll/drag/move use a desktop app like a person would, in the
2467
+ pixels of its screenshot (bivy app shot), and print a new screenshot after
2468
+ (when screenshots are on) — look at it to see what happened. --app picks the
2469
+ app (default: the one opened last). Keys: a character or enter, tab, escape,
2470
+ backspace, delete, arrows (up…), home, end, pageup, pagedown, f1–f12, with
2471
+ modifiers shift, ctrl, alt/option, cmd. On a Mac, the pointer and keyboard
2472
+ focus move to the app while it's used.
2473
+ "menu" (macOS) lists the app's menu bar with shortcuts; "menu 'File > Save'"
2474
+ chooses an item — more reliable than clicking, and prints a screenshot after.
2475
+ "present" says a change is ready for the user to look at: they get a card in
2476
+ the chat with the app at phone width (and before/after), and it opens the live
2477
+ preview. Use it when you finish a visible change, or when the user asks to see
2478
+ it. [view] is an app or view name or ID (default: the one opened last).
2479
+ "share" mints a reusable link to one web view and prints its URL and expiry
2480
+ (24 hours, or until Revoke access in Apps, the app is removed or the machine
2481
+ restarts). [app-id] is an app ID or name, --view a view ID or name (default: the
2482
+ one opened last). Anyone who has the link can use the app, including a live
2483
+ server's backend, until it expires or is revoked: only post it where everyone
2484
+ who can read it may use the app.
2438
2485
  Web previews require operator setup; see docs/apps.md.`);
2439
2486
  return;
2440
2487
  }
2441
- if (!["publish", "list", "remove"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
2488
+ if (INPUT_ACTIONS[action]) return appInput(action, rest);
2489
+ if (action === "menu") return appMenu(rest);
2490
+ if (!["publish", "list", "remove", "shot", "run", "present", "share"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
2491
+ // `run -- <command> [args…]`: publish a one-window desktop app without a manifest file.
2492
+ let runManifest;
2493
+ if (action === "run") {
2494
+ const split = rest.indexOf("--");
2495
+ if (split < 0 || !rest[split + 1]) throw new Error("Usage: bivy app run [--name <name>] [--restart-on-change] -- <command> [args…]");
2496
+ const [command, ...commandArgs] = rest.splice(split).slice(1);
2497
+ const nameIndex = rest.indexOf("--name");
2498
+ const name = nameIndex >= 0 ? rest.splice(nameIndex, 2)[1] : path.basename(command);
2499
+ if (!name) throw new Error("--name requires a value.");
2500
+ const restartIndex = rest.indexOf("--restart-on-change");
2501
+ if (restartIndex >= 0) rest.splice(restartIndex, 1);
2502
+ runManifest = { version: 1, name: name.slice(0, 100), views: [{ kind: "display", name: "Window", command, args: commandArgs, ...(restartIndex >= 0 ? { restartOnChange: true } : {}) }] };
2503
+ }
2504
+ const shot = {};
2505
+ if (action === "share") {
2506
+ const i = rest.indexOf("--view");
2507
+ if (i >= 0) {
2508
+ const value = rest[i + 1];
2509
+ if (!value || value.startsWith("--")) throw new Error("--view requires a view ID or name.");
2510
+ shot.view = value;
2511
+ rest.splice(i, 2);
2512
+ }
2513
+ }
2514
+ if (action === "shot" || action === "present") {
2515
+ for (const flag of action === "shot" ? ["--widths", "--themes", "--path"] : ["--path", "--note"]) {
2516
+ const i = rest.indexOf(flag);
2517
+ if (i < 0) continue;
2518
+ const value = rest[i + 1];
2519
+ if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value.`);
2520
+ if (flag === "--widths") shot.widths = value.split(",").map((w) => Number(w.trim()));
2521
+ else if (flag === "--themes") shot.themes = value.split(",").map((t) => t.trim());
2522
+ else if (flag === "--note") shot.note = value;
2523
+ else shot.path = value;
2524
+ rest.splice(i, 2);
2525
+ }
2526
+ }
2442
2527
  const sessionIndex = rest.indexOf("--session");
2443
2528
  if (sessionIndex >= 0 && (!rest[sessionIndex + 1] || rest[sessionIndex + 1].startsWith("--"))) throw new Error("--session requires an id.");
2444
2529
  const sessionId = resolveAttachSessionId({ sessionFlag: sessionIndex >= 0 ? rest[sessionIndex + 1] : undefined, env: process.env });
2445
2530
  if (!sessionId) throw new Error("Set --session <id> or run inside a Bivy agent session.");
2446
2531
  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 };
2532
+ if (positional.some((a) => a.startsWith("-")) || (action === "shot" || action === "present" || action === "share" ? positional.length > 1 : positional.length !== (action === "list" || action === "run" ? 0 : 1))) throw new Error("Invalid arguments. Run bivy app --help.");
2533
+ const body = { sessionId, ...shot };
2534
+ if (action === "shot" && positional[0]) body.appId = positional[0];
2535
+ if (action === "present" && positional[0]) body.target = positional[0];
2536
+ if (action === "share" && positional[0]) body.appId = positional[0];
2449
2537
  if (action === "publish") {
2450
2538
  const file = path.resolve(positional[0]);
2451
2539
  if (fs.statSync(file).size > 64 * 1024) throw new Error("App manifest exceeds 64 KiB.");
@@ -2457,7 +2545,44 @@ Web previews require operator setup; see docs/apps.md.`);
2457
2545
  }
2458
2546
  }
2459
2547
  body.manifest = manifest;
2460
- } else if (action === "remove") body.appId = positional[0];
2548
+ } else if (action === "run") body.manifest = runManifest;
2549
+ else if (action === "remove") body.appId = positional[0];
2550
+ await appRequest(action === "run" ? "publish" : action, body);
2551
+ }
2552
+
2553
+ async function appMenu(rest) {
2554
+ const options = {};
2555
+ const positional = [];
2556
+ for (let i = 0; i < rest.length; i++) {
2557
+ if (rest[i] === "--app" || rest[i] === "--session") {
2558
+ const value = rest[++i];
2559
+ if (value === undefined || value.startsWith("--")) throw new Error(`${rest[i - 1]} requires a value.`);
2560
+ options[rest[i - 1]] = value;
2561
+ } else if (rest[i].startsWith("--")) throw new Error(`Unknown option ${rest[i]}. Run bivy app --help.`);
2562
+ else positional.push(rest[i]);
2563
+ }
2564
+ if (positional.length > 1) throw new Error('Usage: bivy app menu ["File > Save"] [--app <app>]');
2565
+ const sessionId = resolveAttachSessionId({ sessionFlag: options["--session"], env: process.env });
2566
+ if (!sessionId) throw new Error("Set --session <id> or run inside a Bivy agent session.");
2567
+ const body = { sessionId, ...(options["--app"] ? { target: options["--app"] } : {}) };
2568
+ if (positional[0]) body.path = positional[0].split(">").map((part) => part.trim());
2569
+ const result = await appRequest("menu", body, { print: false });
2570
+ if (!result.menus) { console.log(JSON.stringify(result, null, 2)); return; }
2571
+ // A tree an agent can read: one item per line, indented by level.
2572
+ const lines = [];
2573
+ const walk = (items, depth) => {
2574
+ for (const item of items) {
2575
+ if (item.separator) continue;
2576
+ const note = [item.shortcut, item.checked && "checked", item.enabled === false && "disabled"].filter(Boolean).join(", ");
2577
+ lines.push(`${" ".repeat(depth)}${item.title}${note ? ` (${note})` : ""}`);
2578
+ if (item.items) walk(item.items, depth + 1);
2579
+ }
2580
+ };
2581
+ for (const menu of result.menus) { lines.push(menu.title); walk(menu.items, 1); }
2582
+ console.log(lines.length ? `${lines.join("\n")}\n\nChoose one with: bivy app menu "<Menu> > <Item>"` : "This app has no menus.");
2583
+ }
2584
+
2585
+ async function appRequest(action, body, { print = true } = {}) {
2461
2586
  const config = loadConfig();
2462
2587
  if (!(await ensureNodeRunning(config))) throw new Error("Could not reach the Bivy node.");
2463
2588
  const token = await localDeviceToken(config);
@@ -2466,7 +2591,46 @@ Web previews require operator setup; see docs/apps.md.`);
2466
2591
  });
2467
2592
  const result = await response.json();
2468
2593
  if (!response.ok || result.error) throw new Error(result.error || `App command failed (${response.status}).`);
2469
- console.log(JSON.stringify(result, null, 2));
2594
+ if (action === "share") {
2595
+ const { type: _type, requestId: _requestId, ...link } = result;
2596
+ console.log(JSON.stringify({ ...link, expires: new Date(link.expiresAt).toISOString() }, null, 2));
2597
+ console.error("Anyone with this link can use the app, including its live backend, until it expires or you revoke it (Apps → Revoke access).");
2598
+ return;
2599
+ }
2600
+ if (print) console.log(JSON.stringify(result, null, 2));
2601
+ return result;
2602
+ }
2603
+
2604
+ // Computer use in desktop apps: each command's positional arguments, and the
2605
+ // flags that shape its action. The node validates the action.
2606
+ const INPUT_ACTIONS = {
2607
+ click: { args: ["x", "y"], flags: { "--right": { button: "right" }, "--middle": { button: "middle" }, "--double": { count: 2 } } },
2608
+ move: { args: ["x", "y"], flags: {} },
2609
+ drag: { args: ["x", "y", "toX", "toY"], flags: {} },
2610
+ scroll: { args: ["x", "y"], flags: { "--up": { direction: "up" }, "--down": { direction: "down" }, "--left": { direction: "left" }, "--right": { direction: "right" } }, defaults: { direction: "down" }, values: { "--steps": "steps" } },
2611
+ type: { args: ["text"], flags: {}, text: true },
2612
+ key: { args: ["keys"], flags: {}, text: true },
2613
+ };
2614
+ async function appInput(kind, rest) {
2615
+ const spec = INPUT_ACTIONS[kind];
2616
+ const action = { kind, ...spec.defaults };
2617
+ const options = {};
2618
+ const positional = [];
2619
+ for (let i = 0; i < rest.length; i++) {
2620
+ const arg = rest[i];
2621
+ if (spec.flags[arg]) Object.assign(action, spec.flags[arg]);
2622
+ else if (arg === "--app" || arg === "--session" || spec.values?.[arg]) {
2623
+ const value = rest[++i];
2624
+ if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value.`);
2625
+ if (spec.values?.[arg]) action[spec.values[arg]] = Number(value); else options[arg] = value;
2626
+ } else if (arg.startsWith("--")) throw new Error(`Unknown option ${arg}. Run bivy app --help.`);
2627
+ else positional.push(arg);
2628
+ }
2629
+ if (positional.length !== spec.args.length) throw new Error(`Usage: bivy app ${kind} ${spec.args.map((name) => `<${name}>`).join(" ")}. Run bivy app --help.`);
2630
+ spec.args.forEach((name, i) => { action[name] = spec.text ? positional[i] : Number(positional[i]); });
2631
+ const sessionId = resolveAttachSessionId({ sessionFlag: options["--session"], env: process.env });
2632
+ if (!sessionId) throw new Error("Set --session <id> or run inside a Bivy agent session.");
2633
+ await appRequest("input", { sessionId, action, ...(options["--app"] ? { target: options["--app"] } : {}) });
2470
2634
  }
2471
2635
 
2472
2636
  // Map a saved session's runtime id to the `bivy run` agent whose native CLI can
@@ -2507,6 +2671,7 @@ async function resumeSessionItem(item, config, token) {
2507
2671
  const resumeArgs = agentResumeArgs(agentId, resumeRef);
2508
2672
  const runArgs = [agentId, ...resumeArgs];
2509
2673
  if (item.workspace) runArgs.push("--workspace", item.workspace); // native resume finds the session by its original cwd
2674
+ if (item.id) runArgs.push("--bivy-session", item.id);
2510
2675
  console.log(c.dim(`Resuming ${c.cyan(item.name)} with ${agentId} ${resumeArgs.join(" ")}…`));
2511
2676
  await cmdRun(runArgs);
2512
2677
  }
@@ -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,94 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { readFileSync } from "node:fs";
4
+ import { decodePng } from "./review.js";
5
+ import { encodePng } from "./rfb.js";
6
+ const MAX_STROKES = 50;
7
+ const MAX_POINTS = 10_000;
8
+ /** Validates strokes from the client (untrusted shape): bounded, finite. */
9
+ export function readStrokes(input) {
10
+ if (!Array.isArray(input) || !input.length || input.length > MAX_STROKES)
11
+ throw new Error("Draw at least one mark (at most 50).");
12
+ let total = 0;
13
+ return input.map((raw) => {
14
+ const stroke = raw;
15
+ if ((stroke?.tool !== "pen" && stroke?.tool !== "box") || !Array.isArray(stroke.points) || !stroke.points.length)
16
+ throw new Error("Invalid mark.");
17
+ total += stroke.points.length;
18
+ if (total > MAX_POINTS)
19
+ throw new Error("Too many points in the marks.");
20
+ const points = stroke.points.map((p) => {
21
+ if (!Array.isArray(p) || p.length !== 2 || !p.every((n) => typeof n === "number" && Number.isFinite(n) && Math.abs(n) < 1e6))
22
+ throw new Error("Invalid mark point.");
23
+ return [p[0], p[1]];
24
+ });
25
+ return { tool: stroke.tool, points };
26
+ });
27
+ }
28
+ /** Whether a picture retaken on the machine may not match what the user saw:
29
+ * the page kept state a fresh browser doesn't have (storage, cookies, typed
30
+ * input, an open menu or dialog, anything the user did since it loaded), or
31
+ * the retake couldn't scroll to where they were. A Compare screenshot or a
32
+ * desktop app's frame is the exact picture, so never approximate. */
33
+ export function approximate(source, signals = {}, scrolledTo = true) {
34
+ if (source === "exact")
35
+ return false;
36
+ return !scrolledTo || Object.values(signals).some(Boolean);
37
+ }
38
+ /** The annotation colours, read from the design tokens (single source). */
39
+ function tokenColor(name, fallback) {
40
+ let hex = fallback;
41
+ try {
42
+ hex = new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`).exec(readFileSync(new URL("./tokens.css", import.meta.url), "utf8"))?.[1] ?? fallback;
43
+ }
44
+ catch { /* fallback */ }
45
+ return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
46
+ }
47
+ const INK = tokenColor("annotate", "#ff2d78");
48
+ const HALO = tokenColor("annotate-halo", "#ffffff");
49
+ /** Mark width in CSS pixels, and its halo on each side. */
50
+ const WIDTH = 4;
51
+ const HALO_WIDTH = 1.5;
52
+ /** Draws marks onto a PNG. `scale` maps CSS pixels to image pixels;
53
+ * `offset` (CSS px) is subtracted first — the scroll position the picture
54
+ * was taken at. Halos go under every mark first, so crossings stay clean. */
55
+ export function composite(png, strokes, map) {
56
+ const image = decodePng(png);
57
+ const { width, height, channels } = image;
58
+ const rgb = Buffer.alloc(width * height * 3);
59
+ for (let p = 0; p < width * height; p++)
60
+ image.pixels.copy(rgb, p * 3, p * channels, p * channels + 3);
61
+ const off = map.offset ?? { x: 0, y: 0 };
62
+ const toImage = ([x, y]) => [(x - off.x) * map.scale, (y - off.y) * map.scale];
63
+ const paths = strokes.map((stroke) => {
64
+ const points = stroke.points.map(toImage);
65
+ if (stroke.tool === "pen")
66
+ return points;
67
+ const [a, b] = [points[0], points[points.length - 1]];
68
+ return [a, [b[0], a[1]], b, [a[0], b[1]], a];
69
+ });
70
+ const disc = (cx, cy, r, color) => {
71
+ for (let y = Math.max(0, Math.floor(cy - r)); y <= Math.min(height - 1, Math.ceil(cy + r)); y++) {
72
+ for (let x = Math.max(0, Math.floor(cx - r)); x <= Math.min(width - 1, Math.ceil(cx + r)); x++) {
73
+ if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r)
74
+ rgb.set(color, (y * width + x) * 3);
75
+ }
76
+ }
77
+ };
78
+ const trace = (path, r, color) => {
79
+ const step = Math.max(0.5, r / 2);
80
+ for (let i = 0; i < path.length; i++) {
81
+ const [x0, y0] = path[i];
82
+ const [x1, y1] = path[i + 1] ?? path[i];
83
+ const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / step));
84
+ for (let k = 0; k <= n; k++)
85
+ disc(x0 + ((x1 - x0) * k) / n, y0 + ((y1 - y0) * k) / n, r, color);
86
+ }
87
+ };
88
+ const r = (WIDTH / 2) * map.scale;
89
+ for (const path of paths)
90
+ trace(path, r + HALO_WIDTH * map.scale, HALO);
91
+ for (const path of paths)
92
+ trace(path, r, INK);
93
+ return encodePng(width, height, rgb);
94
+ }
@@ -0,0 +1,154 @@
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 DISPLAY_MENU_PATH = "/__bivy/display-menu";
6
+ export const NOVNC_PATH = "/__bivy/novnc/";
7
+ /** The modifier apps paste with, as a keysym and noVNC key code: ⌘ on a Mac. */
8
+ const PASTE_MODIFIER = { darwin: [0xffeb, "MetaLeft"] };
9
+ /** The page a display view serves on its own origin: noVNC's client streaming
10
+ * the app's display. It asks the display to match its size in device pixels
11
+ * (`scale` is what the display was started at), scales down when the display
12
+ * is larger, reconnects while the app starts, carries the clipboard both ways
13
+ * on a tap, gives phones a keyboard, offers the app's menu bar where the
14
+ * display can read it (macOS), and reports stream latency to Bivy. */
15
+ export function displayViewer(nonce, name, scale, platform = process.platform) {
16
+ const [modifier, modifierCode] = PASTE_MODIFIER[platform] ?? [0xffe3, "ControlLeft"];
17
+ const title = name.replace(/[<>&"]/g, (c) => `&#${c.charCodeAt(0)};`);
18
+ 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>
19
+ <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}
20
+ #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}
21
+ #tools{position:fixed;left:12px;bottom:72px;display:flex;flex-direction:column;align-items:flex-start;gap:8px}
22
+ button,#paste{font:inherit;font-size:14px;min-height:44px;padding:0 14px;border-radius:22px;border:1px solid GrayText;background:Canvas;color:CanvasText}
23
+ #keys{display:none;min-width:44px;padding:0}@media (pointer:coarse){#keys{display:block}}
24
+ #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}
25
+ #typing{position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0}
26
+ #menu{position:fixed;left:12px;bottom:72px;width:min(22rem,calc(100vw - 24px));max-height:min(70vh,36rem);display:flex;flex-direction:column;background:Canvas;color:CanvasText;border:1px solid GrayText;border-radius:16px;box-shadow:0 8px 24px color-mix(in srgb,CanvasText 25%,transparent);overflow:hidden}
27
+ #menu header{display:flex;align-items:center;gap:4px;padding:4px;border-bottom:1px solid GrayText}
28
+ #menu header button{border:0;min-width:44px;padding:0;font-size:18px}
29
+ #menu h2{flex:1;margin:0;padding:0 8px;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
30
+ #menu-status{margin:0;padding:12px 16px;color:GrayText;font-size:14px}#menu-status:empty{display:none}
31
+ #menu ul{list-style:none;margin:0;padding:4px 0;overflow:auto;overscroll-behavior:contain}
32
+ #menu li[role=separator]{border-top:1px solid GrayText;margin:4px 16px;opacity:.5}
33
+ #menu li button{display:flex;align-items:center;gap:8px;width:100%;border:0;border-radius:0;padding:0 16px;text-align:start}
34
+ #menu li button:hover:not(:disabled),#menu li button:focus-visible{background:Highlight;color:HighlightText;outline:none}
35
+ #menu li button:disabled{color:GrayText}
36
+ #menu .mark{width:1em;flex:none}#menu .label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#menu .keys{flex:none;opacity:.7;font-variant-numeric:tabular-nums}</style>
37
+ <div id="screen"></div><p id="status" role="status">Starting the app…</p>
38
+ <div id="tools"><button id="menu-open" type="button" aria-haspopup="menu" aria-controls="menu" aria-expanded="false" hidden>Menu</button><button id="copy" type="button" hidden>Copy from app</button>
39
+ <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>
40
+ <button id="paste-open" type="button">Paste</button><button id="keys" type="button" aria-label="Show keyboard">⌨</button></div>
41
+ <div id="menu" role="dialog" aria-labelledby="menu-title" hidden><header><button id="menu-back" type="button" aria-label="Back">‹</button><h2 id="menu-title">Menu</h2><button id="menu-close" type="button" aria-label="Close menu">✕</button></header>
42
+ <p id="menu-status" role="status"></p><ul id="menu-items" role="menu" aria-labelledby="menu-title"></ul></div>
43
+ <input id="typing" aria-label="Type into the app" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false">
44
+ <script type="module" nonce="${nonce}">import RFB from "${NOVNC_PATH}core/rfb.js";
45
+ const $=id=>document.getElementById(id),screen=$("screen"),status=$("status"),typing=$("typing"),SCALE=${scale === 2 ? 2 : 1};
46
+ let rfb,wait=500,remote="";
47
+ const say=text=>{status.textContent=text;status.hidden=!text;};
48
+
49
+ // Latency: time from each input sent to the next frame back, in this browser.
50
+ const lat=[];let pending=0,bytes=0,since=performance.now();
51
+ // (Patched in place: noVNC rejects a WebSocket subclass.)
52
+ const send=WebSocket.prototype.send,probed=new WeakSet();
53
+ WebSocket.prototype.send=function(data){
54
+ 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;}});}
55
+ const first=data instanceof ArrayBuffer?new Uint8Array(data)[0]:ArrayBuffer.isView(data)?new Uint8Array(data.buffer,data.byteOffset,1)[0]:-1;
56
+ if((first===4||first===5)&&!pending)pending=performance.now();
57
+ return send.call(this,data);
58
+ };
59
+ setInterval(()=>{
60
+ if(lat.length<5)return;
61
+ const sorted=lat.splice(0).sort((a,b)=>a-b),at=p=>sorted[Math.min(sorted.length-1,Math.floor(sorted.length*p))];
62
+ const secs=(performance.now()-since)/1000;since=performance.now();
63
+ 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(()=>{});
64
+ bytes=0;
65
+ },10000);
66
+
67
+ function connect(){
68
+ rfb=new RFB(screen,(location.protocol==="https:"?"wss://":"ws://")+location.host+"${DISPLAY_SOCKET_PATH}",{shared:true});
69
+ // Ask for device pixels, so a display started at 2× stays sharp once scaled
70
+ // to fit. Only the resize request uses them; scaling still fits CSS pixels.
71
+ let ask=1;const measure=rfb._screenSize.bind(rfb),request=rfb._requestRemoteResize.bind(rfb);
72
+ rfb._screenSize=()=>{const s=measure();return{w:Math.round(s.w*ask),h:Math.round(s.h*ask)};};
73
+ rfb._requestRemoteResize=()=>{ask=SCALE;try{request();}finally{ask=1;}};
74
+ rfb.resizeSession=true;rfb.scaleViewport=true;rfb.focusOnClick=true;
75
+ rfb.addEventListener("connect",()=>{wait=500;say("");hasMenu();});
76
+ rfb.addEventListener("disconnect",()=>{say("Starting the app…");setTimeout(connect,wait);wait=Math.min(wait*2,5000);});
77
+ // The app copied text: offer it; the browser only lets a tap write the clipboard.
78
+ rfb.addEventListener("clipboard",e=>{remote=e.detail.text;$("copy").hidden=!remote;$("copy").textContent="Copy from app";});
79
+ }
80
+ connect();
81
+
82
+ $("copy").onclick=async()=>{try{await navigator.clipboard.writeText(remote);$("copy").textContent="Copied";setTimeout(()=>{$("copy").hidden=true;},1500);}catch{$("copy").textContent="Couldn’t copy";}};
83
+ // Paste: hand the text to the app's clipboard, then press Ctrl+V (⌘V on a Mac) in it.
84
+ const paste=text=>{if(!text)return;rfb.clipboardPasteFrom(text);rfb.sendKey(${modifier},"${modifierCode}",true);rfb.sendKey(0x76,"KeyV");rfb.sendKey(${modifier},"${modifierCode}",false);rfb.focus();};
85
+ $("paste-open").onclick=async()=>{
86
+ try{const text=await navigator.clipboard.readText();if(text){paste(text);return;}}catch{}
87
+ // No clipboard access here: paste into a field instead.
88
+ $("paste").hidden=false;$("paste-text").focus();
89
+ };
90
+ $("paste").onsubmit=e=>{e.preventDefault();paste($("paste-text").value);$("paste-text").value="";$("paste").hidden=true;};
91
+ $("paste-text").addEventListener("keydown",e=>{if(e.key==="Escape"){$("paste").hidden=true;rfb.focus();}});
92
+
93
+ // The app's menu bar, read when opened so enabled and checked states are
94
+ // current. Drill down through submenus; choosing an item closes the menu.
95
+ // Titles come from the app: shown as text only.
96
+ const MENU="${DISPLAY_MENU_PATH}",menu=$("menu"),items=$("menu-items"),menuStatus=$("menu-status");
97
+ let stack=[];
98
+ const hasMenu=()=>fetch(MENU,{method:"HEAD"}).then(r=>{$("menu-open").hidden=!r.ok;}).catch(()=>{});
99
+ const setOpen=open=>{menu.hidden=!open;$("tools").hidden=open;$("menu-open").setAttribute("aria-expanded",String(open));};
100
+ const closeMenu=()=>{setOpen(false);stack=[];rfb.focus();};
101
+ const cell=(cls,text)=>{const span=document.createElement("span");span.className=cls;span.textContent=text;return span;};
102
+ function show(){
103
+ const level=stack[stack.length-1];
104
+ $("menu-title").textContent=level.title;$("menu-back").hidden=stack.length<2;
105
+ items.replaceChildren(...level.items.map(item=>{
106
+ const li=document.createElement("li");
107
+ if(item.separator){li.setAttribute("role","separator");return li;}
108
+ li.setAttribute("role","none");
109
+ const button=document.createElement("button");
110
+ button.type="button";button.setAttribute("role",item.checked?"menuitemcheckbox":"menuitem");
111
+ if(item.checked)button.setAttribute("aria-checked","true");
112
+ button.disabled=item.enabled===false;
113
+ button.append(cell("mark",item.checked?"✓":""),cell("label",item.title));
114
+ if(item.items){button.setAttribute("aria-haspopup","menu");button.append(cell("keys","›"));button.onclick=()=>{stack.push({title:item.title,items:item.items,path:[...level.path,item.title]});show();};}
115
+ else{if(item.shortcut){button.append(cell("keys",item.shortcut));button.setAttribute("aria-keyshortcuts",item.shortcut);}button.onclick=()=>choose([...level.path,item.title]);}
116
+ li.append(button);return li;
117
+ }));
118
+ items.querySelector("button:not(:disabled)")?.focus();
119
+ }
120
+ async function choose(path){
121
+ menuStatus.textContent="Choosing "+path.join(" › ")+"…";
122
+ try{
123
+ const r=await fetch(MENU,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path})});
124
+ if(!r.ok)throw new Error((await r.json().catch(()=>({}))).error||"The app didn’t take it.");
125
+ menuStatus.textContent="";closeMenu();
126
+ }catch(e){menuStatus.textContent=e.message;}
127
+ }
128
+ $("menu-open").onclick=async()=>{
129
+ stack=[];items.replaceChildren();setOpen(true);$("menu-back").hidden=true;$("menu-title").textContent="Menu";
130
+ menuStatus.textContent="Reading the app’s menus…";$("menu-close").focus();
131
+ try{
132
+ const r=await fetch(MENU);const body=await r.json();
133
+ if(!r.ok)throw new Error(body.error||"Couldn’t read the app’s menus.");
134
+ menuStatus.textContent=body.menus.length?"":"This app has no menus.";
135
+ stack=[{title:"Menu",items:body.menus.map(m=>({title:m.title,enabled:true,items:m.items})),path:[]}];show();
136
+ }catch(e){menuStatus.textContent=e.message;}
137
+ };
138
+ $("menu-close").onclick=closeMenu;
139
+ $("menu-back").onclick=()=>{stack.pop();show();};
140
+ menu.addEventListener("keydown",e=>{
141
+ if(e.key==="Escape"){e.preventDefault();if(stack.length>1){stack.pop();show();}else closeMenu();return;}
142
+ if(e.key!=="ArrowDown"&&e.key!=="ArrowUp")return;
143
+ const all=[...items.querySelectorAll("button:not(:disabled)")];if(!all.length)return;
144
+ e.preventDefault();const at=all.indexOf(document.activeElement);
145
+ all[(at+(e.key==="ArrowDown"?1:-1)+all.length)%all.length].focus();
146
+ });
147
+
148
+ // Phones: a soft keyboard needs a text field; forward what's typed as keys.
149
+ const SPECIAL={Backspace:0xff08,Enter:0xff0d,Tab:0xff09,Escape:0xff1b,ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54};
150
+ $("keys").onclick=()=>typing.focus();
151
+ typing.addEventListener("keydown",e=>{const key=SPECIAL[e.key];if(key){e.preventDefault();rfb.sendKey(key,null);}});
152
+ typing.addEventListener("input",()=>{for(const ch of typing.value){const cp=ch.codePointAt(0);rfb.sendKey(cp<256?cp:0x01000000+cp,null);}typing.value="";});
153
+ </script></html>`;
154
+ }