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/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;
|
|
@@ -105,11 +112,11 @@ export class RendererHost {
|
|
|
105
112
|
entry = DEFAULT_ENTRY, args = [], timeout = 30_000, onRestart = null, onNavigate = null, onDownload = null,
|
|
106
113
|
fetch: fetchImpl = fetch,
|
|
107
114
|
network = new NetworkService({ fetch: fetchImpl }), onFrame = null, onLifecycle = null,
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
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,
|
|
112
118
|
} = {}) {
|
|
119
|
+
this.#clipboardWrite = clipboardWrite;
|
|
113
120
|
this.#onFrame = onFrame;
|
|
114
121
|
this.#onLifecycle = onLifecycle;
|
|
115
122
|
this.#compositor = compositor;
|
|
@@ -131,47 +138,61 @@ export class RendererHost {
|
|
|
131
138
|
resolveReady = resolve;
|
|
132
139
|
rejectReady = reject;
|
|
133
140
|
});
|
|
141
|
+
this.#rejectStartup = rejectReady;
|
|
134
142
|
const logging = logEnabled();
|
|
135
143
|
const compositor = this.#compositor;
|
|
136
|
-
const
|
|
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
|
-
|
|
174
|
-
|
|
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); });
|
|
175
196
|
return this.#ready;
|
|
176
197
|
}
|
|
177
198
|
|
|
@@ -213,12 +234,13 @@ export class RendererHost {
|
|
|
213
234
|
* waiting for the renderer's main thread or its call queue.
|
|
214
235
|
*/
|
|
215
236
|
async composite(method, ...params) {
|
|
216
|
-
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);
|
|
217
238
|
// A dead renderer is restarted (and its page restored) through the call path.
|
|
218
239
|
if (!this.#child) await this.call("url");
|
|
219
240
|
await this.start();
|
|
220
241
|
const channel = this.#channel;
|
|
221
|
-
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;
|
|
222
244
|
const result = await new Promise((resolve, reject) => {
|
|
223
245
|
const id = channel.nextId++;
|
|
224
246
|
const timer = setTimeout(() => {
|
|
@@ -235,56 +257,25 @@ export class RendererHost {
|
|
|
235
257
|
reject(error);
|
|
236
258
|
},
|
|
237
259
|
});
|
|
238
|
-
channel.
|
|
239
|
-
channel.
|
|
260
|
+
try { channel.transport.send({id,method,params}); }
|
|
261
|
+
catch(error) { channel.pending.get(id).reject(error); channel.pending.delete(id); }
|
|
240
262
|
});
|
|
241
|
-
|
|
263
|
+
if (channel.closed || this.#child !== child) throw Error("Renderer closed during capture");
|
|
264
|
+
const sanitized = this.#captureResult(method, result, params);
|
|
242
265
|
if (method === "scroll") this.#pendingScroll = sanitized;
|
|
243
266
|
return sanitized;
|
|
244
267
|
}
|
|
245
268
|
|
|
246
|
-
#
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
buffer += decoder.decode(chunk, { stream: true });
|
|
254
|
-
let newline;
|
|
255
|
-
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
256
|
-
this.#handleCompositorLine(channel, buffer.slice(0, newline));
|
|
257
|
-
buffer = buffer.slice(newline + 1);
|
|
258
|
-
}
|
|
259
|
-
if (buffer.length > MAX_CHANNEL_LINE) buffer = "";
|
|
260
|
-
}
|
|
261
|
-
} catch {
|
|
262
|
-
// The renderer went away.
|
|
263
|
-
}
|
|
264
|
-
this.#closeChannel(channel);
|
|
265
|
-
// Close everything that refers to the socket once, after end of
|
|
266
|
-
// stream, so no descriptor number can have been reused meanwhile.
|
|
267
|
-
try {
|
|
268
|
-
channel.writer.end();
|
|
269
|
-
} catch {
|
|
270
|
-
// Already closed.
|
|
271
|
-
}
|
|
272
|
-
try {
|
|
273
|
-
closeSync(fd);
|
|
274
|
-
} catch {
|
|
275
|
-
// Already closed.
|
|
276
|
-
}
|
|
277
|
-
})();
|
|
278
|
-
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);
|
|
279
276
|
}
|
|
280
277
|
|
|
281
|
-
#
|
|
282
|
-
let message;
|
|
283
|
-
try {
|
|
284
|
-
message = JSON.parse(line);
|
|
285
|
-
} catch {
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
278
|
+
#handleCompositorMessage(channel, message, pixels) {
|
|
288
279
|
if (channel.closed || typeof message !== "object" || message === null) return;
|
|
289
280
|
if (message.type === "frame") {
|
|
290
281
|
if (this.#channel === channel) this.#onFrame?.();
|
|
@@ -292,19 +283,23 @@ export class RendererHost {
|
|
|
292
283
|
const pending = channel.pending.get(message.id);
|
|
293
284
|
channel.pending.delete(message.id);
|
|
294
285
|
if (Object.hasOwn(message, "error")) pending.reject(new Error(`compositor: ${String(message.error).slice(0, 200)}`));
|
|
295
|
-
else pending.resolve(message.result);
|
|
286
|
+
else pending.resolve(pixels.length ? {...message.result,pixels} : message.result);
|
|
296
287
|
}
|
|
297
288
|
}
|
|
298
289
|
|
|
299
290
|
#closeChannel(channel) {
|
|
300
291
|
if (!channel || channel.closed) return;
|
|
301
292
|
channel.closed = true;
|
|
293
|
+
channel.transport?.close();
|
|
302
294
|
for (const { reject } of channel.pending.values()) reject(new Error("compositor closed"));
|
|
303
295
|
channel.pending.clear();
|
|
304
296
|
if (this.#channel === channel) this.#channel = null;
|
|
305
297
|
}
|
|
306
298
|
|
|
307
299
|
close() {
|
|
300
|
+
this.#startEpoch++;
|
|
301
|
+
this.#rejectStartup?.(Error("Renderer closed during startup")); this.#rejectStartup=null;
|
|
302
|
+
this.#rawFrames?.close(); this.#rawFrames=null;
|
|
308
303
|
this.#closeChannel(this.#channel);
|
|
309
304
|
const child = this.#child;
|
|
310
305
|
this.#closeSockets(child);
|
|
@@ -312,6 +307,8 @@ export class RendererHost {
|
|
|
312
307
|
this.#closePageFetches(child);
|
|
313
308
|
this.#child = null;
|
|
314
309
|
this.#ready = null;
|
|
310
|
+
for (const {reject} of this.#pending.values()) reject(Error("Renderer closed"));
|
|
311
|
+
this.#pending.clear();
|
|
315
312
|
child?.kill();
|
|
316
313
|
}
|
|
317
314
|
|
|
@@ -319,6 +316,8 @@ export class RendererHost {
|
|
|
319
316
|
if (!this.#child) await this.#restart();
|
|
320
317
|
await this.start();
|
|
321
318
|
const child = this.#child;
|
|
319
|
+
if (method === 'click' || method === 'press') this.#clipboardUntil = Date.now()+5000;
|
|
320
|
+
if (method === 'loadDocument') this.#clipboardUntil = 0;
|
|
322
321
|
const id = this.#nextId++;
|
|
323
322
|
return new Promise((resolve, reject) => {
|
|
324
323
|
const timer = setTimeout(() => {
|
|
@@ -327,6 +326,7 @@ export class RendererHost {
|
|
|
327
326
|
this.#child = null;
|
|
328
327
|
this.#ready = null;
|
|
329
328
|
console.error(`buninu-browser: renderer ${child.pid} missed the ${timeout}ms deadline for ${method}; killing it`);
|
|
329
|
+
this.#rawFrames?.close(); this.#rawFrames=null;
|
|
330
330
|
child.kill(9);
|
|
331
331
|
reject(new Error(`renderer ${method} timed out after ${timeout}ms`));
|
|
332
332
|
}, timeout);
|
|
@@ -365,6 +365,18 @@ export class RendererHost {
|
|
|
365
365
|
pending.reject(new Error(`renderer: ${String(message.error).split("\n")[0] || "unknown error"}`));
|
|
366
366
|
}
|
|
367
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;});
|
|
368
380
|
} else if (message.type === "fetch" && Number.isInteger(message.id)) {
|
|
369
381
|
this.#fetchForRenderer(child, message);
|
|
370
382
|
} else if (message.type === "page-fetch" && Number.isInteger(message.id)) {
|
|
@@ -495,8 +507,7 @@ export class RendererHost {
|
|
|
495
507
|
child.send({ type: "audio-event", id, ...payload });
|
|
496
508
|
// The compositor's A/V clock must not wait behind page JavaScript.
|
|
497
509
|
if (payload.event === "timing" && this.#channel && !this.#channel.closed) {
|
|
498
|
-
this.#channel.
|
|
499
|
-
this.#channel.writer.flush();
|
|
510
|
+
this.#channel.transport.send({type:"audio-timing",audioId:id,seconds:payload.seconds});
|
|
500
511
|
}
|
|
501
512
|
}
|
|
502
513
|
};
|
package/lib/renderer/main.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* closed with sentBytes: the PCM that reached the output before audio-close)
|
|
29
29
|
*
|
|
30
30
|
* With --compositor, a compositor Worker (compositor.js) answers screenshots
|
|
31
|
-
* and scrolling on
|
|
31
|
+
* and scrolling on a dedicated socket while this thread is busy; frames go to it with
|
|
32
32
|
* postMessage.
|
|
33
33
|
*
|
|
34
34
|
* Startup order matters for the sandbox:
|
|
@@ -172,6 +172,8 @@ function compositorCaughtUp() {
|
|
|
172
172
|
|
|
173
173
|
async function startCompositor() {
|
|
174
174
|
const worker = new Worker(new URL("./compositor.js", import.meta.url).href);
|
|
175
|
+
const endpoint = JSON.parse(process.argv.find(arg=>arg.startsWith('--compositor-endpoint=')).slice('--compositor-endpoint='.length));
|
|
176
|
+
worker.postMessage({type:'connect',endpoint});
|
|
175
177
|
await new Promise((resolve, reject) => {
|
|
176
178
|
worker.addEventListener("message", resolve, { once: true });
|
|
177
179
|
worker.addEventListener("error", reject, { once: true });
|
|
@@ -237,12 +239,24 @@ async function startScriptRewriter() {
|
|
|
237
239
|
});
|
|
238
240
|
}
|
|
239
241
|
|
|
242
|
+
let nextClipboardId=1;
|
|
243
|
+
const clipboardWrites=new Map();
|
|
244
|
+
function clipboardWrite(text) {
|
|
245
|
+
const id=nextClipboardId++;
|
|
246
|
+
return new Promise((resolve,reject)=>{
|
|
247
|
+
const timer=setTimeout(()=>{clipboardWrites.delete(id);reject(Error('Clipboard timed out'));},5000);
|
|
248
|
+
clipboardWrites.set(id,{resolve,reject,timer});
|
|
249
|
+
process.send({type:'clipboard-write',id,text});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
240
253
|
const renderer = await createPageRenderer({
|
|
241
254
|
fetchResource,
|
|
242
255
|
rewriteScript,
|
|
243
256
|
requestNavigation: (url, download = null, fragmentFrom = null, post = null) => process.send({ type: "navigate", url, download, fragmentFrom, post }),
|
|
244
257
|
requestDownload: ({ url, name, data }) => process.send({ type: "download-data", url, name, data }),
|
|
245
258
|
pageFetch,
|
|
259
|
+
clipboardWrite,
|
|
246
260
|
webSockets,
|
|
247
261
|
audio,
|
|
248
262
|
setCookie: (value) => process.send({ type: "cookie", value }),
|
|
@@ -323,6 +337,13 @@ async function warmUp() {
|
|
|
323
337
|
}
|
|
324
338
|
|
|
325
339
|
process.on("message", async (message) => {
|
|
340
|
+
if(message?.type==='clipboard-result') {
|
|
341
|
+
const pending=clipboardWrites.get(message.id);if(!pending)return;
|
|
342
|
+
clipboardWrites.delete(message.id);clearTimeout(pending.timer);
|
|
343
|
+
if(message.error)pending.reject(Error(String(message.error)));else pending.resolve();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
326
347
|
if (message?.type === "page-fetch-head") {
|
|
327
348
|
const pending = pendingFetches.get(message.id);
|
|
328
349
|
if (!pending?.page || pending.head) return;
|
|
@@ -1590,6 +1590,15 @@ export function createPageRealm({ window, document, mode, hooks }) {
|
|
|
1590
1590
|
const platform = platformObjects({
|
|
1591
1591
|
eventTargets, userAgent, userAgentDataMembers: chromeVersion ? userAgentDataMembers : null, mobile: mobileUserAgent, viewport: hooks.viewport,
|
|
1592
1592
|
performance: userTiming.performance, storages: { local: hostStorage.get(origin), session: new Map() },
|
|
1593
|
+
clipboardWrite: async text => {
|
|
1594
|
+
try {
|
|
1595
|
+
const url = new URL(document.URL);
|
|
1596
|
+
const secure = url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost','127.0.0.1','[::1]'].includes(url.hostname));
|
|
1597
|
+
if (!secure || !hooks.clipboardWrite) throw Error('Clipboard unavailable');
|
|
1598
|
+
await hooks.clipboardWrite(text);
|
|
1599
|
+
}
|
|
1600
|
+
catch { throw new DOMException('Clipboard write was not allowed or failed', 'NotAllowedError'); }
|
|
1601
|
+
},
|
|
1593
1602
|
sendBeacon: (url, data) => {
|
|
1594
1603
|
network.fetch(url, { method: "POST", body: data, mode: "no-cors", credentials: "include", keepalive: true })
|
|
1595
1604
|
.catch(() => {});
|
|
@@ -2672,7 +2681,7 @@ function navigatorPlatform(userAgent) {
|
|
|
2672
2681
|
* global exposes for them. Desktop Chromium lists five PDF viewer plugins
|
|
2673
2682
|
* and their two MIME types whatever is installed (HTML §8.9.1.6).
|
|
2674
2683
|
*/
|
|
2675
|
-
function platformObjects({ eventTargets, userAgent, userAgentDataMembers, mobile, viewport, performance, storages, sendBeacon }) {
|
|
2684
|
+
function platformObjects({ eventTargets, userAgent, userAgentDataMembers, mobile, viewport, performance, storages, sendBeacon, clipboardWrite }) {
|
|
2676
2685
|
const EventTargetPrototype = eventTargets.EventTarget.prototype;
|
|
2677
2686
|
// An instance an event target's listeners can be added to.
|
|
2678
2687
|
const eventTarget = (prototype) => {
|
|
@@ -2750,6 +2759,12 @@ function platformObjects({ eventTargets, userAgent, userAgentDataMembers, mobile
|
|
|
2750
2759
|
}, targetOptions);
|
|
2751
2760
|
const NavigatorUAData = userAgentDataMembers ? define("NavigatorUAData", userAgentDataMembers) : null;
|
|
2752
2761
|
const languages = Object.freeze(["en-US"]);
|
|
2762
|
+
const Clipboard = define('Clipboard', {
|
|
2763
|
+
writeText(text) {
|
|
2764
|
+
if (arguments.length===0) return Promise.reject(new TypeError('writeText requires text'));
|
|
2765
|
+
return clipboardWrite(String(text));
|
|
2766
|
+
},
|
|
2767
|
+
}, targetOptions);
|
|
2753
2768
|
const Navigator = define("Navigator", {
|
|
2754
2769
|
userAgent,
|
|
2755
2770
|
appCodeName: "Mozilla",
|
|
@@ -2775,6 +2790,7 @@ function platformObjects({ eventTargets, userAgent, userAgentDataMembers, mobile
|
|
|
2775
2790
|
...(NavigatorUAData ? { userAgentData: NavigatorUAData.instance } : {}),
|
|
2776
2791
|
connection: NetworkInformation.instance,
|
|
2777
2792
|
permissions: Permissions.instance,
|
|
2793
|
+
clipboard: Clipboard.instance,
|
|
2778
2794
|
sendBeacon(url, data = null) { return sendBeacon(url, data); },
|
|
2779
2795
|
javaEnabled() { return false; },
|
|
2780
2796
|
getGamepads() { return [null, null, null, null]; },
|
|
@@ -42,7 +42,7 @@ import { absolutizeStyleSheetUrls, extractFontFaces, matchSelectors, mediaQueryM
|
|
|
42
42
|
import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
|
|
43
43
|
import { elementFeatures, sheetTraits } from "../style/selector-traits.js";
|
|
44
44
|
import { buildDisplayList, visibleItems } from "../paint/display-list.js";
|
|
45
|
-
import { backgroundBytes, cropViewport, encodeImageBase64 } from "../paint/frame.js";
|
|
45
|
+
import { backgroundBytes, rawFrame, captureViewport, cropViewport, encodeImageBase64 } from "../paint/frame.js";
|
|
46
46
|
import { FrameCaptureCache } from "../paint/frame-cache.js";
|
|
47
47
|
import { RasterReadback } from "../paint/raster-readback.js";
|
|
48
48
|
import { insetRadii, roundedRect } from "../paint/rounded-rect.js";
|
|
@@ -227,6 +227,7 @@ function buildPageRenderer({
|
|
|
227
227
|
requestDownload = () => {},
|
|
228
228
|
reportError = () => {},
|
|
229
229
|
pageFetch = () => Promise.reject(new TypeError("network access is not available")),
|
|
230
|
+
clipboardWrite = () => Promise.reject(Error("Clipboard unavailable")),
|
|
230
231
|
webSockets = null,
|
|
231
232
|
audio = null,
|
|
232
233
|
setCookie = () => {},
|
|
@@ -787,18 +788,22 @@ function buildPageRenderer({
|
|
|
787
788
|
frameTimer = hostSetTimeout(() => {
|
|
788
789
|
frameTimer = null;
|
|
789
790
|
refresh();
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
}, Math.max(
|
|
791
|
+
// Coalesce ordinary mutations for 16ms. Expensive style/layout rebuilds
|
|
792
|
+
// retain a longer idle window so related script mutations can settle
|
|
793
|
+
// instead of continuously rebuilding a large document.
|
|
794
|
+
}, Math.max(16, lastRebuildMs * 2));
|
|
794
795
|
}
|
|
795
796
|
|
|
796
797
|
/** The viewport as an encoded image (base64), cut from the latest tile. */
|
|
797
|
-
async function viewportPng({ format = "png", quality = 80, optimizeForSpeed = false } = {}) {
|
|
798
|
+
async function viewportPng({ format = "png", quality = 80, optimizeForSpeed = false, clip } = {}) {
|
|
798
799
|
if (!lastTile) return "";
|
|
799
|
-
|
|
800
|
+
if (format === 'rgba' || format === 'bgra') {
|
|
801
|
+
const {pixels,width,height} = captureViewport(lastTile, viewport.x, viewport.y, page.width, page.height,clip);
|
|
802
|
+
return rawFrame(pixels,width,height,format);
|
|
803
|
+
}
|
|
804
|
+
const key = `${tileSerial}:${viewport.x}:${viewport.y}:${page.width}:${page.height}:${format}:${quality}:${optimizeForSpeed}:${JSON.stringify(clip)}`;
|
|
800
805
|
return captures.get(key, () => {
|
|
801
|
-
const { pixels, width, height } =
|
|
806
|
+
const { pixels, width, height } = captureViewport(lastTile, viewport.x, viewport.y, page.width, page.height,clip);
|
|
802
807
|
return encodeImageBase64(pixels, width, height, { format, quality, optimizeForSpeed });
|
|
803
808
|
});
|
|
804
809
|
}
|
|
@@ -2191,6 +2196,7 @@ function buildPageRenderer({
|
|
|
2191
2196
|
page.cookie = mergeDocumentCookie(page.cookie, String(value));
|
|
2192
2197
|
setCookie(String(value));
|
|
2193
2198
|
},
|
|
2199
|
+
clipboardWrite,
|
|
2194
2200
|
webSockets,
|
|
2195
2201
|
pageFetch(request) {
|
|
2196
2202
|
const networkPending = pageFetch(request);
|
|
@@ -2437,6 +2443,7 @@ function buildPageRenderer({
|
|
|
2437
2443
|
* visualViewport.scale.
|
|
2438
2444
|
*/
|
|
2439
2445
|
resize(width, height, deviceScaleFactor = 1, pageScale = 1) {
|
|
2446
|
+
const resized = width !== page.width || height !== page.height;
|
|
2440
2447
|
page.pageScale = Number.isFinite(pageScale) && pageScale > 0 ? pageScale : 1;
|
|
2441
2448
|
page.width = width;
|
|
2442
2449
|
page.height = height;
|
|
@@ -2445,6 +2452,10 @@ function buildPageRenderer({
|
|
|
2445
2452
|
: 1;
|
|
2446
2453
|
viewport.resize(width, height);
|
|
2447
2454
|
rebuildRenderTree();
|
|
2455
|
+
// Listeners must observe the new viewport and layout. A repeated metrics
|
|
2456
|
+
// request (or DPR-only change) does not resize the CSS viewport.
|
|
2457
|
+
if (resized && page.realm)
|
|
2458
|
+
page.realm.dispatch(page.window, new page.window.Event("resize"));
|
|
2448
2459
|
render();
|
|
2449
2460
|
},
|
|
2450
2461
|
screenshot(options) {
|
|
@@ -2969,6 +2980,7 @@ function buildPageRenderer({
|
|
|
2969
2980
|
requestDownload,
|
|
2970
2981
|
reportError,
|
|
2971
2982
|
pageFetch,
|
|
2983
|
+
clipboardWrite,
|
|
2972
2984
|
webSockets,
|
|
2973
2985
|
setCookie,
|
|
2974
2986
|
consoleMessage,
|
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {writeClipboard} from '../lib/clipboard.js';
|
|
3
|
+
import {RendererHost} from '../lib/renderer/host.js';
|
|
4
|
+
|
|
5
|
+
test('clipboard prefers xclip, preserves exact stdin, and falls back on failure',async()=>{
|
|
6
|
+
const calls=[];let fail=false;
|
|
7
|
+
const options={platform:'linux',which:name=>'/bin/'+name,spawn:(args,opts)=>{calls.push({args,opts});return {exited:Promise.resolve(fail&&args[0].endsWith('xclip')?1:0),kill(){}};}};
|
|
8
|
+
const text='中文\n$(do not execute) `literal`';
|
|
9
|
+
await writeClipboard(text,options);
|
|
10
|
+
expect(calls[0].args).toEqual(['/bin/xclip','-selection','clipboard']);
|
|
11
|
+
expect(calls[0].opts.stdin.toString()).toBe(text);expect(calls).toHaveLength(1);
|
|
12
|
+
fail=true;calls.length=0;await writeClipboard('',options);
|
|
13
|
+
expect(calls.map(c=>c.args[0])).toEqual(['/bin/xclip','/bin/wl-copy']);
|
|
14
|
+
await expect(writeClipboard('x',{...options,which:()=>null})).rejects.toThrow('No working');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('a real button click writes through controller; page-only calls cannot overwrite clipboard',async()=>{
|
|
18
|
+
const copies=[];const host=new RendererHost({clipboardWrite:async text=>{copies.push(text);}});
|
|
19
|
+
try{
|
|
20
|
+
await host.start();await host.call('resize',400,200,1);
|
|
21
|
+
await host.call('loadDocument',{url:'https://clipboard.test/',contentType:'text/html',source:`<body style="margin:0"><button id="b" style="width:200px;height:60px">Copy</button><script>
|
|
22
|
+
window.copyStatus='idle';document.getElementById('b').onclick=async()=>{
|
|
23
|
+
try{await navigator.clipboard.writeText('npx buninu 中文');window.copyStatus='copied';}
|
|
24
|
+
catch(e){window.copyStatus=e.name;}
|
|
25
|
+
};</script>`});
|
|
26
|
+
await host.call('whenLoaded');
|
|
27
|
+
await host.call('evaluate',"document.getElementById('b').click()");await Bun.sleep(30);
|
|
28
|
+
expect(copies).toEqual([]);expect(await host.call('evaluate','window.copyStatus')).toBe('NotAllowedError');
|
|
29
|
+
await host.call('click',30,25);
|
|
30
|
+
for(let i=0;i<40 && !copies.length;i++)await Bun.sleep(25);
|
|
31
|
+
expect(copies).toEqual(['npx buninu 中文']);
|
|
32
|
+
for(let i=0;i<40 && await host.call('evaluate','window.copyStatus')!=='copied';i++)await Bun.sleep(25);
|
|
33
|
+
expect(await host.call('evaluate','window.copyStatus')).toBe('copied');
|
|
34
|
+
}finally{host.close();}
|
|
35
|
+
},20000);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Browser raw frame demo</title>
|
|
6
|
+
<style>body{margin:0;padding:32px;background:#10252f;color:#e4f4fa;font:22px sans-serif}h1{color:#63dedb}a{color:#73d8ff}.box{padding:20px;margin:16px 0;background:#183c4c;border:2px solid #3b7a8d}.track{height:75px;background:#244451;position:relative;overflow:hidden}#bar{position:absolute;left:0;top:0;width:150px;height:75px;background:#49d6c8}.stripe{height:70px;padding:15px;margin-top:10px}</style>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<h1>Buninu Browser · Raw BGRA</h1>
|
|
10
|
+
<div class="box">這是 buninu-browser 自己繪製的頁面,不是 Firefox 或 Chromium。<br>Raw 像素經 Casty 傳給 bunterm。請試著捲動、點網址列,或瀏覽其他網站。</div>
|
|
11
|
+
<div class="track">
|
|
12
|
+
<div id="bar">
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
<p id="tick">Animation starting…</p>
|
|
16
|
+
<p>
|
|
17
|
+
<a href="https://example.com">example.com</a> · <a href="https://www.wikipedia.org">Wikipedia</a>
|
|
18
|
+
</p>
|
|
19
|
+
<div class="stripe" style="background:rgb(30,55,75)">Scroll test 1 — 中文 ABC 0123456789</div>
|
|
20
|
+
<div class="stripe" style="background:rgb(35,62,80)">Scroll test 2 — 中文 ABC 0123456789</div>
|
|
21
|
+
<div class="stripe" style="background:rgb(40,69,85)">Scroll test 3 — 中文 ABC 0123456789</div>
|
|
22
|
+
<div class="stripe" style="background:rgb(45,76,90)">Scroll test 4 — 中文 ABC 0123456789</div>
|
|
23
|
+
<div class="stripe" style="background:rgb(50,83,95)">Scroll test 5 — 中文 ABC 0123456789</div>
|
|
24
|
+
<div class="stripe" style="background:rgb(55,90,100)">Scroll test 6 — 中文 ABC 0123456789</div>
|
|
25
|
+
<div class="stripe" style="background:rgb(60,97,105)">Scroll test 7 — 中文 ABC 0123456789</div>
|
|
26
|
+
<div class="stripe" style="background:rgb(65,104,110)">Scroll test 8 — 中文 ABC 0123456789</div>
|
|
27
|
+
<div class="stripe" style="background:rgb(70,111,115)">Scroll test 9 — 中文 ABC 0123456789</div>
|
|
28
|
+
<div class="stripe" style="background:rgb(75,118,120)">Scroll test 10 — 中文 ABC 0123456789</div>
|
|
29
|
+
<div class="stripe" style="background:rgb(80,125,125)">Scroll test 11 — 中文 ABC 0123456789</div>
|
|
30
|
+
<div class="stripe" style="background:rgb(85,132,130)">Scroll test 12 — 中文 ABC 0123456789</div>
|
|
31
|
+
<div class="stripe" style="background:rgb(90,139,135)">Scroll test 13 — 中文 ABC 0123456789</div>
|
|
32
|
+
<div class="stripe" style="background:rgb(95,146,140)">Scroll test 14 — 中文 ABC 0123456789</div>
|
|
33
|
+
<div class="stripe" style="background:rgb(100,153,145)">Scroll test 15 — 中文 ABC 0123456789</div>
|
|
34
|
+
<script>let n=0;setInterval(()=>{n++;document.getElementById('bar').style.left=((n*7)%900)+'px';document.getElementById('tick').textContent='Animation tick: '+n;},50);</script>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|