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.
@@ -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
- * channel (fd 3) to its compositor thread, which answers screenshots and
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 { closeSync } from "node:fs";
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
- // The compositor's channel is an extra stdio pipe used as fd 3, which
109
- // Windows does not have (Bun hands out libuv pipes there, not file
110
- // descriptors): there the main thread answers screenshots and scrolling.
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 child = Bun.spawn({
137
- cmd: [process.execPath, this.#entry, ...this.#args,
138
- ...(logging ? [RENDERER_LOG_FLAG] : []), ...(compositor ? [COMPOSITOR_FLAG] : []),
139
- ...(process.env.BUNINU_VERIFY_STYLE ? [VERIFY_STYLE_FLAG] : []), ...(process.env.BUNINU_FULL_STYLE ? [FULL_STYLE_FLAG] : [])],
140
- // No inherited environment: nothing from the controller leaks in.
141
- // Windows programs need SystemRoot to load system libraries.
142
- env: process.platform === "win32" && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {},
143
- // With BUNINU_LOG, output goes through the controller into the log file.
144
- // fd 3 is a socket to the renderer's compositor thread.
145
- stdio: ["ignore", logging ? "pipe" : "inherit", logging ? "pipe" : "inherit", ...(compositor ? ["pipe"] : [])],
146
- serialization: "advanced",
147
- ipc: (message) => {
148
- if (message?.type === "ready") {
149
- this.#sandbox = sanitizeSandboxReport(message.sandbox);
150
- resolveReady();
151
- }
152
- else this.#handleMessage(child, message);
153
- },
154
- onExit: (_process, exitCode, signalCode) => {
155
- this.#closeChannel(channel);
156
- this.#closeSockets(child);
157
- this.#closeAudio(child);
158
- this.#closePageFetches(child);
159
- if (this.#child !== child) return;
160
- console.error(`buninu-browser: renderer ${child.pid} exited unexpectedly (${signalCode ?? `code ${exitCode}`})`);
161
- this.#child = null;
162
- this.#ready = null;
163
- rejectReady(new Error("renderer exited during startup"));
164
- for (const { reject } of this.#pending.values()) reject(new Error("renderer exited"));
165
- this.#pending.clear();
166
- },
167
- });
168
- this.#child = child;
169
- const channel = compositor && Number.isInteger(child.stdio[3]) ? this.#openChannel(child.stdio[3]) : null;
170
- this.#channel = channel;
171
- if (logging) {
172
- pipeToLog(child.stdout, `renderer ${child.pid}`, (chunk) => process.stdout.write(chunk));
173
- pipeToLog(child.stderr, `renderer ${child.pid}`, (chunk) => process.stderr.write(chunk));
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.writer.write(`${JSON.stringify({ id, method, params })}\n`);
239
- channel.writer.flush();
260
+ try { channel.transport.send({id,method,params}); }
261
+ catch(error) { channel.pending.get(id).reject(error); channel.pending.delete(id); }
240
262
  });
241
- const sanitized = sanitizeCompositorResult(method, result);
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
- #openChannel(fd) {
247
- const channel = { writer: Bun.file(fd).writer(), pending: new Map(), nextId: 1, closed: false };
248
- (async () => {
249
- const decoder = new TextDecoder();
250
- let buffer = "";
251
- try {
252
- for await (const chunk of Bun.file(fd).stream()) {
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
- #handleCompositorLine(channel, line) {
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.writer.write(`${JSON.stringify({ type: "audio-timing", audioId: id, seconds: payload.seconds })}\n`);
499
- this.#channel.writer.flush();
510
+ this.#channel.transport.send({type:"audio-timing",audioId:id,seconds:payload.seconds});
500
511
  }
501
512
  }
502
513
  };
@@ -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 fd 3 while this thread is busy; frames go to it with
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
- // Leave one rebuild-sized idle window after an expensive frame. Without
791
- // it, a large document can spend all its time producing back-to-back
792
- // frames while scripts are still making related mutations.
793
- }, Math.max(50, lastRebuildMs * 2));
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
- const key = `${tileSerial}:${viewport.x}:${viewport.y}:${page.width}:${page.height}:${format}:${quality}:${optimizeForSpeed}`;
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 } = cropViewport(lastTile, viewport.x, viewport.y, page.width, page.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-browser",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Buninu Browser is a browser engine written in JavaScript and WebAssembly for Bun.",
5
5
  "keywords": [
6
6
  "buninu",
@@ -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>