@bivy/bivy 0.18.4-staging.9 → 0.18.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.
@@ -12,6 +12,7 @@ import { previewShell } from "./preview-shell.js";
12
12
  import { displayViewer, DISPLAY_MENU_PATH, DISPLAY_SOCKET_PATH, DISPLAY_STATS_PATH, NOVNC_PATH } from "./display-viewer.js";
13
13
  import { pressMenu, readMenuPath, readMenus } from "./menu.js";
14
14
  import { inspectorScript } from "./inspector.js";
15
+ import { readStrokes } from "./annotate.js";
15
16
  const MIME = {
16
17
  ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
17
18
  ".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
@@ -199,6 +200,7 @@ export class AppGateway {
199
200
  registry;
200
201
  returnOrigins;
201
202
  signIn;
203
+ captureNote;
202
204
  server;
203
205
  template;
204
206
  tickets = new Map();
@@ -210,13 +212,16 @@ export class AppGateway {
210
212
  * `embedded`: it runs framed inside a Bivy client (a third-party frame). */
211
213
  sessions = new Map();
212
214
  sockets = new Map();
215
+ captureAfter = 0;
216
+ capturing = false;
213
217
  displays = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: false });
214
218
  /** `signIn` sends a signed-out visit to a view's stable address back
215
219
  * through Bivy, which re-opens it for a signed-in device. */
216
- constructor(registry, originTemplate, returnOrigins = () => [], signIn) {
220
+ constructor(registry, originTemplate, returnOrigins = () => [], signIn, captureNote) {
217
221
  this.registry = registry;
218
222
  this.returnOrigins = returnOrigins;
219
223
  this.signIn = signIn;
224
+ this.captureNote = captureNote;
220
225
  this.template = previewOriginTemplate(originTemplate);
221
226
  this.server = http.createServer((req, res) => { void this.handle(req, res).catch(() => { if (!res.headersSent)
222
227
  res.writeHead(502); res.end("Preview request failed."); }); });
@@ -253,12 +258,12 @@ export class AppGateway {
253
258
  this.requireWeb(id);
254
259
  return `${this.origin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: Date.now() + 60_000 })}`;
255
260
  }
256
- /** A reusable link straight to the app origin (no shell frame), so it opens
257
- * in any browser and can be inspected. Valid until revoked or SHARE_TTL. */
261
+ /** Public reviewers use the same isolated controls as the owner, but can
262
+ * only submit notes. Valid until revoked or SHARE_TTL. */
258
263
  share(id) {
259
264
  this.requireWeb(id);
260
265
  const expiresAt = Date.now() + SHARE_TTL;
261
- return { url: `${this.origin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: expiresAt, reusable: true })}`, expiresAt };
266
+ return { url: `${this.shellOrigin(id)}${OPEN_PATH}#${this.grant({ appId: id, expires: expiresAt, reusable: true })}`, expiresAt };
262
267
  }
263
268
  revoke(id) {
264
269
  for (const map of [this.tickets, this.sessions])
@@ -366,7 +371,7 @@ export class AppGateway {
366
371
  return;
367
372
  }
368
373
  res.setHeader("Content-Type", "application/json");
369
- res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo, ...(entry.target.kind === "display" ? { inspect: false } : {}) }));
374
+ res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo, ...(grant.reusable ? { reviewer: true } : {}), ...(entry.target.kind === "display" ? { inspect: false } : {}) }));
370
375
  return;
371
376
  }
372
377
  res.writeHead(404);
@@ -731,7 +736,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
731
736
  let body = "";
