@bivy/bivy 0.17.3-staging.2 → 0.17.3-staging.21
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 +57 -10
- package/dist/agents/claude-code/runtime.js +17 -0
- package/dist/apps/display-viewer.js +81 -0
- package/dist/apps/display.js +135 -0
- package/dist/apps/gateway.js +388 -25
- package/dist/apps/inspector.js +106 -0
- package/dist/apps/listeners.js +115 -0
- package/dist/apps/preview-shell.js +206 -17
- package/dist/apps/registry.js +125 -21
- package/dist/apps/remote-preview.js +10 -2
- package/dist/apps/rfb.js +126 -0
- package/dist/apps/screenshot.js +194 -0
- package/dist/apps/service.js +244 -5
- package/dist/apps/styles.css +124 -9
- package/dist/apps/x11.js +273 -0
- package/dist/attach.js +37 -4
- package/dist/config-cli.js +2 -0
- package/dist/controllers/app-commands.js +20 -7
- package/dist/controllers/artifact-commands.js +42 -0
- package/dist/controllers/presence-commands.js +19 -0
- package/dist/node-config.js +6 -3
- package/dist/policy/session-limit.js +63 -0
- package/dist/protocol/client-command-routes.js +3 -1
- package/dist/protocol/client-command-schemas.js +11 -2
- package/dist/server.js +168 -14
- package/dist/session/presence.js +82 -0
- package/dist/session/run-terminal.js +114 -70
- package/dist/session/session-new-dedupe.js +1 -1
- package/package.json +2 -1
package/dist/apps/registry.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
6
7
|
const MAX_BYTES = 25 * 1024 * 1024;
|
|
7
8
|
const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
|
|
8
9
|
const MAX_FILES = 2000;
|
|
10
|
+
const sameFiles = (a, b) => a.size === b.size && [...a].every(([key, data]) => b.get(key)?.equals(data));
|
|
9
11
|
/** Snapshot, not a live filesystem server: symlinks and hidden files are never published. */
|
|
10
12
|
function snapshot(workspace, directory) {
|
|
11
13
|
const base = fs.realpathSync(workspace);
|
|
@@ -72,15 +74,70 @@ function snapshot(workspace, directory) {
|
|
|
72
74
|
throw new Error("Static directory needs an index.html entry point.");
|
|
73
75
|
return { kind: "static", files, bytes };
|
|
74
76
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
function command(spec, workspace, label) {
|
|
78
|
+
const value = spec;
|
|
79
|
+
if (typeof value?.command !== "string" || !value.command.trim() || value.command.length > 1000 || value.command.includes("\u0000"))
|
|
80
|
+
throw new Error(`${label} requires an executable command.`);
|
|
81
|
+
if (value.args !== undefined && (!Array.isArray(value.args) || value.args.length > 100 || value.args.some((arg) => typeof arg !== "string" || arg.length > 4000 || arg.includes("\u0000"))))
|
|
82
|
+
throw new Error(`Invalid ${label.toLowerCase()} arguments.`);
|
|
83
|
+
return { command: value.command, args: [...(value.args ?? [])], workspace: fs.realpathSync(workspace) };
|
|
84
|
+
}
|
|
85
|
+
/** Apps persist (with their IDs) when given a file, so chat launchers and
|
|
86
|
+
* preview addresses survive restarts. Views whose server Bivy doesn't own —
|
|
87
|
+
* a service without `start` — are not restored: a reused local port could
|
|
88
|
+
* belong to anything by then. Detection offers them again. */
|
|
89
|
+
export class AppRegistry extends EventEmitter {
|
|
77
90
|
reservedPorts;
|
|
91
|
+
file;
|
|
78
92
|
apps = new Map();
|
|
79
93
|
views = new Map();
|
|
80
|
-
|
|
94
|
+
persisted = new Map();
|
|
95
|
+
constructor(reservedPorts = [], file) {
|
|
96
|
+
super();
|
|
81
97
|
this.reservedPorts = reservedPorts;
|
|
98
|
+
this.file = file;
|
|
99
|
+
this.setMaxListeners(0);
|
|
100
|
+
this.restore();
|
|
101
|
+
}
|
|
102
|
+
restore() {
|
|
103
|
+
if (!this.file)
|
|
104
|
+
return;
|
|
105
|
+
let records = [];
|
|
106
|
+
try {
|
|
107
|
+
records = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
113
|
+
const keep = record.manifest?.views?.map((spec, i) => ({ spec, id: record.viewIds?.[i] }))
|
|
114
|
+
.filter(({ spec, id }) => typeof id === "string" && !(spec?.kind === "web" && spec.source?.kind === "service" && !spec.source.start)) ?? [];
|
|
115
|
+
if (!keep.length)
|
|
116
|
+
continue;
|
|
117
|
+
// A workspace that moved or a build that vanished drops that app, not the rest.
|
|
118
|
+
try {
|
|
119
|
+
this.publish(record.sessionId, record.workspace, { ...record.manifest, views: keep.map((k) => k.spec) }, { id: record.id, viewIds: keep.map((k) => k.id), createdAt: record.createdAt });
|
|
120
|
+
}
|
|
121
|
+
catch { /* skipped */ }
|
|
122
|
+
}
|
|
123
|
+
this.save();
|
|
124
|
+
}
|
|
125
|
+
save() {
|
|
126
|
+
if (!this.file)
|
|
127
|
+
return;
|
|
128
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(tmp, JSON.stringify([...this.persisted.values()]), { mode: 0o600 });
|
|
131
|
+
fs.renameSync(tmp, this.file);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
try {
|
|
135
|
+
fs.rmSync(tmp, { force: true });
|
|
136
|
+
}
|
|
137
|
+
catch { /* ignore */ }
|
|
138
|
+
}
|
|
82
139
|
}
|
|
83
|
-
publish(sessionId, workspace, input) {
|
|
140
|
+
publish(sessionId, workspace, input, restore) {
|
|
84
141
|
const name = (value) => {
|
|
85
142
|
if (typeof value !== "string" || !value.trim() || value.length > 100)
|
|
86
143
|
throw new Error("App and view names must contain 1–100 characters.");
|
|
@@ -90,54 +147,99 @@ export class AppRegistry {
|
|
|
90
147
|
throw new Error("Expected manifest version 1 with 1–8 views.");
|
|
91
148
|
if (this.apps.size >= 50)
|
|
92
149
|
throw new Error("Remove an app before publishing more (limit: 50).");
|
|
93
|
-
const
|
|
150
|
+
const id = (i) => (i === undefined ? restore?.id : restore?.viewIds[i]) ?? randomBytes(16).toString("hex");
|
|
151
|
+
const app = { id: id(), sessionId, name: name(input.name), views: [], createdAt: restore?.createdAt ?? Date.now() };
|
|
94
152
|
const entries = [];
|
|
95
153
|
let total = [...this.views.values()].reduce((sum, item) => sum + (item.target.kind === "static" ? item.target.bytes : 0), 0);
|
|
96
|
-
for (const spec of input.views) {
|
|
97
|
-
const base = { id:
|
|
154
|
+
for (const [index, spec] of input.views.entries()) {
|
|
155
|
+
const base = { id: id(index), name: name(spec?.name) };
|
|
98
156
|
let target;
|
|
99
157
|
let view;
|
|
158
|
+
let source;
|
|
100
159
|
if (spec.kind === "web" && spec.source?.kind === "service") {
|
|
101
160
|
const port = spec.source.port;
|
|
102
161
|
if (!Number.isInteger(port) || port < 1024 || port > 65535 || this.reservedPorts.includes(port))
|
|
103
162
|
throw new Error("Choose a non-reserved local service port between 1024 and 65535.");
|
|
104
|
-
|
|
105
|
-
|
|
163
|
+
const start = spec.source.start === undefined ? undefined : command(spec.source.start, workspace, "Service start");
|
|
164
|
+
target = { kind: "service", port, start };
|
|
165
|
+
view = { ...base, kind: "web", source: "service", ...(start ? { managed: true } : {}) };
|
|
106
166
|
}
|
|
107
167
|
else if (spec.kind === "web" && spec.source?.kind === "static") {
|
|
108
168
|
if (typeof spec.source.directory !== "string" || !spec.source.directory)
|
|
109
169
|
throw new Error("Static directory is required.");
|
|
110
170
|
target = snapshot(workspace, spec.source.directory);
|
|
111
|
-
|
|
112
|
-
|
|
171
|
+
source = { workspace, directory: spec.source.directory };
|
|
172
|
+
total += target.bytes;
|
|
113
173
|
if (total > MAX_TOTAL_BYTES)
|
|
114
174
|
throw new Error("Static apps exceed the node's 100 MiB snapshot budget.");
|
|
115
175
|
view = { ...base, kind: "web", source: "static" };
|
|
116
176
|
}
|
|
117
177
|
else if (spec.kind === "terminal") {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
target = { kind: "
|
|
124
|
-
view = { ...base, kind: "
|
|
178
|
+
target = { kind: "terminal", ...command(spec, workspace, "Terminal view") };
|
|
179
|
+
view = { ...base, kind: "terminal", command: target.command, args: target.args };
|
|
180
|
+
}
|
|
181
|
+
else if (spec.kind === "display") {
|
|
182
|
+
// Shown through the web preview: Bivy's viewer streams the display.
|
|
183
|
+
target = { kind: "display", ...command(spec, workspace, "Display view"), restartOnChange: spec.restartOnChange === true };
|
|
184
|
+
view = { ...base, kind: "web", source: "display", managed: true };
|
|
125
185
|
}
|
|
126
186
|
else
|
|
127
|
-
throw new Error("Unsupported app view.
|
|
187
|
+
throw new Error("Unsupported app view. Supported views: web, terminal and display.");
|
|
128
188
|
app.views.push(view);
|
|
129
|
-
entries.push({ app, view, target });
|
|
189
|
+
entries.push({ app, view, target, revision: 0, source });
|
|
130
190
|
}
|
|
131
191
|
// Commit all views together: a bad later view cannot leave a partial app.
|
|
132
192
|
this.apps.set(app.id, app);
|
|
133
193
|
for (const entry of entries)
|
|
134
194
|
this.views.set(entry.view.id, entry);
|
|
195
|
+
this.persisted.set(app.id, { sessionId, workspace, manifest: structuredClone(input), id: app.id, viewIds: app.views.map((v) => v.id), createdAt: app.createdAt });
|
|
196
|
+
if (!restore)
|
|
197
|
+
this.save();
|
|
135
198
|
return structuredClone(app);
|
|
136
199
|
}
|
|
137
200
|
list(sessionId) {
|
|
138
|
-
return structuredClone([...this.apps.values()].filter((app) => app.sessionId === sessionId));
|
|
201
|
+
return structuredClone([...this.apps.values()].filter((app) => sessionId === undefined || app.sessionId === sessionId));
|
|
139
202
|
}
|
|
140
203
|
getView(id) { return this.views.get(id); }
|
|
204
|
+
/** An agent turn changed files: re-take changed static snapshots and bump
|
|
205
|
+
* every affected web view (emits `revision`). Returns the bumped view IDs. */
|
|
206
|
+
touch(sessionId) {
|
|
207
|
+
const bumped = [];
|
|
208
|
+
let total = [...this.views.values()].reduce((sum, item) => sum + (item.target.kind === "static" ? item.target.bytes : 0), 0);
|
|
209
|
+
for (const entry of this.views.values()) {
|
|
210
|
+
// A display streams live pixels: only one that restarts on changes has a new revision.
|
|
211
|
+
if (entry.app.sessionId !== sessionId || entry.view.kind !== "web" || (entry.target.kind === "display" && !entry.target.restartOnChange))
|
|
212
|
+
continue;
|
|
213
|
+
if (entry.target.kind === "static") {
|
|
214
|
+
if (!entry.source)
|
|
215
|
+
continue;
|
|
216
|
+
let next;
|
|
217
|
+
// A broken build keeps the last good snapshot rather than blanking the preview.
|
|
218
|
+
try {
|
|
219
|
+
next = snapshot(entry.source.workspace, entry.source.directory);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (sameFiles(entry.target.files, next.files) || total - entry.target.bytes + next.bytes > MAX_TOTAL_BYTES)
|
|
225
|
+
continue;
|
|
226
|
+
total += next.bytes - entry.target.bytes;
|
|
227
|
+
entry.target = next;
|
|
228
|
+
}
|
|
229
|
+
entry.revision++;
|
|
230
|
+
bumped.push(entry.view.id);
|
|
231
|
+
this.emit("revision", entry.view.id);
|
|
232
|
+
}
|
|
233
|
+
return bumped;
|
|
234
|
+
}
|
|
235
|
+
/** Ports a session already previews, and ports no app may claim. */
|
|
236
|
+
claimedPorts(sessionId) {
|
|
237
|
+
const ports = new Set(this.reservedPorts);
|
|
238
|
+
for (const { app, target } of this.views.values())
|
|
239
|
+
if (app.sessionId === sessionId && target.kind === "service")
|
|
240
|
+
ports.add(target.port);
|
|
241
|
+
return ports;
|
|
242
|
+
}
|
|
141
243
|
require(sessionId, id) {
|
|
142
244
|
const app = this.apps.get(id);
|
|
143
245
|
if (!app || app.sessionId !== sessionId)
|
|
@@ -156,5 +258,7 @@ export class AppRegistry {
|
|
|
156
258
|
for (const view of app.views)
|
|
157
259
|
this.views.delete(view.id);
|
|
158
260
|
this.apps.delete(id);
|
|
261
|
+
this.persisted.delete(id);
|
|
262
|
+
this.save();
|
|
159
263
|
}
|
|
160
264
|
}
|
|
@@ -8,14 +8,16 @@ import { PreviewStream } from "./preview-stream.js";
|
|
|
8
8
|
export class RemotePreview {
|
|
9
9
|
registry;
|
|
10
10
|
returnOrigins;
|
|
11
|
+
signIn;
|
|
11
12
|
gateway;
|
|
12
13
|
origin;
|
|
13
14
|
relayUrl;
|
|
14
15
|
online = false;
|
|
15
16
|
sockets = new Set();
|
|
16
|
-
constructor(registry, returnOrigins) {
|
|
17
|
+
constructor(registry, returnOrigins, signIn) {
|
|
17
18
|
this.registry = registry;
|
|
18
19
|
this.returnOrigins = returnOrigins;
|
|
20
|
+
this.signIn = signIn;
|
|
19
21
|
}
|
|
20
22
|
get available() { return this.online && Boolean(this.gateway); }
|
|
21
23
|
ready(origin, relayUrl) {
|
|
@@ -28,7 +30,7 @@ export class RemotePreview {
|
|
|
28
30
|
return;
|
|
29
31
|
if (this.origin !== template) {
|
|
30
32
|
this.gateway?.close();
|
|
31
|
-
this.gateway = new AppGateway(this.registry, template, this.returnOrigins);
|
|
33
|
+
this.gateway = new AppGateway(this.registry, template, this.returnOrigins, this.signIn);
|
|
32
34
|
this.origin = template;
|
|
33
35
|
}
|
|
34
36
|
this.relayUrl = relayUrl.replace(/\/$/, "") + "/preview/stream";
|
|
@@ -65,6 +67,12 @@ export class RemotePreview {
|
|
|
65
67
|
return this.gateway.share(id);
|
|
66
68
|
}
|
|
67
69
|
revoke(id) { this.gateway?.revoke(id); }
|
|
70
|
+
address(id) { return this.available ? this.gateway.address(id) : undefined; }
|
|
71
|
+
openDirect(id) {
|
|
72
|
+
if (!this.available)
|
|
73
|
+
throw new Error("Preview delivery is unavailable. Reconnect the machine and try again.");
|
|
74
|
+
return this.gateway.openDirect(id);
|
|
75
|
+
}
|
|
68
76
|
disconnect() {
|
|
69
77
|
this.online = false;
|
|
70
78
|
for (const ws of this.sockets)
|
package/dist/apps/rfb.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { deflateSync } from "node:zlib";
|
|
5
|
+
/** Reads exact byte counts from a stream. */
|
|
6
|
+
class Reader {
|
|
7
|
+
buffer = Buffer.alloc(0);
|
|
8
|
+
waiting;
|
|
9
|
+
failure;
|
|
10
|
+
reject;
|
|
11
|
+
constructor(socket) {
|
|
12
|
+
socket.on("data", (chunk) => { this.buffer = Buffer.concat([this.buffer, chunk]); this.pump(); });
|
|
13
|
+
const fail = (error) => { this.failure = error; this.reject?.(error); };
|
|
14
|
+
socket.on("error", fail);
|
|
15
|
+
socket.on("close", () => fail(new Error("The display closed the connection.")));
|
|
16
|
+
}
|
|
17
|
+
read(n) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
if (this.failure && this.buffer.length < n) {
|
|
20
|
+
reject(this.failure);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
this.waiting = { n, resolve };
|
|
24
|
+
this.reject = reject;
|
|
25
|
+
this.pump();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
pump() {
|
|
29
|
+
if (!this.waiting || this.buffer.length < this.waiting.n)
|
|
30
|
+
return;
|
|
31
|
+
const { n, resolve } = this.waiting;
|
|
32
|
+
this.waiting = undefined;
|
|
33
|
+
const out = this.buffer.subarray(0, n);
|
|
34
|
+
this.buffer = this.buffer.subarray(n);
|
|
35
|
+
resolve(out);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
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. */
|
|
40
|
+
export async function captureFrame(socketPath, timeoutMs = 10_000) {
|
|
41
|
+
const socket = net.connect(socketPath);
|
|
42
|
+
const timer = setTimeout(() => socket.destroy(new Error("The display didn't answer in time.")), timeoutMs);
|
|
43
|
+
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
|
|
59
|
+
// 32 bpp true colour, little-endian, red at bit 16: bytes arrive B, G, R, X.
|
|
60
|
+
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
|
+
const encodings = Buffer.from([2, 0, 0, 1, 0, 0, 0, 0]); // Raw only
|
|
62
|
+
const request = Buffer.alloc(10);
|
|
63
|
+
request[0] = 3;
|
|
64
|
+
request.writeUInt16BE(width, 6);
|
|
65
|
+
request.writeUInt16BE(height, 8);
|
|
66
|
+
socket.write(Buffer.concat([format, encodings, request]));
|
|
67
|
+
const rgb = Buffer.alloc(width * height * 3);
|
|
68
|
+
for (;;) {
|
|
69
|
+
const type = (await reader.read(1))[0];
|
|
70
|
+
if (type === 2)
|
|
71
|
+
continue; // bell
|
|
72
|
+
if (type === 3) {
|
|
73
|
+
const head = await reader.read(7);
|
|
74
|
+
await reader.read(head.readUInt32BE(3));
|
|
75
|
+
continue;
|
|
76
|
+
} // clipboard
|
|
77
|
+
if (type !== 0)
|
|
78
|
+
throw new Error("Unexpected message from the display.");
|
|
79
|
+
const rects = (await reader.read(3)).readUInt16BE(1);
|
|
80
|
+
for (let r = 0; r < rects; r++) {
|
|
81
|
+
const head = await reader.read(12);
|
|
82
|
+
const x = head.readUInt16BE(0), y = head.readUInt16BE(2), w = head.readUInt16BE(4), h = head.readUInt16BE(6);
|
|
83
|
+
if (head.readInt32BE(8) !== 0)
|
|
84
|
+
throw new Error("Unexpected encoding from the display.");
|
|
85
|
+
const pixels = await reader.read(w * h * 4);
|
|
86
|
+
for (let row = 0; row < h; row++)
|
|
87
|
+
for (let col = 0; col < w; col++) {
|
|
88
|
+
if (x + col >= width || y + row >= height)
|
|
89
|
+
continue;
|
|
90
|
+
const from = (row * w + col) * 4, to = ((y + row) * width + x + col) * 3;
|
|
91
|
+
rgb[to] = pixels[from + 2];
|
|
92
|
+
rgb[to + 1] = pixels[from + 1];
|
|
93
|
+
rgb[to + 2] = pixels[from];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { width, height, rgb };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
socket.destroy();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const CRC = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++)
|
|
105
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
|
|
106
|
+
function crc32(data) { let c = 0xffffffff; for (const byte of data)
|
|
107
|
+
c = CRC[(c ^ byte) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }
|
|
108
|
+
function chunk(type, data) {
|
|
109
|
+
const head = Buffer.alloc(8);
|
|
110
|
+
head.writeUInt32BE(data.length, 0);
|
|
111
|
+
head.write(type, 4, "latin1");
|
|
112
|
+
const crc = Buffer.alloc(4);
|
|
113
|
+
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])));
|
|
114
|
+
return Buffer.concat([head, data, crc]);
|
|
115
|
+
}
|
|
116
|
+
export function encodePng(width, height, rgb) {
|
|
117
|
+
const header = Buffer.alloc(13);
|
|
118
|
+
header.writeUInt32BE(width, 0);
|
|
119
|
+
header.writeUInt32BE(height, 4);
|
|
120
|
+
header[8] = 8;
|
|
121
|
+
header[9] = 2; // 8-bit RGB
|
|
122
|
+
const rows = Buffer.alloc((width * 3 + 1) * height);
|
|
123
|
+
for (let y = 0; y < height; y++)
|
|
124
|
+
rgb.copy(rows, y * (width * 3 + 1) + 1, y * width * 3, (y + 1) * width * 3);
|
|
125
|
+
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", header), chunk("IDAT", deflateSync(rows)), chunk("IEND", Buffer.alloc(0))]);
|
|
126
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
import { once } from "node:events";
|
|
8
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
9
|
+
import { WebSocket } from "ws";
|
|
10
|
+
import { captureFrame, encodePng } from "./rfb.js";
|
|
11
|
+
/** Browsers tried in order; BIVY_CHROME overrides. Any Chromium works. */
|
|
12
|
+
const CANDIDATES = [
|
|
13
|
+
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge",
|
|
14
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
15
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
16
|
+
];
|
|
17
|
+
export function findChrome(env = process.env) {
|
|
18
|
+
if (env.BIVY_CHROME)
|
|
19
|
+
return fs.existsSync(env.BIVY_CHROME) ? env.BIVY_CHROME : undefined;
|
|
20
|
+
for (const candidate of CANDIDATES) {
|
|
21
|
+
if (candidate.startsWith("/")) {
|
|
22
|
+
if (fs.existsSync(candidate))
|
|
23
|
+
return candidate;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync("which", [candidate], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
28
|
+
}
|
|
29
|
+
catch { /* next */ }
|
|
30
|
+
}
|
|
31
|
+
// A Playwright install (common on dev machines) ships a headless shell.
|
|
32
|
+
const cache = path.join(os.homedir(), ".cache", "ms-playwright");
|
|
33
|
+
for (const dir of fs.existsSync(cache) ? fs.readdirSync(cache).sort().reverse() : []) {
|
|
34
|
+
for (const bin of ["chrome-headless-shell-linux64/chrome-headless-shell", "chrome-linux/chrome"]) {
|
|
35
|
+
const file = path.join(cache, dir, bin);
|
|
36
|
+
if (dir.startsWith("chromium") && fs.existsSync(file))
|
|
37
|
+
return file;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
/** Serves a static snapshot on loopback for the duration of a shot. */
|
|
43
|
+
async function serveSnapshot(files) {
|
|
44
|
+
const server = http.createServer((req, res) => {
|
|
45
|
+
let file = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
46
|
+
if (file.endsWith("/"))
|
|
47
|
+
file += "index.html";
|
|
48
|
+
const data = files.get(file) ?? (path.extname(file) ? undefined : files.get("/index.html"));
|
|
49
|
+
if (!data) {
|
|
50
|
+
res.writeHead(404);
|
|
51
|
+
res.end();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
res.end(data);
|
|
55
|
+
});
|
|
56
|
+
server.listen(0, "127.0.0.1");
|
|
57
|
+
await once(server, "listening");
|
|
58
|
+
return { origin: `http://127.0.0.1:${server.address().port}`, close: () => { server.close(); server.closeAllConnections(); } };
|
|
59
|
+
}
|
|
60
|
+
/** Minimal CDP client over one browser-level WebSocket (flattened sessions). */
|
|
61
|
+
class Cdp {
|
|
62
|
+
ws;
|
|
63
|
+
next = 0;
|
|
64
|
+
pending = new Map();
|
|
65
|
+
listeners = new Set();
|
|
66
|
+
constructor(ws) {
|
|
67
|
+
this.ws = ws;
|
|
68
|
+
ws.on("message", (raw) => {
|
|
69
|
+
const msg = JSON.parse(raw.toString());
|
|
70
|
+
if (msg.id !== undefined) {
|
|
71
|
+
const call = this.pending.get(msg.id);
|
|
72
|
+
this.pending.delete(msg.id);
|
|
73
|
+
if (msg.error)
|
|
74
|
+
call?.reject(new Error(msg.error.message));
|
|
75
|
+
else
|
|
76
|
+
call?.resolve(msg.result);
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
for (const fn of this.listeners)
|
|
80
|
+
fn(msg.method, msg.params, msg.sessionId);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
send(method, params = {}, sessionId) {
|
|
84
|
+
const id = ++this.next;
|
|
85
|
+
return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params, sessionId })); });
|
|
86
|
+
}
|
|
87
|
+
once(method, sessionId, ms) {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const fn = (m, _p, s) => { if (m === method && s === sessionId)
|
|
90
|
+
finish(); };
|
|
91
|
+
const finish = () => { clearTimeout(timer); this.listeners.delete(fn); resolve(); };
|
|
92
|
+
const timer = setTimeout(finish, ms);
|
|
93
|
+
this.listeners.add(fn);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Screenshots web views with one headless browser, one shot at a time, so
|
|
98
|
+
* small machines cope. Service views are loaded straight from loopback;
|
|
99
|
+
* static views from a throwaway loopback server. Desktop apps are read from
|
|
100
|
+
* their display, at its current size, without a browser. Files are PNGs in `outDir`. */
|
|
101
|
+
export async function takeShots(views, request, outDir, chrome = findChrome()) {
|
|
102
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
103
|
+
const shots = [];
|
|
104
|
+
for (const entry of views) {
|
|
105
|
+
if (entry.target.kind !== "display" || !entry.display)
|
|
106
|
+
continue;
|
|
107
|
+
const frame = await captureFrame(entry.display);
|
|
108
|
+
const file = path.join(outDir, `${entry.view.id.slice(0, 8)}-${frame.width}-native.png`);
|
|
109
|
+
fs.writeFileSync(file, encodePng(frame.width, frame.height, frame.rgb));
|
|
110
|
+
shots.push({ viewId: entry.view.id, view: entry.view.name, width: frame.width, theme: "native", file });
|
|
111
|
+
}
|
|
112
|
+
views = views.filter((entry) => entry.target.kind === "service" || entry.target.kind === "static");
|
|
113
|
+
if (!views.length)
|
|
114
|
+
return shots;
|
|
115
|
+
if (!chrome)
|
|
116
|
+
throw new Error("No Chrome or Chromium found on this machine. Install one, or set BIVY_CHROME to its path.");
|
|
117
|
+
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "bivy-shot-"));
|
|
118
|
+
const servers = [];
|
|
119
|
+
let ws;
|
|
120
|
+
let browser;
|
|
121
|
+
const deadline = setTimeout(() => browser?.kill("SIGKILL"), 120_000);
|
|
122
|
+
// Chromium's sandbox is unavailable as root and where AppArmor blocks user
|
|
123
|
+
// namespaces (Ubuntu 23.10+). The pages are the session's own apps, already
|
|
124
|
+
// running as this user, so retrying without it doesn't widen what they can do.
|
|
125
|
+
const launch = (sandbox) => new Promise((resolve, reject) => {
|
|
126
|
+
const args = ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-gpu", "--hide-scrollbars", "--mute-audio", ...(sandbox ? [] : ["--no-sandbox"]), "about:blank"];
|
|
127
|
+
const child = spawn(chrome, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
128
|
+
browser = child;
|
|
129
|
+
let text = "";
|
|
130
|
+
const timer = setTimeout(() => reject(new Error("The browser didn't start in time.")), 20_000);
|
|
131
|
+
child.stderr.on("data", (chunk) => { text += chunk; const m = /DevTools listening on (ws:\/\/\S+)/.exec(text); if (m) {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
resolve(m[1]);
|
|
134
|
+
} });
|
|
135
|
+
child.once("exit", () => { clearTimeout(timer); reject(Object.assign(new Error("The browser exited before it was ready."), { noSandbox: /No usable sandbox|--no-sandbox/i.test(text) })); });
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
const endpoint = await launch(process.getuid?.() !== 0).catch((error) => {
|
|
139
|
+
if (error.noSandbox)
|
|
140
|
+
return launch(false);
|
|
141
|
+
throw error;
|
|
142
|
+
});
|
|
143
|
+
ws = new WebSocket(endpoint, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 });
|
|
144
|
+
await once(ws, "open");
|
|
145
|
+
const cdp = new Cdp(ws);
|
|
146
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
|
|
147
|
+
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
|
|
148
|
+
await cdp.send("Page.enable", {}, sessionId);
|
|
149
|
+
for (const entry of views) {
|
|
150
|
+
let origin;
|
|
151
|
+
if (entry.target.kind === "service")
|
|
152
|
+
origin = `http://127.0.0.1:${entry.target.port}`;
|
|
153
|
+
else if (entry.target.kind === "static") {
|
|
154
|
+
const server = await serveSnapshot(entry.target.files);
|
|
155
|
+
servers.push(server);
|
|
156
|
+
origin = server.origin;
|
|
157
|
+
}
|
|
158
|
+
else
|
|
159
|
+
continue;
|
|
160
|
+
for (const width of request.widths)
|
|
161
|
+
for (const theme of request.themes) {
|
|
162
|
+
const mobile = width < 600;
|
|
163
|
+
await cdp.send("Emulation.setDeviceMetricsOverride", { width, height: mobile ? 844 : 800, deviceScaleFactor: mobile ? 2 : 1, mobile }, sessionId);
|
|
164
|
+
await cdp.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: theme }] }, sessionId);
|
|
165
|
+
const loaded = cdp.once("Page.loadEventFired", sessionId, 15_000);
|
|
166
|
+
await cdp.send("Page.navigate", { url: origin + request.path }, sessionId);
|
|
167
|
+
await loaded;
|
|
168
|
+
await new Promise((r) => setTimeout(r, 400)); // let fonts and first effects settle
|
|
169
|
+
const { data } = await cdp.send("Page.captureScreenshot", { format: "png" }, sessionId);
|
|
170
|
+
const file = path.join(outDir, `${entry.view.id.slice(0, 8)}-${width}-${theme}.png`);
|
|
171
|
+
fs.writeFileSync(file, Buffer.from(data, "base64"));
|
|
172
|
+
shots.push({ viewId: entry.view.id, view: entry.view.name, width, theme, file });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return shots;
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
clearTimeout(deadline);
|
|
179
|
+
ws?.terminate();
|
|
180
|
+
for (const server of servers)
|
|
181
|
+
server.close();
|
|
182
|
+
// Wait for the browser to exit before removing its profile: a killed
|
|
183
|
+
// Chrome can still be writing into it. Cleanup never fails a shot.
|
|
184
|
+
if (browser && browser.exitCode === null && browser.signalCode === null) {
|
|
185
|
+
const exited = once(browser, "exit").catch(() => { });
|
|
186
|
+
browser.kill("SIGKILL");
|
|
187
|
+
await exited;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
191
|
+
}
|
|
192
|
+
catch { /* temp dir; the OS reclaims it */ }
|
|
193
|
+
}
|
|
194
|
+
}
|