@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.
@@ -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
- /** Deliberately ephemeral: restart invalidates apps rather than trusting reused local ports. */
76
- export class AppRegistry {
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
- constructor(reservedPorts = []) {
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 app = { id: randomBytes(16).toString("hex"), sessionId, name: name(input.name), views: [], createdAt: Date.now() };
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: randomBytes(16).toString("hex"), name: name(spec?.name) };
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
- target = { kind: "service", port };
105
- view = { ...base, kind: "web", source: "service" };
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
- if (target.kind === "static")
112
- total += target.bytes;
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
- if (typeof spec.command !== "string" || !spec.command.trim() || spec.command.length > 1000 || spec.command.includes("\u0000"))
119
- throw new Error("Terminal view requires an executable command.");
120
- if (spec.args !== undefined && (!Array.isArray(spec.args) || spec.args.length > 100 || spec.args.some((arg) => typeof arg !== "string" || arg.length > 4000 || arg.includes("\u0000"))))
121
- throw new Error("Invalid terminal arguments.");
122
- const args = [...(spec.args ?? [])];
123
- target = { kind: "terminal", command: spec.command, args, workspace: fs.realpathSync(workspace) };
124
- view = { ...base, kind: "terminal", command: spec.command, args };
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. This machine supports web and terminal views.");
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)
@@ -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
+ }