@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.
- package/bin/bivy.mjs +157 -12
- package/dist/apps/annotate.js +94 -0
- package/dist/apps/display-viewer.js +80 -7
- package/dist/apps/display.js +11 -1
- package/dist/apps/gateway.js +106 -14
- package/dist/apps/input.js +96 -0
- package/dist/apps/inspector.js +84 -29
- package/dist/apps/macos-display.js +170 -0
- package/dist/apps/macos-display.swift +873 -0
- package/dist/apps/menu.js +76 -0
- package/dist/apps/preview-shell.js +232 -16
- package/dist/apps/preview-stream.js +17 -3
- package/dist/apps/registry.js +76 -5
- package/dist/apps/review.js +111 -0
- package/dist/apps/rfb.js +69 -20
- package/dist/apps/screenshot.js +8 -2
- package/dist/apps/service.js +343 -13
- package/dist/apps/styles.css +162 -10
- package/dist/apps/tokens.css +6 -0
- package/dist/apps/types.js +17 -0
- package/dist/attach.js +37 -4
- package/dist/controllers/app-commands.js +16 -4
- package/dist/controllers/artifact-commands.js +42 -0
- package/dist/controllers/presence-commands.js +19 -0
- package/dist/protocol/client-command-routes.js +3 -1
- package/dist/protocol/client-command-schemas.js +19 -3
- package/dist/server.js +200 -21
- package/dist/session/attachment-store.js +15 -0
- package/dist/session/event-log.js +25 -3
- package/dist/session/presence.js +82 -0
- package/dist/session/run-terminal.js +113 -69
- package/dist/session/session-new-dedupe.js +1 -1
- package/dist/terminal.js +23 -0
- package/dist/worktree.js +40 -3
- package/package.json +1 -1
package/dist/apps/gateway.js
CHANGED
|
@@ -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
|
|
129
|
-
result["cache-control"] = "
|
|
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
|
|
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
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
+
}
|
package/dist/apps/inspector.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
const
|
|
40
|
-
const
|
|
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=
|
|
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
|
|
56
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|