@bivy/bivy 0.17.3-staging.9 → 0.18.0

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.
@@ -2,13 +2,15 @@
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
3
  import http from "node:http";
4
4
  import net from "node:net";
5
- import { randomBytes } from "node:crypto";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { gzipSync } from "node:zlib";
6
7
  import path from "node:path";
7
8
  import { readFileSync } from "node:fs";
8
9
  import { createRequire } from "node:module";
9
10
  import { WebSocketServer } from "ws";
10
11
  import { previewShell } from "./preview-shell.js";
11
- import { displayViewer, DISPLAY_SOCKET_PATH, DISPLAY_STATS_PATH, NOVNC_PATH } from "./display-viewer.js";
12
+ import { displayViewer, DISPLAY_MENU_PATH, DISPLAY_SOCKET_PATH, DISPLAY_STATS_PATH, NOVNC_PATH } from "./display-viewer.js";
13
+ import { pressMenu, readMenuPath, readMenus } from "./menu.js";
12
14
  import { inspectorScript } from "./inspector.js";
13
15
  const MIME = {
14
16
  ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
@@ -24,7 +26,6 @@ const REVISION_PATH = "/__bivy/revision";
24
26
  const INSPECTOR_PATH = "/__bivy/inspector.js";
25
27
  const COMPARE_PATH = "/__bivy/compare";
26
28
  const NOTES_PATH = "/__bivy/notes";
27
- const MAX_NOTES = 50;
28
29
  /** Larger HTML documents pass through without the inspector. */
29
30
  const MAX_INJECT_BYTES = 5 * 1024 * 1024;
30
31
  /** Put the inspector first in <head>, so it sees errors from the app's own scripts. */
@@ -50,6 +51,47 @@ function allowInspector(policy, url) {
50
51
  }
51
52
  return directives.join("; ");
52
53
  }
54
+ /** Text-like types that shrink well; images and fonts are compressed already. */
55
+ const COMPRESSIBLE = /^(?:text\/|application\/(?:json|javascript|xml|wasm)|image\/svg\+xml)/i;
56
+ /** Snapshot and noVNC buffers never change, so validators and gzip copies are made once. */
57
+ const encodings = new WeakMap();
58
+ function encoded(data, type) {
59
+ let entry = encodings.get(data);
60
+ if (!entry) {
61
+ // Weak: the identity and gzip bodies share it.
62
+ entry = { etag: `W/"${createHash("sha256").update(data).digest("base64url").slice(0, 27)}"` };
63
+ if (data.length >= 1024 && COMPRESSIBLE.test(type)) {
64
+ const gzip = gzipSync(data);
65
+ if (gzip.length < data.length)
66
+ entry.gzip = gzip;
67
+ }
68
+ encodings.set(data, entry);
69
+ }
70
+ return entry;
71
+ }
72
+ /** Serves fixed bytes with a validator (304 on a match) and gzip when the browser takes it. */
73
+ function sendBytes(req, res, status, headers, data) {
74
+ const file = encoded(data, String(headers["content-type"] ?? ""));
75
+ headers.etag = file.etag;
76
+ headers.vary = "Accept-Encoding";
77
+ if (status === 200 && (req.headers["if-none-match"] ?? "").split(",").some((tag) => tag.trim() === file.etag)) {
78
+ res.writeHead(304, headers);
79
+ res.end();
80
+ return;
81
+ }
82
+ const gzip = file.gzip && /\bgzip\b/i.test(req.headers["accept-encoding"] ?? "") ? file.gzip : undefined;
83
+ if (gzip)
84
+ headers["content-encoding"] = "gzip";
85
+ const body = gzip ?? data;
86
+ res.writeHead(status, { ...headers, "content-length": body.length });
87
+ res.end(req.method === "HEAD" ? undefined : body);
88
+ }
89
+ /** App content may sit in the browser's own cache, never a shared one, and is
90
+ * revalidated on every use whatever the app says: a preview must show what the
91
+ * agent just built. The app's validators still turn unchanged files into 304s. */
92
+ function privateCache(policy) {
93
+ return /\bno-store\b/i.test(policy ?? "") ? "no-store" : "private, no-cache";
94
+ }
53
95
  const HOUR = 60 * 60_000;
54
96
  /** A viewer that stops reading doesn't make the node buffer the display. */
55
97
  const DISPLAY_BUFFER_BYTES = 4 * 1024 * 1024;
@@ -125,8 +167,8 @@ function responseHeaders(headers, ancestors, inspector) {
125
167
  .map((cookie) => cookie.replace(/;\s*domain=[^;]*/ig, ""));
126
168
  }
127
169
  // Preview data must not become a shared proxy cache entry. Apps cannot frame
