@bivy/bivy 0.17.3-staging.9 → 0.18.0

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.
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { inflateSync } from "node:zlib";
4
+ /** What each review-card mode lets through. `present`: the agent's
5
+ * `bivy app present` makes a card. `minChange`: at the end of a run whose
6
+ * preview changed, the share of pixels that must differ from the page before
7
+ * the run; undefined = never on its own. A new mode is a new row. */
8
+ export const REVIEW_MODES = {
9
+ // ~8×8 CSS px at 2× on a phone-sized page: a moved button or changed
10
+ // label, not a blinking caret (~0.01%).
11
+ ready: { present: true, minChange: 0.0002 },
12
+ every: { present: true, minChange: 0 },
13
+ off: { present: false },
14
+ };
15
+ /** Whether a moment gets a review card. Show me ("asked") is the user's own
16
+ * request, so it always does. Without a baseline, a run can't prove a visible
17
+ * change, so it gets no card: a backend-only run must never produce one. */
18
+ export function shouldReview(input) {
19
+ if (input.trigger === "asked")
20
+ return true;
21
+ if (input.muted)
22
+ return false;
23
+ const rule = REVIEW_MODES[input.mode];
24
+ if (input.trigger === "present")
25
+ return rule.present;
26
+ return input.revisionChanged && rule.minChange !== undefined && input.change !== undefined && input.change > rule.minChange;
27
+ }
28
+ /** What the "session finished" notification carries about a review: IDs only.
29
+ * The device fetches the screenshot over the encrypted session channel. */
30
+ export function reviewHint(review) {
31
+ return {
32
+ review: { appId: review.appId, viewId: review.viewId, reviewId: review.id },
33
+ body: review.trigger === "notes"
34
+ ? `${review.notes === 1 ? "A reviewer note is" : `${review.notes ?? "Reviewer"} notes are`} waiting on ${review.name} — tap to read ${review.notes === 1 ? "it" : "them"}.`
35
+ : `${review.name} ${review.trigger === "present" ? "is ready to review" : "changed"} — tap to see it.`,
36
+ };
37
+ }
38
+ /** Decodes the 8-bit, non-interlaced RGB(A) PNGs Chrome and rfb.ts write. */
39
+ export function decodePng(png) {
40
+ if (png.readUInt32BE(0) !== 0x89504e47)
41
+ throw new Error("Not a PNG.");
42
+ let offset = 8, width = 0, height = 0, channels = 0;
43
+ const data = [];
44
+ while (offset < png.length) {
45
+ const length = png.readUInt32BE(offset);
46
+ const type = png.toString("latin1", offset + 4, offset + 8);
47
+ const chunk = png.subarray(offset + 8, offset + 8 + length);
48
+ if (type === "IHDR") {
49
+ width = chunk.readUInt32BE(0);
50
+ height = chunk.readUInt32BE(4);
51
+ const [depth, color, , , interlace] = chunk.subarray(8, 13);
52
+ channels = color === 6 ? 4 : color === 2 ? 3 : 0;
53
+ if (depth !== 8 || !channels || interlace)
54
+ throw new Error("Unsupported PNG.");
55
+ }
56
+ else if (type === "IDAT")
57
+ data.push(chunk);
58
+ else if (type === "IEND")
59
+ break;
60
+ offset += 12 + length;
61
+ }
62
+ const raw = inflateSync(Buffer.concat(data));
63
+ const stride = width * channels;
64
+ const pixels = Buffer.alloc(stride * height);
65
+ for (let y = 0; y < height; y++) {
66
+ const filter = raw[y * (stride + 1)];
67
+ const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
68
+ const out = y * stride;
69
+ for (let x = 0; x < stride; x++) {
70
+ const a = x >= channels ? pixels[out + x - channels] : 0;
71
+ const b = y ? pixels[out - stride + x] : 0;
72
+ const c = y && x >= channels ? pixels[out - stride + x - channels] : 0;
73
+ let value = line[x];
74
+ if (filter === 1)
75
+ value += a;
76
+ else if (filter === 2)
77
+ value += b;
78
+ else if (filter === 3)
79
+ value += (a + b) >> 1;
80
+ else if (filter === 4) {
81
+ const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
82
+ value += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
83
+ }
84
+ pixels[out + x] = value & 0xff;
85
+ }
86
+ }
87
+ return { width, height, channels, pixels };
88
+ }
89
+ /** Share of pixels that visibly differ (0–1). Different sizes count as a
90
+ * full change: the page grew or shrank. */
91
+ export function visualChange(before, after) {
92
+ if (before.equals(after))
93
+ return 0;
94
+ const a = decodePng(before), b = decodePng(after);
95
+ if (a.width !== b.width || a.height !== b.height)
96
+ return 1;
97
+ let changed = 0;
98
+ for (let i = 0, p = 0; p < a.width * a.height; p++, i += a.channels) {
99
+ const j = p * b.channels;
100
+ const delta = Math.abs(a.pixels[i] - b.pixels[j]) + Math.abs(a.pixels[i + 1] - b.pixels[j + 1]) + Math.abs(a.pixels[i + 2] - b.pixels[j + 2]);
101
+ // Re-renders of the same page are pixel-identical; this only skips
102
+ // sub-pixel antialiasing shifts, not a light grey on white.
103
+ if (delta > 12)
104
+ changed++;
105
+ }
106
+ return changed / (a.width * a.height);
107
+ }
108
+ /** PNG dimensions, from the header. */
109
+ export function pngSize(png) {
110
+ return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
111
+ }
package/dist/apps/rfb.js CHANGED
@@ -5,18 +5,21 @@ import { deflateSync } from "node:zlib";
5
5
  /** Reads exact byte counts from a stream. */
