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 +86 -1
- package/lib/clipboard.js +22 -0
- package/lib/headless-shell.js +31 -5
- package/lib/paint/frame.js +35 -3
- package/lib/paint/raw-frames.js +47 -0
- package/lib/renderer/compositor-channel.js +103 -0
- package/lib/renderer/compositor.js +25 -33
- package/lib/renderer/flags.js +1 -1
- package/lib/renderer/host.js +105 -94
- package/lib/renderer/main.js +22 -1
- package/lib/renderer/page-realm.js +17 -1
- package/lib/renderer/page-renderer.js +20 -8
- package/package.json +1 -1
- package/test/clipboard.test.js +35 -0
- package/test/fixtures/browser-raw-demo.html +36 -0
- package/test/fixtures/raw-capture-benchmark.json +76 -0
- package/test/raw-capture.test.js +152 -0
- package/test/window-resize.test.js +36 -0
- package/tools/bench-raw-capture.js +32 -0
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.
|
|
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:
|
package/lib/clipboard.js
ADDED
|
@@ -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
|
+
}
|
package/lib/headless-shell.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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`
|
package/lib/paint/frame.js
CHANGED
|
@@ -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:
|
|
10
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
44
|
+
let channel;
|
|
47
45
|
function send(message) {
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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 } =
|
|
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
|
|
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);
|
package/lib/renderer/flags.js
CHANGED
|
@@ -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
|
-
/**
|
|
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). */
|