732
737
  for await (const chunk of req) {
733
738
  body += chunk.toString();
734
- if (body.length > 4096) {
739
+ if (body.length > 256_000) {
735
740
  res.writeHead(413);
736
741
  res.end();
737
742
  return;
@@ -747,6 +752,16 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
747
752
  return;
748
753
  }
749
754
  const text = (value, max) => typeof value === "string" ? value.trim().slice(0, max) : "";
755
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
756
+ res.writeHead(400);
757
+ res.end();
758
+ return;
759
+ }
760
+ if (input.mark === undefined && input.context === undefined && body.length > 4096) {
761
+ res.writeHead(413);
762
+ res.end();
763
+ return;
764
+ }
750
765
  const note = text(input.note, 1000);
751
766
  if (!note) {
752
767
  res.writeHead(400);
@@ -755,12 +770,71 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
755
770
  }
756
771
  const viewport = input.viewport;
757
772
  const path = text(input.path, 2048);
773
+ let shot;
774
+ if (input.mark !== undefined) {
775
+ // Public input cannot choose a session, app, Compare image, or capture
776
+ // dimensions beyond a modest viewport. No agent commands are exposed.
777
+ if (!this.session(req, entry.view.id)?.reviewer) {
778
+ res.writeHead(403);
779
+ res.end();
780
+ return;
781
+ }
782
+ let mark;
783
+ try {
784
+ const raw = input.mark;
785
+ const strokes = readStrokes(raw?.strokes);
786
+ const v = raw.viewport;
787
+ if (!Number.isFinite(v?.width) || !Number.isFinite(v?.height) || v.width < 40 || v.width > 4000 || v.height < 40 || v.height > 4000 || v.width * v.height > 4_000_000)
788
+ throw Error();
789
+ const scroll = raw.scroll;
790
+ if (scroll && (![scroll.x, scroll.y].every(n => Number.isFinite(n) && n >= 0 && n <= 100_000)))
791
+ throw Error();
792
+ if (strokes.some(s => s.points.some(([x, y]) => x < 0 || y < 0 || x > 104_000 || y > 104_000)))
793
+ throw Error();
794
+ // Bound raster work as well as JSON size (long off-screen paths can
795
+ // otherwise consume CPU even though they paint no visible pixels).
796
+ const distance = strokes.reduce((total, s) => total + s.points.reduce((length, p, i) => i ? length + Math.hypot(p[0] - s.points[i - 1][0], p[1] - s.points[i - 1][1]) : length, 0), 0);
797
+ if (distance > 50_000)
798
+ throw Error();
799
+ mark = { appId: entry.app.id, viewId: entry.view.id, strokes, viewport: v, path: text(raw.path, 2048), scroll, dpr: 1, theme: raw.theme === "dark" ? "dark" : "light", signals: { unknown: true } };
800
+ }
801
+ catch {
802
+ res.writeHead(400);
803
+ res.end("Invalid or oversized annotation. Try fewer marks or a narrower preview.");
804
+ return;
805
+ }
806
+ if (this.capturing || Date.now() < this.captureAfter) {
807
+ res.writeHead(429);
808
+ res.end("Please wait a moment before sending another picture.");
809
+ return;
810
+ }
811
+ this.capturing = true;
812
+ this.captureAfter = Date.now() + 10_000;
813
+ try {
814
+ shot = await this.captureNote?.(entry, mark);
815
+ }
816
+ catch {
817
+ res.writeHead(502);
818
+ res.end("Could not take the picture. Your note has not been sent; please retry.");
819
+ return;
820
+ }
821
+ finally {
822
+ this.capturing = false;
823
+ }
824
+ if (!this.session(req, entry.view.id) || this.registry.getView(entry.view.id) !== entry) {
825
+ res.writeHead(401);
826
+ res.end();
827
+ return;
828
+ }
829
+ }
758
830
  this.registry.addNote(entry.view.id, {
831
+ ...(shot ? { shot } : {}),
832
+ ...(input.context ? { context: text(input.context, 8000) } : {}),
759
833
  id: randomBytes(8).toString("hex"), at: Date.now(), note, selector: text(input.selector, 300), text: text(input.text, 200),
760
834
  path: path.startsWith("/") ? path : "/", viewport: { width: Number(viewport?.width) || 0, height: Number(viewport?.height) || 0 },
761
835
  });
762
- res.writeHead(204);
763
- res.end();
836
+ res.writeHead(200, { "content-type": "application/json" });
837
+ res.end(JSON.stringify({ screenshot: Boolean(shot) }));
764
838
  }