6
6
  class Reader {
7
7
  buffer = Buffer.alloc(0);
8
+ /** Chunks not yet joined onto `buffer`: joined once enough have arrived, so a large frame isn't copied per chunk. */
9
+ chunks = [];
10
+ queued = 0;
8
11
  waiting;
9
12
  failure;
10
13
  reject;
11
14
  constructor(socket) {
12
- socket.on("data", (chunk) => { this.buffer = Buffer.concat([this.buffer, chunk]); this.pump(); });
15
+ socket.on("data", (chunk) => { this.chunks.push(chunk); this.queued += chunk.length; this.pump(); });
13
16
  const fail = (error) => { this.failure = error; this.reject?.(error); };
14
17
  socket.on("error", fail);
15
18
  socket.on("close", () => fail(new Error("The display closed the connection.")));
16
19
  }
17
20
  read(n) {
18
21
  return new Promise((resolve, reject) => {
19
- if (this.failure && this.buffer.length < n) {
22
+ if (this.failure && this.buffer.length + this.queued < n) {
20
23
  reject(this.failure);
21
24
  return;
22
25
  }
@@ -26,8 +29,13 @@ class Reader {
26
29
  });
27
30
  }
28
31
  pump() {
29
- if (!this.waiting || this.buffer.length < this.waiting.n)
32
+ if (!this.waiting || this.buffer.length + this.queued < this.waiting.n)
30
33
  return;
34
+ if (this.buffer.length < this.waiting.n) {
35
+ this.buffer = Buffer.concat([this.buffer, ...this.chunks]);
36
+ this.chunks = [];
37
+ this.queued = 0;
38
+ }
31
39
  const { n, resolve } = this.waiting;
32
40
  this.waiting = undefined;
33
41
  const out = this.buffer.subarray(0, n);
@@ -35,27 +43,68 @@ class Reader {
35
43
  resolve(out);
36
44
  }
37
45
  }
38
- /** One full frame from a VNC server (security type None), as packed RGB. Joins
39
- * as a shared client, so open viewers stay connected. */
46
+ /** Joins a VNC server (security type None) as a shared client, so open viewers
47
+ * stay connected: the connection and the display's size. */
48
+ async function join(socket) {
49
+ const reader = new Reader(socket);
50
+ const version = (await reader.read(12)).toString("latin1");
51
+ if (!/^RFB 003\.\d{3}\n$/.test(version))
52
+ throw new Error("Not a VNC display.");
53
+ socket.write("RFB 003.008\n");
54
+ const types = await reader.read((await reader.read(1))[0]);
55
+ if (!types.includes(1))
56
+ throw new Error("The display requires a password.");
57
+ socket.write(Buffer.from([1]));
58
+ if ((await reader.read(4)).readUInt32BE(0) !== 0)
59
+ throw new Error("The display refused the connection.");
60
+ socket.write(Buffer.from([1]));
61
+ const init = await reader.read(24);
62
+ await reader.read(init.readUInt32BE(20)); // desktop name
63
+ return { reader, width: init.readUInt16BE(0), height: init.readUInt16BE(2) };
64
+ }
65
+ /** Sends input to a display the way a viewer would, so it works for any VNC
66
+ * display. Returns the display's size; points outside it are refused. */
67
+ export async function sendInput(socketPath, events, timeoutMs = 10_000) {
68
+ const socket = net.connect(socketPath);
69
+ const timer = setTimeout(() => socket.destroy(new Error("The display didn't answer in time.")), timeoutMs);
70
+ try {
71
+ const { width, height } = await join(socket);
72
+ for (const event of events)
73
+ if ("pointer" in event && (event.pointer.x >= width || event.pointer.y >= height)) {
74
+ throw new Error(`${event.pointer.x},${event.pointer.y} is outside the app, which is ${width}×${height} pixels (the size of its screenshot).`);
75
+ }
76
+ for (const event of events) {
77
+ if ("wait" in event) {
78
+ await new Promise((r) => setTimeout(r, event.wait));
79
+ continue;
80
+ }
81
+ const message = Buffer.alloc(8);
82
+ if ("pointer" in event) {
83
+ message[0] = 5;
84
+ message[1] = event.pointer.buttons;
85
+ message.writeUInt16BE(event.pointer.x, 2);
86
+ message.writeUInt16BE(event.pointer.y, 4);
87
+ }
88
+ else {
89
+ message[0] = 4;
90
+ message[1] = event.key.down ? 1 : 0;
91
+ message.writeUInt32BE(event.key.keysym >>> 0, 4);
92
+ }
93
+ await new Promise((resolve, reject) => socket.write("pointer" in event ? message.subarray(0, 6) : message, (error) => error ? reject(error) : resolve()));
94
+ }
95
+ return { width, height };
96
+ }
97
+ finally {
98
+ clearTimeout(timer);
99
+ socket.end();
100
+ }
101
+ }
102
+ /** One full frame from a VNC server, as packed RGB. */
40
103
  export async function captureFrame(socketPath, timeoutMs = 10_000) {
41
104
  const socket = net.connect(socketPath);
42
105
  const timer = setTimeout(() => socket.destroy(new Error("The display didn't answer in time.")), timeoutMs);
43
106
  try {
44
- const reader = new Reader(socket);
45
- const version = (await reader.read(12)).toString("latin1");
46
- if (!/^RFB 003\.\d{3}\n$/.test(version))
47
- throw new Error("Not a VNC display.");
48
- socket.write("RFB 003.008\n");
49
- const types = await reader.read((await reader.read(1))[0]);
50
- if (!types.includes(1))
51
- throw new Error("The display requires a password.");
52
- socket.write(Buffer.from([1]));
53
- if ((await reader.read(4)).readUInt32BE(0) !== 0)
54
- throw new Error("The display refused the connection.");
55
- socket.write(Buffer.from([1]));
56
- const init = await reader.read(24);
57
- const width = init.readUInt16BE(0), height = init.readUInt16BE(2);
58
- await reader.read(init.readUInt32BE(20)); // desktop name
107
+ const { reader, width, height } = await join(socket);
59
108
  // 32 bpp true colour, little-endian, red at bit 16: bytes arrive B, G, R, X.
60
109
  const format = Buffer.from([0, 0, 0, 0, 32, 24, 0, 1, 0, 255, 0, 255, 0, 255, 16, 8, 0, 0, 0, 0]);
61
110
  const encodings = Buffer.from([2, 0, 0, 1, 0, 0, 0, 0]); // Raw only
@@ -160,16 +160,22 @@ export async function takeShots(views, request, outDir, chrome = findChrome()) {
160
160
  for (const width of request.widths)
161
161
  for (const theme of request.themes) {
162
162
  const mobile = width < 600;
163
- await cdp.send("Emulation.setDeviceMetricsOverride", { width, height: mobile ? 844 : 800, deviceScaleFactor: mobile ? 2 : 1, mobile }, sessionId);
163
+ await cdp.send("Emulation.setDeviceMetricsOverride", { width, height: request.height ?? (mobile ? 844 : 800), deviceScaleFactor: request.scale ?? (mobile ? 2 : 1), mobile }, sessionId);
164
164
  await cdp.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: theme }] }, sessionId);
165
165
  const loaded = cdp.once("Page.loadEventFired", sessionId, 15_000);
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
+ let scroll;
170
+ if (request.scroll) {
171
+ const { result } = await cdp.send("Runtime.evaluate", { expression: `scrollTo(${Number(request.scroll.x) || 0}, ${Number(request.scroll.y) || 0}); [scrollX, scrollY]`, returnByValue: true }, sessionId);
172
+ scroll = { x: Number(result?.value?.[0]) || 0, y: Number(result?.value?.[1]) || 0 };
173
+ await new Promise((r) => setTimeout(r, 150));
174
+ }
169
175
  const { data } = await cdp.send("Page.captureScreenshot", { format: "png" }, sessionId);
170
176
  const file = path.join(outDir, `${entry.view.id.slice(0, 8)}-${width}-${theme}.png`);
171
177
  fs.writeFileSync(file, Buffer.from(data, "base64"));
172
- shots.push({ viewId: entry.view.id, view: entry.view.name, width, theme, file });
178
+ shots.push({ viewId: entry.view.id, view: entry.view.name, width, theme, file, ...(scroll ? { scroll } : {}) });
173
179
  }
174
180
  }
175
181
  return shots;