buninu-browser 0.2.4 → 0.3.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/README.md CHANGED
@@ -97,6 +97,23 @@ the rest is passed on:
97
97
  casty's and the engine's options:
98
98
  `--casty --help`, `--headless --help`.
99
99
 
100
+ ### Websites with self-signed certificates
101
+
102
+ To browse a website with an untrusted self-signed HTTPS certificate, set
103
+ `NODE_TLS_REJECT_UNAUTHORIZED=0` for that invocation:
104
+
105
+ ```sh
106
+ env NODE_TLS_REJECT_UNAUTHORIZED=0 npx buninu-browser https://localhost:8443
107
+
108
+ # From a local checkout
109
+ env NODE_TLS_REJECT_UNAUTHORIZED=0 bun index.js https://localhost:8443
110
+ ```
111
+
112
+ This disables TLS certificate verification for the process and inherited
113
+ child processes, including requests to other sites. Use it only for trusted
114
+ test sites; avoid exporting it globally. Without this variable, certificate
115
+ verification remains enabled.
116
+
100
117
  ### Requirements
101
118
 
102
119
  - [Bun](https://bun.sh) 1.4.2 or
@@ -201,6 +218,12 @@ not start a browser process or CDP listener.
201
218
 
202
219
  ### Page sandbox
203
220
 
221
+ Pages can copy text with `navigator.clipboard.writeText()` after a user click
222
+ or key press on HTTPS (or localhost). The controller writes the clipboard,
223
+ preferring `xclip -selection clipboard`, with platform clipboard commands as
224
+ fallbacks. Clipboard command access stays outside the page sandbox; unavailable
225
+ providers reject the promise instead of reporting a successful copy.
226
+
204
227
  Page JavaScript runs behind three layers:
205
228
 
206
229
  1. **SES and bindings.** The renderer calls `lockdown()`, and each document
@@ -231,7 +254,64 @@ threads:
231
254
 
232
255
  When idle, the main thread rasterizes a tile reaching half a viewport above
233
256
  and one viewport below the visible area, so the compositor can scroll
234
- without waiting. Screenshots are encoded with `Bun.Image`.
257
+ without waiting. Standard screenshots are encoded with `Bun.Image`.
258
+
259
+ Casty can negotiate raw RGBA/BGRA file screenshots with native bunterm. Raw
260
+ pixels travel over the compositor's binary channel to the controller, which
261
+ owns a private temporary directory and returns a versioned file descriptor
262
+ through CDP. The renderer cannot create files: its seccomp policy is unchanged.
263
+ Each delivery has a unique file, bounded to 16 outstanding files / 128 MiB;
264
+ abandoned files expire on subsequent writes after 60 seconds, and closing the
265
+ host removes the directory. Standard PNG/JPEG/WebP output and CLI screenshots
266
+ retain their existing behavior. Casty falls back to its configured codec when
267
+ raw transport is unsupported.
268
+
269
+ Standard CDP `clip` rectangles also apply to raw captures. Fractional CSS
270
+ width/height are retained until physical-pixel rounding, so a 1.75× viewport
271
+ can deliver exactly 1918×1053 pixels. At `clip.scale: 1`, a full-width crop
272
+ borrows a contiguous raster view instead of rescaling; encoded output uses
273
+ the same crop. `Page.getLayoutMetrics` provides the viewport position so
274
+ Casty can follow document scrolling.
275
+
276
+ Casty also sends a private `clip` on `Page.startScreencast`. It takes precedence over fallback max limits and crops at native pixel density, without resizing. Width/height are CSS units; output pixels still follow device scale. This extension is not standard CDP.
277
+
278
+ Casty 也會在 `Page.startScreencast` 傳入私有 `clip`;優先於相容用的 max 限制,以原像素密度裁切、不縮放。寬高是 CSS 單位,實體像素仍隨 device scale 換算。此擴充不是標準 CDP。
279
+
280
+ 標準 CDP `clip` 也適用於 raw 截圖;小數 CSS 尺寸保留到最後才換算成實體像素,
281
+ 避免字體大小改變後差一列而觸發 bunterm 縮放。`clip.scale: 1` 的全寬裁切
282
+ 直接借用連續像素,不重新縮放;Casty 透過 viewport 位置跟隨頁面捲動。
283
+
284
+ Read-only captures interrupted by an origin renderer swap retry once against
285
+ its replacement, after the new renderer has loaded its document. Scroll
286
+ commands are never replayed, avoiding duplicate input.
287
+
288
+ 跨來源導航更換 renderer 時,唯讀截圖會等待新的 renderer 載入文件後重試一次;
289
+ 捲動命令不會重送,避免重複套用輸入。
290
+
291
+ DOM-triggered rendering coalesces updates with a 16 ms minimum wait, increased
292
+ to twice the last style/layout rebuild time for expensive documents. This is
293
+ separate from Casty's screenshot interval and is not a 60 FPS guarantee:
294
+ raster work and frame delivery still take time. More frequent updates can
295
+ increase CPU usage on continuously animated pages.
296
+
297
+ DOM 變更觸發的繪製會以至少 16 ms 等待合併更新;排版昂貴時則延長為上次
298
+ 樣式/排版重建耗時的兩倍。這與 Casty 的截圖間隔不同,也不保證 60 FPS;
299
+ 光柵繪製與畫面傳遞仍需時間,持續動畫的 CPU 用量可能增加。
300
+
301
+ The compositor now connects to an authenticated, length-prefixed loopback
302
+ socket before the sandbox is installed, instead of reading numeric fd 3.
303
+ It connects directly to the controller, so a busy page main thread cannot
304
+ block screenshot or scroll requests. This removes the previous Windows-only
305
+ compositor disablement; the same tests can run on Windows, but this change
306
+ has so far been executed on Linux only. No additional native library is used.
307
+
308
+ 原生 bunterm 可與 Casty 協商 RGBA/BGRA 原始像素檔案。renderer 經二進位通道
309
+ 交給 controller 寫檔,不放寬 seccomp;標準截圖與不支援 raw 的終端維持原路徑。
310
+ compositor 改用沙箱啟用前建立、帶驗證的本機連線,不再依賴 fd 3,因此移除了
311
+ Windows 專用的停用條件;目前已實測 Linux,Windows 仍待真機驗證。
312
+
313
+ `bun tools/bench-raw-capture.js` measures changing-viewport capture delivery
314
+ (including controller file writes for raw), not terminal FPS.
235
315
 
236
316
  `<video>` can decode and display MP4 video frames with basic playback and
237
317
  seeking. Video bytes are fetched through the controller; the renderer uses
@@ -342,6 +422,11 @@ rendered video position, and the output queue/latency for diagnosis.
342
422
 
343
423
  ### Diagnostics
344
424
 
425
+ [`test/fixtures/browser-raw-demo.html`](test/fixtures/browser-raw-demo.html)
426
+ is a self-contained animated page for checking rendering, scrolling, and links
427
+ through Casty and bunterm. Serve it over a local HTTP server to reuse the visual
428
+ demo; it does not require external assets.
429
+
345
430
  casty does not show the browser's stderr. Set `BUNINU_LOG=1` to also write
346
431
  it to `buninu-browser.log` in the working directory, or set
347
432
  `BUNINU_LOG=<path>` to choose the file. The log includes:
@@ -0,0 +1,22 @@
1
+ // Host-side text clipboard. No shell interpolation; page bytes go only to stdin.
2
+ export function clipboardCommands(platform = process.platform) {
3
+ return [
4
+ ['xclip','-selection','clipboard'],
5
+ ...(platform === 'win32' ? [['powershell.exe','-NoProfile','-NonInteractive','-Command',
6
+ '[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())']]
7
+ : platform === 'darwin' ? [['pbcopy']] : [['wl-copy'],['termux-clipboard-set']]),
8
+ ];
9
+ }
10
+ export async function writeClipboard(text, {which = Bun.which, spawn = Bun.spawn, platform = process.platform} = {}) {
11
+ if(typeof text !== 'string' || Buffer.byteLength(text)>1024*1024)throw Error('Clipboard text exceeds limit');
12
+ for(const command of clipboardCommands(platform)) {
13
+ const executable=which(command[0]); if(!executable)continue;
14
+ let child,timer;
15
+ try {
16
+ child=spawn([executable,...command.slice(1)],{stdin:Buffer.from(text),stdout:'ignore',stderr:'ignore'});
17
+ timer=setTimeout(()=>child.kill(9),3000);
18
+ if(await child.exited === 0)return;
19
+ } catch {} finally {clearTimeout(timer);}
20
+ }
21
+ throw Error('No working clipboard command (xclip preferred)');
22
+ }
@@ -101,6 +101,7 @@ export async function createBrowser({
101
101
  let closed = false;
102
102
  // Settles when the first renderer shows about:blank (assigned below).
103
103
  let rendererReady = null;
104
+ const documentReady = new WeakMap();
104
105
  // Renderer-independent state replayed into every new renderer.
105
106
  const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
106
107
  let userAgent = webkit ? initialUserAgent : null;
@@ -231,6 +232,8 @@ export async function createBrowser({
231
232
  await next.start();
232
233
  await replayState((method, ...params) => next.call(method, ...params));
233
234
  const previous = current;
235
+ const ready=Promise.withResolvers();
236
+ documentReady.set(next,ready);
234
237
  current = next;
235
238
  currentKey = key;
236
239
  previous?.close();
@@ -305,7 +308,10 @@ export async function createBrowser({
305
308
  + `${document.url === url ? "" : ` -> ${document.url}`} in ${Math.round(fetched - started)}ms`);
306
309
  if (!invoke) await rendererReady;
307
310
  const host = invoke ? current : await rendererFor(document.url);
308
- const generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
311
+ let generation;
312
+ try {
313
+ generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
314
+ } finally { documentReady.get(host)?.resolve(); }
309
315
  committed = { host, generation };
310
316
  for (const name of reachedLifecycle(host, generation)) notify(lifecycleListeners, name);
311
317
  writeLog("navigation", `${document.url}: first paint in ${Math.round(performance.now() - fetched)}ms`);
@@ -315,7 +321,20 @@ export async function createBrowser({
315
321
  // answered even while the renderer's main thread is busy.
316
322
  const composite = async (method, ...params) => {
317
323
  await rendererReady;
318
- return current.composite(method, ...params);
324
+ // An origin change retires the old compositor. Read-only requests may
325
+ // follow that swap once; scrolling must never be applied twice.
326
+ const readOnly = method === "screenshot" || method === "scrollOffset";
327
+ for (let attempt=0; ; attempt++) {
328
+ const host=current;
329
+ await documentReady.get(host)?.promise;
330
+ if (closed) throw Error("Browser is closed");
331
+ try {
332
+ if (host !== current) throw Error("Capture superseded by navigation");
333
+ return await host.composite(method, ...params);
334
+ } catch (error) {
335
+ if (closed || !readOnly || host === current || attempt >= 1) throw error;
336
+ }
337
+ }
319
338
  };
320
339
 
321
340
  async function rememberScroll() {
@@ -405,9 +424,10 @@ export async function createBrowser({
405
424
  applyState("setInitScripts", () => [[...initScripts.values()]]);
406
425
  },
407
426
  screenshot: (options = {}) => composite("screenshot", {
408
- format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
427
+ format: ["jpeg", "webp", "rgba", "bgra"].includes(options.format) ? options.format : "png",
409
428
  quality: Number.isFinite(options.quality) ? options.quality : 80,
410
429
  optimizeForSpeed: options.optimizeForSpeed === true,
430
+ clip: options.clip ? {...options.clip,x:options.clip.x/pageScale(),y:options.clip.y/pageScale(),width:options.clip.width/pageScale(),height:options.clip.height/pageScale()} : undefined,
411
431
  }),
412
432
  title: () => call("title"),
413
433
  /**
@@ -473,7 +493,12 @@ export async function createBrowser({
473
493
  return result === null ? null : { url: href, title: await call("title") };
474
494
  },
475
495
  evaluate: (expression) => call("evaluate", String(expression)),
476
- cdp() {
496
+ async cdp(method) {
497
+ if(method==='Page.getLayoutMetrics') {
498
+ const offset=await composite('scrollOffset');
499
+ const visual={pageX:offset.x*pageScale(),pageY:offset.y*pageScale(),offsetX:0,offsetY:0,clientWidth:viewport.width,clientHeight:viewport.height,scale:1,zoom:1};
500
+ return {cssVisualViewport:visual,cssLayoutViewport:visual,visualViewport:visual,layoutViewport:visual};
501
+ }
477
502
  return {};
478
503
  },
479
504
  };
@@ -484,7 +509,8 @@ export async function createBrowser({
484
509
  const first = rendererFor("about:blank");
485
510
  prepareSpare();
486
511
  await first;
487
- await current.call("loadDocument", { url: "about:blank" });
512
+ try { await current.call("loadDocument", { url: "about:blank" }); }
513
+ finally { documentReady.get(current)?.resolve(); }
488
514
  })();
489
515
  if (waitForRenderer) await rendererReady;
490
516
  else rendererReady.catch(() => {}); // the caller awaits `ready`
@@ -15,10 +15,10 @@
15
15
  * modify them, including while an asynchronous image encoder is using them.
16
16
  * @returns {{ pixels: Uint8Array, width: number, height: number }}
17
17
  */
18
- export function cropViewport(tile, scrollX, scrollY, viewportWidth, viewportHeight) {
18
+ export function cropViewport(tile, scrollX, scrollY, viewportWidth, viewportHeight, exact = false) {
19
19
  const scale = tile.scale;
20
- const width = Math.max(1, Math.round(Math.floor(viewportWidth) * scale));
21
- const height = Math.max(1, Math.round(Math.floor(viewportHeight) * scale));
20
+ const width = Math.max(1, Math.round((exact ? viewportWidth : Math.floor(viewportWidth)) * scale));
21
+ const height = Math.max(1, Math.round((exact ? viewportHeight : Math.floor(viewportHeight)) * scale));
22
22
  const offsetX = Math.round((scrollX - tile.x) * scale);
23
23
  const offsetY = Math.round((scrollY - tile.y) * scale);
24
24
  // Raster tiles are normally viewport-wide. A full-width crop is already
@@ -115,3 +115,35 @@ export function backgroundBytes(color) {
115
115
  if (!match) return [255, 255, 255, 255];
116
116
  return [Number(match[1]), Number(match[2]), Number(match[3]), Math.round(Number(match[4] ?? 1) * 255)];
117
117
  }
118
+
119
+ /** Raw screenshot payload, owned independently of a recyclable raster tile. */
120
+ export function rawFrame(pixels, width, height, format) {
121
+ if (!['rgba', 'bgra'].includes(format) || width * height > 16777216) throw Error('Invalid raw screenshot');
122
+ const data = new Uint8Array(pixels);
123
+ if (format === 'bgra') {
124
+ for (let i=0; i<data.length; i+=4) { const red=data[i]; data[i]=data[i+2]; data[i+2]=red; }
125
+ }
126
+ return {format, width, height, pixels:data};
127
+ }
128
+
129
+ // CDP clip coordinates are document CSS pixels, not viewport-relative pixels.
130
+ export function captureViewport(tile, x, y, width, height, clip) {
131
+ if (!clip) return cropViewport(tile,x,y,width,height);
132
+ if (![clip.x,clip.y,clip.width,clip.height,clip.scale].every(Number.isFinite)
133
+ || clip.width<=0 || clip.height<=0 || clip.scale<=0 || clip.scale>8)
134
+ throw Error('Invalid screenshot clip');
135
+ const w=Math.max(1,Math.round(clip.width*tile.scale*clip.scale));
136
+ const h=Math.max(1,Math.round(clip.height*tile.scale*clip.scale));
137
+ const sourceW=Math.max(1,Math.round(clip.width*tile.scale));
138
+ const sourceH=Math.max(1,Math.round(clip.height*tile.scale));
139
+ if(Math.max(w,h,sourceW,sourceH)>8192 || w*h>16777216 || sourceW*sourceH>16777216)
140
+ throw Error('Invalid screenshot clip size');
141
+ const frame=cropViewport(tile,clip.x,clip.y,clip.width,clip.height,true);
142
+ if(clip.scale===1)return frame;
143
+ const pixels=new Uint8Array(w*h*4);
144
+ for(let row=0;row<h;row++)for(let col=0;col<w;col++) {
145
+ const from=(Math.min(frame.height-1,Math.floor(row/clip.scale))*frame.width+Math.min(frame.width-1,Math.floor(col/clip.scale)))*4;
146
+ pixels.set(frame.pixels.subarray(from,from+4),(row*w+col)*4);
147
+ }
148
+ return {pixels,width:w,height:h};
149
+ }
@@ -0,0 +1,47 @@
1
+ import {mkdtempSync,writeFileSync,readdirSync,statSync,unlinkSync,rmSync,realpathSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {tmpdir} from 'node:os';
4
+
5
+ // The producer owns the directory; Kitty t=t consumers unlink individual frames.
6
+ // A worker borrows a directory owned by its parent, so terminating the worker
7
+ // cannot skip cleanup. No pixel data is encoded into the CDP response.
8
+ export class RawFrames {
9
+ constructor({directory,ownsDirectory=true}={}) {
10
+ this.externalDirectory=directory;this.ownsDirectory=ownsDirectory;
11
+ this.sequence=0;this.closed=false;
12
+ this.onExit=()=>this.close();
13
+ }
14
+ get directory() {
15
+ if(this.closed)throw Error('Raw frame store is closed');
16
+ if(!this.path){
17
+ this.path=this.externalDirectory ? this.externalDirectory() : realpathSync(mkdtempSync(join(tmpdir(),'tty-graphics-protocol-browser-')));
18
+ if(!this.path)throw Error('Missing raw frame directory');
19
+ if(this.ownsDirectory)process.once('exit',this.onExit);
20
+ }
21
+ return this.path;
22
+ }
23
+ write(bytes,width,height,format) {
24
+ if (!['rgba','bgra'].includes(format) || !Number.isInteger(width) || !Number.isInteger(height)
25
+ || width<1 || height<1 || width*height>16777216 || !(bytes instanceof Uint8Array)
26
+ || bytes.byteLength!==width*height*4) throw Error('Invalid raw frame');
27
+ const directory=this.directory;
28
+ let count=0,total=0;
29
+ for(const name of readdirSync(directory)){
30
+ const path=join(directory,name);
31
+ try{
32
+ const stat=statSync(path);
33
+ if(Date.now()-stat.mtimeMs>60000){unlinkSync(path);continue;}
34
+ count++;total+=stat.size;
35
+ }catch(error){if(error.code!=='ENOENT')throw error;}
36
+ }
37
+ if(count>=16||total+bytes.byteLength>128*1024*1024)throw Error('Raw frame consumer stalled');
38
+ const path=join(directory,`${++this.sequence}.${format}`);
39
+ writeFileSync(path,bytes,{flag:'wx',mode:0o600});
40
+ return JSON.stringify({version:1,transport:'file',path,width,height,stride:width*4,format,bytes:bytes.byteLength});
41
+ }
42
+ close(){
43
+ if(this.closed)return;this.closed=true;
44
+ process.removeListener('exit',this.onExit);
45
+ if(this.ownsDirectory&&this.path)rmSync(this.path,{recursive:true,force:true});
46
+ }
47
+ }
@@ -0,0 +1,103 @@
1
+ // Private loopback transport, connected before renderer seccomp. No inherited
2
+ // numeric descriptors: node:net also supports Windows sockets.
3
+ import {createServer, createConnection} from 'node:net';
4
+ import {randomBytes} from 'node:crypto';
5
+ const MAX_JSON = 96 * 1024 * 1024;
6
+ const MAX_PIXELS = 64 * 1024 * 1024;
7
+ const MAX_QUEUE = 128 * 1024 * 1024;
8
+
9
+ // Length-prefixed JSON followed by optional binary pixels. Copy once into an
10
+ // owned packet so a raster lease may be released before the socket drains.
11
+ export function encodePacket(message, pixels = null) {
12
+ const json = Buffer.from(JSON.stringify(message));
13
+ const size = pixels?.byteLength ?? 0;
14
+ if (json.length > MAX_JSON || size > MAX_PIXELS || json.length + size > MAX_QUEUE) throw Error('Compositor packet too large');
15
+ const packet = Buffer.allocUnsafe(8 + json.length + size);
16
+ packet.writeUInt32LE(json.length, 0); packet.writeUInt32LE(size, 4);
17
+ packet.set(json, 8); if (size) packet.set(pixels, 8 + json.length);
18
+ return packet;
19
+ }
20
+
21
+ export class PacketReader {
22
+ constructor(receive, limit = MAX_QUEUE) { this.receive = receive; this.limit = limit; this.header = Buffer.alloc(8); this.used = 0; this.packet = null; }
23
+ push(chunk) {
24
+ let offset = 0;
25
+ while (offset < chunk.length) {
26
+ const target = this.packet ?? this.header;
27
+ const count = Math.min(target.length - this.used, chunk.length - offset);
28
+ target.set(chunk.subarray(offset, offset + count), this.used);
29
+ this.used += count; offset += count;
30
+ if (this.used !== target.length) continue;
31
+ this.used = 0;
32
+ if (!this.packet) {
33
+ this.jsonLength = this.header.readUInt32LE(0);
34
+ const binaryLength = this.header.readUInt32LE(4);
35
+ const total = this.jsonLength + binaryLength;
36
+ if (!this.jsonLength || this.jsonLength > MAX_JSON || binaryLength > MAX_PIXELS || total > this.limit) throw Error('Invalid compositor packet size');
37
+ this.packet = Buffer.allocUnsafe(total);
38
+ } else {
39
+ const packet = this.packet; this.packet = null;
40
+ const message = JSON.parse(packet.subarray(0, this.jsonLength).toString());
41
+ const pixels = packet.subarray(this.jsonLength);
42
+ this.receive(message, pixels);
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ function attach(socket, receive, closed, limit) {
49
+ socket.setNoDelay(true);
50
+ const reader = new PacketReader(receive, limit);
51
+ socket.on('data', chunk => { try { reader.push(chunk); } catch { socket.destroy(); } });
52
+ socket.on('error', () => {});
53
+ socket.once('close', closed);
54
+ return {
55
+ reader,
56
+ send(message, pixels) {
57
+ if (socket.destroyed) throw Error('Compositor channel closed');
58
+ const packet = encodePacket(message, pixels);
59
+ if (socket.writableLength + packet.length > MAX_QUEUE) { socket.destroy(); throw Error('Compositor channel backpressure limit'); }
60
+ socket.write(packet);
61
+ },
62
+ close() { socket.destroy(); },
63
+ };
64
+ }
65
+
66
+ export async function listenCompositor(receive, closed) {
67
+ const token = randomBytes(32).toString('hex');
68
+ const sockets = new Set(); let peer = null, ended = false;
69
+ const server = createServer(socket => {
70
+ if (ended || peer || sockets.size >= 4) { socket.destroy(); return; }
71
+ sockets.add(socket);
72
+ const timer = setTimeout(() => socket.destroy(), 3000);
73
+ const candidate = attach(socket, (message, pixels) => {
74
+ if (!peer) {
75
+ if (pixels.length || message?.type !== 'hello' || message.token !== token) { socket.destroy(); return; }
76
+ peer = candidate; clearTimeout(timer); candidate.reader.limit = MAX_QUEUE;
77
+ for (const other of sockets) if (other !== socket) other.destroy();
78
+ server.close(); candidate.send({type:'accepted'});
79
+ } else if (peer === candidate) receive(message, pixels);
80
+ }, () => { clearTimeout(timer); sockets.delete(socket); if (peer === candidate && !ended) closed(); }, 4096);
81
+ });
82
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
83
+ return {
84
+ endpoint: {port:server.address().port, token},
85
+ send(message) { if (!peer || ended) throw Error('Compositor not connected'); peer.send(message); },
86
+ close() { ended = true; server.close(); for (const socket of sockets) socket.destroy(); },
87
+ };
88
+ }
89
+
90
+ export function connectCompositor(endpoint, receive, closed) {
91
+ return new Promise((resolve, reject) => {
92
+ const socket = createConnection({host:'127.0.0.1', port:endpoint.port});
93
+ let accepted = false;
94
+ const timer = setTimeout(() => socket.destroy(), 5000);
95
+ const peer = attach(socket, (message, pixels) => {
96
+ if (!accepted) {
97
+ if (message?.type !== 'accepted' || pixels.length) { socket.destroy(); return; }
98
+ accepted = true; clearTimeout(timer); resolve(peer);
99
+ } else receive(message, pixels);
100
+ }, () => { clearTimeout(timer); if (!accepted) reject(Error('Compositor connection failed')); else closed(); });
101
+ socket.once('connect', () => peer.send({type:'hello', token:endpoint.token}));
102
+ });
103
+ }
@@ -6,8 +6,8 @@
6
6
  * screenshots and scrolling on its own channel, so the page stays visible and
7
7
  * scrollable while the main thread works.
8
8
  *
9
- * Channel to the controller: fd 3, a socket shared with the controller,
10
- * newline-delimited JSON (it does not go through the main thread's IPC):
9
+ * Channel to the controller: authenticated loopback socket, with length-prefixed
10
+ * JSON and optional binary pixels (does not go through main-thread IPC):
11
11
  * controller -> compositor { id, method: "screenshot" | "scroll" | "scrollOffset", params }
12
12
  * (screenshot params: [{ format?: "png" | "jpeg" | "webp", quality? }])
13
13
  * compositor -> controller { id, result } | { id, error }
@@ -22,14 +22,13 @@
22
22
  * a fragment link, a key press or a new document.
23
23
  */
24
24
  import { ScrollViewport } from "../layout/scroll-viewport.js";
25
- import { cropViewport, encodeImage, encodeImageBase64 } from "../paint/frame.js";
25
+ import { rawFrame, captureViewport, encodeImage, encodeImageBase64 } from "../paint/frame.js";
26
26
  import { FrameCaptureCache } from "../paint/frame-cache.js";
27
27
  import { FrameLeaseTracker } from "../paint/shared-frame-pool.js";
28
28
  import wasmpeg from "../../vendor/wasmpeg/src/js/index.js";
29
29
  import { VideoPresentation, compositeVideo } from "./video-presentation.js";
30
30
 
31
- const CHANNEL_FD = 3;
32
- const MAX_LINE_BYTES = 1024 * 1024;
31
+ import {connectCompositor} from './compositor-channel.js';
33
32
 
34
33
  const viewport = new ScrollViewport(800, 600);
35
34
  let tile = null;
@@ -42,12 +41,18 @@ const video = new VideoPresentation(wasmpeg, () => {
42
41
  send({ type: "frame" });
43
42
  }, report => postMessage({ type: "video-report", ...report }));
44
43
 
45
- const channel = Bun.file(CHANNEL_FD).writer();
46
-
44
+ let channel;
47
45
  function send(message) {
48
- channel.write(`${JSON.stringify(message)}\n`);
49
- channel.flush();
46
+ const result = message.result;
47
+ if (result?.pixels instanceof Uint8Array) {
48
+ const {pixels,...metadata} = result;
49
+ channel.send({...message,result:metadata},pixels);
50
+ } else channel.send(message);
50
51
  }
52
+ const endpoint = await new Promise(resolve => {
53
+ self.onmessage = ({data}) => { if (data?.type === 'connect') resolve(data.endpoint); };
54
+ });
55
+ channel = await connectCompositor(endpoint, request => { void handleRequest(request); }, () => process.exit(1));
51
56
 
52
57
  self.onmessage = ({ data }) => {
53
58
  if (data?.type === "video-open") { void video.open(data); return; }
@@ -77,12 +82,19 @@ self.onmessage = ({ data }) => {
77
82
  };
78
83
 
79
84
  const methods = {
80
- async screenshot({ format = "png", quality = 80, optimizeForSpeed = false } = {}) {
85
+ async screenshot({ format = "png", quality = 80, optimizeForSpeed = false, clip } = {}) {
81
86
  if (!tile) return "";
82
- const key = `${frameSerial}:${viewport.x}:${viewport.y}:${format}:${quality}:${optimizeForSpeed}`;
87
+ if (format === 'rgba' || format === 'bgra') {
88
+ return frameLeases.capture(frame => {
89
+ const composited = frame.videoLayer?.id === video.config?.id ? compositeVideo(frame, video.frame) : frame;
90
+ const {pixels,width,height} = captureViewport(composited,viewport.x,viewport.y,viewport.width,viewport.height,clip);
91
+ return rawFrame(pixels,width,height,format);
92
+ });
93
+ }
94
+ const key = `${frameSerial}:${viewport.x}:${viewport.y}:${format}:${quality}:${optimizeForSpeed}:${JSON.stringify(clip)}`;
83
95
  return captures.get(key, () => frameLeases.capture(frame => {
84
96
  const composited = frame.videoLayer?.id === video.config?.id ? compositeVideo(frame, video.frame) : frame;
85
- const { pixels, width, height } = cropViewport(composited, viewport.x, viewport.y, viewport.width, viewport.height);
97
+ const { pixels, width, height } = captureViewport(composited, viewport.x, viewport.y, viewport.width, viewport.height,clip);
86
98
  return encodeImageBase64(pixels, width, height, { format, quality, optimizeForSpeed });
87
99
  }));
88
100
  },
@@ -98,13 +110,7 @@ const methods = {
98
110
  },
99
111
  };
100
112
 
101
- async function handleLine(line) {
102
- let request;
103
- try {
104
- request = JSON.parse(line);
105
- } catch {
106
- return;
107
- }
113
+ async function handleRequest(request) {
108
114
  if (request?.type === "audio-timing" && Number.isInteger(request.audioId)) {
109
115
  video.timing(request.audioId, request.seconds);
110
116
  return;
@@ -118,20 +124,6 @@ async function handleLine(line) {
118
124
  }
119
125
  }
120
126
 
121
- (async () => {
122
- const decoder = new TextDecoder();
123
- let pending = "";
124
- for await (const chunk of Bun.file(CHANNEL_FD).stream()) {
125
- pending += decoder.decode(chunk, { stream: true });
126
- let newline;
127
- while ((newline = pending.indexOf("\n")) >= 0) {
128
- handleLine(pending.slice(0, newline));
129
- pending = pending.slice(newline + 1);
130
- }
131
- if (pending.length > MAX_LINE_BYTES) pending = "";
132
- }
133
- })();
134
-
135
127
  // Bun.Image initialises its codecs on first use; do that before the
136
128
  // renderer installs seccomp, which forbids opening files.
137
129
  await encodeImage(new Uint8Array(4), 1, 1);
@@ -1,7 +1,7 @@
1
1
  /** Command-line flags the controller passes to renderer processes. */
2
2
  import { CHROMIUM_VERSION } from "../user-agent.js";
3
3
 
4
- /** fd 3 is the controller's compositor channel; start the compositor Worker. */
4
+ /** Start the compositor Worker with its pre-sandbox loopback channel. */
5
5
  export const COMPOSITOR_FLAG = "--compositor";
6
6
 
7
7
  /** BUNINU_VERIFY_STYLE: check every incremental restyle against a full one (development). */