@bivy/bivy 0.18.5 → 0.18.6-staging.2

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.
@@ -3,6 +3,19 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { decodePng } from "./review.js";
5
5
  import { encodePng } from "./rfb.js";
6
+ /** Scroll containers are page data, never executable capture instructions. */
7
+ export function readElementScrolls(input) {
8
+ if (input === undefined)
9
+ return [];
10
+ if (!Array.isArray(input) || input.length > 50)
11
+ throw new Error("Invalid scroll containers.");
12
+ return input.map((raw) => {
13
+ if (!raw || typeof raw.selector !== "string" || !raw.selector.length || raw.selector.length > 2048
14
+ || ![raw.x, raw.y].every(n => typeof n === "number" && Number.isFinite(n) && Math.abs(n) <= 100_000))
15
+ throw new Error("Invalid scroll container.");
16
+ return { selector: raw.selector, x: raw.x, y: raw.y };
17
+ });
18
+ }
6
19
  const MAX_STROKES = 50;
7
20
  const MAX_POINTS = 10_000;
8
21
  /** Validates strokes from the client (untrusted shape): bounded, finite. */
@@ -12,7 +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
+ import { readStrokes, readElementScrolls } from "./annotate.js";
16
16
  const MIME = {
17
17
  ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
18
18
  ".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
@@ -783,6 +783,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
783
783
  try {
784
784
  const raw = input.mark;
785
785
  const strokes = readStrokes(raw?.strokes);
786
+ const elementScrolls = readElementScrolls(raw?.elementScrolls);
786
787
  const v = raw.viewport;
787
788
  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
789
  throw Error();
@@ -796,7 +797,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
796
797
  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
798
  if (distance > 50_000)
798
799
  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
+ mark = { appId: entry.app.id, viewId: entry.view.id, strokes, viewport: v, path: text(raw.path, 2048), scroll, elementScrolls, dpr: 1, theme: raw.theme === "dark" ? "dark" : "light", signals: { unknown: true } };
800
801
  }
801
802
  catch {
802
803
  res.writeHead(400);
@@ -92,8 +92,13 @@ const signals=()=>{
92
92
  const open=Boolean(document.querySelector('dialog[open],details[open],[aria-expanded="true"],[aria-modal="true"]'));
93
93
  return {storage,cookies:Boolean(document.cookie),interacted,open,edited};
94
94
  };
95
+ // Use a unique DOM path: repeated class names must not restore another panel.
96
+ const elementScrolls=()=>[...document.querySelectorAll('*')].filter(el=>el!==document.scrollingElement&&(el.scrollLeft||el.scrollTop)).slice(0,50).map(el=>{
97
+ const parts=[];for(let n=el;n&&n.nodeType===1;n=n.parentElement){if(n.id){parts.unshift('#'+CSS.escape(n.id));break;}parts.unshift(n.localName+':nth-child('+([...n.parentNode.children].indexOf(n)+1)+')');}
98
+ return {selector:parts.join(' > '),x:el.scrollLeft,y:el.scrollTop};
99
+ });
95
100
  const drawState=()=>({type:'draw-state',scroll:{x:Math.round(scrollX),y:Math.round(scrollY)},viewport:{width:innerWidth,height:innerHeight},dpr:devicePixelRatio,
96
- theme:matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light',path:location.pathname+location.search+location.hash,signals:signals()});
101
+ theme:matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light',path:location.pathname+location.search+location.hash,elementScrolls:elementScrolls(),signals:signals()});
97
102
  /** Elements inside a mark (viewport rect): a grid sample, then the largest
98
103
  * element under each hit that sits mostly inside the mark — circling a
99
104
  * button names the button, not the bar it's in. A line through something
@@ -338,7 +338,7 @@ function startDraw(target){
338
338
  if(target==='frame'&&metadata.inspect!==false){
339
339
  const current=draw;
340
340
  current.state.signals={unknown:true};
341
- current.waiting=d=>{current.waiting=null;current.scroll=pos(d.scroll);current.state={viewport:{width:Math.round(Number(d.viewport?.width))||current.state.viewport.width,height:Math.round(Number(d.viewport?.height))||current.state.viewport.height},dpr:Number(d.dpr)||devicePixelRatio,theme:d.theme==='dark'?'dark':'light',path:safePath(d.path),signals:Object.fromEntries(Object.entries(d.signals||{}).map(([k,v])=>[k,v===true]))};renderInk();};
341
+ current.waiting=d=>{current.waiting=null;current.scroll=pos(d.scroll);current.state={viewport:{width:Math.round(Number(d.viewport?.width))||current.state.viewport.width,height:Math.round(Number(d.viewport?.height))||current.state.viewport.height},dpr:Number(d.dpr)||devicePixelRatio,theme:d.theme==='dark'?'dark':'light',path:safePath(d.path),elementScrolls:d.elementScrolls,signals:Object.fromEntries(Object.entries(d.signals||{}).map(([k,v])=>[k,v===true]))};renderInk();};
342
342
  frame.contentWindow?.postMessage({type:'bivy:draw'},metadata.origin);
343
343
  }
344
344
  for(const b of $('draw-bar').querySelectorAll('[data-tool]'))b.setAttribute('aria-checked',String(b.dataset.tool===draw.tool));
@@ -428,7 +428,7 @@ $('draw-done').onclick=()=>{
428
428
  +(els.length?els.map(el=>'- '+String(el.selector).slice(0,300)+(el.text?' ("'+String(el.text).slice(0,120)+'")':'')).join('\\n')+'\\n':'')
429
429
  +'Marks ('+(d.target==='compare'?'screenshot':'page')+' px): '+d.strokes.map(bounds).join('; ');
430
430
  const mark=Object.assign({path:st.path,viewport:st.viewport,dpr:st.dpr,strokes:d.strokes.map(s=>({tool:s.tool,points:s.points})),signals:st.signals},
431
- d.target==='frame'?{scroll:d.scroll,theme:st.theme}:{compare:compareAfter});
431
+ d.target==='frame'?{scroll:d.scroll,elementScrolls:st.elementScrolls,theme:st.theme}:{compare:compareAfter});
432
432
  // The marks stay on screen, frozen, while you add your words.
433
433
  d.done=true;draw=null;
434
434
  draft(context,false);
@@ -166,6 +166,16 @@ export async function takeShots(views, request, outDir, chrome = findChrome()) {
166
166
  await cdp.send("Page.navigate", { url: origin + request.path }, sessionId);
167
167
  await loaded;
168
168
  await new Promise((r) => setTimeout(r, 400)); // let fonts and first effects settle
169
+ // Match scrolling inside app panels as well as the document. Marks are
170
+ // viewport positions within these panels; subtract only document scroll.
171
+ let elementScrollsRestored;
172
+ if (request.elementScrolls?.length) {
173
+ const positions = JSON.stringify(request.elementScrolls);
174
+ await cdp.send("Runtime.evaluate", { expression: `(${positions}).forEach(p => { try { document.querySelector(p.selector)?.scrollTo({left:p.x,top:p.y,behavior:'instant'}); } catch {} })` }, sessionId);
175
+ await new Promise((r) => setTimeout(r, 150));
176
+ const { result } = await cdp.send("Runtime.evaluate", { expression: `(${positions}).every(p => { try { const el=document.querySelector(p.selector); return el && Math.abs(el.scrollLeft-p.x)<=2 && Math.abs(el.scrollTop-p.y)<=2; } catch { return false; } })`, returnByValue: true }, sessionId);
177
+ elementScrollsRestored = result?.value === true;
178
+ }
169
179
  let scroll;
170
180
  if (request.scroll) {
171
181
  const { result } = await cdp.send("Runtime.evaluate", { expression: `scrollTo(${Number(request.scroll.x) || 0}, ${Number(request.scroll.y) || 0}); [scrollX, scrollY]`, returnByValue: true }, sessionId);
@@ -175,7 +185,7 @@ export async function takeShots(views, request, outDir, chrome = findChrome()) {
175
185
  const { data } = await cdp.send("Page.captureScreenshot", { format: "png" }, sessionId);
176
186
  const file = path.join(outDir, `${entry.view.id.slice(0, 8)}-${width}-${theme}.png`);
177
187
  fs.writeFileSync(file, Buffer.from(data, "base64"));
178
- shots.push({ viewId: entry.view.id, view: entry.view.name, width, theme, file, ...(scroll ? { scroll } : {}) });
188
+ shots.push({ viewId: entry.view.id, view: entry.view.name, width, theme, file, ...(scroll ? { scroll } : {}), ...(elementScrollsRestored !== undefined ? { elementScrollsRestored } : {}) });
179
189
  }
180
190
  }
181
191
  return shots;
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { scanListeners } from "./listeners.js";
7
7
  import { takeShots } from "./screenshot.js";
8
- import { approximate, composite, readStrokes } from "./annotate.js";
8
+ import { approximate, composite, readStrokes, readElementScrolls } from "./annotate.js";
9
9
  import { captureFrame, encodePng, sendInput } from "./rfb.js";
10
10
  import { inputEvents, readAction } from "./input.js";
11
11
  import { pressMenu, readMenuPath, readMenus } from "./menu.js";
@@ -435,6 +435,7 @@ export class AppService {
435
435
  if (entry.view.kind !== "web")
436
436
  throw new Error("Only previews can be drawn on.");
437
437
  const strokes = readStrokes(input.strokes);
438
+ const elementScrolls = readElementScrolls(input.elementScrolls);
438
439
  const whole = (n, min, max) => typeof n === "number" && Number.isFinite(n) && n >= min && n <= max;
439
440
  // Compare's shot is drawn on at its on-screen size, which can be small.
440
441
  if (!whole(input.viewport?.width, 40, 4000) || !whole(input.viewport?.height, 40, 8000))
@@ -466,7 +467,7 @@ export class AppService {
466
467
  }
467
468
  const outDir = path.join(os.tmpdir(), "bivy-shots", "annotate", entry.view.id);
468
469
  const dpr = whole(input.dpr, 1, 3) ? Math.round(input.dpr * 4) / 4 : 2;
469
- const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [viewport.width], themes: [input.theme === "dark" ? "dark" : "light"], path: page, height: viewport.height, scale: dpr, scroll }, outDir));
470
+ const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [viewport.width], themes: [input.theme === "dark" ? "dark" : "light"], path: page, height: viewport.height, scale: dpr, scroll, ...(elementScrolls.length ? { elementScrolls } : {}) }, outDir));
470
471
  this.shooting = run;
471
472
  const [shot] = await run;
472
473
  if (!shot)
@@ -476,7 +477,7 @@ export class AppService {
476
477
  const landed = shot.scroll ?? scroll;
477
478
  const scrolledTo = Math.abs(landed.x - scroll.x) <= 2 && Math.abs(landed.y - scroll.y) <= 2;
478
479
  // Marks stay on the content they were drawn on, wherever the retake scrolled.
479
- return finish(base, landed, approximate("retake", input.signals, scrolledTo));
480
+ return finish(base, landed, approximate("retake", input.signals, scrolledTo && shot.elementScrollsRestored !== false));
480
481
  }
481
482
  /** The managed server's output, as an attachable terminal (starts it if needed). */
482
483
  async logs(sessionId, appId, viewId) {
@@ -23,6 +23,7 @@ export const CLIENT_COMMAND_SCHEMAS = {
23
23
  "apps.reviewMode": Type.Object({ ...request, ...session, appId: Type.String(), mode: Type.Union([Type.Literal("ready"), Type.Literal("every"), Type.Literal("off")]) }),
24
24
  "apps.annotate": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), path: Type.Optional(Type.String({ maxLength: 2048 })),
25
25
  viewport: Type.Object({ width: Type.Number(), height: Type.Number() }), scroll: Type.Optional(Type.Object({ x: Type.Number(), y: Type.Number() })),
26
+ elementScrolls: Type.Optional(Type.Array(Type.Object({ selector: Type.String({ minLength: 1, maxLength: 2048 }), x: Type.Number({ minimum: -100000, maximum: 100000 }), y: Type.Number({ minimum: -100000, maximum: 100000 }) }), { maxItems: 50 })),
26
27
  dpr: Type.Optional(Type.Number()), theme: Type.Optional(Type.Union([Type.Literal("light"), Type.Literal("dark")])), compare: Type.Optional(Type.Integer({ minimum: 0 })),
27
28
  strokes: Type.Array(Type.Object({ tool: Type.Union([Type.Literal("pen"), Type.Literal("box")]), points: Type.Array(Type.Array(Type.Number(), { minItems: 2, maxItems: 2 }), { maxItems: 10000 }) }), { minItems: 1, maxItems: 50 }),
28
29
  signals: Type.Optional(Type.Record(Type.String(), Type.Boolean())) }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.18.5",
3
+ "version": "0.18.6-staging.2",
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.",