@bivy/bivy 0.17.3-staging.3 → 0.17.3-staging.4
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 +25 -4
- package/dist/apps/gateway.js +180 -19
- package/dist/apps/inspector.js +97 -0
- package/dist/apps/preview-shell.js +180 -36
- package/dist/apps/registry.js +70 -15
- package/dist/apps/remote-preview.js +10 -2
- package/dist/apps/screenshot.js +181 -0
- package/dist/apps/service.js +166 -8
- package/dist/apps/styles.css +3 -0
- package/dist/config-cli.js +2 -0
- package/dist/controllers/app-commands.js +4 -1
- package/dist/node-config.js +6 -3
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +4 -1
- package/dist/server.js +27 -4
- package/package.json +1 -1
|
@@ -1,47 +1,115 @@
|
|
|
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
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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); }
|
|
16
18
|
#status { padding:var(--space-4); margin:0; font-size:var(--text-sm); }
|
|
17
|
-
|
|
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
|
+
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); }
|
|
24
|
+
nav .btn { flex-shrink:0; border-radius:var(--radius-full); }
|
|
25
|
+
nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accent); }
|
|
26
|
+
#name { display:flex; flex-direction:column; min-width:0; padding:0 var(--space-2); }
|
|
27
|
+
#title { font-size:var(--text-sm); font-weight:var(--weight-semibold); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:14em; }
|
|
28
|
+
#stamp { font-size:var(--text-xs); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
29
|
+
#stamp:empty { display:none; }
|
|
30
|
+
#lens { flex-shrink:0; }
|
|
31
|
+
.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; }
|
|
32
|
+
.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); }
|
|
33
|
+
.panel-actions { display:flex; justify-content:flex-end; gap:var(--space-2); margin-top:var(--space-2); flex-wrap:wrap; }
|
|
34
|
+
#compare-stage { display:grid; justify-content:center; background:var(--surface-2); border-radius:var(--radius-md); overflow:hidden; }
|
|
35
|
+
/* Both shots share one grid cell, so they overlay exactly at any size. */
|
|
36
|
+
#compare-stage img { grid-area:1 / 1; display:block; max-height:32vh; max-width:100%; }
|
|
37
|
+
/* Same column as the shots, so the handle sits on the split. */
|
|
38
|
+
#compare-slider { grid-area:2 / 1; width:100%; margin:var(--space-2) 0; accent-color:var(--accent); }
|
|
39
|
+
#compare-hint { font-size:var(--text-xs); margin:var(--space-1) 0 0; }
|
|
40
|
+
#entries { list-style:none; margin:0; padding:0; font-family:var(--font-mono); font-size:var(--text-xs); }
|
|
41
|
+
#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; }
|
|
42
|
+
#draft-text { width:100%; min-height:5em; box-sizing:border-box; font:inherit; font-size:var(--text-sm); resize:vertical; }
|
|
43
|
+
#draft-hint { font-size:var(--text-xs); margin:var(--space-2) 0 var(--space-1); }
|
|
44
|
+
#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); }
|
|
45
|
+
#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); }
|
|
46
|
+
.label-narrow { display:none; }
|
|
47
|
+
@media (max-width: 699px) { #lens { display:none; } #name { display:none; } .label-wide { display:none; } .label-narrow { display:inline; } }
|
|
18
48
|
[hidden] { display:none !important; }
|
|
19
49
|
</style></head><body>
|
|
20
|
-
<nav aria-label="Bivy preview controls"><button class="btn ghost" id="back">‹ Back to chat</button><span id="title">App preview</span><span id="stamp" class="muted" role="status"></span><button class="btn ghost" id="reload" disabled>Reload</button></nav>
|
|
21
50
|
<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>
|
|
22
51
|
<p id="status" role="status">Opening app…</p>
|
|
23
|
-
<iframe id="app" title="App preview" hidden sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups" referrerpolicy="no-referrer"></iframe>
|
|
52
|
+
<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>
|
|
53
|
+
<div id="dock">
|
|
54
|
+
<section class="panel" id="console" hidden aria-labelledby="console-title">
|
|
55
|
+
<h2><span id="console-title">Console</span><button class="btn sm ghost" id="clear">Clear</button></h2>
|
|
56
|
+
<p class="muted" id="console-empty">No errors or warnings since this page loaded.</p>
|
|
57
|
+
<ul id="entries"></ul>
|
|
58
|
+
<div class="panel-actions"><button class="btn sm" id="send-errors" disabled>Send to agent…</button></div>
|
|
59
|
+
</section>
|
|
60
|
+
<section class="panel" id="compare" hidden aria-labelledby="compare-title">
|
|
61
|
+
<h2 id="compare-title">Before and after the agent’s last change</h2>
|
|
62
|
+
<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>
|
|
63
|
+
<p class="muted" id="compare-hint">Left of the handle: before. Right: now. Screenshots at phone width.</p>
|
|
64
|
+
</section>
|
|
65
|
+
<section class="panel" id="draft" hidden aria-labelledby="draft-title">
|
|
66
|
+
<h2 id="draft-title">Draft for the agent</h2>
|
|
67
|
+
<textarea class="field" id="draft-text" aria-label="What should change?" placeholder="What should change?"></textarea>
|
|
68
|
+
<p class="muted" id="draft-hint">Sent with this context from the preview. Nothing is sent until you send it in the chat.</p>
|
|
69
|
+
<pre id="draft-context"></pre>
|
|
70
|
+
<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>
|
|
71
|
+
</section>
|
|
72
|
+
<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>
|
|
73
|
+
<nav aria-label="Bivy preview controls">
|
|
74
|
+
<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>
|
|
75
|
+
<span id="name"><span id="title">App preview</span><span id="stamp" class="muted" role="status"></span></span>
|
|
76
|
+
<button class="btn sm ghost" id="hide" aria-label="Hide Bivy controls" title="Hide controls">⌄</button>
|
|
77
|
+
<button class="btn sm ghost" id="point" aria-pressed="false" disabled>Point</button>
|
|
78
|
+
<button class="btn sm ghost" id="compare-btn" aria-pressed="false" hidden>Compare</button>
|
|
79
|
+
<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>
|
|
80
|
+
<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>
|
|
81
|
+
<button class="btn sm ghost" id="reload" disabled>Reload</button>
|
|
82
|
+
</nav>
|
|
83
|
+
</div>
|
|
84
|
+
<button class="btn sm" id="show" hidden aria-label="Show Bivy controls">Bivy</button>
|
|
24
85
|
<script nonce="${nonce}">
|
|
25
|
-
const
|
|
86
|
+
const $=id=>document.getElementById(id);
|
|
87
|
+
const frame=$('app'),status=$('status'),back=$('back'),reload=$('reload'),stage=$('stage');
|
|
26
88
|
const ticket=location.hash.slice(1);history.replaceState(null,'',location.pathname);
|
|
27
|
-
let metadata;
|
|
89
|
+
let metadata,currentPath='/';
|
|
90
|
+
// Peek: framed by a Bivy client, which owns closing and the composer.
|
|
91
|
+
const embedded=parent!==window;
|
|
92
|
+
const toBivy=m=>parent.postMessage(Object.assign({source:'bivy-preview'},m),new URL(metadata.returnTo).origin);
|
|
28
93
|
const storageKey='bivy-preview';
|
|
94
|
+
const safePath=p=>typeof p==='string'&&p.startsWith('/')&&!p.startsWith('//')&&p.length<=2048?p:'/';
|
|
95
|
+
const open=(path,message)=>{status.hidden=false;status.textContent=message;resetConsole();frame.src=metadata.origin+safePath(path);};
|
|
29
96
|
function show(data,launch){
|
|
30
97
|
metadata=data;
|
|
31
|
-
|
|
32
|
-
|
|
98
|
+
$('title').textContent=data.name;
|
|
99
|
+
$('title').title=data.name;
|
|
33
100
|
document.title='Bivy · '+data.name;
|
|
34
101
|
back.title=data.returnTo?'Return to '+new URL(data.returnTo).host:'Close preview';
|
|
35
102
|
frame.title=data.name;
|
|
36
103
|
frame.hidden=false;
|
|
37
|
-
frame.src=data.origin+(launch?'/__bivy/open#'+launch:'/');
|
|
38
|
-
|
|
104
|
+
frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch:'/');
|
|
105
|
+
back.hidden=embedded&&Boolean(data.returnTo);
|
|
106
|
+
for(const b of [reload,$('point'),$('errors')])b.disabled=false;
|
|
39
107
|
status.textContent='Loading app…';
|
|
40
|
-
frame.onload=()=>{status.hidden=true;if(revision===null){revision=-1;watch();}else if(wake)wake();};
|
|
108
|
+
frame.onload=()=>{status.hidden=true;void loadCompare();if(revision===null){revision=-1;watch();}else if(wake)wake();};
|
|
41
109
|
// Store navigation metadata only, never tickets or cookies.
|
|
42
110
|
try{sessionStorage.setItem(storageKey,JSON.stringify(data));}catch{}
|
|
43
111
|
}
|
|
44
|
-
// Reload when an agent turn changes files, returning to the
|
|
112
|
+
// Reload when an agent turn changes files, returning to the page in view.
|
|
45
113
|
// Until the frame has redeemed its access cookie, polls fail; back off, and
|
|
46
114
|
// let the next frame load retry at once.
|
|
47
115
|
let revision=null,retry=0,wake=null;
|
|
@@ -52,31 +120,107 @@ async function watch(){
|
|
|
52
120
|
if(!r.ok)throw Error();
|
|
53
121
|
const d=await r.json();
|
|
54
122
|
if(revision>=0&&d.revision!==revision){
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
123
|
+
open(currentPath!=='/'?currentPath:d.path,'Updating…');
|
|
124
|
+
$('stamp').textContent='Updated after the agent’s turn';
|
|
125
|
+
// Screenshots for Compare land a few seconds after the change.
|
|
126
|
+
for(const ms of [5000,15000])setTimeout(()=>void loadCompare(),ms);
|
|
58
127
|
}
|
|
59
128
|
revision=d.revision;retry=0;setTimeout(watch,0);
|
|
60
129
|
}catch{retry=Math.min(30000,(retry||1000)*2);const t=setTimeout(watch,retry);wake=()=>{clearTimeout(t);watch();};}
|
|
61
130
|
}
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
let downPort=0;
|
|
66
|
-
addEventListener('message',e=>{
|
|
67
|
-
if(!metadata||e.origin!==metadata.origin||e.source!==frame.contentWindow||e.data?.type!=='bivy:upstream')return;
|
|
68
|
-
downPort=Number(e.data.port)||0;
|
|
69
|
-
down.hidden=e.data.state!=='down';
|
|
70
|
-
downText.textContent='Nothing is answering on port '+downPort+'.';
|
|
71
|
-
ask.hidden=!metadata.returnTo;
|
|
72
|
-
});
|
|
73
|
-
ask.onclick=()=>{
|
|
131
|
+
// Drafts go to the session's composer; nothing is ever sent from here.
|
|
132
|
+
function toChat(text){
|
|
133
|
+
if(embedded)return toBivy({type:'draft',text});
|
|
74
134
|
const to=new URL(metadata.returnTo),session=to.pathname.split('/').pop();
|
|
75
|
-
const text='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.';
|
|
76
135
|
location.assign(to.origin+'/share?session='+encodeURIComponent(session)+'&text='+encodeURIComponent(text));
|
|
136
|
+
}
|
|
137
|
+
function draft(context){
|
|
138
|
+
panels(null);
|
|
139
|
+
$('draft').hidden=false;
|
|
140
|
+
$('draft-add').textContent=metadata.returnTo?'Add to chat':'Copy';
|
|
141
|
+
$('draft-context').textContent=context;
|
|
142
|
+
const box=$('draft-text');box.value='';box.focus();
|
|
143
|
+
}
|
|
144
|
+
$('draft-cancel').onclick=()=>{$('draft').hidden=true;};
|
|
145
|
+
$('draft-add').onclick=async()=>{
|
|
146
|
+
const note=$('draft-text').value.trim(),text=(note?note+'\\n\\n':'')+$('draft-context').textContent;
|
|
147
|
+
if(metadata.returnTo)return toChat(text);
|
|
148
|
+
try{await navigator.clipboard.writeText(text);$('draft-add').textContent='Copied';}catch{$('draft-text').select();}
|
|
149
|
+
};
|
|
150
|
+
// Console
|
|
151
|
+
let entries=[];
|
|
152
|
+
function resetConsole(){entries=[];renderConsole();}
|
|
153
|
+
function renderConsole(){
|
|
154
|
+
const errors=entries.filter(e=>e.level==='error').length,count=$('error-count');
|
|
155
|
+
count.hidden=!errors;count.textContent=String(errors);
|
|
156
|
+
$('errors').setAttribute('aria-label',errors?'Console, '+errors+' error'+(errors===1?'':'s'):'Console');
|
|
157
|
+
$('console-empty').hidden=entries.length>0;$('send-errors').disabled=!entries.length;
|
|
158
|
+
$('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;}));
|
|
159
|
+
}
|
|
160
|
+
$('errors').onclick=()=>panels($('console').hidden?'console':null);
|
|
161
|
+
$('clear').onclick=resetConsole;
|
|
162
|
+
$('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'));
|
|
163
|
+
// Point and tell
|
|
164
|
+
const point=$('point');
|
|
165
|
+
function pointing(on){point.setAttribute('aria-pressed',String(on));$('pointing').hidden=!on;frame.contentWindow?.postMessage({type:'bivy:point',on},metadata.origin);}
|
|
166
|
+
point.onclick=()=>pointing(point.getAttribute('aria-pressed')!=='true');
|
|
167
|
+
function picked(d){
|
|
168
|
+
pointing(false);
|
|
169
|
+
if(d.cancelled)return;
|
|
170
|
+
const r=d.rect||{},v=d.viewport||{};
|
|
171
|
+
const errors=entries.filter(e=>e.level==='error').slice(-5);
|
|
172
|
+
draft('In the app preview "'+metadata.name+'" (page '+safePath(d.path)+', viewport '+v.width+'×'+v.height+'):\\n'
|
|
173
|
+
+'Element: '+String(d.selector).slice(0,300)+(d.text?' ("'+String(d.text).slice(0,200)+'")':'')+', '+r.width+'×'+r.height+' at '+r.x+','+r.y
|
|
174
|
+
+(errors.length?'\\nRecent errors:\\n'+errors.map(e=>'- '+e.text).join('\\n'):''));
|
|
175
|
+
}
|
|
176
|
+
// Compare: screenshots around the agent's last change (agent screenshots on).
|
|
177
|
+
const compareBtn=$('compare-btn');let shots=[],urls=[];
|
|
178
|
+
async function loadCompare(){
|
|
179
|
+
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{}
|
|
180
|
+
}
|
|
181
|
+
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;}
|
|
182
|
+
compareBtn.onclick=async()=>{
|
|
183
|
+
if(!$('compare').hidden)return panels(null);
|
|
184
|
+
panels('compare');
|
|
185
|
+
for(const u of urls)URL.revokeObjectURL(u);
|
|
186
|
+
const pick=[shots[shots.length-2],shots[shots.length-1]];
|
|
187
|
+
urls=await Promise.all(pick.map(async s=>URL.createObjectURL(await (await fetch(metadata.origin+'/__bivy/compare/'+s.index,{credentials:'include'})).blob())));
|
|
188
|
+
const before=$('compare-before'),after=$('compare-after');
|
|
189
|
+
before.src=urls[0];after.src=urls[1];
|
|
190
|
+
split();
|
|
191
|
+
};
|
|
192
|
+
const split=()=>{$('compare-before').style.clipPath='inset(0 '+(100-Number($('compare-slider').value))+'% 0 0)';};
|
|
193
|
+
$('compare-slider').oninput=split;
|
|
194
|
+
// The pill can cover an app's own bottom bar; collapse it to a corner button.
|
|
195
|
+
$('hide').onclick=()=>{if(point.getAttribute('aria-pressed')==='true')pointing(false);$('dock').hidden=true;$('show').hidden=false;$('show').focus();};
|
|
196
|
+
$('show').onclick=()=>{$('dock').hidden=false;$('show').hidden=true;$('hide').focus();};
|
|
197
|
+
// Device lens (desktop widths)
|
|
198
|
+
for(const b of $('lens').querySelectorAll('button'))b.onclick=()=>{
|
|
199
|
+
stage.dataset.lens=b.dataset.lens;
|
|
200
|
+
for(const o of $('lens').querySelectorAll('button'))o.setAttribute('aria-selected',String(o===b));
|
|
77
201
|
};
|
|
202
|
+
// Messages from the app origin: server state and inspector reports.
|
|
203
|
+
const down=$('down'),downText=$('down-text'),ask=$('ask');
|
|
204
|
+
let downPort=0;
|
|
205
|
+
addEventListener('message',e=>{
|
|
206
|
+
if(!metadata||e.origin!==metadata.origin||e.source!==frame.contentWindow)return;
|
|
207
|
+
const d=e.data||{};
|
|
208
|
+
if(d.type==='bivy:access'&&d.state==='blocked'&&embedded&&metadata.returnTo)toBivy({type:'blocked'});
|
|
209
|
+
else if(d.type==='bivy:upstream'){
|
|
210
|
+
downPort=Number(d.port)||0;
|
|
211
|
+
down.hidden=d.state!=='down';
|
|
212
|
+
downText.textContent='Nothing is answering on port '+downPort+'.';
|
|
213
|
+
ask.hidden=!metadata.returnTo;
|
|
214
|
+
}else if(d.source==='bivy-inspector'){
|
|
215
|
+
if(d.type==='route')currentPath=safePath(d.path);
|
|
216
|
+
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();}
|
|
217
|
+
else if(d.type==='picked')picked(d);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
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.');
|
|
221
|
+
addEventListener('keydown',e=>{if(e.key==='Escape'){if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
|
|
78
222
|
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.';}};
|
|
79
|
-
reload.onclick=()=>{if(metadata)
|
|
223
|
+
reload.onclick=()=>{if(metadata)open(currentPath,'Reloading app…');};
|
|
80
224
|
(async()=>{
|
|
81
225
|
if(ticket){
|
|
82
226
|
const response=await fetch('/__bivy/launch',{method:'POST',headers:{'Content-Type':'text/plain'},body:ticket});
|
package/dist/apps/registry.js
CHANGED
|
@@ -74,17 +74,70 @@ function snapshot(workspace, directory) {
|
|
|
74
74
|
throw new Error("Static directory needs an index.html entry point.");
|
|
75
75
|
return { kind: "static", files, bytes };
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
function command(spec, workspace, label) {
|
|
78
|
+
const value = spec;
|
|
79
|
+
if (typeof value?.command !== "string" || !value.command.trim() || value.command.length > 1000 || value.command.includes("\u0000"))
|
|
80
|
+
throw new Error(`${label} requires an executable command.`);
|
|
81
|
+
if (value.args !== undefined && (!Array.isArray(value.args) || value.args.length > 100 || value.args.some((arg) => typeof arg !== "string" || arg.length > 4000 || arg.includes("\u0000"))))
|
|
82
|
+
throw new Error(`Invalid ${label.toLowerCase()} arguments.`);
|
|
83
|
+
return { command: value.command, args: [...(value.args ?? [])], workspace: fs.realpathSync(workspace) };
|
|
84
|
+
}
|
|
85
|
+
/** Apps persist (with their IDs) when given a file, so chat launchers and
|
|
86
|
+
* preview addresses survive restarts. Views whose server Bivy doesn't own —
|
|
87
|
+
* a service without `start` — are not restored: a reused local port could
|
|
88
|
+
* belong to anything by then. Detection offers them again. */
|
|
78
89
|
export class AppRegistry extends EventEmitter {
|
|
79
90
|
reservedPorts;
|
|
91
|
+
file;
|
|
80
92
|
apps = new Map();
|
|
81
93
|
views = new Map();
|
|
82
|
-
|
|
94
|
+
persisted = new Map();
|
|
95
|
+
constructor(reservedPorts = [], file) {
|
|
83
96
|
super();
|
|
84
97
|
this.reservedPorts = reservedPorts;
|
|
98
|
+
this.file = file;
|
|
85
99
|
this.setMaxListeners(0);
|
|
100
|
+
this.restore();
|
|
101
|
+
}
|
|
102
|
+
restore() {
|
|
103
|
+
if (!this.file)
|
|
104
|
+
return;
|
|
105
|
+
let records = [];
|
|
106
|
+
try {
|
|
107
|
+
records = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
113
|
+
const keep = record.manifest?.views?.map((spec, i) => ({ spec, id: record.viewIds?.[i] }))
|
|
114
|
+
.filter(({ spec, id }) => typeof id === "string" && !(spec?.kind === "web" && spec.source?.kind === "service" && !spec.source.start)) ?? [];
|
|
115
|
+
if (!keep.length)
|
|
116
|
+
continue;
|
|
117
|
+
// A workspace that moved or a build that vanished drops that app, not the rest.
|
|
118
|
+
try {
|
|
119
|
+
this.publish(record.sessionId, record.workspace, { ...record.manifest, views: keep.map((k) => k.spec) }, { id: record.id, viewIds: keep.map((k) => k.id), createdAt: record.createdAt });
|
|
120
|
+
}
|
|
121
|
+
catch { /* skipped */ }
|
|
122
|
+
}
|
|
123
|
+
this.save();
|
|
124
|
+
}
|
|
125
|
+
save() {
|
|
126
|
+
if (!this.file)
|
|
127
|
+
return;
|
|
128
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(tmp, JSON.stringify([...this.persisted.values()]), { mode: 0o600 });
|
|
131
|
+
fs.renameSync(tmp, this.file);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
try {
|
|
135
|
+
fs.rmSync(tmp, { force: true });
|
|
136
|
+
}
|
|
137
|
+
catch { /* ignore */ }
|
|
138
|
+
}
|
|
86
139
|
}
|
|
87
|
-
publish(sessionId, workspace, input) {
|
|
140
|
+
publish(sessionId, workspace, input, restore) {
|
|
88
141
|
const name = (value) => {
|
|
89
142
|
if (typeof value !== "string" || !value.trim() || value.length > 100)
|
|
90
143
|
throw new Error("App and view names must contain 1–100 characters.");
|
|
@@ -94,11 +147,12 @@ export class AppRegistry extends EventEmitter {
|
|
|
94
147
|
throw new Error("Expected manifest version 1 with 1–8 views.");
|
|
95
148
|
if (this.apps.size >= 50)
|
|
96
149
|
throw new Error("Remove an app before publishing more (limit: 50).");
|
|
97
|
-
const
|
|
150
|
+
const id = (i) => (i === undefined ? restore?.id : restore?.viewIds[i]) ?? randomBytes(16).toString("hex");
|
|
151
|
+
const app = { id: id(), sessionId, name: name(input.name), views: [], createdAt: restore?.createdAt ?? Date.now() };
|
|
98
152
|
const entries = [];
|
|
99
153
|
let total = [...this.views.values()].reduce((sum, item) => sum + (item.target.kind === "static" ? item.target.bytes : 0), 0);
|
|
100
|
-
for (const spec of input.views) {
|
|
101
|
-
const base = { id:
|
|
154
|
+
for (const [index, spec] of input.views.entries()) {
|
|
155
|
+
const base = { id: id(index), name: name(spec?.name) };
|
|
102
156
|
let target;
|
|
103
157
|
let view;
|
|
104
158
|
let source;
|
|
@@ -106,8 +160,9 @@ export class AppRegistry extends EventEmitter {
|
|
|
106
160
|
const port = spec.source.port;
|
|
107
161
|
if (!Number.isInteger(port) || port < 1024 || port > 65535 || this.reservedPorts.includes(port))
|
|
108
162
|
throw new Error("Choose a non-reserved local service port between 1024 and 65535.");
|
|
109
|
-
|
|
110
|
-
|
|
163
|
+
const start = spec.source.start === undefined ? undefined : command(spec.source.start, workspace, "Service start");
|
|
164
|
+
target = { kind: "service", port, start };
|
|
165
|
+
view = { ...base, kind: "web", source: "service", ...(start ? { managed: true } : {}) };
|
|
111
166
|
}
|
|
112
167
|
else if (spec.kind === "web" && spec.source?.kind === "static") {
|
|
113
168
|
if (typeof spec.source.directory !== "string" || !spec.source.directory)
|
|
@@ -120,13 +175,8 @@ export class AppRegistry extends EventEmitter {
|
|
|
120
175
|
view = { ...base, kind: "web", source: "static" };
|
|
121
176
|
}
|
|
122
177
|
else if (spec.kind === "terminal") {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (spec.args !== undefined && (!Array.isArray(spec.args) || spec.args.length > 100 || spec.args.some((arg) => typeof arg !== "string" || arg.length > 4000 || arg.includes("\u0000"))))
|
|
126
|
-
throw new Error("Invalid terminal arguments.");
|
|
127
|
-
const args = [...(spec.args ?? [])];
|
|
128
|
-
target = { kind: "terminal", command: spec.command, args, workspace: fs.realpathSync(workspace) };
|
|
129
|
-
view = { ...base, kind: "terminal", command: spec.command, args };
|
|
178
|
+
target = { kind: "terminal", ...command(spec, workspace, "Terminal view") };
|
|
179
|
+
view = { ...base, kind: "terminal", command: target.command, args: target.args };
|
|
130
180
|
}
|
|
131
181
|
else
|
|
132
182
|
throw new Error("Unsupported app view. This machine supports web and terminal views.");
|
|
@@ -137,6 +187,9 @@ export class AppRegistry extends EventEmitter {
|
|
|
137
187
|
this.apps.set(app.id, app);
|
|
138
188
|
for (const entry of entries)
|
|
139
189
|
this.views.set(entry.view.id, entry);
|
|
190
|
+
this.persisted.set(app.id, { sessionId, workspace, manifest: structuredClone(input), id: app.id, viewIds: app.views.map((v) => v.id), createdAt: app.createdAt });
|
|
191
|
+
if (!restore)
|
|
192
|
+
this.save();
|
|
140
193
|
return structuredClone(app);
|
|
141
194
|
}
|
|
142
195
|
list(sessionId) {
|
|
@@ -199,5 +252,7 @@ export class AppRegistry extends EventEmitter {
|
|
|
199
252
|
for (const view of app.views)
|
|
200
253
|
this.views.delete(view.id);
|
|
201
254
|
this.apps.delete(id);
|
|
255
|
+
this.persisted.delete(id);
|
|
256
|
+
this.save();
|
|
202
257
|
}
|
|
203
258
|
}
|
|
@@ -8,14 +8,16 @@ import { PreviewStream } from "./preview-stream.js";
|
|
|
8
8
|
export class RemotePreview {
|
|
9
9
|
registry;
|
|
10
10
|
returnOrigins;
|
|
11
|
+
signIn;
|
|
11
12
|
gateway;
|
|
12
13
|
origin;
|
|
13
14
|
relayUrl;
|
|
14
15
|
online = false;
|
|
15
16
|
sockets = new Set();
|
|
16
|
-
constructor(registry, returnOrigins) {
|
|
17
|
+
constructor(registry, returnOrigins, signIn) {
|
|
17
18
|
this.registry = registry;
|
|
18
19
|
this.returnOrigins = returnOrigins;
|
|
20
|
+
this.signIn = signIn;
|
|
19
21
|
}
|
|
20
22
|
get available() { return this.online && Boolean(this.gateway); }
|
|
21
23
|
ready(origin, relayUrl) {
|
|
@@ -28,7 +30,7 @@ export class RemotePreview {
|
|
|
28
30
|
return;
|
|
29
31
|
if (this.origin !== template) {
|
|
30
32
|
this.gateway?.close();
|
|
31
|
-
this.gateway = new AppGateway(this.registry, template, this.returnOrigins);
|
|
33
|
+
this.gateway = new AppGateway(this.registry, template, this.returnOrigins, this.signIn);
|
|
32
34
|
this.origin = template;
|
|
33
35
|
}
|
|
34
36
|
this.relayUrl = relayUrl.replace(/\/$/, "") + "/preview/stream";
|
|
@@ -65,6 +67,12 @@ export class RemotePreview {
|
|
|
65
67
|
return this.gateway.share(id);
|
|
66
68
|
}
|
|
67
69
|
revoke(id) { this.gateway?.revoke(id); }
|
|
70
|
+
address(id) { return this.available ? this.gateway.address(id) : undefined; }
|
|
71
|
+
openDirect(id) {
|
|
72
|
+
if (!this.available)
|
|
73
|
+
throw new Error("Preview delivery is unavailable. Reconnect the machine and try again.");
|
|
74
|
+
return this.gateway.openDirect(id);
|
|
75
|
+
}
|
|
68
76
|
disconnect() {
|
|
69
77
|
this.online = false;
|
|
70
78
|
for (const ws of this.sockets)
|
|
@@ -0,0 +1,181 @@
|
|
|
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 http from "node:http";
|
|
7
|
+
import { once } from "node:events";
|
|
8
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
9
|
+
import { WebSocket } from "ws";
|
|
10
|
+
/** Browsers tried in order; BIVY_CHROME overrides. Any Chromium works. */
|
|
11
|
+
const CANDIDATES = [
|
|
12
|
+
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge",
|
|
13
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
14
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
15
|
+
];
|
|
16
|
+
export function findChrome(env = process.env) {
|
|
17
|
+
if (env.BIVY_CHROME)
|
|
18
|
+
return fs.existsSync(env.BIVY_CHROME) ? env.BIVY_CHROME : undefined;
|
|
19
|
+
for (const candidate of CANDIDATES) {
|
|
20
|
+
if (candidate.startsWith("/")) {
|
|
21
|
+
if (fs.existsSync(candidate))
|
|
22
|
+
return candidate;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return execFileSync("which", [candidate], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
27
|
+
}
|
|
28
|
+
catch { /* next */ }
|
|
29
|
+
}
|
|
30
|
+
// A Playwright install (common on dev machines) ships a headless shell.
|
|
31
|
+
const cache = path.join(os.homedir(), ".cache", "ms-playwright");
|
|
32
|
+
for (const dir of fs.existsSync(cache) ? fs.readdirSync(cache).sort().reverse() : []) {
|
|
33
|
+
for (const bin of ["chrome-headless-shell-linux64/chrome-headless-shell", "chrome-linux/chrome"]) {
|
|
34
|
+
const file = path.join(cache, dir, bin);
|
|
35
|
+
if (dir.startsWith("chromium") && fs.existsSync(file))
|
|
36
|
+
return file;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
/** Serves a static snapshot on loopback for the duration of a shot. */
|
|
42
|
+
async function serveSnapshot(files) {
|
|
43
|
+
const server = http.createServer((req, res) => {
|
|
44
|
+
let file = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
45
|
+
if (file.endsWith("/"))
|
|
46
|
+
file += "index.html";
|
|
47
|
+
const data = files.get(file) ?? (path.extname(file) ? undefined : files.get("/index.html"));
|
|
48
|
+
if (!data) {
|
|
49
|
+
res.writeHead(404);
|
|
50
|
+
res.end();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
res.end(data);
|
|
54
|
+
});
|
|
55
|
+
server.listen(0, "127.0.0.1");
|
|
56
|
+
await once(server, "listening");
|
|
57
|
+
return { origin: `http://127.0.0.1:${server.address().port}`, close: () => { server.close(); server.closeAllConnections(); } };
|
|
58
|
+
}
|
|
59
|
+
/** Minimal CDP client over one browser-level WebSocket (flattened sessions). */
|
|
60
|
+
class Cdp {
|
|
61
|
+
ws;
|
|
62
|
+
next = 0;
|
|
63
|
+
pending = new Map();
|
|
64
|
+
listeners = new Set();
|
|
65
|
+
constructor(ws) {
|
|
66
|
+
this.ws = ws;
|
|
67
|
+
ws.on("message", (raw) => {
|
|
68
|
+
const msg = JSON.parse(raw.toString());
|
|
69
|
+
if (msg.id !== undefined) {
|
|
70
|
+
const call = this.pending.get(msg.id);
|
|
71
|
+
this.pending.delete(msg.id);
|
|
72
|
+
if (msg.error)
|
|
73
|
+
call?.reject(new Error(msg.error.message));
|
|
74
|
+
else
|
|
75
|
+
call?.resolve(msg.result);
|
|
76
|
+
}
|
|
77
|
+
else
|
|
78
|
+
for (const fn of this.listeners)
|
|
79
|
+
fn(msg.method, msg.params, msg.sessionId);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
send(method, params = {}, sessionId) {
|
|
83
|
+
const id = ++this.next;
|
|
84
|
+
return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params, sessionId })); });
|
|
85
|
+
}
|
|
86
|
+
once(method, sessionId, ms) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const fn = (m, _p, s) => { if (m === method && s === sessionId)
|
|
89
|
+
finish(); };
|
|
90
|
+
const finish = () => { clearTimeout(timer); this.listeners.delete(fn); resolve(); };
|
|
91
|
+
const timer = setTimeout(finish, ms);
|
|
92
|
+
this.listeners.add(fn);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Screenshots web views with one headless browser, one shot at a time, so
|
|
97
|
+
* small machines cope. Service views are loaded straight from loopback;
|
|
98
|
+
* static views from a throwaway loopback server. Files are PNGs in `outDir`. */
|
|
99
|
+
export async function takeShots(views, request, outDir, chrome = findChrome()) {
|
|
100
|
+
if (!chrome)
|
|
101
|
+
throw new Error("No Chrome or Chromium found on this machine. Install one, or set BIVY_CHROME to its path.");
|
|
102
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
103
|
+
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "bivy-shot-"));
|
|
104
|
+
const servers = [];
|
|
105
|
+
let ws;
|
|
106
|
+
let browser;
|
|
107
|
+
const deadline = setTimeout(() => browser?.kill("SIGKILL"), 120_000);
|
|
108
|
+
// Chromium's sandbox is unavailable as root and where AppArmor blocks user
|
|
109
|
+
// namespaces (Ubuntu 23.10+). The pages are the session's own apps, already
|
|
110
|
+
// running as this user, so retrying without it doesn't widen what they can do.
|
|
111
|
+
const launch = (sandbox) => new Promise((resolve, reject) => {
|
|
112
|
+
const args = ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "--hide-scrollbars", "--mute-audio", ...(sandbox ? [] : ["--no-sandbox"]), "about:blank"];
|
|
113
|
+
const child = spawn(chrome, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
114
|
+
browser = child;
|
|
115
|
+
let text = "";
|
|
116
|
+
const timer = setTimeout(() => reject(new Error("The browser didn't start in time.")), 20_000);
|
|
117
|
+
child.stderr.on("data", (chunk) => { text += chunk; const m = /DevTools listening on (ws:\/\/\S+)/.exec(text); if (m) {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve(m[1]);
|
|
120
|
+
} });
|
|
121
|
+
child.once("exit", () => { clearTimeout(timer); reject(Object.assign(new Error("The browser exited before it was ready."), { noSandbox: /No usable sandbox|--no-sandbox/i.test(text) })); });
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
const endpoint = await launch(process.getuid?.() !== 0).catch((error) => {
|
|
125
|
+
if (error.noSandbox)
|
|
126
|
+
return launch(false);
|
|
127
|
+
throw error;
|
|
128
|
+
});
|
|
129
|
+
ws = new WebSocket(endpoint, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 });
|
|
130
|
+
await once(ws, "open");
|
|
131
|
+
const cdp = new Cdp(ws);
|
|
132
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
|
|
133
|
+
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
134
|
+
await cdp.send("Page.enable", {}, sessionId);
|
|
135
|
+
const shots = [];
|
|
136
|
+
for (const entry of views) {
|
|
137
|
+
let origin;
|
|
138
|
+
if (entry.target.kind === "service")
|
|
139
|
+
origin = `http://127.0.0.1:${entry.target.port}`;
|
|
140
|
+
else if (entry.target.kind === "static") {
|
|
141
|
+
const server = await serveSnapshot(entry.target.files);
|
|
142
|
+
servers.push(server);
|
|
143
|
+
origin = server.origin;
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
continue;
|
|
147
|
+
for (const width of request.widths)
|
|
148
|
+
for (const theme of request.themes) {
|
|
149
|
+
const mobile = width < 600;
|
|
150
|
+
await cdp.send("Emulation.setDeviceMetricsOverride", { width, height: mobile ? 844 : 800, deviceScaleFactor: mobile ? 2 : 1, mobile }, sessionId);
|
|
151
|
+
await cdp.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: theme }] }, sessionId);
|
|
152
|
+
const loaded = cdp.once("Page.loadEventFired", sessionId, 15_000);
|
|
153
|
+
await cdp.send("Page.navigate", { url: origin + request.path }, sessionId);
|
|
154
|
+
await loaded;
|
|
155
|
+
await new Promise((r) => setTimeout(r, 400)); // let fonts and first effects settle
|
|
156
|
+
const { data } = await cdp.send("Page.captureScreenshot", { format: "png" }, sessionId);
|
|
157
|
+
const file = path.join(outDir, `${entry.view.id.slice(0, 8)}-${width}-${theme}.png`);
|
|
158
|
+
fs.writeFileSync(file, Buffer.from(data, "base64"));
|
|
159
|
+
shots.push({ viewId: entry.view.id, view: entry.view.name, width, theme, file });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return shots;
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
clearTimeout(deadline);
|
|
166
|
+
ws?.terminate();
|
|
167
|
+
for (const server of servers)
|
|
168
|
+
server.close();
|
|
169
|
+
// Wait for the browser to exit before removing its profile: a killed
|
|
170
|
+
// Chrome can still be writing into it. Cleanup never fails a shot.
|
|
171
|
+
if (browser && browser.exitCode === null && browser.signalCode === null) {
|
|
172
|
+
const exited = once(browser, "exit").catch(() => { });
|
|
173
|
+
browser.kill("SIGKILL");
|
|
174
|
+
await exited;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
178
|
+
}
|
|
179
|
+
catch { /* temp dir; the OS reclaims it */ }
|
|
180
|
+
}
|
|
181
|
+
}
|