128
- // Bivy, retain an opener, or cache an offline service-worker copy of the gate.
129
- result["cache-control"] = "no-store";
170
+ // Bivy or retain an opener; service workers are refused (see handle).
171
+ result["cache-control"] = privateCache(headers["cache-control"]);
130
172
  result["referrer-policy"] = "no-referrer";
131
173
  result["cross-origin-opener-policy"] = "same-origin";
132
174
  result["x-content-type-options"] = "nosniff";
@@ -474,8 +516,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
474
516
  headers["content-type"] = MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
475
517
  if (inspect && path.extname(file).toLowerCase() === ".html" && data.length <= MAX_INJECT_BYTES)
476
518
  data = withInspector(data);
477
- res.writeHead(status, { ...headers, "content-length": data.length });
478
- res.end(req.method === "HEAD" ? undefined : data);
519
+ sendBytes(req, res, status, headers, data);
479
520
  return;
480
521
  }
481
522
  if (entry.target.kind !== "service") {
@@ -560,6 +601,10 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
560
601
  res.end();
561
602
  return;
562
603
  }
604
+ if (req.url === DISPLAY_MENU_PATH) {
605
+ await this.displayMenu(req, res, entry);
606
+ return;
607
+ }
563
608
  if (!["GET", "HEAD"].includes(req.method ?? "")) {
564
609
  res.writeHead(405, { Allow: "GET, HEAD" });
565
610
  res.end();
@@ -573,8 +618,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
573
618
  res.end();
574
619
  return;
575
620
  }
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);
621
+ sendBytes(req, res, 200, { ...responseHeaders({}, this.ancestors(id)), "content-type": "text/javascript; charset=utf-8" }, data);
578
622
  return;
579
623
  }
580
624
  if (!isPageLoad(req)) {
@@ -589,6 +633,55 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
589
633
  res.writeHead(200, { ...headers, "content-type": "text/html; charset=utf-8", "content-length": body.length });
590
634
  res.end(req.method === "HEAD" ? undefined : body);
591
635
  }
636
+ /** The app's menu bar for the viewer: HEAD says whether it has one, GET
637
+ * reads it, POST {path} chooses an item (same-origin, checked above). */
638
+ async displayMenu(req, res, entry) {
639
+ const control = entry.displayControl;
640
+ const json = (status, body) => { res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(body)); };
641
+ if (req.method === "HEAD") {
642
+ res.writeHead(control ? 204 : 404);
643
+ res.end();
644
+ return;
645
+ }
646
+ if (!control) {
647
+ json(404, { error: "This app's menus aren't available." });
648
+ return;
649
+ }
650
+ try {
651
+ if (req.method === "GET") {
652
+ json(200, { menus: await readMenus(control) });
653
+ return;
654
+ }
655
+ if (req.method !== "POST") {
656
+ res.writeHead(405, { Allow: "GET, HEAD, POST" });
657
+ res.end();
658
+ return;
659
+ }
660
+ let body = "";
661
+ for await (const chunk of req) {
662
+ body += chunk.toString();
663
+ if (body.length > 4096) {
664
+ res.writeHead(413);
665
+ res.end();
666
+ return;
667
+ }
668
+ }
669
+ let path;
670
+ try {
671
+ path = readMenuPath(JSON.parse(body).path);
672
+ }
673
+ catch (error) {
674
+ json(400, { error: error.message });
675
+ return;
676
+ }
677
+ await pressMenu(control, path);
678
+ res.writeHead(204);
679
+ res.end();
680
+ }
681
+ catch (error) {
682
+ json(409, { error: error.message });
683
+ }
684
+ }
592
685
  /** Relays a viewer's WebSocket to the display's private VNC socket. */
