@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.
- package/bin/bivy.mjs +157 -12
- package/dist/apps/annotate.js +94 -0
- package/dist/apps/display-viewer.js +80 -7
- package/dist/apps/display.js +11 -1
- package/dist/apps/gateway.js +106 -14
- package/dist/apps/input.js +96 -0
- package/dist/apps/inspector.js +84 -29
- package/dist/apps/macos-display.js +170 -0
- package/dist/apps/macos-display.swift +873 -0
- package/dist/apps/menu.js +76 -0
- package/dist/apps/preview-shell.js +232 -16
- package/dist/apps/preview-stream.js +17 -3
- package/dist/apps/registry.js +76 -5
- package/dist/apps/review.js +111 -0
- package/dist/apps/rfb.js +69 -20
- package/dist/apps/screenshot.js +8 -2
- package/dist/apps/service.js +343 -13
- package/dist/apps/styles.css +162 -10
- package/dist/apps/tokens.css +6 -0
- package/dist/apps/types.js +17 -0
- package/dist/attach.js +37 -4
- package/dist/controllers/app-commands.js +16 -4
- package/dist/controllers/artifact-commands.js +42 -0
- package/dist/controllers/presence-commands.js +19 -0
- package/dist/protocol/client-command-routes.js +3 -1
- package/dist/protocol/client-command-schemas.js +19 -3
- package/dist/server.js +200 -21
- package/dist/session/attachment-store.js +15 -0
- package/dist/session/event-log.js +25 -3
- package/dist/session/presence.js +82 -0
- package/dist/session/run-terminal.js +113 -69
- package/dist/session/session-new-dedupe.js +1 -1
- package/dist/terminal.js +23 -0
- package/dist/worktree.js +40 -3
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
/**
|
|
39
|
-
*
|
|
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 =
|
|
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
|
package/dist/apps/screenshot.js
CHANGED
|
@@ -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;
|