buninu-browser 0.2.3 → 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/cdp-server.js +47 -14
- package/lib/clipboard.js +22 -0
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +40 -11
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +77 -16
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/raw-frames.js +47 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor-channel.js +103 -0
- package/lib/renderer/compositor.js +34 -40
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/flags.js +1 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +133 -98
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +52 -5
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +287 -99
- package/lib/renderer/page-renderer.js +663 -239
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/clipboard.test.js +35 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/browser-raw-demo.html +36 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/raw-capture-benchmark.json +76 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/raw-capture.test.js +152 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/test/window-resize.test.js +36 -0
- package/tools/bench-raw-capture.js +32 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
- package/ai-debug.log +0 -5537
package/lib/renderer/host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {writeClipboard} from '../clipboard.js';
|
|
1
2
|
/**
|
|
2
3
|
* Controller-side handle for a renderer process.
|
|
3
4
|
*
|
|
@@ -9,10 +10,11 @@
|
|
|
9
10
|
* Everything the renderer sends is treated as untrusted input.
|
|
10
11
|
*
|
|
11
12
|
* Besides IPC with the renderer's main thread, each renderer gets a second
|
|
12
|
-
*
|
|
13
|
+
* authenticated loopback channel to its compositor thread, which answers screenshots and
|
|
13
14
|
* scrolling even while the main thread is busy (see compositor.js).
|
|
14
15
|
*/
|
|
15
|
-
import {
|
|
16
|
+
import {listenCompositor} from "./compositor-channel.js";
|
|
17
|
+
import {RawFrames} from "../paint/raw-frames.js";
|
|
16
18
|
import { PulseClient } from "../audio/pulse-client.js";
|
|
17
19
|
import { logEnabled, pipeToLog, RENDERER_LOG_FLAG, writeLog } from "../log.js";
|
|
18
20
|
import { NetworkService } from "../network/network-service.js";
|
|
@@ -47,13 +49,18 @@ const MAX_WEB_SOCKETS = 64;
|
|
|
47
49
|
const MAX_WEB_SOCKET_MESSAGE = 16 * 1024 * 1024;
|
|
48
50
|
const COMPOSITOR_METHODS = new Set(["screenshot", "scroll", "scrollOffset"]);
|
|
49
51
|
const COMPOSITOR_TIMEOUT = 10_000;
|
|
50
|
-
const MAX_CHANNEL_LINE = 96 * 1024 * 1024;
|
|
51
52
|
const LIFECYCLE_NAMES = new Set(["DOMContentLoaded", "load"]);
|
|
52
53
|
|
|
53
54
|
// A file path, not a URL's pathname ("/C:/..." on Windows).
|
|
54
55
|
const DEFAULT_ENTRY = Bun.fileURLToPath(new URL("./main.js", import.meta.url));
|
|
55
56
|
|
|
56
57
|
export class RendererHost {
|
|
58
|
+
#clipboardWrite = writeClipboard;
|
|
59
|
+
#clipboardUntil = 0;
|
|
60
|
+
#clipboardPending = false;
|
|
61
|
+
#rawFrames = null;
|
|
62
|
+
#startEpoch = 0;
|
|
63
|
+
#rejectStartup = null;
|
|
57
64
|
#entry;
|
|
58
65
|
#args;
|
|
59
66
|
#timeout;
|
|
@@ -73,6 +80,7 @@ export class RendererHost {
|
|
|
73
80
|
#audio = new Map();
|
|
74
81
|
// In-flight page fetches, so AbortController reaches the actual HTTP request.
|
|
75
82
|
#pageFetches = new Map();
|
|
83
|
+
#resourceFetches = new Map();
|
|
76
84
|
#nextId = 1;
|
|
77
85
|
#queue = Promise.resolve();
|
|
78
86
|
#restarting = false;
|
|
@@ -104,11 +112,11 @@ export class RendererHost {
|
|
|
104
112
|
entry = DEFAULT_ENTRY, args = [], timeout = 30_000, onRestart = null, onNavigate = null, onDownload = null,
|
|
105
113
|
fetch: fetchImpl = fetch,
|
|
106
114
|
network = new NetworkService({ fetch: fetchImpl }), onFrame = null, onLifecycle = null,
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
compositor = entry === DEFAULT_ENTRY && process.platform !== "win32",
|
|
115
|
+
// A dedicated loopback socket works independently of the main thread on all platforms.
|
|
116
|
+
compositor = entry === DEFAULT_ENTRY,
|
|
117
|
+
clipboardWrite = writeClipboard,
|
|
111
118
|
} = {}) {
|
|
119
|
+
this.#clipboardWrite = clipboardWrite;
|
|
112
120
|
this.#onFrame = onFrame;
|
|
113
121
|
this.#onLifecycle = onLifecycle;
|
|
114
122
|
this.#compositor = compositor;
|
|
@@ -130,47 +138,61 @@ export class RendererHost {
|
|
|
130
138
|
resolveReady = resolve;
|
|
131
139
|
rejectReady = reject;
|
|
132
140
|
});
|
|
141
|
+
this.#rejectStartup = rejectReady;
|
|
133
142
|
const logging = logEnabled();
|
|
134
143
|
const compositor = this.#compositor;
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
144
|
+
const epoch = ++this.#startEpoch;
|
|
145
|
+
void (async () => {
|
|
146
|
+
const channel = compositor ? {pending:new Map(),nextId:1,closed:false,transport:null} : null;
|
|
147
|
+
if (channel) channel.transport = await listenCompositor(
|
|
148
|
+
(message,pixels)=>this.#handleCompositorMessage(channel,message,pixels),
|
|
149
|
+
()=>this.#closeChannel(channel));
|
|
150
|
+
if (epoch !== this.#startEpoch) { channel?.transport.close(); throw Error('Renderer startup cancelled'); }
|
|
151
|
+
this.#channel = channel;
|
|
152
|
+
const child = Bun.spawn({
|
|
153
|
+
cmd: [process.execPath, this.#entry, ...this.#args,
|
|
154
|
+
...(logging ? [RENDERER_LOG_FLAG] : []), ...(compositor ? [COMPOSITOR_FLAG, `--compositor-endpoint=${JSON.stringify(channel.transport.endpoint)}`] : []),
|
|
155
|
+
...(process.env.BUNINU_VERIFY_STYLE ? [VERIFY_STYLE_FLAG] : []), ...(process.env.BUNINU_FULL_STYLE ? [FULL_STYLE_FLAG] : [])],
|
|
156
|
+
// No inherited environment: nothing from the controller leaks in.
|
|
157
|
+
// Windows programs need SystemRoot to load system libraries.
|
|
158
|
+
env: process.platform === "win32" && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {},
|
|
159
|
+
// With BUNINU_LOG, output goes through the controller into the log file.
|
|
160
|
+
stdio: ["ignore", logging ? "pipe" : "inherit", logging ? "pipe" : "inherit"],
|
|
161
|
+
serialization: "advanced",
|
|
162
|
+
ipc: (message) => {
|
|
163
|
+
if (message?.type === "ready") {
|
|
164
|
+
this.#sandbox = sanitizeSandboxReport(message.sandbox);
|
|
165
|
+
this.#rejectStartup=null;
|
|
166
|
+
clearTimeout(startupTimer);
|
|
167
|
+
resolveReady();
|
|
168
|
+
}
|
|
169
|
+
else this.#handleMessage(child, message);
|
|
170
|
+
},
|
|
171
|
+
onExit: (_process, exitCode, signalCode) => {
|
|
172
|
+
clearTimeout(startupTimer);
|
|
173
|
+
this.#closeChannel(channel);
|
|
174
|
+
this.#closeSockets(child);
|
|
175
|
+
this.#closeAudio(child);
|
|
176
|
+
this.#closePageFetches(child);
|
|
177
|
+
rejectReady(new Error("renderer exited during startup"));
|
|
178
|
+
if (this.#child !== child) return;
|
|
179
|
+
console.error(`buninu-browser: renderer ${child.pid} exited unexpectedly (${signalCode ?? `code ${exitCode}`})`);
|
|
180
|
+
this.#child = null;
|
|
181
|
+
this.#ready = null;
|
|
182
|
+
this.#rawFrames?.close(); this.#rawFrames=null;
|
|
183
|
+
for (const { reject } of this.#pending.values()) reject(new Error("renderer exited"));
|
|
184
|
+
this.#pending.clear();
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
this.#child = child;
|
|
188
|
+
const startupTimer = setTimeout(() => {
|
|
189
|
+
this.#closeChannel(channel); child.kill(9); rejectReady(Error('Renderer startup timed out'));
|
|
190
|
+
}, this.#timeout);
|
|
191
|
+
if (logging) {
|
|
192
|
+
pipeToLog(child.stdout, `renderer ${child.pid}`, (chunk) => process.stdout.write(chunk));
|
|
193
|
+
pipeToLog(child.stderr, `renderer ${child.pid}`, (chunk) => process.stderr.write(chunk));
|
|
194
|
+
}
|
|
195
|
+
})().catch(error=>{ if(epoch===this.#startEpoch){this.#closeChannel(this.#channel);this.#ready=null;} rejectReady(error); });
|
|
174
196
|
return this.#ready;
|
|
175
197
|
}
|
|
176
198
|
|
|
@@ -189,6 +211,7 @@ export class RendererHost {
|
|
|
189
211
|
this.call("applyCompositorScroll", x, y).catch(() => {});
|
|
190
212
|
}
|
|
191
213
|
if ((method === "loadDocument" || method === "showFragment") && params[0]) {
|
|
214
|
+
if (method === "loadDocument") this.#closeResourceFetches(this.#child);
|
|
192
215
|
this.#documentUrl = String(method === "loadDocument" ? params[0].url : params[0]);
|
|
193
216
|
}
|
|
194
217
|
const run = () => this.#call(method, params);
|
|
@@ -211,12 +234,13 @@ export class RendererHost {
|
|
|
211
234
|
* waiting for the renderer's main thread or its call queue.
|
|
212
235
|
*/
|
|
213
236
|
async composite(method, ...params) {
|
|
214
|
-
if (!this.#compositor || !COMPOSITOR_METHODS.has(method)) return this.call(method, ...params);
|
|
237
|
+
if (!this.#compositor || !COMPOSITOR_METHODS.has(method)) return this.#captureResult(method, await this.call(method, ...params), params);
|
|
215
238
|
// A dead renderer is restarted (and its page restored) through the call path.
|
|
216
239
|
if (!this.#child) await this.call("url");
|
|
217
240
|
await this.start();
|
|
218
241
|
const channel = this.#channel;
|
|
219
|
-
if (!channel) return this.call(method, ...params);
|
|
242
|
+
if (!channel) return this.#captureResult(method, await this.call(method, ...params), params);
|
|
243
|
+
const child = this.#child;
|
|
220
244
|
const result = await new Promise((resolve, reject) => {
|
|
221
245
|
const id = channel.nextId++;
|
|
222
246
|
const timer = setTimeout(() => {
|
|
@@ -233,56 +257,25 @@ export class RendererHost {
|
|
|
233
257
|
reject(error);
|
|
234
258
|
},
|
|
235
259
|
});
|
|
236
|
-
channel.
|
|
237
|
-
channel.
|
|
260
|
+
try { channel.transport.send({id,method,params}); }
|
|
261
|
+
catch(error) { channel.pending.get(id).reject(error); channel.pending.delete(id); }
|
|
238
262
|
});
|
|
239
|
-
|
|
263
|
+
if (channel.closed || this.#child !== child) throw Error("Renderer closed during capture");
|
|
264
|
+
const sanitized = this.#captureResult(method, result, params);
|
|
240
265
|
if (method === "scroll") this.#pendingScroll = sanitized;
|
|
241
266
|
return sanitized;
|
|
242
267
|
}
|
|
243
268
|
|
|
244
|
-
#
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
buffer += decoder.decode(chunk, { stream: true });
|
|
252
|
-
let newline;
|
|
253
|
-
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
254
|
-
this.#handleCompositorLine(channel, buffer.slice(0, newline));
|
|
255
|
-
buffer = buffer.slice(newline + 1);
|
|
256
|
-
}
|
|
257
|
-
if (buffer.length > MAX_CHANNEL_LINE) buffer = "";
|
|
258
|
-
}
|
|
259
|
-
} catch {
|
|
260
|
-
// The renderer went away.
|
|
261
|
-
}
|
|
262
|
-
this.#closeChannel(channel);
|
|
263
|
-
// Close everything that refers to the socket once, after end of
|
|
264
|
-
// stream, so no descriptor number can have been reused meanwhile.
|
|
265
|
-
try {
|
|
266
|
-
channel.writer.end();
|
|
267
|
-
} catch {
|
|
268
|
-
// Already closed.
|
|
269
|
-
}
|
|
270
|
-
try {
|
|
271
|
-
closeSync(fd);
|
|
272
|
-
} catch {
|
|
273
|
-
// Already closed.
|
|
274
|
-
}
|
|
275
|
-
})();
|
|
276
|
-
return channel;
|
|
269
|
+
#captureResult(method,result,params) {
|
|
270
|
+
if (method === 'screenshot' && ['rgba','bgra'].includes(params[0]?.format)) {
|
|
271
|
+
if (result?.format !== params[0].format) throw Error('Raw screenshot format mismatch');
|
|
272
|
+
this.#rawFrames ??= new RawFrames();
|
|
273
|
+
return this.#rawFrames.write(result.pixels,result.width,result.height,result.format);
|
|
274
|
+
}
|
|
275
|
+
return sanitizeCompositorResult(method,result);
|
|
277
276
|
}
|
|
278
277
|
|
|
279
|
-
#
|
|
280
|
-
let message;
|
|
281
|
-
try {
|
|
282
|
-
message = JSON.parse(line);
|
|
283
|
-
} catch {
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
278
|
+
#handleCompositorMessage(channel, message, pixels) {
|
|
286
279
|
if (channel.closed || typeof message !== "object" || message === null) return;
|
|
287
280
|
if (message.type === "frame") {
|
|
288
281
|
if (this.#channel === channel) this.#onFrame?.();
|
|
@@ -290,19 +283,23 @@ export class RendererHost {
|
|
|
290
283
|
const pending = channel.pending.get(message.id);
|
|
291
284
|
channel.pending.delete(message.id);
|
|
292
285
|
if (Object.hasOwn(message, "error")) pending.reject(new Error(`compositor: ${String(message.error).slice(0, 200)}`));
|
|
293
|
-
else pending.resolve(message.result);
|
|
286
|
+
else pending.resolve(pixels.length ? {...message.result,pixels} : message.result);
|
|
294
287
|
}
|
|
295
288
|
}
|
|
296
289
|
|
|
297
290
|
#closeChannel(channel) {
|
|
298
291
|
if (!channel || channel.closed) return;
|
|
299
292
|
channel.closed = true;
|
|
293
|
+
channel.transport?.close();
|
|
300
294
|
for (const { reject } of channel.pending.values()) reject(new Error("compositor closed"));
|
|
301
295
|
channel.pending.clear();
|
|
302
296
|
if (this.#channel === channel) this.#channel = null;
|
|
303
297
|
}
|
|
304
298
|
|
|
305
299
|
close() {
|
|
300
|
+
this.#startEpoch++;
|
|
301
|
+
this.#rejectStartup?.(Error("Renderer closed during startup")); this.#rejectStartup=null;
|
|
302
|
+
this.#rawFrames?.close(); this.#rawFrames=null;
|
|
306
303
|
this.#closeChannel(this.#channel);
|
|
307
304
|
const child = this.#child;
|
|
308
305
|
this.#closeSockets(child);
|
|
@@ -310,6 +307,8 @@ export class RendererHost {
|
|
|
310
307
|
this.#closePageFetches(child);
|
|
311
308
|
this.#child = null;
|
|
312
309
|
this.#ready = null;
|
|
310
|
+
for (const {reject} of this.#pending.values()) reject(Error("Renderer closed"));
|
|
311
|
+
this.#pending.clear();
|
|
313
312
|
child?.kill();
|
|
314
313
|
}
|
|
315
314
|
|
|
@@ -317,6 +316,8 @@ export class RendererHost {
|
|
|
317
316
|
if (!this.#child) await this.#restart();
|
|
318
317
|
await this.start();
|
|
319
318
|
const child = this.#child;
|
|
319
|
+
if (method === 'click' || method === 'press') this.#clipboardUntil = Date.now()+5000;
|
|
320
|
+
if (method === 'loadDocument') this.#clipboardUntil = 0;
|
|
320
321
|
const id = this.#nextId++;
|
|
321
322
|
return new Promise((resolve, reject) => {
|
|
322
323
|
const timer = setTimeout(() => {
|
|
@@ -325,6 +326,7 @@ export class RendererHost {
|
|
|
325
326
|
this.#child = null;
|
|
326
327
|
this.#ready = null;
|
|
327
328
|
console.error(`buninu-browser: renderer ${child.pid} missed the ${timeout}ms deadline for ${method}; killing it`);
|
|
329
|
+
this.#rawFrames?.close(); this.#rawFrames=null;
|
|
328
330
|
child.kill(9);
|
|
329
331
|
reject(new Error(`renderer ${method} timed out after ${timeout}ms`));
|
|
330
332
|
}, timeout);
|
|
@@ -363,6 +365,18 @@ export class RendererHost {
|
|
|
363
365
|
pending.reject(new Error(`renderer: ${String(message.error).split("\n")[0] || "unknown error"}`));
|
|
364
366
|
}
|
|
365
367
|
else pending.resolve(message.result);
|
|
368
|
+
} else if (message.type === 'clipboard-write' && Number.isInteger(message.id)) {
|
|
369
|
+
const reply = error => { if(this.#child===child)child.send({type:'clipboard-result',id:message.id,...(error?{error}: {})}); };
|
|
370
|
+
let secure=false;
|
|
371
|
+
try { const url=new URL(this.#documentUrl); secure=url.protocol==='https:' || (url.protocol==='http:' && ['localhost','127.0.0.1','[::1]'].includes(url.hostname)); } catch {}
|
|
372
|
+
if (!secure || Date.now()>this.#clipboardUntil || this.#clipboardPending
|
|
373
|
+
|| typeof message.text!=='string' || Buffer.byteLength(message.text)>1024*1024) {
|
|
374
|
+
reply('Clipboard write requires a recent user action in a secure document'); return;
|
|
375
|
+
}
|
|
376
|
+
this.#clipboardUntil=0; this.#clipboardPending=true;
|
|
377
|
+
Promise.resolve().then(()=>this.#clipboardWrite(message.text))
|
|
378
|
+
.then(()=>reply(),()=>reply('Unable to write system clipboard'))
|
|
379
|
+
.finally(()=>{this.#clipboardPending=false;});
|
|
366
380
|
} else if (message.type === "fetch" && Number.isInteger(message.id)) {
|
|
367
381
|
this.#fetchForRenderer(child, message);
|
|
368
382
|
} else if (message.type === "page-fetch" && Number.isInteger(message.id)) {
|
|
@@ -493,8 +507,7 @@ export class RendererHost {
|
|
|
493
507
|
child.send({ type: "audio-event", id, ...payload });
|
|
494
508
|
// The compositor's A/V clock must not wait behind page JavaScript.
|
|
495
509
|
if (payload.event === "timing" && this.#channel && !this.#channel.closed) {
|
|
496
|
-
this.#channel.
|
|
497
|
-
this.#channel.writer.flush();
|
|
510
|
+
this.#channel.transport.send({type:"audio-timing",audioId:id,seconds:payload.seconds});
|
|
498
511
|
}
|
|
499
512
|
}
|
|
500
513
|
};
|
|
@@ -534,6 +547,7 @@ export class RendererHost {
|
|
|
534
547
|
}
|
|
535
548
|
|
|
536
549
|
#closePageFetches(child) {
|
|
550
|
+
this.#closeResourceFetches(child);
|
|
537
551
|
for (const [id, entry] of this.#pageFetches) {
|
|
538
552
|
if (entry.child !== child) continue;
|
|
539
553
|
this.#pageFetches.delete(id);
|
|
@@ -541,6 +555,14 @@ export class RendererHost {
|
|
|
541
555
|
}
|
|
542
556
|
}
|
|
543
557
|
|
|
558
|
+
#closeResourceFetches(child) {
|
|
559
|
+
for (const [id, entry] of this.#resourceFetches) {
|
|
560
|
+
if (entry.child !== child) continue;
|
|
561
|
+
this.#resourceFetches.delete(id);
|
|
562
|
+
entry.controller.abort();
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
544
566
|
#closeSockets(child) {
|
|
545
567
|
for (const [id, entry] of this.#sockets) {
|
|
546
568
|
if (entry.child !== child) continue;
|
|
@@ -552,6 +574,16 @@ export class RendererHost {
|
|
|
552
574
|
}
|
|
553
575
|
|
|
554
576
|
async #fetchForRenderer(child, { id, url, kind }) {
|
|
577
|
+
const entry = { child, controller: new AbortController() };
|
|
578
|
+
this.#resourceFetches.set(id, entry);
|
|
579
|
+
try {
|
|
580
|
+
await this.#fetchResource(child, { id, url, kind }, entry.controller.signal);
|
|
581
|
+
} finally {
|
|
582
|
+
if (this.#resourceFetches.get(id) === entry) this.#resourceFetches.delete(id);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async #fetchResource(child, { id, url, kind }, signal) {
|
|
555
587
|
const reply = (payload) => {
|
|
556
588
|
if (this.#child === child) child.send({ type: "fetch-result", id, ...payload });
|
|
557
589
|
};
|
|
@@ -561,7 +593,10 @@ export class RendererHost {
|
|
|
561
593
|
} catch {
|
|
562
594
|
return reply({ error: "invalid URL" });
|
|
563
595
|
}
|
|
564
|
-
|
|
596
|
+
// Local documents may render local resources. Authorize this from the
|
|
597
|
+
// controller's document URL, never from a renderer-supplied referrer.
|
|
598
|
+
const localFile = target.protocol === "file:" && new URL(this.#documentUrl).protocol === "file:";
|
|
599
|
+
if ((!ALLOWED_SCHEMES.has(target.protocol) && !localFile) || !RESOURCE_KINDS.has(kind)) {
|
|
565
600
|
return reply({ error: `blocked ${kind} request for ${target.protocol}` });
|
|
566
601
|
}
|
|
567
602
|
// A renderer only ever holds documents of its own origin (site isolation
|
|
@@ -570,7 +605,7 @@ export class RendererHost {
|
|
|
570
605
|
// being framed.
|
|
571
606
|
const frame = kind === "iframe";
|
|
572
607
|
if (frame && !sameOrigin(target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
|
|
573
|
-
// Video and audio over http(s) stream. data: stays one response: it is
|
|
608
|
+
// Video and audio over http(s)/file stream. data: stays one response: it is
|
|
574
609
|
// already in hand, and the renderer wraps it in a finished pipe.
|
|
575
610
|
if ((kind === "video" || kind === "audio") && target.protocol !== "data:") {
|
|
576
611
|
const send = (payload) => {
|
|
@@ -599,7 +634,7 @@ export class RendererHost {
|
|
|
599
634
|
} else if (event.type === "error") {
|
|
600
635
|
send({ type: "fetch-error", id, error: String(event.error ?? "media fetch failed") });
|
|
601
636
|
}
|
|
602
|
-
});
|
|
637
|
+
}, { signal });
|
|
603
638
|
} catch (error) {
|
|
604
639
|
send({ type: "fetch-error", id, error: String(error?.message ?? error) });
|
|
605
640
|
}
|
|
@@ -608,7 +643,7 @@ export class RendererHost {
|
|
|
608
643
|
try {
|
|
609
644
|
const response = target.protocol === "data:"
|
|
610
645
|
? await dataResponse(target)
|
|
611
|
-
: await this.#network.subresource(target.href, this.#documentUrl, kind);
|
|
646
|
+
: await this.#network.subresource(target.href, this.#documentUrl, kind, { signal });
|
|
612
647
|
if (response.body.byteLength > MAX_RESOURCE_BYTES) return reply({ error: "resource too large" });
|
|
613
648
|
if (frame && !sameOrigin(response.url || target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
|
|
614
649
|
if (frame && framingDenied(response.rawHeaders ?? [])) return reply({ error: "framing denied" });
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesce concurrent requests for the same image within one document. Each
|
|
3
|
+
* consumer gets its own CanvasKit handle, sharing the underlying pixels, so
|
|
4
|
+
* removing one element cannot delete another element's image. Completed
|
|
5
|
+
* requests are dropped; HTTP freshness and persistent caching belong to the
|
|
6
|
+
* controller's network service.
|
|
7
|
+
*/
|
|
8
|
+
export function imageCrossOrigin(element) {
|
|
9
|
+
const value = element.getAttribute("crossorigin");
|
|
10
|
+
return value === null ? null : value.toLowerCase() === "use-credentials" ? "include" : "same-origin";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function imageRequestKey(src, credentials) {
|
|
14
|
+
return JSON.stringify([src, credentials]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createImageResourceLoader(CanvasKit, fetchResource, fetchCors) {
|
|
18
|
+
const documents = new WeakMap();
|
|
19
|
+
const corsRequests = new WeakMap();
|
|
20
|
+
const loadImage = function (document, url, credentials = null) {
|
|
21
|
+
let requests = documents.get(document);
|
|
22
|
+
if (!requests) documents.set(document, requests = new Map());
|
|
23
|
+
const key = imageRequestKey(url, credentials);
|
|
24
|
+
let entry = requests.get(key);
|
|
25
|
+
if (!entry) {
|
|
26
|
+
entry = { consumers: 0, ready: (async () => {
|
|
27
|
+
let response;
|
|
28
|
+
if (credentials !== null) {
|
|
29
|
+
const request = fetchCors({ url, method: "GET", headers: [], body: null,
|
|
30
|
+
mode: "cors", credentials, redirect: "follow" });
|
|
31
|
+
let pending = corsRequests.get(document);
|
|
32
|
+
if (!pending) corsRequests.set(document, pending = new Set());
|
|
33
|
+
pending.add(request);
|
|
34
|
+
try {
|
|
35
|
+
const result = await request;
|
|
36
|
+
const body = result.body instanceof Uint8Array ? result.body : await Bun.readableStreamToBytes(result.body);
|
|
37
|
+
response = { ...result, body };
|
|
38
|
+
} finally { pending.delete(request); }
|
|
39
|
+
} else response = await fetchResource(url, "image");
|
|
40
|
+
if (!(response.ok ?? (response.status >= 200 && response.status < 300))) throw new Error(`HTTP ${response.status}`);
|
|
41
|
+
// Keep Bun's header validation/decompression-size guard before Skia
|
|
42
|
+
// takes ownership of these untrusted encoded bytes.
|
|
43
|
+
const { width, height } = await new Bun.Image(response.body).metadata();
|
|
44
|
+
const image = CanvasKit.MakeImageFromEncoded(response.body);
|
|
45
|
+
if (!image) throw new Error("Image decoding failed");
|
|
46
|
+
return { width, height, image, url: response.url ?? url, requestedURL: url, crossOrigin: credentials,
|
|
47
|
+
originClean: credentials !== null ? true : undefined };
|
|
48
|
+
})() };
|
|
49
|
+
requests.set(key, entry);
|
|
50
|
+
}
|
|
51
|
+
entry.consumers++;
|
|
52
|
+
return entry.ready.then(resource => Object.freeze({ ...resource, image: resource.image.clone() })).finally(() => {
|
|
53
|
+
if (--entry.consumers) return;
|
|
54
|
+
requests.delete(key);
|
|
55
|
+
// All successful consumers already own their clone. Failures have no
|
|
56
|
+
// image to release, and must allow a later request to try again.
|
|
57
|
+
entry.ready.then(resource => resource.image.delete(), () => {});
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
loadImage.cancelDocument = (document) => {
|
|
61
|
+
for (const request of corsRequests.get(document) ?? []) request.abort?.();
|
|
62
|
+
corsRequests.delete(document);
|
|
63
|
+
};
|
|
64
|
+
return loadImage;
|
|
65
|
+
}
|
|
@@ -74,6 +74,34 @@ export function platformObject(name, template, { parent = Object.prototype, base
|
|
|
74
74
|
|
|
75
75
|
const accessor = (object, key) => Object.getOwnPropertyDescriptor(object, key);
|
|
76
76
|
|
|
77
|
+
/** A page-local Error constructor around SES's tamed intrinsic, retaining native error branding. */
|
|
78
|
+
export function errorInterface(BaseError, isError) {
|
|
79
|
+
const Error = function Error(message, options) {
|
|
80
|
+
return Reflect.construct(BaseError, arguments, new.target ?? Error);
|
|
81
|
+
};
|
|
82
|
+
const prototype = Object.create(BaseError.prototype);
|
|
83
|
+
for (const key of Reflect.ownKeys(BaseError.prototype)) {
|
|
84
|
+
if (key === "constructor") continue;
|
|
85
|
+
const descriptor = Object.getOwnPropertyDescriptor(BaseError.prototype, key);
|
|
86
|
+
Object.defineProperty(prototype, key, { ...descriptor, configurable: true, ...("value" in descriptor ? { writable: true } : {}) });
|
|
87
|
+
}
|
|
88
|
+
Object.defineProperty(prototype, "constructor", { value: Error, writable: true, configurable: true });
|
|
89
|
+
Error.prototype = prototype;
|
|
90
|
+
Object.setPrototypeOf(Error, BaseError);
|
|
91
|
+
for (const key of Reflect.ownKeys(BaseError)) {
|
|
92
|
+
if (["length", "name", "prototype"].includes(key)) continue;
|
|
93
|
+
const descriptor = Object.getOwnPropertyDescriptor(BaseError, key);
|
|
94
|
+
Object.defineProperty(Error, key, { ...descriptor, configurable: true, ...("value" in descriptor ? { writable: true } : {}) });
|
|
95
|
+
}
|
|
96
|
+
Object.defineProperty(Error, "length", { value: 1, configurable: true });
|
|
97
|
+
Object.defineProperty(Error, Symbol.hasInstance, {
|
|
98
|
+
value(value) { return Function.prototype[Symbol.hasInstance].call(this === Error ? BaseError : this, value); },
|
|
99
|
+
configurable: true,
|
|
100
|
+
});
|
|
101
|
+
if (isError) Object.defineProperty(Error, "isError", { value: isError, writable: true, configurable: true });
|
|
102
|
+
return Error;
|
|
103
|
+
}
|
|
104
|
+
|
|
77
105
|
/**
|
|
78
106
|
* window.localStorage and sessionStorage (HTML §12.2.1): the methods on
|
|
79
107
|
* Storage.prototype, and the stored items as each storage's own properties
|