@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.
- package/dist/apps/gateway.js +82 -8
- package/dist/apps/inspector.js +7 -2
- package/dist/apps/preview-shell.js +51 -11
- package/dist/apps/registry.js +7 -1
- package/dist/apps/remote-preview.js +4 -2
- package/dist/server.js +14 -2
- package/package.json +1 -1
package/dist/apps/gateway.js
CHANGED
|
@@ -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
|
-
/**
|
|
257
|
-
*
|
|
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.
|
|
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 >
|
|
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(
|
|
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) {
|
package/dist/apps/inspector.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
//
|
|
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()
|
|
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.
|
|
283
|
-
//
|
|
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==='
|
|
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();}
|
package/dist/apps/registry.js
CHANGED
|
@@ -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
|
-
|
|
1846
|
-
|
|
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
|