@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.
@@ -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(--bg); color:var(--ink); font-family:var(--font-sans); }
11
- nav { display:flex; align-items:center; gap:var(--space-2); padding:var(--space-2) var(--space-3); border-bottom:thin solid var(--line); background:var(--surface); flex-shrink:0; }
12
- nav .btn { min-height:var(--space-7); flex-shrink:0; }
13
- #stamp { font-size:var(--text-xs); white-space:nowrap; }
14
- #title { flex:1; min-width:0; font-size:var(--text-sm); font-weight:var(--weight-semibold); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
15
- #down { flex-shrink:0; }
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
- iframe { flex:1; min-height:0; width:100%; border:0; background:var(--bg); }
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 frame=document.getElementById('app'),status=document.getElementById('status'),back=document.getElementById('back'),reload=document.getElementById('reload');
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
- document.getElementById('title').textContent=data.name;
32
- document.getElementById('title').title=data.name;
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
- reload.disabled=false;
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 last framed page.
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
- const path=typeof d.path==='string'&&d.path.startsWith('/')&&!d.path.startsWith('//')?d.path:'/';
56
- frame.src=metadata.origin+path;
57
- document.getElementById('stamp').textContent='Updated after the agent’s turn';
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
- // The app origin reports when its server stops answering. Its content is
63
- // untrusted: the port is only ever placed in a draft the user reviews.
64
- const down=document.getElementById('down'),downText=document.getElementById('down-text'),ask=document.getElementById('ask');
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){status.hidden=false;status.textContent='Reloading app…';frame.src=metadata.origin+'/';}};
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});
@@ -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
- /** Deliberately ephemeral: restart invalidates apps rather than trusting reused local ports. */
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
- constructor(reservedPorts = []) {
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 app = { id: randomBytes(16).toString("hex"), sessionId, name: name(input.name), views: [], createdAt: Date.now() };
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: randomBytes(16).toString("hex"), name: name(spec?.name) };
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
- target = { kind: "service", port };
110
- view = { ...base, kind: "web", source: "service" };
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
- if (typeof spec.command !== "string" || !spec.command.trim() || spec.command.length > 1000 || spec.command.includes("\u0000"))
124
- throw new Error("Terminal view requires an executable command.");
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
+ }