593
686
  displaySocket(req, socket, head, entry) {
594
687
  this.displays.handleUpgrade(req, socket, head, (ws) => {
@@ -646,11 +739,10 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
646
739
  }
647
740
  const viewport = input.viewport;
648
741
  const path = text(input.path, 2048);
649
- entry.notes = [...(entry.notes ?? []), {
650
- id: randomBytes(8).toString("hex"), at: Date.now(), note, selector: text(input.selector, 300), text: text(input.text, 200),
651
- path: path.startsWith("/") ? path : "/", viewport: { width: Number(viewport?.width) || 0, height: Number(viewport?.height) || 0 },
652
- }].slice(-MAX_NOTES);
653
- this.registry.emit("notes", entry.view.id);
742
+ this.registry.addNote(entry.view.id, {
743
+ id: randomBytes(8).toString("hex"), at: Date.now(), note, selector: text(input.selector, 300), text: text(input.text, 200),
744
+ path: path.startsWith("/") ? path : "/", viewport: { width: Number(viewport?.width) || 0, height: Number(viewport?.height) || 0 },
745
+ });
654
746
  res.writeHead(204);
655
747
  res.end();
656
748
  }
@@ -0,0 +1,96 @@
1
+ /** VNC button-mask bits: buttons, then the wheel's four directions. */
2
+ const BUTTONS = { left: 1, middle: 2, right: 4 };
3
+ const WHEEL = { up: 8, down: 16, left: 32, right: 64 };
4
+ /** X keysyms by name. Super is ⌘ on a Mac. */
5
+ const MODIFIERS = {
6
+ shift: 0xffe1, ctrl: 0xffe3, control: 0xffe3, alt: 0xffe9, option: 0xffe9, opt: 0xffe9,
7
+ cmd: 0xffeb, command: 0xffeb, super: 0xffeb, meta: 0xffeb, win: 0xffeb,
8
+ };
9
+ const KEYS = {
10
+ enter: 0xff0d, return: 0xff0d, tab: 0xff09, escape: 0xff1b, esc: 0xff1b, backspace: 0xff08, delete: 0xffff, insert: 0xff63,
11
+ home: 0xff50, end: 0xff57, pageup: 0xff55, pagedown: 0xff56, left: 0xff51, up: 0xff52, right: 0xff53, down: 0xff54,
12
+ space: 0x20, plus: 0x2b, ...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`f${i + 1}`, 0xffbe + i])),
13
+ };
14
+ /** Characters typed as keys rather than as text. */
15
+ const TYPED = { "\n": KEYS.enter, "\t": KEYS.tab };
16
+ const PAUSE = 30;
17
+ const keysym = (char) => { const cp = char.codePointAt(0); return cp <= 0xff ? cp : 0x0100_0000 + cp; };
18
+ const press = (sym) => [{ key: { keysym: sym, down: true } }, { key: { keysym: sym, down: false } }];
19
+ /** "cmd+shift+s", "ctrl+c", "enter": modifiers held around one key. */
20
+ function combo(keys) {
21
+ const parts = keys.toLowerCase().split("+").map((part) => part.trim());
22
+ const key = parts.pop();
23
+ const held = parts.map((part) => {
24
+ const sym = MODIFIERS[part];
25
+ if (sym === undefined)
26
+ throw new Error(`Unknown modifier "${part}". Use ${[...new Set(Object.keys(MODIFIERS))].join(", ")}.`);
27
+ return sym;
28
+ });
29
+ // A letter with Shift is its capital, as a keyboard sends it.
30
+ const char = [...key].length === 1 ? (held.includes(MODIFIERS.shift) ? key.toUpperCase() : key) : undefined;
31
+ const sym = KEYS[key] ?? MODIFIERS[key] ?? (char && keysym(char));
32
+ if (!sym)
33
+ throw new Error(`Unknown key "${key}". Use a character or ${Object.keys(KEYS).join(", ")}.`);
34
+ return [...held.map((s) => ({ key: { keysym: s, down: true } })), ...press(sym), ...held.reverse().map((s) => ({ key: { keysym: s, down: false } }))];
35
+ }
36
+ const point = (n) => Number.isInteger(n) && n >= 0 && n < 65536;
37
+ /** Checks an action from the command line or API. */
38
+ export function readAction(input) {
39
+ const a = (input ?? {});
40
+ const at = (...keys) => { if (!keys.every((k) => point(a[k])))
41
+ throw new Error("Coordinates must be whole pixels in the app's screenshot."); };
42
+ switch (a.kind) {
43
+ case "click":
44
+ at("x", "y");
45
+ if (a.button !== undefined && !(typeof a.button === "string" && a.button in BUTTONS))
46
+ throw new Error("Button must be left, middle or right.");
47
+ if (a.count !== undefined && ![1, 2, 3].includes(a.count))
48
+ throw new Error("Click count must be 1, 2 or 3.");
49
+ return a;
50
+ case "move":
51
+ at("x", "y");
52
+ return a;
53
+ case "drag":
54
+ at("x", "y", "toX", "toY");
55
+ return a;
56
+ case "scroll":
57
+ at("x", "y");
58
+ if (!(typeof a.direction === "string" && a.direction in WHEEL))
59
+ throw new Error("Scroll direction must be up, down, left or right.");
60
+ if (a.steps !== undefined && !(Number.isInteger(a.steps) && a.steps >= 1 && a.steps <= 50))
61
+ throw new Error("Scroll steps must be 1–50.");
62
+ return a;
63
+ case "type":
64
+ if (typeof a.text !== "string" || !a.text || a.text.length > 2000)
65
+ throw new Error("Text must be 1–2000 characters.");
66
+ return a;
67
+ case "key":
68
+ if (typeof a.keys !== "string" || !a.keys.trim() || a.keys.length > 100)
69
+ throw new Error("Keys must name a key, like enter or cmd+s.");
70
+ combo(a.keys);
71
+ return a;
72
+ default: throw new Error("Action must be click, move, drag, scroll, type or key.");
73
+ }
74
+ }
75
+ /** The pointer and key events that perform an action. */
76
+ export function inputEvents(action) {
77
+ const at = (x, y, buttons = 0) => ({ pointer: { x, y, buttons } });
78
+ switch (action.kind) {
79
+ case "move": return [at(action.x, action.y)];
80
+ case "click": {
81
+ const button = BUTTONS[action.button ?? "left"];
82
+ return [at(action.x, action.y), ...Array.from({ length: action.count ?? 1 }, () => [at(action.x, action.y, button), at(action.x, action.y)]).flat()];
83
+ }
84
+ case "drag": {
85
+ // Through a few points on the way, so apps see a drag, not a jump.
86
+ const path = Array.from({ length: 8 }, (_, i) => at(Math.round(action.x + (action.toX - action.x) * (i + 1) / 8), Math.round(action.y + (action.toY - action.y) * (i + 1) / 8), 1));
87
+ return [at(action.x, action.y), at(action.x, action.y, 1), ...path.flatMap((step) => [{ wait: PAUSE }, step]), at(action.toX, action.toY)];
88
+ }
89
+ case "scroll": {
90
+ const bit = WHEEL[action.direction];
91
+ return [at(action.x, action.y), ...Array.from({ length: action.steps ?? 3 }, () => [at(action.x, action.y, bit), at(action.x, action.y)]).flat()];
92
+ }
93
+ case "type": return [...action.text].flatMap((char) => [...press(TYPED[char] ?? keysym(char)), { wait: 5 }]);
94
+ case "key": return combo(action.keys);
95
+ }
96
+ }
@@ -16,7 +16,9 @@ const log=(level,text)=>post({type:'console',level,text:String(text).slice(0,500
16
16
  for(const level of ['error','warn']){const original=console[level];console[level]=function(...args){log(level,args.map(fmt).join(' '));return original.apply(this,args);};}
17
17
  addEventListener('error',e=>{if(e.message)log('error',e.message+(e.filename?' ('+e.filename.split('/').pop()+':'+e.lineno+')':''));},true);
18
18
  addEventListener('unhandledrejection',e=>log('error','Unhandled rejection: '+fmt(e.reason)));
19
- const route=()=>post({type:'route',path:location.pathname+location.search+location.hash});
19
+ // Taps and keys since this page (or route) loaded: state a retaken picture may lack.
20
+ let interacted=false;
21
+ const route=()=>{interacted=false;post({type:'route',path:location.pathname+location.search+location.hash});};
20
22
  for(const name of ['pushState','replaceState']){const original=history[name];history[name]=function(...args){const result=original.apply(this,args);route();return result;};}
21
23
  addEventListener('popstate',route);addEventListener('hashchange',route);route();
22
24
  const selector=el=>{
@@ -32,48 +34,100 @@ const selector=el=>{
32
34
  }
33
35
  return parts.join(' > ')||el.localName;
34
36
  };
35
- let box=null,target=null,onPicked=post,host=null;
36
- // Picks on pointerup: iOS never fires click for a tap on a non-clickable
37
- // element when only window listens, so click would miss most of the page.
38
- // While pointing, the app sees none of the gesture, including the click after.
39
- const SWALLOW=['pointerdown','mousedown','mouseup','touchend','click','dblclick','contextmenu'];
40
- const swallow=e=>{e.preventDefault();e.stopPropagation();};
41
- // A second listener, so stopping (which the shell does right after a pick)
42
- // doesn't also drop the guard on the rest of the tap. The tap's click ends it.
43
- const aftermath=e=>{swallow(e);if(e.type==='click')listen(false,aftermath);};
44
- const listen=(on,handler=swallow)=>{
45
- const method=on?addEventListener:removeEventListener;
46
- if(handler===swallow){method('pointermove',hover,true);method('pointerup',pick,true);method('keydown',key,true);}
47
- for(const type of SWALLOW)method(type,handler,true);
48
- };
49
- const stop=()=>{box?.remove();box=null;target=null;listen(false);};
37
+ let box=null,layer=null,onPicked=post,host=null;
38
+ // While pointing, a clear layer over the page takes every tap itself, so the
39
+ // app never sees one and no browser's event order matters (iOS sends no click
40
+ // to a window listener for most taps). The element is looked up beneath it.
41
+ const stop=()=>{box?.remove();layer?.remove();box=layer=null;removeEventListener('keydown',key,true);};
42
+ const under=e=>document.elementsFromPoint(e.clientX,e.clientY).find(el=>el!==layer&&el!==box&&el!==host);
50
43
  const hover=e=>{
51
- const el=document.elementFromPoint(e.clientX,e.clientY);
52
- if(!el||el===box||el===host)return;target=el;const r=el.getBoundingClientRect();
44
+ const el=under(e);if(!el)return;const r=el.getBoundingClientRect();
53
45
  Object.assign(box.style,{left:r.left+'px',top:r.top+'px',width:r.width+'px',height:r.height+'px'});
54
46
  };
55
- const pick=e=>{
56
- swallow(e);
57
- const el=document.elementFromPoint(e.clientX,e.clientY)||target;stop();
58
- // Keep eating the click (and iOS's delayed mouse events) this tap still fires.
59
- listen(true,aftermath);setTimeout(()=>listen(false,aftermath),500);
60
- if(!el||el===host){onPicked({type:'picked',cancelled:true});return;}const r=el.getBoundingClientRect();
47
+ const report=(el,hold)=>{
48
+ const r=el.getBoundingClientRect();
61
49
  onPicked({type:'picked',selector:selector(el),tag:el.localName,text:(el.innerText||el.getAttribute('aria-label')||el.getAttribute('alt')||'').trim().replace(/\\s+/g,' ').slice(0,200),
62
50
  rect:{x:Math.round(r.left),y:Math.round(r.top),width:Math.round(r.width),height:Math.round(r.height)},
63
- viewport:{width:innerWidth,height:innerHeight},path:location.pathname+location.search+location.hash});
51
+ viewport:{width:innerWidth,height:innerHeight},path:location.pathname+location.search+location.hash,hold});
52
+ };
53
+ // A long press picks at once and says so (the shell starts listening); the
54
+ // layer stays until the finger lifts, so the release never reaches the app.
55
+ let press=null,held=false;
56
+ const pick=e=>{
57
+ e.preventDefault();
58
+ if(held){held=false;stop();return;}
59
+ const el=under(e);if(!el)return;stop();report(el,false);
60
+ };
61
+ const down=e=>{
62
+ hover(e);clearTimeout(press?.timer);held=false;
63
+ const x=e.clientX,y=e.clientY;
64
+ press={x,y,timer:setTimeout(()=>{const el=under({clientX:x,clientY:y});if(!el)return;held=true;box?.remove();report(el,true);},450)};
64
65
  };
66
+ const move=e=>{hover(e);if(press&&Math.hypot(e.clientX-press.x,e.clientY-press.y)>10)clearTimeout(press.timer);};
67
+ const up=()=>{clearTimeout(press?.timer);press=null;if(held){post({type:'release'});setTimeout(()=>{if(held){held=false;stop();}},0);}};
65
68
  const key=e=>{if(e.key==='Escape'){stop();onPicked({type:'picked',cancelled:true});}};
66
69
  function startPointing(){
67
70
  stop();
68
71
  box=document.createElement('div');
69
72
  box.setAttribute('aria-hidden','true');
70
73
  Object.assign(box.style,{position:'fixed',zIndex:2147483647,pointerEvents:'none',outline:'2px solid Highlight',outlineOffset:'2px',borderRadius:'4px',transition:'all 60ms ease-out'});
71
- document.documentElement.append(box);
72
- listen(true);
74
+ // One below the reviewer dock, so its controls stay usable while pointing.
75
+ layer=document.createElement('div');
76
+ layer.setAttribute('aria-hidden','true');
77
+ Object.assign(layer.style,{position:'fixed',inset:0,zIndex:2147483646,cursor:'crosshair',background:'transparent',touchAction:'manipulation',userSelect:'none',webkitUserSelect:'none',webkitTouchCallout:'none'});
78
+ layer.addEventListener('pointermove',move);layer.addEventListener('pointerdown',down);layer.addEventListener('pointerup',up);layer.addEventListener('pointercancel',up);layer.addEventListener('click',pick);
79
+ layer.addEventListener('contextmenu',e=>e.preventDefault());
80
+ document.documentElement.append(layer,box);
81
+ addEventListener('keydown',key,true);
73
82
  }
83
+ for(const type of ['pointerdown','keydown'])addEventListener(type,e=>{if(!layer&&!(host&&e.composedPath?.().includes(host)))interacted=true;},true);
84
+ // Draw (in the shell, over this page): where the page is, what state it holds
85
+ // that a fresh browser on the machine wouldn't, and what's under each mark.
86
+ const signals=()=>{
87
+ let storage=false;try{storage=localStorage.length+sessionStorage.length>0;}catch{}
88
+ const edited=[...document.querySelectorAll('input,textarea,select')].some(el=>el.type==='checkbox'||el.type==='radio'?el.checked!==el.defaultChecked:el.localName==='select'?[...el.options].some(o=>o.selected!==o.defaultSelected):el.type!=='hidden'&&el.value!==el.defaultValue);
89
+ const open=Boolean(document.querySelector('dialog[open],details[open],[aria-expanded="true"],[aria-modal="true"]'));
90
+ return {storage,cookies:Boolean(document.cookie),interacted,open,edited};
91
+ };
92
+ const drawState=()=>({type:'draw-state',scroll:{x:Math.round(scrollX),y:Math.round(scrollY)},viewport:{width:innerWidth,height:innerHeight},dpr:devicePixelRatio,
93
+ theme:matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light',path:location.pathname+location.search+location.hash,signals:signals()});
94
+ /** Elements inside a mark (viewport rect): a grid sample, then the largest
95
+ * element under each hit that sits mostly inside the mark — circling a
96
+ * button names the button, not the bar it's in. A line through something
97
+ * falls back to what it crossed. Outermost first, at most eight. */
98
+ const marked=r=>{
99
+ const hits=new Set();
100
+ for(let i=0;i<8;i++)for(let j=0;j<8;j++){
101
+ const x=Math.min(innerWidth-1,Math.max(0,r.x+r.width*(i+0.5)/8)),y=Math.min(innerHeight-1,Math.max(0,r.y+r.height*(j+0.5)/8));
102
+ const el=document.elementsFromPoint(x,y).find(n=>n!==layer&&n!==box&&n!==host);if(el)hits.add(el);
103
+ }
104
+ const big=innerWidth*innerHeight*0.6,page=n=>n===document.body||n===document.documentElement;
105
+ // Where an element's content is: a full-width block around "Total $102"
106
+ // is judged by its text, which is what a circle goes around.
107
+ const range=document.createRange();
108
+ const rectOf=n=>{const b=n.getBoundingClientRect();if(!n.firstChild||/^(img|svg|video|canvas|input|select|textarea|button)$/.test(n.localName))return b;range.selectNodeContents(n);const c=range.getBoundingClientRect();return c.width&&c.height?c:b;};
109
+ const inside=n=>{const b=rectOf(n),a=b.width*b.height;if(!a)return 0;const w=Math.max(0,Math.min(b.right,r.x+r.width)-Math.max(b.left,r.x)),h=Math.max(0,Math.min(b.bottom,r.y+r.height)-Math.max(b.top,r.y));return w*h/a;};
110
+ const area=n=>{const b=rectOf(n);return b.width*b.height;};
111
+ const picked=new Set();
112
+ for(const hit of hits){let best=null;for(let n=hit;n&&n.nodeType===1&&!page(n);n=n.parentElement){if(inside(n)>=0.6&&area(n)<big)best=n;else if(best)break;}if(best)picked.add(best);}
113
+ if(!picked.size)for(const hit of hits)if(!page(hit)&&area(hit)<big&&(hit.innerText||'').trim())picked.add(hit);
114
+ const list=[...picked].filter(n=>![...picked].some(o=>o!==n&&o.contains(n)))
115
+ .sort((a,b)=>{const p=a.getBoundingClientRect(),q=b.getBoundingClientRect();return p.top-q.top||p.left-q.left;});
116
+ return list.slice(0,8).map(el=>{const b=el.getBoundingClientRect();return {selector:selector(el).slice(0,300),tag:el.localName,
117
+ text:(el.innerText||el.getAttribute('aria-label')||el.getAttribute('alt')||'').trim().replace(/\\s+/g,' ').slice(0,120),
118
+ rect:{x:Math.round(b.left+scrollX),y:Math.round(b.top+scrollY),width:Math.round(b.width),height:Math.round(b.height)}};});
119
+ };
120
+ addEventListener('message',e=>{
121
+ if(!framed||e.origin!==SHELL||e.source!==parent)return;
122
+ const d=e.data||{},n=v=>Number.isFinite(+v)?+v:0;
123
+ if(d.type==='bivy:draw')post(drawState());
124
+ else if(d.type==='bivy:scroll'){scrollBy({left:n(d.dx),top:n(d.dy),behavior:'instant'});post({type:'scrolled',scroll:{x:Math.round(scrollX),y:Math.round(scrollY)}});}
125
+ else if(d.type==='bivy:marks'&&d.rect)post({type:'marked',id:n(d.id),elements:marked({x:n(d.rect.x),y:n(d.rect.y),width:n(d.rect.width),height:n(d.rect.height)})});
126
+ });
74
127
  addEventListener('message',e=>{
75
128
  if(!framed||e.origin!==SHELL||e.source!==parent||e.data?.type!=='bivy:point')return;
76
- if(e.data.on)startPointing();else stop();
129
+ // Held: the layer goes when the finger lifts (see up).
130
+ if(e.data.on)startPointing();else if(!held)stop();
77
131
  });
78
132
  if(REVIEWER&&!framed){
79
133
  // A shadow root keeps the app's CSS off these controls (and ours off the app).
@@ -84,7 +138,8 @@ if(REVIEWER&&!framed){
84
138
  +'button{border:1px solid ButtonBorder;background:ButtonFace;color:ButtonText;border-radius:999px;padding:10px 14px;font-weight:600;cursor:pointer;min-height:44px}'
85
139
  +'.primary{background:CanvasText;color:Canvas;border-color:CanvasText}'
86
140
  +'form{width:min(340px,calc(100vw - 24px));background:Canvas;color:CanvasText;border:1px solid GrayText;border-radius:14px;padding:12px;box-shadow:0 8px 30px rgb(0 0 0/.25)}'
87
- +'textarea{width:100%;min-height:5em;margin:8px 0;border:1px solid GrayText;border-radius:8px;padding:8px;background:Canvas;color:CanvasText;resize:vertical}'
141
+ // 16px: smaller, and iOS zooms the page in when the note box is focused.
142
+ +'textarea{width:100%;min-height:5em;margin:8px 0;border:1px solid GrayText;border-radius:8px;padding:8px;background:Canvas;color:CanvasText;resize:vertical;font-size:16px}'
88
143
  +'.row{display:flex;justify-content:flex-end;gap:8px}.muted{color:GrayText;font-size:12px;overflow-wrap:anywhere}[hidden]{display:none!important}</style>'
89
144
  +'<div class="dock"><p class="muted" role="status" id="status" hidden></p>'
90
145
  +'<form id="form" hidden><label for="note">Your note</label><div class="muted" id="about"></div><textarea id="note" required maxlength="1000"></textarea>'
@@ -0,0 +1,170 @@
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 { createHash, randomBytes } from "node:crypto";
7
+ import { createInterface } from "node:readline";
8
+ import { fileURLToPath } from "node:url";
9
+ import { spawn, execFile, execFileSync } from "node:child_process";
10
+ /** The helper's source ships next to this module; it is compiled on this Mac on first use. */
11
+ const SOURCE = fileURLToPath(new URL("./macos-display.swift", import.meta.url));
12
+ const START_MS = 10_000;
13
+ const BUILD_MS = 5 * 60_000;
14
+ /** Darwin 22 is macOS 13, the first with everything the helper uses. */
15
+ const MIN_DARWIN = 22;
16
+ /** What each missing permission is called in System Settings. */
17
+ const PERMISSIONS = { screenRecording: "Screen Recording", accessibility: "Accessibility" };
18
+ /** Desktop views on macOS. There are no private displays on a Mac, so each
19
+ * view gets a helper that serves its app's own windows as a VNC display on a
20
+ * private socket (see macos-display.swift): the same contract as an Xvnc
21
+ * display, so the viewer, relay, screenshots and input are shared. */
22
+ export class MacDisplayHost {
23
+ options;
24
+ displays = new Map();
25
+ build;
26
+ checked;
27
+ prompted = false;
28
+ constructor(options) {
29
+ this.options = options;
30
+ }
31
+ compiled;
32
+ get source() { return this.options.source ?? SOURCE; }
33
+ binary() {
34
+ this.compiled ??= path.join(this.options.cacheDir, `macos-display-${createHash("sha256").update(fs.readFileSync(this.source)).digest("hex").slice(0, 16)}`);
35
+ return this.compiled;
36
+ }
37
+ /** Why desktop views can't run here, or undefined when they can. */
38
+ unavailable() {
39
+ if ((this.options.platform ?? process.platform) !== "darwin")
40
+ return "This display host runs on macOS only.";
41
+ if (Number((this.options.release ?? os.release()).split(".")[0]) < MIN_DARWIN)
42
+ return "Desktop app views need macOS 13 or later.";
43
+ const binary = this.binary();
44
+ if (fs.existsSync(binary))
45
+ return this.missing(binary);
46
+ if (!(this.options.swiftc ?? hasSwiftc)())
47
+ return "Desktop app views on macOS need Apple's command line tools to build their helper. Install them with: xcode-select --install";
48
+ void this.helper().catch(() => { }); // build ahead of the first open
49
+ return undefined;
50
+ }
51
+ /** Permissions the helper lacks, as a message; checked at most every few seconds. */
52
+ missing(binary) {
53
+ if (this.checked && Date.now() - this.checked.at < 5_000)
54
+ return this.checked.missing;
55
+ let missing;
56
+ try {
57
+ const granted = JSON.parse(execFileSync(binary, ["check"], { encoding: "utf8", timeout: 5_000 }));
58
+ const lacking = Object.keys(PERMISSIONS).filter((key) => !granted[key]).map((key) => PERMISSIONS[key]);
59
+ if (lacking.length) {
60
+ missing = `Desktop app views on macOS need ${lacking.join(" and ")} permission. In System Settings → Privacy & Security, allow ${lacking.join(" and ")} for the program that runs Bivy (your terminal app, or ${process.execPath} when Bivy runs in the background), then restart Bivy.`;
61
+ }
62
+ }
63
+ catch {
64
+ missing = "Couldn't run Bivy's macOS display helper.";
65
+ }
66
+ this.checked = { at: Date.now(), missing };
67
+ return missing;
68
+ }
69
+ /** The compiled helper: built once per version of its source, then reused. */
70
+ helper() {
71
+ const binary = this.binary();
72
+ if (fs.existsSync(binary))
73
+ return Promise.resolve(binary);
74
+ this.build ??= new Promise((resolve, reject) => {
75
+ fs.mkdirSync(this.options.cacheDir, { recursive: true, mode: 0o700 });
76
+ const temp = `${binary}.${process.pid}.tmp`;
77
+ execFile("xcrun", ["swiftc", "-O", "-swift-version", "5", this.source, "-o", temp], { timeout: BUILD_MS }, (error, _stdout, stderr) => {
78
+ if (error) {
79
+ fs.rmSync(temp, { force: true });
80
+ reject(new Error(`Couldn't build Bivy's macOS display helper: ${String(stderr).trim().split("\n").at(-1) ?? error.message}`));
81
+ return;
82
+ }
83
+ fs.renameSync(temp, binary);
84
+ for (const old of fs.readdirSync(this.options.cacheDir))
85
+ if (old.startsWith("macos-display-") && path.join(this.options.cacheDir, old) !== binary)
86
+ fs.rmSync(path.join(this.options.cacheDir, old), { force: true });
87
+ resolve(binary);
88
+ });
89
+ }).finally(() => { this.build = undefined; });
90
+ return this.build;
91
+ }
92
+ running(id) { return this.displays.get(id)?.ready; }
93
+ /** `scale` (1 or 2) is the pixel density frames are captured at. */
94
+ ensure(id, _name, scale = 1) {
95
+ const existing = this.displays.get(id);
96
+ if (existing)
97
+ return existing.ready;
98
+ const state = { dir: fs.mkdtempSync(path.join(os.tmpdir(), "bivy-display-")) };
99
+ state.ready = this.start(state, scale === 2 ? 2 : 1).then((display) => {
100
+ state.child.once("exit", () => { if (this.displays.get(id) === state)
101
+ this.stop(id); });
102
+ return display;
103
+ }, (error) => { this.stop(id); throw error; });
104
+ this.displays.set(id, state);
105
+ return state.ready;
106
+ }
107
+ async start(state, scale) {
108
+ const reason = this.unavailable();
109
+ if (reason)
110
+ throw new Error(reason);
111
+ const binary = await this.helper();
112
+ const missing = this.missing(binary);
113
+ if (missing) {
114
+ // Once per run, let macOS ask: it lists the program in System Settings.
115
+ if (!this.prompted) {
116
+ this.prompted = true;
117
+ execFile(binary, ["check", "--prompt"], () => { });
118
+ }
119
+ throw new Error(missing);
120
+ }
121
+ const socket = path.join(state.dir, "vnc.sock");
122
+ const control = path.join(state.dir, "control.sock");
123
+ const token = randomBytes(16).toString("hex");
124
+ const child = spawn(binary, ["serve", socket, control, token, String(scale)], { stdio: ["ignore", "pipe", "pipe"] });
125
+ state.child = child;
126
+ const wm = { count: 0 };
127
+ let errors = "";
128
+ child.stderr.on("data", (chunk) => { errors = (errors + chunk).slice(-2000); console.warn(`[apps] macOS display: ${String(chunk).trim()}`); });
129
+ const up = await new Promise((resolve) => {
130
+ const timer = setTimeout(() => { child.kill("SIGKILL"); resolve(false); }, START_MS);
131
+ child.once("exit", () => { clearTimeout(timer); resolve(false); });
132
+ child.once("error", () => { clearTimeout(timer); resolve(false); });
133
+ createInterface({ input: child.stdout }).on("line", (line) => {
134
+ if (line === "ready") {
135
+ clearTimeout(timer);
136
+ resolve(true);
137
+ }
138
+ const windows = /^windows (\d+)$/.exec(line);
139
+ if (windows)
140
+ wm.count = Number(windows[1]);
141
+ });
142
+ });
143
+ if (!up)
144
+ throw new Error(`Couldn't start a display for this app.${errors ? ` ${errors.trim().split("\n").at(-1)}` : ""}`);
145
+ return { socket, control, wm, scale, env: { BIVY_MAC_DISPLAY: token }, launch: [binary, "run", "--"] };
146
+ }
147
+ stop(id) {
148
+ const state = this.displays.get(id);
149
+ if (!state)
150
+ return;
151
+ this.displays.delete(id);
152
+ state.child?.kill("SIGTERM");
153
+ fs.rmSync(state.dir, { recursive: true, force: true });
154
+ }
155
+ stopAll() { for (const id of [...this.displays.keys()])
156
+ this.stop(id); }
157
+ }
158
+ let swiftc;
159
+ function hasSwiftc() {
160
+ if (swiftc === undefined) {
161
+ try {
162
+ execFileSync("xcrun", ["--find", "swiftc"], { stdio: "ignore", timeout: 10_000 });
163
+ swiftc = true;
164
+ }
165
+ catch {
166
+ swiftc = false;
167
+ }
168
+ }
169
+ return swiftc;
170
+ }