@bivy/bivy 0.17.2 → 0.17.3-staging.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/bivy.mjs +57 -10
- package/dist/agents/claude-code/runtime.js +17 -0
- package/dist/apps/display-viewer.js +81 -0
- package/dist/apps/display.js +135 -0
- package/dist/apps/gateway.js +388 -25
- package/dist/apps/inspector.js +115 -0
- package/dist/apps/listeners.js +115 -0
- package/dist/apps/preview-shell.js +206 -17
- package/dist/apps/registry.js +124 -20
- package/dist/apps/remote-preview.js +10 -2
- package/dist/apps/rfb.js +126 -0
- package/dist/apps/screenshot.js +194 -0
- package/dist/apps/service.js +243 -5
- package/dist/apps/styles.css +5 -0
- package/dist/apps/x11.js +273 -0
- package/dist/attach.js +30 -1
- package/dist/config-cli.js +2 -0
- package/dist/controllers/app-commands.js +19 -6
- package/dist/node-config.js +6 -3
- package/dist/policy/session-limit.js +63 -0
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +7 -1
- package/dist/server.js +99 -7
- package/dist/session/run-terminal.js +114 -70
- package/package.json +2 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/** Served on the app's own origin and injected into its HTML. It reports only
|
|
4
|
+
* to the trusted shell that frames it, and everything it reports is untrusted
|
|
5
|
+
* app data: the shell shows it and turns it into drafts, never into actions.
|
|
6
|
+
* Agent-neutral: it observes the browser, not the framework or the agent. */
|
|
7
|
+
export function inspectorScript(shellOrigin, reviewer = false) {
|
|
8
|
+
return `(()=>{
|
|
9
|
+
// Framed by the shell: report to it. Opened from a shared link: offer notes.
|
|
10
|
+
const framed=parent!==window,REVIEWER=${reviewer};
|
|
11
|
+
if(window.__bivyInspector||(!framed&&!REVIEWER))return;window.__bivyInspector=1;
|
|
12
|
+
const SHELL=${JSON.stringify(shellOrigin)};
|
|
13
|
+
const post=m=>{if(!framed)return;try{parent.postMessage(Object.assign({source:'bivy-inspector'},m),SHELL);}catch{}};
|
|
14
|
+
const fmt=v=>{if(v instanceof Error)return v.name+': '+v.message;if(typeof v==='string')return v;try{return JSON.stringify(v);}catch{return String(v);}};
|
|
15
|
+
const log=(level,text)=>post({type:'console',level,text:String(text).slice(0,500),at:Date.now()});
|
|
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
|
+
addEventListener('error',e=>{if(e.message)log('error',e.message+(e.filename?' ('+e.filename.split('/').pop()+':'+e.lineno+')':''));},true);
|
|
18
|
+
addEventListener('unhandledrejection',e=>log('error','Unhandled rejection: '+fmt(e.reason)));
|
|
19
|
+
const route=()=>post({type:'route',path:location.pathname+location.search+location.hash});
|
|
20
|
+
for(const name of ['pushState','replaceState']){const original=history[name];history[name]=function(...args){const result=original.apply(this,args);route();return result;};}
|
|
21
|
+
addEventListener('popstate',route);addEventListener('hashchange',route);route();
|
|
22
|
+
const selector=el=>{
|
|
23
|
+
const parts=[];
|
|
24
|
+
for(let node=el;node&&node.nodeType===1&&node!==document.body&&node!==document.documentElement&&parts.length<4;node=node.parentElement){
|
|
25
|
+
if(node.id){parts.unshift('#'+CSS.escape(node.id));break;}
|
|
26
|
+
let part=node.localName;
|
|
27
|
+
const classes=[...node.classList].filter(c=>!/\\d{3,}|^css-|^_/.test(c)).slice(0,2);
|
|
28
|
+
if(classes.length)part+='.'+classes.map(c=>CSS.escape(c)).join('.');
|
|
29
|
+
const same=node.parentElement?[...node.parentElement.children].filter(c=>c.localName===node.localName):[];
|
|
30
|
+
if(same.length>1)part+=':nth-of-type('+(same.indexOf(node)+1)+')';
|
|
31
|
+
parts.unshift(part);
|
|
32
|
+
}
|
|
33
|
+
return parts.join(' > ')||el.localName;
|
|
34
|
+
};
|
|
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);};
|
|
50
|
+
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();
|
|
53
|
+
Object.assign(box.style,{left:r.left+'px',top:r.top+'px',width:r.width+'px',height:r.height+'px'});
|
|
54
|
+
};
|
|
55
|
+
const pick=e=>{
|
|
56
|
+
swallow(e);
|
|
57
|
+
// The event's own target, not a coordinate lookup: iOS gets elementFromPoint
|
|
58
|
+
// wrong in a frame of a zoomed page. Found nothing? Keep pointing.
|
|
59
|
+
const el=e.target instanceof Element?e.target:document.elementFromPoint(e.clientX,e.clientY)||target;
|
|
60
|
+
if(!el)return;stop();
|
|
61
|
+
// Keep eating the click (and iOS's delayed mouse events) this tap still fires.
|
|
62
|
+
listen(true,aftermath);setTimeout(()=>listen(false,aftermath),500);
|
|
63
|
+
if(el===host){onPicked({type:'picked',cancelled:true});return;}const r=el.getBoundingClientRect();
|
|
64
|
+
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),
|
|
65
|
+
rect:{x:Math.round(r.left),y:Math.round(r.top),width:Math.round(r.width),height:Math.round(r.height)},
|
|
66
|
+
viewport:{width:innerWidth,height:innerHeight},path:location.pathname+location.search+location.hash});
|
|
67
|
+
};
|
|
68
|
+
const key=e=>{if(e.key==='Escape'){stop();onPicked({type:'picked',cancelled:true});}};
|
|
69
|
+
function startPointing(){
|
|
70
|
+
stop();
|
|
71
|
+
box=document.createElement('div');
|
|
72
|
+
box.setAttribute('aria-hidden','true');
|
|
73
|
+
Object.assign(box.style,{position:'fixed',zIndex:2147483647,pointerEvents:'none',outline:'2px solid Highlight',outlineOffset:'2px',borderRadius:'4px',transition:'all 60ms ease-out'});
|
|
74
|
+
document.documentElement.append(box);
|
|
75
|
+
listen(true);
|
|
76
|
+
}
|
|
77
|
+
addEventListener('message',e=>{
|
|
78
|
+
if(!framed||e.origin!==SHELL||e.source!==parent||e.data?.type!=='bivy:point')return;
|
|
79
|
+
if(e.data.on)startPointing();else stop();
|
|
80
|
+
});
|
|
81
|
+
if(REVIEWER&&!framed){
|
|
82
|
+
// A shadow root keeps the app's CSS off these controls (and ours off the app).
|
|
83
|
+
host=document.createElement('bivy-review');
|
|
84
|
+
const root=host.attachShadow({mode:'open'});
|
|
85
|
+
root.innerHTML='<style>:host{all:initial}*{box-sizing:border-box;font:14px/1.4 system-ui,sans-serif}'
|
|
86
|
+
+'.dock{position:fixed;right:12px;bottom:calc(12px + env(safe-area-inset-bottom));z-index:2147483647;display:flex;flex-direction:column;align-items:flex-end;gap:8px;max-width:calc(100vw - 24px)}'
|
|
87
|
+
+'button{border:1px solid ButtonBorder;background:ButtonFace;color:ButtonText;border-radius:999px;padding:10px 14px;font-weight:600;cursor:pointer;min-height:44px}'
|
|
88
|
+
+'.primary{background:CanvasText;color:Canvas;border-color:CanvasText}'
|
|
89
|
+
+'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)}'
|
|
90
|
+
+'textarea{width:100%;min-height:5em;margin:8px 0;border:1px solid GrayText;border-radius:8px;padding:8px;background:Canvas;color:CanvasText;resize:vertical}'
|
|
91
|
+
+'.row{display:flex;justify-content:flex-end;gap:8px}.muted{color:GrayText;font-size:12px;overflow-wrap:anywhere}[hidden]{display:none!important}</style>'
|
|
92
|
+
+'<div class="dock"><p class="muted" role="status" id="status" hidden></p>'
|
|
93
|
+
+'<form id="form" hidden><label for="note">Your note</label><div class="muted" id="about"></div><textarea id="note" required maxlength="1000"></textarea>'
|
|
94
|
+
+'<div class="row"><button type="button" id="cancel">Cancel</button><button class="primary" id="send">Send note</button></div></form>'
|
|
95
|
+
+'<button id="leave">Leave a note</button></div>';
|
|
96
|
+
const $=id=>root.getElementById(id);let picked=null;
|
|
97
|
+
const say=text=>{$('status').hidden=!text;$('status').textContent=text;};
|
|
98
|
+
$('leave').onclick=()=>{say('Tap the part of the page your note is about. Escape cancels.');$('leave').hidden=true;startPointing();};
|
|
99
|
+
onPicked=d=>{
|
|
100
|
+
$('leave').hidden=false;say('');
|
|
101
|
+
if(d.cancelled)return;picked=d;
|
|
102
|
+
$('about').textContent='About: '+(d.text?'“'+d.text.slice(0,80)+'” ':'')+'('+d.selector+')';
|
|
103
|
+
$('form').hidden=false;$('leave').hidden=true;$('note').focus();
|
|
104
|
+
};
|
|
105
|
+
$('cancel').onclick=()=>{$('form').hidden=true;$('leave').hidden=false;};
|
|
106
|
+
$('form').onsubmit=async e=>{
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
const r=await fetch('/__bivy/notes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(Object.assign({},picked,{note:$('note').value}))}).catch(()=>null);
|
|
109
|
+
if(r&&r.ok){$('note').value='';$('form').hidden=true;$('leave').hidden=false;say('Sent. Thanks — the app’s owner will see it.');setTimeout(()=>say(''),4000);}
|
|
110
|
+
else say('Couldn’t send the note. Your link may have expired.');
|
|
111
|
+
};
|
|
112
|
+
(document.body||document.documentElement).append(host);
|
|
113
|
+
}
|
|
114
|
+
})();`;
|
|
115
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
const execFileP = promisify(execFile);
|
|
8
|
+
/** Loopback or wildcard TCP listeners whose process runs inside a workspace.
|
|
9
|
+
* Agent-neutral by design: every framework and agent starts servers the same
|
|
10
|
+
* way, so the process table is the one hook that works for all of them. A
|
|
11
|
+
* listener is only an offer — access still needs a user's tap and a grant. */
|
|
12
|
+
export async function scanListeners(workspace, platform = process.platform) {
|
|
13
|
+
let root;
|
|
14
|
+
try {
|
|
15
|
+
root = await fs.realpath(workspace);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const found = platform === "linux" ? await scanProc(root) : platform === "darwin" ? await scanLsof(root) : [];
|
|
21
|
+
const byPort = new Map();
|
|
22
|
+
for (const offer of found)
|
|
23
|
+
if (offer.pid !== process.pid && offer.port >= 1024 && !byPort.has(offer.port))
|
|
24
|
+
byPort.set(offer.port, offer);
|
|
25
|
+
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
26
|
+
}
|
|
27
|
+
const inside = (root, dir) => dir === root || dir.startsWith(root + path.sep);
|
|
28
|
+
/** `local_address` in /proc/net/tcp{,6}: hex address in little-endian 32-bit words. */
|
|
29
|
+
export function listenPort(line) {
|
|
30
|
+
const cols = line.trim().split(/\s+/);
|
|
31
|
+
if (cols.length < 10 || cols[3] !== "0A")
|
|
32
|
+
return undefined; // 0A = LISTEN
|
|
33
|
+
const [address, portHex] = cols[1].split(":");
|
|
34
|
+
const words = address.match(/.{8}/g) ?? [];
|
|
35
|
+
const last = words[words.length - 1] ?? "";
|
|
36
|
+
const zero = /^0+$/.test(address);
|
|
37
|
+
const v4Loopback = last.endsWith("7F") && (words.length === 1 || words.slice(0, 3).join("") === "0000000000000000FFFF0000");
|
|
38
|
+
const v6Loopback = address === "00000000000000000000000001000000";
|
|
39
|
+
if (!zero && !v4Loopback && !v6Loopback)
|
|
40
|
+
return undefined;
|
|
41
|
+
return { inode: cols[9], port: parseInt(portHex, 16) };
|
|
42
|
+
}
|
|
43
|
+
async function scanProc(root) {
|
|
44
|
+
const sockets = new Map();
|
|
45
|
+
for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
46
|
+
const text = await fs.readFile(file, "utf8").catch(() => "");
|
|
47
|
+
for (const line of text.split("\n").slice(1)) {
|
|
48
|
+
const hit = listenPort(line);
|
|
49
|
+
if (hit && hit.inode !== "0")
|
|
50
|
+
sockets.set(hit.inode, hit.port);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!sockets.size)
|
|
54
|
+
return [];
|
|
55
|
+
const offers = [];
|
|
56
|
+
const pids = (await fs.readdir("/proc").catch(() => [])).filter((name) => /^\d+$/.test(name));
|
|
57
|
+
// cwd first: other users' processes fail here, and only workspace processes
|
|
58
|
+
// pay for an fd walk.
|
|
59
|
+
await Promise.all(pids.map(async (pid) => {
|
|
60
|
+
const cwd = await fs.readlink(`/proc/${pid}/cwd`).catch(() => "");
|
|
61
|
+
if (!cwd || !inside(root, cwd))
|
|
62
|
+
return;
|
|
63
|
+
const fds = await fs.readdir(`/proc/${pid}/fd`).catch(() => []);
|
|
64
|
+
const ports = new Set();
|
|
65
|
+
for (const fd of fds) {
|
|
66
|
+
const target = await fs.readlink(`/proc/${pid}/fd/${fd}`).catch(() => "");
|
|
67
|
+
const inode = /^socket:\[(\d+)\]$/.exec(target)?.[1];
|
|
68
|
+
const port = inode ? sockets.get(inode) : undefined;
|
|
69
|
+
if (port)
|
|
70
|
+
ports.add(port);
|
|
71
|
+
}
|
|
72
|
+
if (!ports.size)
|
|
73
|
+
return;
|
|
74
|
+
const argv = (await fs.readFile(`/proc/${pid}/cmdline`, "utf8").catch(() => "")).split("\0").filter(Boolean);
|
|
75
|
+
for (const port of ports)
|
|
76
|
+
offers.push({ port, pid: Number(pid), command: commandLabel(argv) });
|
|
77
|
+
}));
|
|
78
|
+
return offers;
|
|
79
|
+
}
|
|
80
|
+
/** `lsof -F` field output: p<pid>, c<command>, n<name> records. */
|
|
81
|
+
export function parseLsof(stdout) {
|
|
82
|
+
const records = [];
|
|
83
|
+
for (const line of stdout.split("\n")) {
|
|
84
|
+
if (line.startsWith("p"))
|
|
85
|
+
records.push({ pid: Number(line.slice(1)), values: [] });
|
|
86
|
+
else if (line.startsWith("n") && records.length)
|
|
87
|
+
records[records.length - 1].values.push(line.slice(1));
|
|
88
|
+
}
|
|
89
|
+
return records;
|
|
90
|
+
}
|
|
91
|
+
async function scanLsof(root) {
|
|
92
|
+
const run = (args) => execFileP("lsof", args, { timeout: 4000, maxBuffer: 4 * 1024 * 1024 }).then((r) => r.stdout, (e) => e.stdout ?? "");
|
|
93
|
+
const listening = parseLsof(await run(["-nP", "-iTCP", "-sTCP:LISTEN", "-Fpn"]));
|
|
94
|
+
if (!listening.length)
|
|
95
|
+
return [];
|
|
96
|
+
const cwds = new Map(parseLsof(await run(["-a", "-d", "cwd", "-Fpn", "-p", listening.map((r) => r.pid).join(",")])).map((r) => [r.pid, r.values[0] ?? ""]));
|
|
97
|
+
const offers = [];
|
|
98
|
+
for (const { pid, values } of listening) {
|
|
99
|
+
const cwd = await fs.realpath(cwds.get(pid) ?? "").catch(() => "");
|
|
100
|
+
if (!cwd || !inside(root, cwd))
|
|
101
|
+
continue;
|
|
102
|
+
const args = (await execFileP("ps", ["-p", String(pid), "-o", "args="], { timeout: 4000 }).then((r) => r.stdout, () => "")).trim();
|
|
103
|
+
for (const name of values) {
|
|
104
|
+
const match = /^(\*|127\.[\d.]+|\[::1\]|\[::\]|localhost):(\d+)$/.exec(name);
|
|
105
|
+
if (match)
|
|
106
|
+
offers.push({ port: Number(match[2]), pid, command: commandLabel(args.split(/\s+/)) });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return offers;
|
|
110
|
+
}
|
|
111
|
+
/** "node /x/node_modules/.bin/vite --port 5173" → "node vite --port 5173". */
|
|
112
|
+
function commandLabel(argv) {
|
|
113
|
+
const label = argv.slice(0, 4).map((arg) => arg.startsWith("/") || arg.startsWith(".") ? path.basename(arg) : arg).join(" ");
|
|
114
|
+
return label.length > 60 ? `${label.slice(0, 59)}…` : label || "server";
|
|
115
|
+
}
|
|
@@ -1,45 +1,234 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
3
|
/** This document lives on a different origin from app code. Never put a
|
|
4
|
-
* generated document on the shell host or let the frame navigate its parent.
|
|
4
|
+
* generated document on the shell host or let the frame navigate its parent.
|
|
5
|
+
* Everything the app's inspector reports is untrusted: it is displayed and
|
|
6
|
+
* turned into drafts the user reviews, never sent or acted on by itself. */
|
|
5
7
|
export function previewShell(nonce) {
|
|
6
|
-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Bivy · App preview</title>
|
|
8
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><title>Bivy · App preview</title>
|
|
7
9
|
<link rel="stylesheet" href="/__bivy/tokens.css"><link rel="stylesheet" href="/__bivy/styles.css">
|
|
8
10
|
<style nonce="${nonce}">
|
|
9
11
|
html,body { margin:0; width:100%; height:100%; }
|
|
10
|
-
body { display:flex; flex-direction:column; background:var(--
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
12
|
+
body { display:flex; flex-direction:column; background:var(--surface-2); color:var(--ink); font-family:var(--font-sans); }
|
|
13
|
+
#stage { flex:1; min-height:0; display:flex; justify-content:center; }
|
|
14
|
+
iframe { width:100%; height:100%; border:0; background:var(--bg); }
|
|
15
|
+
#stage[data-lens="tablet"] iframe { max-width:768px; }
|
|
16
|
+
#stage[data-lens="phone"] iframe { max-width:390px; }
|
|
17
|
+
#stage:not([data-lens="full"]) iframe { border-inline:thin solid var(--line); box-shadow:var(--shadow-sm); }
|
|
14
18
|
#status { padding:var(--space-4); margin:0; font-size:var(--text-sm); }
|
|
15
|
-
|
|
19
|
+
#down { flex-shrink:0; }
|
|
20
|
+
/* The pill floats above the app instead of taking a header row from it. */
|
|
21
|
+
#dock { position:fixed; left:var(--space-2); right:var(--space-2); bottom:calc(var(--space-2) + env(safe-area-inset-bottom)); z-index:var(--z-sticky); display:flex; flex-direction:column; align-items:center; gap:var(--space-2); pointer-events:none; }
|
|
22
|
+
#dock > * { pointer-events:auto; }
|
|
23
|
+
/* The hint sits over the app; a tap on it is meant for the app underneath. */
|
|
24
|
+
#dock > #pointing { pointer-events:none; }
|
|
25
|
+
nav { display:flex; align-items:center; gap:var(--space-1); max-width:100%; padding:var(--space-1); background:var(--surface); border:thin solid var(--line); border-radius:var(--radius-full); box-shadow:var(--shadow-lg); }
|
|
26
|
+
nav .btn { flex-shrink:0; border-radius:var(--radius-full); }
|
|
27
|
+
nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accent); }
|
|
28
|
+
#name { display:flex; flex-direction:column; min-width:0; padding:0 var(--space-2); }
|
|
29
|
+
#title { font-size:var(--text-sm); font-weight:var(--weight-semibold); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:14em; }
|
|
30
|
+
#stamp { font-size:var(--text-xs); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
31
|
+
#stamp:empty { display:none; }
|
|
32
|
+
#lens { flex-shrink:0; }
|
|
33
|
+
.panel { width:min(560px, 100%); max-height:50vh; overflow:auto; background:var(--surface); border:thin solid var(--line); border-radius:var(--radius-lg); box-shadow:var(--shadow-lg); padding:var(--space-3); box-sizing:border-box; }
|
|
34
|
+
.panel h2 { font-size:var(--text-sm); margin:0 0 var(--space-2); display:flex; align-items:center; justify-content:space-between; gap:var(--space-2); }
|
|
35
|
+
.panel-actions { display:flex; justify-content:flex-end; gap:var(--space-2); margin-top:var(--space-2); flex-wrap:wrap; }
|
|
36
|
+
#compare-stage { display:grid; justify-content:center; background:var(--surface-2); border-radius:var(--radius-md); overflow:hidden; }
|
|
37
|
+
/* Both shots share one grid cell, so they overlay exactly at any size. */
|
|
38
|
+
#compare-stage img { grid-area:1 / 1; display:block; max-height:32vh; max-width:100%; }
|
|
39
|
+
/* Same column as the shots, so the handle sits on the split. */
|
|
40
|
+
#compare-slider { grid-area:2 / 1; width:100%; margin:var(--space-2) 0; accent-color:var(--accent); }
|
|
41
|
+
#compare-hint { font-size:var(--text-xs); margin:var(--space-1) 0 0; }
|
|
42
|
+
#entries { list-style:none; margin:0; padding:0; font-family:var(--font-mono); font-size:var(--text-xs); }
|
|
43
|
+
#entries li { display:flex; gap:var(--space-2); align-items:baseline; padding:var(--space-1) 0; border-top:thin solid var(--line); overflow-wrap:anywhere; }
|
|
44
|
+
/* Keeps .field's 16px: smaller, and iOS zooms the shell on focus and stays zoomed. */
|
|
45
|
+
#draft-text { width:100%; min-height:5em; box-sizing:border-box; resize:vertical; }
|
|
46
|
+
#draft-hint { font-size:var(--text-xs); margin:var(--space-2) 0 var(--space-1); }
|
|
47
|
+
#draft-context { margin:0; padding:var(--space-2); background:var(--surface-2); border-radius:var(--radius-md); font-family:var(--font-mono); font-size:var(--text-xs); white-space:pre-wrap; overflow-wrap:anywhere; color:var(--muted); }
|
|
48
|
+
#show { position:fixed; right:var(--space-3); bottom:calc(var(--space-3) + env(safe-area-inset-bottom)); z-index:var(--z-sticky); border-radius:var(--radius-full); box-shadow:var(--shadow-lg); }
|
|
49
|
+
.label-narrow { display:none; }
|
|
50
|
+
@media (max-width: 699px) { #lens { display:none; } #name { display:none; } .label-wide { display:none; } .label-narrow { display:inline; } }
|
|
16
51
|
[hidden] { display:none !important; }
|
|
17
52
|
</style></head><body>
|
|
18
|
-
<
|
|
53
|
+
<div class="banner" data-tone="warn" id="down" hidden><span class="banner-text" id="down-text" role="status"></span><span class="banner-actions"><button class="btn sm" id="ask" hidden>Ask agent to fix</button></span></div>
|
|
19
54
|
<p id="status" role="status">Opening app…</p>
|
|
20
|
-
<iframe id="app" title="App preview" hidden sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups" referrerpolicy="no-referrer"></iframe>
|
|
55
|
+
<div id="stage" data-lens="full"><iframe id="app" title="App preview" hidden sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups" referrerpolicy="no-referrer"></iframe></div>
|
|
56
|
+
<div id="dock">
|
|
57
|
+
<section class="panel" id="console" hidden aria-labelledby="console-title">
|
|
58
|
+
<h2><span id="console-title">Console</span><button class="btn sm ghost" id="clear">Clear</button></h2>
|
|
59
|
+
<p class="muted" id="console-empty">No errors or warnings since this page loaded.</p>
|
|
60
|
+
<ul id="entries"></ul>
|
|
61
|
+
<div class="panel-actions"><button class="btn sm" id="send-errors" disabled>Send to agent…</button></div>
|
|
62
|
+
</section>
|
|
63
|
+
<section class="panel" id="compare" hidden aria-labelledby="compare-title">
|
|
64
|
+
<h2 id="compare-title">Before and after the agent’s last change</h2>
|
|
65
|
+
<div id="compare-stage"><img id="compare-after" alt="After the agent’s last change"><img id="compare-before" alt="Before the agent’s last change"><input type="range" id="compare-slider" min="0" max="100" value="50" aria-label="Show more of before or after"></div>
|
|
66
|
+
<p class="muted" id="compare-hint">Left of the handle: before. Right: now. Screenshots at phone width.</p>
|
|
67
|
+
</section>
|
|
68
|
+
<section class="panel" id="draft" hidden aria-labelledby="draft-title">
|
|
69
|
+
<h2 id="draft-title">Draft for the agent</h2>
|
|
70
|
+
<textarea class="field" id="draft-text" aria-label="What should change?" placeholder="What should change?"></textarea>
|
|
71
|
+
<p class="muted" id="draft-hint">Sent with this context from the preview. Nothing is sent until you send it in the chat.</p>
|
|
72
|
+
<pre id="draft-context"></pre>
|
|
73
|
+
<div class="panel-actions"><button class="btn sm ghost" id="draft-cancel">Cancel</button><button class="btn sm primary" id="draft-add">Add to chat</button></div>
|
|
74
|
+
</section>
|
|
75
|
+
<p class="banner inline" data-tone="accent" id="pointing" role="status" hidden>Tap anything in the app to point at it. Press Escape or Point again to stop.</p>
|
|
76
|
+
<nav aria-label="Bivy preview controls">
|
|
77
|
+
<button class="btn sm ghost" id="back" aria-label="Back to chat">‹ <span class="label-wide">Back to chat</span><span class="label-narrow">Chat</span></button>
|
|
78
|
+
<span id="name"><span id="title">App preview</span><span id="stamp" class="muted" role="status"></span></span>
|
|
79
|
+
<button class="btn sm ghost" id="hide" aria-label="Hide Bivy controls" title="Hide controls">⌄</button>
|
|
80
|
+
<button class="btn sm ghost" id="point" aria-pressed="false" disabled>Point</button>
|
|
81
|
+
<button class="btn sm ghost" id="compare-btn" aria-pressed="false" hidden>Compare</button>
|
|
82
|
+
<button class="btn sm ghost" id="errors" aria-pressed="false" aria-label="Console" disabled>Console <span class="badge" data-variant="solid" data-tone="danger" id="error-count" hidden></span></button>
|
|
83
|
+
<div class="segmented" id="lens" role="radiogroup" aria-label="Preview width"><button class="seg-btn" role="radio" aria-selected="true" data-lens="full">Full</button><button class="seg-btn" role="radio" aria-selected="false" data-lens="tablet">Tablet</button><button class="seg-btn" role="radio" aria-selected="false" data-lens="phone">Phone</button></div>
|
|
84
|
+
<button class="btn sm ghost" id="reload" disabled>Reload</button>
|
|
85
|
+
</nav>
|
|
86
|
+
</div>
|
|
87
|
+
<button class="btn sm" id="show" hidden aria-label="Show Bivy controls">Bivy</button>
|
|
21
88
|
<script nonce="${nonce}">
|
|
22
|
-
const
|
|
89
|
+
const $=id=>document.getElementById(id);
|
|
90
|
+
const frame=$('app'),status=$('status'),back=$('back'),reload=$('reload'),stage=$('stage');
|
|
23
91
|
const ticket=location.hash.slice(1);history.replaceState(null,'',location.pathname);
|
|
24
|
-
let metadata;
|
|
92
|
+
let metadata,currentPath='/';
|
|
93
|
+
// Peek: framed by a Bivy client, which owns closing and the composer.
|
|
94
|
+
const embedded=parent!==window;
|
|
95
|
+
const toBivy=m=>parent.postMessage(Object.assign({source:'bivy-preview'},m),new URL(metadata.returnTo).origin);
|
|
25
96
|
const storageKey='bivy-preview';
|
|
97
|
+
const safePath=p=>typeof p==='string'&&p.startsWith('/')&&!p.startsWith('//')&&p.length<=2048?p:'/';
|
|
98
|
+
const open=(path,message)=>{status.hidden=false;status.textContent=message;resetConsole();frame.src=metadata.origin+safePath(path);};
|
|
26
99
|
function show(data,launch){
|
|
27
100
|
metadata=data;
|
|
28
|
-
|
|
29
|
-
|
|
101
|
+
$('title').textContent=data.name;
|
|
102
|
+
$('title').title=data.name;
|
|
30
103
|
document.title='Bivy · '+data.name;
|
|
31
104
|
back.title=data.returnTo?'Return to '+new URL(data.returnTo).host:'Close preview';
|
|
32
105
|
frame.title=data.name;
|
|
33
106
|
frame.hidden=false;
|
|
34
|
-
|
|
35
|
-
|
|
107
|
+
// Desktop apps have no page to inspect: no Point or Console. Their viewer
|
|
108
|
+
// (Bivy's own code) may use the clipboard; generated web apps may not.
|
|
109
|
+
// (Set before navigating: the policy is fixed when the frame loads.)
|
|
110
|
+
for(const b of [$('point'),$('errors')])b.hidden=data.inspect===false;
|
|
111
|
+
if(data.inspect===false)frame.allow='clipboard-read; clipboard-write';
|
|
112
|
+
frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch:'/');
|
|
113
|
+
back.hidden=embedded&&Boolean(data.returnTo);
|
|
114
|
+
for(const b of [reload,$('point'),$('errors')])b.disabled=false;
|
|
36
115
|
status.textContent='Loading app…';
|
|
37
|
-
frame.onload=()=>{status.hidden=true;};
|
|
116
|
+
frame.onload=()=>{status.hidden=true;void loadCompare();if(revision===null){revision=-1;watch();}else if(wake)wake();};
|
|
38
117
|
// Store navigation metadata only, never tickets or cookies.
|
|
39
118
|
try{sessionStorage.setItem(storageKey,JSON.stringify(data));}catch{}
|
|
40
119
|
}
|
|
120
|
+
// Reload when an agent turn changes files, returning to the page in view.
|
|
121
|
+
// Until the frame has redeemed its access cookie, polls fail; back off, and
|
|
122
|
+
// let the next frame load retry at once.
|
|
123
|
+
let revision=null,retry=0,wake=null;
|
|
124
|
+
async function watch(){
|
|
125
|
+
wake=null;
|
|
126
|
+
try{
|
|
127
|
+
const r=await fetch(metadata.origin+'/__bivy/revision?after='+revision,{credentials:'include',cache:'no-store'});
|
|
128
|
+
if(!r.ok)throw Error();
|
|
129
|
+
const d=await r.json();
|
|
130
|
+
if(revision>=0&&d.revision!==revision){
|
|
131
|
+
open(currentPath!=='/'?currentPath:d.path,'Updating…');
|
|
132
|
+
$('stamp').textContent='Updated after the agent’s turn';
|
|
133
|
+
// Screenshots for Compare land a few seconds after the change.
|
|
134
|
+
for(const ms of [5000,15000])setTimeout(()=>void loadCompare(),ms);
|
|
135
|
+
}
|
|
136
|
+
revision=d.revision;retry=0;setTimeout(watch,0);
|
|
137
|
+
}catch{retry=Math.min(30000,(retry||1000)*2);const t=setTimeout(watch,retry);wake=()=>{clearTimeout(t);watch();};}
|
|
138
|
+
}
|
|
139
|
+
// Drafts go to the session's composer; nothing is ever sent from here.
|
|
140
|
+
function toChat(text){
|
|
141
|
+
if(embedded)return toBivy({type:'draft',text});
|
|
142
|
+
const to=new URL(metadata.returnTo),session=to.pathname.split('/').pop();
|
|
143
|
+
location.assign(to.origin+'/share?session='+encodeURIComponent(session)+'&text='+encodeURIComponent(text));
|
|
144
|
+
}
|
|
145
|
+
function draft(context){
|
|
146
|
+
panels(null);
|
|
147
|
+
$('draft').hidden=false;
|
|
148
|
+
$('draft-add').textContent=metadata.returnTo?'Add to chat':'Copy';
|
|
149
|
+
$('draft-context').textContent=context;
|
|
150
|
+
const box=$('draft-text');box.value='';box.focus();
|
|
151
|
+
}
|
|
152
|
+
$('draft-cancel').onclick=()=>{$('draft').hidden=true;};
|
|
153
|
+
$('draft-add').onclick=async()=>{
|
|
154
|
+
const note=$('draft-text').value.trim(),text=(note?note+'\\n\\n':'')+$('draft-context').textContent;
|
|
155
|
+
if(metadata.returnTo)return toChat(text);
|
|
156
|
+
try{await navigator.clipboard.writeText(text);$('draft-add').textContent='Copied';}catch{$('draft-text').select();}
|
|
157
|
+
};
|
|
158
|
+
// Console
|
|
159
|
+
let entries=[];
|
|
160
|
+
function resetConsole(){entries=[];renderConsole();}
|
|
161
|
+
function renderConsole(){
|
|
162
|
+
const errors=entries.filter(e=>e.level==='error').length,count=$('error-count');
|
|
163
|
+
count.hidden=!errors;count.textContent=String(errors);
|
|
164
|
+
$('errors').setAttribute('aria-label',errors?'Console, '+errors+' error'+(errors===1?'':'s'):'Console');
|
|
165
|
+
$('console-empty').hidden=entries.length>0;$('send-errors').disabled=!entries.length;
|
|
166
|
+
$('entries').replaceChildren(...entries.map(e=>{const li=document.createElement('li');const tag=document.createElement('span');tag.className='badge';tag.dataset.tone=e.level==='error'?'danger':'warn';tag.textContent=e.level;const text=document.createElement('span');text.textContent=e.text;li.append(tag,text);return li;}));
|
|
167
|
+
}
|
|
168
|
+
$('errors').onclick=()=>panels($('console').hidden?'console':null);
|
|
169
|
+
$('clear').onclick=resetConsole;
|
|
170
|
+
$('send-errors').onclick=()=>draft('Console output in the app preview "'+metadata.name+'" (page '+currentPath+'):\\n'+entries.slice(-20).map(e=>'- '+e.level+': '+e.text).join('\\n'));
|
|
171
|
+
// Point and tell
|
|
172
|
+
const point=$('point');
|
|
173
|
+
function pointing(on){point.setAttribute('aria-pressed',String(on));$('pointing').hidden=!on;frame.contentWindow?.postMessage({type:'bivy:point',on},metadata.origin);}
|
|
174
|
+
point.onclick=()=>pointing(point.getAttribute('aria-pressed')!=='true');
|
|
175
|
+
function picked(d){
|
|
176
|
+
pointing(false);
|
|
177
|
+
if(d.cancelled)return;
|
|
178
|
+
const r=d.rect||{},v=d.viewport||{};
|
|
179
|
+
const errors=entries.filter(e=>e.level==='error').slice(-5);
|
|
180
|
+
draft('In the app preview "'+metadata.name+'" (page '+safePath(d.path)+', viewport '+v.width+'×'+v.height+'):\\n'
|
|
181
|
+
+'Element: '+String(d.selector).slice(0,300)+(d.text?' ("'+String(d.text).slice(0,200)+'")':'')+', '+r.width+'×'+r.height+' at '+r.x+','+r.y
|
|
182
|
+
+(errors.length?'\\nRecent errors:\\n'+errors.map(e=>'- '+e.text).join('\\n'):''));
|
|
183
|
+
}
|
|
184
|
+
// Compare: screenshots around the agent's last change (agent screenshots on).
|
|
185
|
+
const compareBtn=$('compare-btn');let shots=[],urls=[];
|
|
186
|
+
async function loadCompare(){
|
|
187
|
+
try{const r=await fetch(metadata.origin+'/__bivy/compare',{credentials:'include',cache:'no-store'});if(!r.ok)return;shots=(await r.json()).shots||[];compareBtn.hidden=shots.length<2;}catch{}
|
|
188
|
+
}
|
|
189
|
+
function panels(open){for(const [id,btn] of [['console','errors'],['compare','compare-btn']]){$(id).hidden=id!==open;$(btn).setAttribute('aria-pressed',String(id===open));}$('draft').hidden=true;}
|
|
190
|
+
compareBtn.onclick=async()=>{
|
|
191
|
+
if(!$('compare').hidden)return panels(null);
|
|
192
|
+
panels('compare');
|
|
193
|
+
for(const u of urls)URL.revokeObjectURL(u);
|
|
194
|
+
const pick=[shots[shots.length-2],shots[shots.length-1]];
|
|
195
|
+
urls=await Promise.all(pick.map(async s=>URL.createObjectURL(await (await fetch(metadata.origin+'/__bivy/compare/'+s.index,{credentials:'include'})).blob())));
|
|
196
|
+
const before=$('compare-before'),after=$('compare-after');
|
|
197
|
+
before.src=urls[0];after.src=urls[1];
|
|
198
|
+
split();
|
|
199
|
+
};
|
|
200
|
+
const split=()=>{$('compare-before').style.clipPath='inset(0 '+(100-Number($('compare-slider').value))+'% 0 0)';};
|
|
201
|
+
$('compare-slider').oninput=split;
|
|
202
|
+
// The pill can cover an app's own bottom bar; collapse it to a corner button.
|
|
203
|
+
$('hide').onclick=()=>{if(point.getAttribute('aria-pressed')==='true')pointing(false);$('dock').hidden=true;$('show').hidden=false;$('show').focus();};
|
|
204
|
+
$('show').onclick=()=>{$('dock').hidden=false;$('show').hidden=true;$('hide').focus();};
|
|
205
|
+
// Device lens (desktop widths)
|
|
206
|
+
for(const b of $('lens').querySelectorAll('button'))b.onclick=()=>{
|
|
207
|
+
stage.dataset.lens=b.dataset.lens;
|
|
208
|
+
for(const o of $('lens').querySelectorAll('button'))o.setAttribute('aria-selected',String(o===b));
|
|
209
|
+
};
|
|
210
|
+
// Messages from the app origin: server state and inspector reports.
|
|
211
|
+
const down=$('down'),downText=$('down-text'),ask=$('ask');
|
|
212
|
+
let downPort=0;
|
|
213
|
+
addEventListener('message',e=>{
|
|
214
|
+
if(!metadata||e.origin!==metadata.origin||e.source!==frame.contentWindow)return;
|
|
215
|
+
const d=e.data||{};
|
|
216
|
+
if(d.type==='bivy:access'&&d.state==='blocked'&&embedded&&metadata.returnTo)toBivy({type:'blocked'});
|
|
217
|
+
else if(d.type==='bivy:upstream'){
|
|
218
|
+
downPort=Number(d.port)||0;
|
|
219
|
+
down.hidden=d.state!=='down';
|
|
220
|
+
downText.textContent='Nothing is answering on port '+downPort+'.';
|
|
221
|
+
ask.hidden=!metadata.returnTo;
|
|
222
|
+
}else if(d.source==='bivy-inspector'){
|
|
223
|
+
if(d.type==='route')currentPath=safePath(d.path);
|
|
224
|
+
else if(d.type==='console'&&(d.level==='error'||d.level==='warn')){entries.push({level:d.level,text:String(d.text).slice(0,500)});if(entries.length>50)entries.shift();renderConsole();}
|
|
225
|
+
else if(d.type==='picked')picked(d);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
ask.onclick=()=>toChat('The app preview "'+metadata.name+'" isn’t loading: nothing is answering on port '+downPort+'. Please find out why the server stopped, restart it, and tell me when it’s back.');
|
|
229
|
+
addEventListener('keydown',e=>{if(e.key==='Escape'){if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
|
|
41
230
|
back.onclick=()=>{if(metadata?.returnTo){window.close();setTimeout(()=>location.replace(metadata.returnTo),100);}else{window.close();status.hidden=false;status.textContent='You can close this tab to return to Bivy.';}};
|
|
42
|
-
reload.onclick=()=>{if(metadata)
|
|
231
|
+
reload.onclick=()=>{if(metadata)open(currentPath,'Reloading app…');};
|
|
43
232
|
(async()=>{
|
|
44
233
|
if(ticket){
|
|
45
234
|
const response=await fetch('/__bivy/launch',{method:'POST',headers:{'Content-Type':'text/plain'},body:ticket});
|