765
839
  /** Compare shots for the shell: the list, or one PNG by index. */
766
840
  compare(req, res, entry) {
@@ -51,7 +51,7 @@ const report=(el,hold)=>{
51
51
  const r=el.getBoundingClientRect();
52
52
  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),
53
53
  rect:{x:Math.round(r.left),y:Math.round(r.top),width:Math.round(r.width),height:Math.round(r.height)},
54
- viewport:{width:innerWidth,height:innerHeight},path:location.pathname+location.search+location.hash,hold});
54
+ viewport:{width:innerWidth,height:innerHeight},scroll:{x:Math.round(scrollX),y:Math.round(scrollY)},theme:matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light',path:location.pathname+location.search+location.hash,hold});
55
55
  };
56
56
  // A long press picks at once and says so (the shell starts listening); the
57
57
  // layer stays until the finger lifts, so the release never reaches the app.
@@ -123,7 +123,12 @@ const marked=r=>{
123
123
  addEventListener('message',e=>{
124
124
  if(!framed||e.origin!==SHELL||e.source!==parent)return;
125
125
  const d=e.data||{},n=v=>Number.isFinite(+v)?+v:0;
126
- if(d.type==='bivy:draw')post(drawState());
126
+ if(d.type==='bivy:note'&&REVIEWER){
127
+ void fetch('/__bivy/notes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(d.note)})
128
+ .then(async r=>{if(!r.ok)throw Error(r.status===429?'Please wait a moment before sending another picture.':r.status===400||r.status===502?(await r.text()).slice(0,200):'Could not send. Check that the link is still valid and try again.');return r.json();})
129
+ .then(result=>post({type:'note-sent',id:d.id,screenshot:result.screenshot===true}),error=>post({type:'note-sent',id:d.id,error:error.message}));
130
+ }
131
+ else if(d.type==='bivy:draw')post(drawState());
127
132
  else if(d.type==='bivy:scroll'){scrollBy({left:n(d.dx),top:n(d.dy),behavior:'instant'});post({type:'scrolled',scroll:{x:Math.round(scrollX),y:Math.round(scrollY)}});}
128
133
  else if(d.type==='bivy:marks'&&d.rect)post({type:'marked',id:n(d.id),elements:marked({x:n(d.rect.x),y:n(d.rect.y),width:n(d.rect.width),height:n(d.rect.height)})});
129
134
  });
@@ -48,8 +48,8 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
48
48
  /* Hold to talk: a phone-sized target that never selects text or opens a callout. */
49
49
  #mic { flex:none; inline-size:var(--space-7); block-size:var(--space-7); padding:0; border-radius:var(--radius-full); touch-action:none; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; }
50
50
  #mic[aria-pressed="true"] { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); }
51
- #voice-status { font-size:var(--text-xs); margin:var(--space-2) 0 0; }
52
- #voice-status:empty { display:none; }
51
+ #voice-status, #note-status { font-size:var(--text-xs); margin:var(--space-2) 0 0; }
52
+ #voice-status:empty, #note-status:empty { display:none; }
53
53
  /* The note owns the dock while editing. Only its body scrolls; actions never
54
54
  cover the text field, even when the keyboard leaves little vertical room. */
55
55
  #dock { max-height:calc(100% - var(--space-4)); }
@@ -107,6 +107,7 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
107
107
  <button class="btn" id="mic" type="button" hidden aria-pressed="false" aria-label="Speak" title="Hold to speak, or tap to start and stop"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5a3 3 0 003-3V6a3 3 0 10-6 0v6.5a3 3 0 003 3z"/><path d="M6 11.5v1a6 6 0 0012 0v-1M12 18.5V21M9 21h6"/></svg></button></div>
108
108
  <p class="muted" id="voice-status" role="status"></p>
109
109
  <p class="muted" id="draft-hint">Review in chat before sending.</p>
110
+ <p class="muted" id="note-status" role="status"></p>
110
111
  <details id="draft-details"><summary>Preview context</summary><pre id="draft-context"></pre></details>
111
112
  </div>
112
113
  <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>
@@ -146,9 +147,16 @@ const embedded=parent!==window;
146
147
  const toBivy=m=>parent.postMessage(Object.assign({source:'bivy-preview'},m),location.ancestorOrigins?.[0]||new URL(metadata.returnTo).origin);
147
148
  const storageKey='bivy-preview';
148
149
  const safePath=p=>typeof p==='string'&&p.startsWith('/')&&!p.startsWith('//')&&p.length<=2048?p:'/';
149
- const open=(path,message)=>{status.hidden=false;status.textContent=message;resetConsole();frame.src=metadata.origin+safePath(path);};
150
+ const reviewerTools=ready=>{if(metadata?.reviewer)for(const id of ['point','draw','errors'])$(id).disabled=!ready;};
151
+ const open=(path,message)=>{status.hidden=false;status.textContent=message;resetConsole();reviewerTools(false);frame.src=metadata.origin+safePath(path);};
150
152
  function show(data,launch){
151
153
  metadata=data;
154
+ if(data.reviewer){
155
+ drawOk=data.inspect!==false;$('draw').hidden=!drawOk;back.hidden=true;
156
+ $('draft-title').textContent='Note for the app’s owner';
157
+ $('draft-text').maxLength=1000;
158
+ $('send-errors').textContent='Leave a note…';
159
+ }
152
160
  $('title').textContent=data.name;
153
161
  $('title').title=data.name;
154
162
  document.title='Bivy · '+data.name;
@@ -161,8 +169,9 @@ function show(data,launch){
161
169
  for(const b of [$('point'),$('errors')])b.hidden=data.inspect===false;
162
170
  if(data.inspect===false)frame.allow='clipboard-read; clipboard-write';
163
171
  frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch+(startPage?'~'+startPage:''):'/');
164
- back.hidden=embedded&&Boolean(data.returnTo);
172
+ back.hidden=Boolean(data.reviewer)||(embedded&&Boolean(data.returnTo));
165
173
  for(const b of [reload,$('point'),$('errors')])b.disabled=false;
174
+ reviewerTools(false);
166
175
  status.textContent='Loading app…';
167
176
  frame.onload=()=>{status.hidden=true;void loadCompare();if(revision===null){revision=-1;watch();}else if(wake)wake();};
168
177
  // A Bivy client framing the shell says whether it can take dictation.
@@ -189,16 +198,21 @@ async function watch(){
189
198
  revision=d.revision;retry=0;setTimeout(watch,0);
190
199
  }catch{retry=Math.min(30000,(retry||1000)*2);const t=setTimeout(watch,retry);wake=()=>{clearTimeout(t);watch();};}
191
200
  }
192
- // Drafts go to the session's composer; nothing is ever sent from here.
201
+ // Owner drafts go to the session's composer. Reviewer drafts use only the
202
+ // bounded notes endpoint, never the owner's command channel.
193
203
  function toChat(text){
194
204
  if(embedded)return toBivy({type:'draft',text});
195
205
  const to=new URL(metadata.returnTo),session=to.pathname.split('/').pop();
196
206
  location.assign(to.origin+'/share?session='+encodeURIComponent(session)+'&text='+encodeURIComponent(text));
197
207
  }
208
+ let noteMark=null,pendingNote=null,noteSequence=0;
198
209
  function draft(context,listen){
199
- panels(null);
210
+ panels(null);noteMark=null;
211
+ $('draft-add').disabled=false;
212
+ $('note-status').textContent='';
213
+ $('draft-hint').textContent=metadata.reviewer?'Feedback only — no agent runs. Pictures are approximate; if screenshots are off, we save text and mark details.':'Review in chat before sending.';
200
214
  $('draft').hidden=false;
201
- $('draft-add').textContent=metadata.returnTo?'Add to chat':'Copy';
215
+ $('draft-add').textContent=metadata.reviewer?'Send note':metadata.returnTo?'Add to chat':'Copy';
202
216
  $('draft-context').textContent=context;
203
217
  $('draft-details').open=false;
204
218
  const box=$('draft-text');box.value='';
@@ -240,12 +254,34 @@ function fromBivy(d){
240
254
  $('draft-cancel').onclick=()=>{const target=marks?$('draw'):point;cancelListening();$('draft').hidden=true;endDraw();target.focus();};
241
255
  $('draft-add').onclick=async()=>{
242
256
  cancelListening();
243
- const note=$('draft-text').value.trim(),text=(note?note+'\\n\\n':'')+$('draft-context').textContent;
257
+ const note=$('draft-text').value.trim();
258
+ if(metadata.reviewer){
259
+ if(!note){$('note-status').textContent='Write a note before sending.';$('draft-text').focus();return;}
260
+ const mark=marks||noteMark,id=++noteSequence;
261
+ $('draft-add').disabled=true;$('draft-cancel').disabled=true;$('draft-text').disabled=true;
262
+ $('note-status').textContent='Sending note…';$('draft-add').textContent='Sending…';
263
+ const timer=setTimeout(()=>finishNote({id,error:'No reply yet. Your note may have arrived; check the connection before retrying.'}),60000);
264
+ pendingNote={id,timer};
265
+ frame.contentWindow?.postMessage({type:'bivy:note',id,note:{note,context:$('draft-context').textContent,selector:mark?.selector||'',text:mark?.text||'',path:mark?.path||currentPath,viewport:mark?.viewport||{width:frame.clientWidth,height:frame.clientHeight},...(mark?{mark}:{})}},metadata.origin);
266
+ return;
267
+ }
268
+ const text=(note?note+'\\n\\n':'')+$('draft-context').textContent;
244
269
  // Marks go to the Bivy client, which adds a picture made on the machine.
245
270
  if(marks){const mark=marks;$('draft').hidden=true;endDraw();return toBivy({type:'annotation',text,mark});}
246
271
  if(metadata.returnTo){$('draft').hidden=true;return toChat(text);}
247
272
  try{await navigator.clipboard.writeText(text);$('draft-add').textContent='Copied';}catch{$('draft-text').select();}
248
273
  };
274
+ $('draft-text').addEventListener('input',()=>{if(!pendingNote)$('note-status').textContent='';});
275
+ function finishNote(d){
276
+ if(!pendingNote||d.id!==pendingNote.id)return;
277
+ clearTimeout(pendingNote.timer);pendingNote=null;
278
+ $('draft-cancel').disabled=false;$('draft-add').disabled=false;$('draft-text').disabled=false;$('draft-add').textContent='Send note';
279
+ if(d.error){$('note-status').textContent=String(d.error).slice(0,200);return;}
280
+ $('draft').hidden=true;endDraw();noteMark=null;
281
+ $('stamp').textContent=d.screenshot?'Note and approximate picture sent':'Note sent without a picture';
282
+ status.hidden=false;status.textContent=$('stamp').textContent+'. The app’s owner will see it.';
283
+ point.focus();
284
+ }
249
285
  // Console
250
286
  let entries=[];
251
287
  function resetConsole(){entries=[];renderConsole();}
@@ -274,13 +310,15 @@ function picked(d){
274
310
  draft('In the app preview "'+metadata.name+'" (page '+safePath(d.path)+', viewport '+v.width+'×'+v.height+'):\\n'
275
311
  +'Element: '+String(d.selector).slice(0,300)+(d.text?' ("'+String(d.text).slice(0,200)+'")':'')+', '+r.width+'×'+r.height+' at '+r.x+','+r.y
276
312
  +(errors.length?'\\nRecent errors:\\n'+errors.map(e=>'- '+e.text).join('\\n'):''),d.hold===true&&voice);
313
+ if(metadata.reviewer){const s=pos(d.scroll);noteMark={path:safePath(d.path),viewport:v,scroll:s,theme:d.theme,selector:String(d.selector).slice(0,300),text:String(d.text||'').slice(0,200),strokes:[{tool:'box',points:[[r.x+s.x,r.y+s.y],[r.x+r.width+s.x,r.y+r.height+s.y]]}]};}
277
314
  }
278
315
  // Draw: freeze the app and mark what's wrong. Marks are kept in page
279
316
  // coordinates (so they stay on the content while two fingers scroll), and
280
317
  // the app reports what's under each one. On Done they go into the draft box
281
318
  // with that context; Add to chat hands them to the Bivy client, which gets a
282
- // picture from the machine. Nothing about them goes through this origin's
283
- // network. Compare's "after" shot can be marked the same way.
319
+ // picture from the machine. Reviewers instead submit marks with their note;
320
+ // pictures stay on the machine for the owner. Compare's "after" shot can be
321
+ // marked the same way by the owner.
284
322
  const ink=$('ink'),inkMarks=$('ink-marks'),SVG='http://www.w3.org/2000/svg';
285
323
  let draw=null,marks=null,drawOk=false,nextStroke=0;
286
324
  const pos=v=>({x:Number(v?.x)||0,y:Number(v?.y)||0});
@@ -401,6 +439,7 @@ $('draw-done').onclick=()=>{
401
439
  // Compare: screenshots around the agent's last change (agent screenshots on).
402
440
  const compareBtn=$('compare-btn');let shots=[],urls=[],compareAfter=-1;
403
441
  async function loadCompare(){
442
+ if(metadata.reviewer)return;
404
443
  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{}
405
444
  }
406
445
  function panels(open){if(listening)cancelListening();if(draw&&!(open==='compare'&&draw.target==='compare'))endDraw();for(const [id,btn] of [['console','errors'],['compare','compare-btn']]){$(id).hidden=id!==open;$(btn).setAttribute('aria-pressed',String(id===open));}$('draft').hidden=true;}
@@ -443,7 +482,8 @@ addEventListener('message',e=>{
443
482
  downText.textContent='Nothing is answering on port '+downPort+'.';
444
483
  ask.hidden=!metadata.returnTo;
445
484
  }else if(d.source==='bivy-inspector'){
446
- if(d.type==='route')currentPath=safePath(d.path);
485
+ if(d.type==='note-sent'&&metadata.reviewer)finishNote(d);
486
+ else if(d.type==='route'){currentPath=safePath(d.path);reviewerTools(true);}
447
487
  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();}
448
488
  else if(d.type==='picked')picked(d);
449
489
  else if(d.type==='release'&&heldAt){heldAt=0;stopListening();}
@@ -15,7 +15,9 @@ const isNote = (value) => {
15
15
  const note = value;
16
16
  return !!note && typeof note.id === "string" && typeof note.at === "number" && typeof note.note === "string" && note.note.length <= 1000
17
17
  && typeof note.selector === "string" && typeof note.text === "string" && typeof note.path === "string"
18
- && typeof note.viewport?.width === "number" && typeof note.viewport?.height === "number";
18
+ && typeof note.viewport?.width === "number" && typeof note.viewport?.height === "number"
19
+ && (note.context === undefined || (typeof note.context === "string" && note.context.length <= 8000))
20
+ && (note.shot === undefined || (typeof note.shot?.hash === "string" && /^[a-f0-9]{64}$/.test(note.shot.hash) && Number.isFinite(note.shot.size) && Number.isFinite(note.shot.width) && Number.isFinite(note.shot.height)));
19
21
  };
20
22
  const sameFiles = (a, b) => a.size === b.size && [...a].every(([key, data]) => b.get(key)?.equals(data));
21
23
  /** Snapshot, not a live filesystem server: symlinks and hidden files are never published. */
@@ -295,6 +297,10 @@ export class AppRegistry extends EventEmitter {
295
297
  this.save();
296
298
  }
297
299
  agentNotes(id) { return this.apps.get(id)?.agentNotes === true; }
300
+ /** Includes persisted apps not restored yet; unread pictures survive GC. */
301
+ notePictureHashes() {
302
+ return [...this.persisted.values()].flatMap(record => Object.values(record.notes ?? {}).flatMap(notes => Array.isArray(notes) ? notes.filter(isNote).flatMap(note => note.shot ? [note.shot.hash] : []) : []));
303
+ }
298
304
  /** A reviewer's note: kept bounded, saved with the app, announced (`notes`). */
299
305
  addNote(viewId, note) {
300
306
  const entry = this.views.get(viewId);
@@ -9,15 +9,17 @@ export class RemotePreview {
9
9
  registry;
10
10
  returnOrigins;
11
11
  signIn;
12
+ captureNote;
12
13
  gateway;
13
14
  origin;
14
15
  relayUrl;
15
16
  online = false;
16
17
  sockets = new Set();
17
- constructor(registry, returnOrigins, signIn) {
18
+ constructor(registry, returnOrigins, signIn, captureNote) {
18
19
  this.registry = registry;
19
20
  this.returnOrigins = returnOrigins;
20
21
  this.signIn = signIn;
22
+ this.captureNote = captureNote;
21
23
  }
22
24
  get available() { return this.online && Boolean(this.gateway); }
23
25
  ready(origin, relayUrl) {
@@ -30,7 +32,7 @@ export class RemotePreview {
30
32
  return;
31
33
  if (this.origin !== template) {
32
34
  this.gateway?.close();
33
- this.gateway = new AppGateway(this.registry, template, this.returnOrigins, this.signIn);
35
+ this.gateway = new AppGateway(this.registry, template, this.returnOrigins, this.signIn, this.captureNote);
34
36
  this.origin = template;
35
37
  }
36
38
  this.relayUrl = relayUrl.replace(/\/$/, "") + "/preview/stream";
package/dist/server.js CHANGED
@@ -1654,6 +1654,8 @@ function referencedAttachmentHashes() {
1654
1654
  hashes.add(entry.ref.hash);
1655
1655
  }
1656
1656
  }
1657
+ for (const hash of appRegistry.notePictureHashes())
1658
+ hashes.add(hash);
1657
1659
  return eventLog.health().ok ? hashes : null;
1658
1660
  }
1659
1661
  // --- Warm session replication (docs/session-replication.md) -----------------
@@ -1842,8 +1844,18 @@ const appSignIn = (view, pagePath) => {
1842
1844
  return undefined;
1843
1845
  return `${new URL(client).origin}/sessions/${encodeURIComponent(view.app.sessionId)}?node=${encodeURIComponent(identity.nodeId)}#preview=${view.app.id}.${view.view.id}.${encodeURIComponent(pagePath)}`;
1844
1846
  };
1845
- const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins, appSignIn) : undefined;
1846
- const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn);
1847
+ // Public notes get owner-only encrypted pictures, never a screenshot response
1848
+ // on the public origin. The annotation service honors appScreenshots being off.
1849
+ const captureReviewerNote = async (entry, mark) => {
1850
+ const result = await appService.annotate(entry.app.sessionId, mark);
1851
+ if (!result.image)
1852
+ return undefined;
1853
+ const image = result.image;
1854
+ const ref = attachmentStore.put(Buffer.from(image.data, "base64"), { name: "Reviewer note (approximate).png", mimeType: "image/png", kind: "image" });
1855
+ return { hash: ref.hash, size: ref.size, width: image.width, height: image.height };
1856
+ };
1857
+ const appGateway = process.env.BIVY_APPS_ORIGIN ? new AppGateway(appRegistry, process.env.BIVY_APPS_ORIGIN, appReturnOrigins, appSignIn, captureReviewerNote) : undefined;
1858
+ const remotePreview = new RemotePreview(appRegistry, appReturnOrigins, appSignIn, captureReviewerNote);
1847
1859
  // Desktop app views: one private display per view, started on first open.
1848
1860
  const appDisplays = createDisplayHost(appDir);
1849
1861
  /** Review cards: screenshots become encrypted attachments (fetched by hash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.18.4-staging.9",
3
+ "version": "0.18.4",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",