buninu-browser 0.6.0 → 0.9.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.
Files changed (144) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +94 -4
  3. package/buninu-browser.js +1 -1
  4. package/frontends/wayland/README.md +35 -2
  5. package/frontends/wayland/client.js +8 -6
  6. package/frontends/wayland/clipboard.js +1 -1
  7. package/frontends/wayland/index.js +1 -1
  8. package/frontends/wayland/input.js +1 -1
  9. package/frontends/wayland/resource-io.js +6 -0
  10. package/frontends/wayland/transport-posix.js +120 -0
  11. package/frontends/wayland/transport-win32.js +9 -0
  12. package/frontends/wayland/transport.js +3 -120
  13. package/frontends/wayland/win32-resources.js +80 -0
  14. package/frontends/wayland/win32-wire.js +62 -0
  15. package/index.js +10 -6
  16. package/lib/audio/biquad.js +25 -0
  17. package/lib/audio/piano.js +6 -3
  18. package/lib/audio/playback-buffer.js +5 -5
  19. package/lib/audio/pulse-client.js +24 -5
  20. package/lib/audio/web-audio.js +570 -0
  21. package/lib/browser-dialog.js +116 -0
  22. package/lib/cdp-server.js +128 -72
  23. package/lib/file-picker.js +48 -0
  24. package/lib/headless-shell.js +34 -12
  25. package/lib/layout/text-layout.js +112 -19
  26. package/lib/media/mp4-frame-times.js +93 -0
  27. package/lib/paint/direct-canvas.ts +39 -0
  28. package/lib/paint/display-list.js +37 -12
  29. package/lib/paint/raster-readback.js +16 -7
  30. package/lib/renderer/animation-frames.js +10 -5
  31. package/lib/renderer/bindings.js +69 -16
  32. package/lib/renderer/canvas-2d.js +265 -15
  33. package/lib/renderer/canvas-round-rect.js +36 -0
  34. package/lib/renderer/css-animations.js +173 -0
  35. package/lib/renderer/dialog-client.js +30 -0
  36. package/lib/renderer/dialog-worker.js +35 -0
  37. package/lib/renderer/eval-environment.js +28 -0
  38. package/lib/renderer/file-input.js +49 -0
  39. package/lib/renderer/host.js +33 -4
  40. package/lib/renderer/html-drag.js +77 -0
  41. package/lib/renderer/image-loading.js +45 -2
  42. package/lib/renderer/main.js +4 -0
  43. package/lib/renderer/module-transform.js +5 -3
  44. package/lib/renderer/page-builtins.js +59 -0
  45. package/lib/renderer/page-realm.js +177 -40
  46. package/lib/renderer/page-renderer.js +397 -49
  47. package/lib/renderer/piano-input.js +3 -1
  48. package/lib/renderer/raster-surface.js +18 -0
  49. package/lib/renderer/script-rewrite.js +165 -9
  50. package/lib/renderer/video-presentation.js +5 -4
  51. package/lib/renderer/webgl/clip.js +24 -0
  52. package/lib/renderer/webgl/context.js +158 -0
  53. package/lib/renderer/webgl/cpu-fragment.js +211 -0
  54. package/lib/renderer/webgl/draw-cache.js +22 -0
  55. package/lib/renderer/webgl/pipeline.js +70 -0
  56. package/lib/renderer/webgl/shaders.js +58 -0
  57. package/lib/renderer/webgl/textures.js +43 -0
  58. package/lib/renderer/window-named-properties.js +46 -0
  59. package/lib/screenshot-cli.js +4 -7
  60. package/lib/style/animation-values.js +29 -0
  61. package/lib/style/computed-style.js +109 -13
  62. package/lib/style/transform.js +44 -0
  63. package/lib/window-size.js +10 -0
  64. package/package.json +1 -1
  65. package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
  66. package/patches/happy-dom/README.md +16 -0
  67. package/test/animation-frames.test.js +15 -0
  68. package/test/canvas-2d.test.js +252 -4
  69. package/test/canvas-frame-pacing.test.js +7 -0
  70. package/test/cdp-viewers.test.js +79 -0
  71. package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
  72. package/test/classic-games-patches/README.md +156 -0
  73. package/test/classic-games-scenarios.js +133 -0
  74. package/test/clipboard.test.js +16 -0
  75. package/test/computed-style.test.js +18 -0
  76. package/test/css-animation-lifecycle.test.js +28 -0
  77. package/test/css-animations.test.js +83 -0
  78. package/test/direct-canvas.test.js +39 -0
  79. package/test/dynamic-inline-handlers.test.js +25 -0
  80. package/test/eval-declarations.test.js +22 -0
  81. package/test/file-picker.test.js +26 -0
  82. package/test/fixtures/canvas-damage.js +23 -0
  83. package/test/fixtures/canvas-frame-pacing.js +35 -0
  84. package/test/fixtures/direct-canvas.js +96 -0
  85. package/test/fixtures/javascript-dialogs.html +8 -0
  86. package/test/fixtures/media-audio-lifetime.js +65 -0
  87. package/test/fixtures/renderer-memory.js +7 -5
  88. package/test/fixtures/video-black-replay.js +8 -6
  89. package/test/fixtures/video-slow-end.js +39 -0
  90. package/test/fixtures/web-audio-renderer.js +45 -0
  91. package/test/flex.test.js +26 -0
  92. package/test/grid.test.js +13 -0
  93. package/test/headless-shell.test.js +9 -0
  94. package/test/headless-window-size.test.js +28 -0
  95. package/test/html-rcdata.test.js +35 -0
  96. package/test/image-decode.test.js +18 -0
  97. package/test/index.test.js +1 -0
  98. package/test/javascript-dialog.test.js +83 -0
  99. package/test/manual-breakout-paddle.js +42 -0
  100. package/test/manual-breakout-static.js +35 -0
  101. package/test/manual-classic-games.js +41 -0
  102. package/test/manual-clumsy-bird-three-pipes.js +81 -0
  103. package/test/manual-clumsy-bird.js +52 -0
  104. package/test/manual-hextris.js +30 -0
  105. package/test/manual-numbered-music.js +24 -0
  106. package/test/manual-t-rex-runner.js +49 -0
  107. package/test/manual-threejs-scene.js +64 -0
  108. package/test/manual-webgl-benchmark.js +27 -0
  109. package/test/manual-webgl-cpu.html +26 -0
  110. package/test/media-audio-lifetime.test.js +8 -0
  111. package/test/mp4-frame-times.test.js +33 -0
  112. package/test/page-scripts.test.js +96 -1
  113. package/test/piano-input.test.js +15 -2
  114. package/test/piano.test.js +11 -2
  115. package/test/playback-buffer.test.js +11 -0
  116. package/test/pointer-drag.test.js +33 -0
  117. package/test/positioning.test.js +82 -0
  118. package/test/pulse-client.test.js +51 -2
  119. package/test/raster-readback.test.js +14 -0
  120. package/test/script-rewrite.test.js +61 -3
  121. package/test/video-slow-end.test.js +6 -0
  122. package/test/viewport-keyboard-overflow.test.js +25 -0
  123. package/test/wayland-win32-resources.test.js +47 -0
  124. package/test/wayland-win32-wire.test.js +59 -0
  125. package/test/web-audio-renderer.test.js +8 -0
  126. package/test/web-audio.test.js +264 -0
  127. package/test/webgl-clip.test.js +9 -0
  128. package/test/webgl-cpu-fragment.test.js +43 -0
  129. package/test/window-named-properties.test.js +41 -0
  130. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
  131. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
  132. package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
  133. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
  134. package/vendor/land-renderer/LICENSE +21 -0
  135. package/vendor/land-renderer/README.md +14 -0
  136. package/vendor/land-renderer/direct-compositor.ts +443 -0
  137. package/vendor/land-renderer/frame.js +88 -0
  138. package/vendor/land-renderer/png-fast.js +85 -0
  139. package/vendor/land-renderer/raw-frames.js +66 -0
  140. package/vendor/land-renderer/render-types.ts +7 -0
  141. package/vendor/land-renderer/renderer.ts +134 -0
  142. package/vendor/land-renderer/snapshot.js +37 -0
  143. package/vendor/land-renderer/viewport.js +18 -0
  144. package/vendor/ses/dist/ses.mjs +1 -1
@@ -0,0 +1,116 @@
1
+ import {FilePicker} from './file-picker.js';
2
+ import {writeClipboard} from "./clipboard.js";
3
+ import {RendererHost} from './renderer/host.js';
4
+
5
+ // Browser-owned modal UI, isolated from the page's DOM and JavaScript realm.
6
+ // Reuse the normal HTML renderer for scrolling, selection, caret and clipboard.
7
+ export class BrowserDialog {
8
+ constructor({viewport, background, changed, opened, closed, clipboard}) {
9
+ Object.assign(this,{viewport,background,changed,opened,closed,clipboard});
10
+ this.active=null;
11
+ }
12
+ open(request,signal) {
13
+ if(signal?.aborted)return Promise.resolve({accept:false});
14
+ if(this.active)return Promise.resolve({accept:false});
15
+ const answer=Promise.withResolvers();
16
+ const entry={request,answer,host:null,ready:null};this.active=entry;
17
+ entry.abort=()=>this.finish({accept:false},entry);entry.signal=signal;
18
+ signal?.addEventListener('abort',entry.abort,{once:true});
19
+ entry.ready=this.prepare(entry).catch(error=>{if(this.active===entry)console.error('buninu-browser: dialog:',error.message);this.finish({accept:false},entry);});
20
+ this.opened(request);
21
+ return answer.promise;
22
+ }
23
+ async prepare(entry) {
24
+ const size=this.viewport();
25
+ // Capture directly from the compositor: page scripts may be paused in load.
26
+ let background='';try{background=await this.background();}catch{}
27
+ if(this.active!==entry)return;
28
+ const host=new RendererHost({onFrame:()=>{if(this.active===entry)this.changed();},
29
+ ...(this.clipboard?{clipboardRead:()=>this.clipboard.read(),clipboardWrite:text=>this.clipboard.write(text)}:{})});
30
+ entry.host=host;
31
+ await host.call('resize',size.width,size.height,size.deviceScaleFactor);
32
+ entry.background=background;
33
+ if(entry.request.type==='file'){entry.picker=new FilePicker(entry.request);await entry.picker.browse('.');}
34
+ await host.call('loadDocument',{url:'about:dialog',source:entry.picker?entry.picker.document(size,background):dialogDocument(entry.request,size,background),contentType:'text/html'});
35
+ if(this.active===entry)this.changed();
36
+ }
37
+ async invoke(method,...args) {
38
+ const entry=this.active;if(!entry)return;
39
+ await entry.ready;
40
+ if(this.active!==entry||!entry.host)return;
41
+ if(method==='press' && (args[1]?.modifiers & 6) && String(args[0]).toLowerCase()==='c') {
42
+ const selected=await entry.host.call('evaluate','window.getSelection().toString()');
43
+ if(selected){if(this.clipboard)await this.clipboard.write(selected);else await writeClipboard(selected);return;}
44
+ }
45
+ const value=method==='screenshot'||method==='scrollOffset'||method==='scroll'
46
+ ? await entry.host.composite(method,...args):await entry.host.call(method,...args);
47
+ if(['press','type','click'].includes(method)) {
48
+ if(entry.picker){const action=await entry.host.call('evaluate','window.__fileAction');
49
+ if(action){await entry.host.call('evaluate','window.__fileAction=null');
50
+ const directory=entry.picker.directory;
51
+ const scroll=await entry.host.call('evaluate','[document.getElementById("message").scrollTop,document.getElementById("dialog").scrollTop]');
52
+ const answer=await entry.picker.action(action);
53
+ if(answer){this.finish(answer,entry);return value;}
54
+ await entry.host.call('loadDocument',{url:'about:dialog',source:entry.picker.document(this.viewport(),entry.background),contentType:'text/html'});
55
+ if(directory===entry.picker.directory)await entry.host.call('evaluate',`document.getElementById('message').scrollTop=${Number(scroll?.[0])||0};document.getElementById('dialog').scrollTop=${Number(scroll?.[1])||0}`);
56
+ this.changed();}
57
+ }
58
+ const result=await entry.host.call('evaluate','window.__dialogAnswer');
59
+ if(result && typeof result.accept==='boolean')this.finish(result,entry);
60
+ }
61
+ return value;
62
+ }
63
+ async resize(width,height,dpr) {
64
+ const entry=this.active;if(!entry)return;
65
+ await entry.ready;if(this.active!==entry)return;
66
+ await entry.host.call('resize',width,height,dpr);
67
+ const w=Math.max(1,Math.min(entry.picker?720:480,width-24)),x=Math.max(0,(width-w)/2),y=Math.min(48,Math.max(0,height/12));
68
+ await entry.host.call('evaluate',`Object.assign(document.getElementById('dialog').style,{width:'${w}px',left:'${x}px',top:'${y}px',maxHeight:'${Math.max(1,height-y-8)}px'});document.getElementById('message').style.maxHeight='${Math.max(20,height-y-180)}px';`);
69
+ this.changed();
70
+ }
71
+ finish(result,entry=this.active) {
72
+ if(!entry||this.active!==entry)return;
73
+ this.active=null;
74
+ entry.signal?.removeEventListener('abort',entry.abort);
75
+ entry.host?.close();
76
+ entry.answer.resolve({accept:Boolean(result.accept),promptText:String(result.promptText??''),...(entry.picker?{files:result.files??[]}: {})});
77
+ this.closed(result);this.changed();
78
+ }
79
+ close(){this.finish({accept:false});}
80
+ }
81
+
82
+ export function dialogDocument({type,message,defaultPrompt='',url=''}, {width,height}, background='') {
83
+ const escape=value=>Bun.escapeHTML(String(value));
84
+ const w=Math.max(1,Math.min(480,width-24)),x=Math.max(0,(width-w)/2),y=Math.min(48,Math.max(0,height/12));
85
+ const maxMessage=Math.max(20,height-y-180);
86
+ let origin;try{origin=new URL(url).origin;}catch{origin='';}
87
+ if(!origin||origin==='null')origin='This page';
88
+ return `<!doctype html><meta charset="utf-8"><style>
89
+ html,body{margin:0;width:100%;height:100%;overflow:hidden;font:16px sans-serif;color:#202124}
90
+ body{background:#444}#background{position:fixed;left:0;top:0;width:100%;height:100%;pointer-events:none}
91
+ #shade{position:fixed;inset:0;background:rgba(0,0,0,.4)}
92
+ #dialog{position:absolute;left:${x}px;top:${y}px;width:${w}px;max-height:${Math.max(1,height-y-8)}px;overflow:auto;box-sizing:border-box;padding:20px;background:#fff;border:1px solid #aaa;border-radius:12px}
93
+ h2{font-size:16px;margin:0 0 16px}#message{white-space:pre-wrap;overflow-wrap:anywhere;max-height:${maxMessage}px;overflow:auto;user-select:text;margin-bottom:16px;outline:none}
94
+ input{display:block;box-sizing:border-box;width:100%;font:16px sans-serif;padding:8px;margin-bottom:16px}
95
+ #buttons{display:flex;justify-content:flex-end;gap:8px}button{font:14px sans-serif;padding:8px 18px;border:1px solid #888;border-radius:6px;background:#fff;color:#202124}#ok{background:#1769d2;color:white}
96
+ </style>${background?`<img id="background" src="data:image/png;base64,${background}">`:''}<div id="shade"></div><section id="dialog" role="${type==='alert'?'alertdialog':'dialog'}" aria-modal="true" aria-labelledby="heading" aria-describedby="message">
97
+ <h2 id="heading">${escape(origin)} says</h2><div id="message" tabindex="0">${escape(message)}</div>
98
+ ${type==='prompt'?`<input id="answer" aria-label="Response" value="${escape(defaultPrompt)}">`:''}
99
+ <div id="buttons">${type!=='alert'?'<button id="cancel" onclick="finish(false)">Cancel</button>':''}<button id="ok" onclick="finish(true)">OK</button></div></section>
100
+ <script>
101
+ window.__dialogAnswer=null;
102
+ function finish(accept){window.__dialogAnswer={accept:accept,promptText:document.getElementById('answer')?.value||''};}
103
+ document.addEventListener('keydown',function(e){
104
+ const active=document.activeElement;
105
+ if(e.key==='Escape'){e.preventDefault();finish(false);}
106
+ else if(e.key==='Enter'||(e.key===' '&&active?.tagName==='BUTTON')){e.preventDefault();finish(active?.id!=='cancel');}
107
+ else if(active?.tagName!=='INPUT'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){
108
+ const message=document.getElementById('message'),step=Math.max(20,message.clientHeight-20);
109
+ const delta={ArrowUp:-31,ArrowDown:31,PageUp:-step,PageDown:step,' ':step}[e.key];
110
+ if(delta!==undefined){e.preventDefault();message.scrollTop+=delta;}
111
+ else if(e.key==='Home'||e.key==='End'){e.preventDefault();message.scrollTop=e.key==='Home'?0:message.scrollHeight;}
112
+ }
113
+ });
114
+ const input=document.getElementById('answer');if(input){input.focus();input.select();}else document.getElementById('message').focus();
115
+ </script>`;
116
+ }
package/lib/cdp-server.js CHANGED
@@ -36,6 +36,10 @@ export class CdpServer {
36
36
  const context = this.#context;
37
37
  const state = createCdpState();
38
38
  state.context = context;
39
+ context.onDialog?.((request, result) => {
40
+ for(const sessions of state.dialogListeners.values())for(const emit of sessions.values())
41
+ emit(request?'Page.javascriptDialogOpening':'Page.javascriptDialogClosed',request?{...request,hasBrowserHandler:true}:{result:result.accept,userInput:result.promptText??''});
42
+ });
39
43
  const remoteAllowOrigins = parseAllowedOrigins(options.remoteAllowOrigins);
40
44
  // Pages change on their own (loading stages, scripts, compositor
41
45
  // scrolling): push a screencast frame whenever the context reports one,
@@ -44,15 +48,10 @@ export class CdpServer {
44
48
  const offset = frame?.scrollOffset;
45
49
  const scrollOffset = Number.isFinite(offset?.x) && Number.isFinite(offset?.y)
46
50
  ? { x: offset.x, y: offset.y } : null;
47
- for (const target of state.targets.values()) {
48
- if (target.screencast && target.screencastEmit) {
49
- target.screencastScrollOffset = scrollOffset;
50
- pushScreencastFrame(context, state, target);
51
- }
52
- }
53
- if (state.implicitTarget?.screencast) {
54
- state.implicitTarget.screencastScrollOffset = scrollOffset;
55
- pushScreencastFrame(context, state, state.implicitTarget);
51
+ for (const target of [...state.targets.values(), state.implicitTarget]) {
52
+ if (!target) continue;
53
+ for (const stream of screencasts(target)) stream.scrollOffset = scrollOffset;
54
+ void pushScreencastFrame(context, state, target);
56
55
  }
57
56
  });
58
57
  context.onLifecycle?.((name) => {
@@ -173,6 +172,8 @@ export class CdpServer {
173
172
  const msg = JSON.parse(text);
174
173
  id = msg.id;
175
174
  sessionId = msg.sessionId;
175
+ if (sessionId && state.sessionOwners.get(sessionId) !== ws)
176
+ throw Error("No session owned by this connection");
176
177
  let dispatchSessionId = sessionId;
177
178
  if (!dispatchSessionId && ws.data?.targetId) {
178
179
  dispatchSessionId = `page-${ws.data.targetId}`;
@@ -218,13 +219,19 @@ export class CdpServer {
218
219
  }
219
220
  },
220
221
  close(ws) {
222
+ state.dialogListeners.delete(ws);
221
223
  for (const target of state.targets.values()) {
222
- if (target.screencastEmit?.owner === ws) stopScreencast(target);
224
+ stopScreencast(target, ws);
223
225
  if (target.lifecycleEmit?.owner === ws) target.lifecycleEmit = null;
224
226
  }
225
227
  const implicit = state.implicitTarget;
226
- if (implicit?.screencastEmit?.owner === ws) stopScreencast(implicit);
228
+ if (implicit) stopScreencast(implicit, ws);
227
229
  if (implicit?.lifecycleEmit?.owner === ws) implicit.lifecycleEmit = null;
230
+ for (const [id, owner] of state.sessionOwners) {
231
+ if (owner !== ws) continue;
232
+ state.sessionOwners.delete(id);
233
+ state.sessions.delete(id);
234
+ }
228
235
  serverLog("[CdpServer] client disconnected");
229
236
  },
230
237
  },
@@ -288,6 +295,8 @@ function createCdpState() {
288
295
  nextScreencastFrameId: 1,
289
296
  targets: new Map(),
290
297
  sessions: new Map(),
298
+ sessionOwners: new Map(),
299
+ dialogListeners: new Map(),
291
300
  objects: new Map(),
292
301
  initialNavigations: new Map(),
293
302
  implicitTarget: null,
@@ -329,6 +338,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
329
338
  const target = state.targets.get(targetId);
330
339
  target.attached = true;
331
340
  state.sessions.set(attachedSessionId, targetId);
341
+ state.sessionOwners.set(attachedSessionId, emit.owner);
332
342
  emit("Target.attachedToTarget", {
333
343
  sessionId: attachedSessionId,
334
344
  targetInfo: createTargetInfo(target),
@@ -345,6 +355,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
345
355
  const sessionId = `session-${state.nextSessionId++}`;
346
356
  target.attached = true;
347
357
  state.sessions.set(sessionId, params.targetId);
358
+ state.sessionOwners.set(sessionId, emit.owner);
348
359
  return { sessionId };
349
360
  }
350
361
  case "Target.getTargets":
@@ -378,6 +389,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
378
389
  for (const [attachedSessionId, targetId] of state.sessions) {
379
390
  if (targetId !== params.targetId) continue;
380
391
  state.sessions.delete(attachedSessionId);
392
+ state.sessionOwners.delete(attachedSessionId);
381
393
  emit("Target.detachedFromTarget", {
382
394
  sessionId: attachedSessionId,
383
395
  targetId,
@@ -387,11 +399,31 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
387
399
  emit("Target.targetDestroyed", { targetId: params.targetId });
388
400
  return { success: true };
389
401
  }
390
- case "Target.detachFromTarget":
402
+ case "Target.detachFromTarget": {
403
+ const target = state.targets.get(state.sessions.get(params.sessionId));
404
+ if (state.sessionOwners.get(params.sessionId) !== emit.owner) throw Error("No session owned by this connection");
405
+ if (target) stopScreencast(target, emit.owner, params.sessionId);
406
+ state.dialogListeners.get(emit.owner)?.delete(params.sessionId);
391
407
  state.sessions.delete(params.sessionId);
408
+ state.sessionOwners.delete(params.sessionId);
409
+ return {};
410
+ }
411
+ case "Page.enable": {
412
+ let sessions=state.dialogListeners.get(emit.owner);
413
+ if(!sessions)state.dialogListeners.set(emit.owner,sessions=new Map());
414
+ sessions.set(sessionId??null,emit);
415
+ if(ctx.dialogState)emit('Page.javascriptDialogOpening',{...ctx.dialogState,hasBrowserHandler:true});
392
416
  return {};
393
- case "Page.enable":
417
+ }
418
+ case "Page.disable": {
419
+ state.dialogListeners.get(emit.owner)?.delete(sessionId??null);
394
420
  return {};
421
+ }
422
+ case "Page.handleJavaScriptDialog": {
423
+ if(!ctx.dialogState)throw Error('No JavaScript dialog is open');
424
+ ctx.handleDialog?.({accept:Boolean(params.accept),promptText:String(params.promptText??'')});
425
+ return {};
426
+ }
395
427
  case "Page.getResourceContent": {
396
428
  // The main document only: its bytes exactly as they arrived.
397
429
  const resource = await ctx.resourceContent?.(params.url);
@@ -470,7 +502,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
470
502
  // A javascript: URL runs in the current document: no new loader or history entry.
471
503
  if (/^\s*javascript:/i.test(String(params.url))) {
472
504
  await ctx.navigate?.(String(params.url).trim());
473
- if (target.screencast) await pushScreencastFrame(ctx, state, target);
505
+ if (target.screencasts.size) await pushScreencastFrame(ctx, state, target);
474
506
  return { frameId: target.frameId, loaderId: target.loaderId };
475
507
  }
476
508
  // Events of the next document belong to its new loader.
@@ -479,7 +511,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
479
511
  navigateTarget(state, target, params.url, emit, { addHistory: true });
480
512
  target.title = await getContextTitle(ctx, target);
481
513
  target.history[target.historyIndex].title = target.title;
482
- if (target.screencast) {
514
+ if (target.screencasts.size) {
483
515
  await pushScreencastFrame(ctx, state, target);
484
516
  }
485
517
  state.initialNavigations.get(target.targetId)?.navigated.resolve();
@@ -532,22 +564,26 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
532
564
  }
533
565
  case "Page.startScreencast": {
534
566
  const target = getSessionTarget(state, sessionId);
535
- stopScreencast(target);
536
- target.screencast = { ...params };
537
- target.screencastEmit = emit;
538
- // As in Chrome, the reply does not wait for the first frame (nor the
539
- // renderer, which may still be starting); frames follow as events.
540
- void pushScreencastFrame(ctx, state, target);
567
+ stopScreencast(target, emit.owner, sessionId ?? null);
568
+ let sessions = target.screencasts.get(emit.owner);
569
+ if (!sessions) target.screencasts.set(emit.owner, sessions = new Map());
570
+ const stream = { params: { ...params }, emit, active: true, busy: false,
571
+ pending: false, scrollOffset: null, frames: new Set() };
572
+ sessions.set(sessionId ?? null, stream);
573
+ // Do not wait for a capture; each viewer owns its own backpressure.
574
+ void pushScreencastFrame(ctx, state, target, stream);
541
575
  return {};
542
576
  }
543
577
  case "Page.screencastFrameAck": {
544
578
  const target = getSessionTarget(state, sessionId);
545
- if (target.screencastFrames.delete(params.sessionId) && target.screencastPending) void pushScreencastFrame(ctx, state, target);
579
+ const stream = target.screencasts.get(emit.owner)?.get(sessionId ?? null);
580
+ if (stream?.frames.delete(params.sessionId) && stream.pending)
581
+ void pushScreencastFrame(ctx, state, target, stream);
546
582
  return {};
547
583
  }
548
584
  case "Page.stopScreencast": {
549
585
  const target = getSessionTarget(state, sessionId);
550
- stopScreencast(target);
586
+ stopScreencast(target, emit.owner, sessionId ?? null);
551
587
  return {};
552
588
  }
553
589
  case "Page.reload": {
@@ -727,7 +763,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
727
763
  navigateTarget(state,target,navigation.url,emit,{addHistory:true});
728
764
  target.title=navigation.title ?? await getContextTitle(ctx,target);
729
765
  target.history[target.historyIndex].title=target.title;
730
- if(target.screencast)await pushScreencastFrame(ctx,state,target);
766
+ if(target.screencasts.size)await pushScreencastFrame(ctx,state,target);
731
767
  }
732
768
  return {};
733
769
  }
@@ -743,28 +779,34 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
743
779
  navigateTarget(state, target, navigation.url, emit, { addHistory: true });
744
780
  target.title = navigation.title ?? await getContextTitle(ctx, target);
745
781
  target.history[target.historyIndex].title = target.title;
746
- if (target.screencast) await pushScreencastFrame(ctx, state, target);
782
+ if (target.screencasts.size) await pushScreencastFrame(ctx, state, target);
747
783
  }
748
784
  }
749
785
  return {};
750
786
  }
751
787
  case "Input.dispatchKeyEvent": {
752
788
  const target = getSessionTarget(state, sessionId);
753
- const key = params.key ?? params.text ?? "";
754
- const signature = `${params.code ?? key}:${params.modifiers ?? 0}`;
789
+ const key = params.type === 'char' ? params.key || params.text || '' : params.key ?? params.text ?? '';
790
+ // Several real or synthesized keys may be held simultaneously. Releasing
791
+ // one key must not erase another key's state or duplicate its char event.
792
+ const signature = params.code || key;
793
+ const held = target.cdpKeysDown ??= new Map();
755
794
  if (params.type === "rawKeyDown" || params.type === "keyDown") {
756
- if (target.cdpKeyDown !== signature || params.autoRepeat) {
795
+ if (!held.has(signature) || params.autoRepeat) {
796
+ // Record arrival order before awaiting renderer IPC. A later key-up
797
+ // must not be overwritten when a slow key-down finally completes.
798
+ held.set(signature,key);
757
799
  await ctx.press?.(key, {
800
+ eventType: "keydown",
758
801
  modifiers: params.modifiers ?? 0,
759
802
  text: params.text ?? "",
760
803
  code: params.code ?? "",
761
804
  repeat: !!params.autoRepeat,
762
805
  windowsVirtualKeyCode: params.windowsVirtualKeyCode,
763
806
  });
764
- target.cdpKeyDown = signature;
765
807
  }
766
808
  } else if (params.type === "char") {
767
- if (!target.cdpKeyDown) {
809
+ if (!held.has(signature) && (params.code || ![...held.values()].includes(key))) {
768
810
  await ctx.press?.(key, {
769
811
  modifiers: params.modifiers ?? 0,
770
812
  text: params.text ?? "",
@@ -774,7 +816,12 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
774
816
  });
775
817
  }
776
818
  } else if (params.type === "keyUp") {
777
- target.cdpKeyDown = null;
819
+ if(params.code)held.delete(signature);
820
+ else for(const [id,value] of held)if(id===signature||value===key)held.delete(id);
821
+ await ctx.press?.(key, {
822
+ eventType: "keyup", modifiers: params.modifiers ?? 0,
823
+ code: params.code ?? "", windowsVirtualKeyCode: params.windowsVirtualKeyCode,
824
+ });
778
825
  }
779
826
  return {};
780
827
  }
@@ -848,13 +895,11 @@ function createTarget(state, url = "about:blank", options = {}) {
848
895
  history: [{ id: state.nextHistoryEntryId++, url, title: "" }],
849
896
  historyIndex: 0,
850
897
  viewport: {
851
- width: options.width ?? 800,
852
- height: options.height ?? 600,
898
+ width: options.width ?? state.context?.viewport?.width ?? 800,
899
+ height: options.height ?? state.context?.viewport?.height ?? 600,
853
900
  deviceScaleFactor: 1,
854
901
  },
855
- screencast: null,
856
- screencastScrollOffset: null,
857
- screencastFrames: new Set(),
902
+ screencasts: new Map(),
858
903
  };
859
904
  state.targets.set(targetId, target);
860
905
  state.initialNavigations.set(targetId, {
@@ -898,54 +943,67 @@ function emitLifecycle(target, name) {
898
943
  // Bound the amount of image data queued to a slow CDP client. Two outstanding
899
944
  // frames keep the pipe moving while acknowledging one; later updates coalesce.
900
945
  const MAX_SCREENCAST_FRAMES = 2;
901
- function stopScreencast(target) {
902
- target.screencast = null;
903
- target.screencastEmit = null;
904
- target.screencastPending = false;
905
- target.screencastScrollOffset = null;
906
- target.screencastFrames.clear();
946
+ function* screencasts(target) {
947
+ for (const sessions of target.screencasts.values()) yield* sessions.values();
907
948
  }
908
949
 
909
- /** One capture at a time, with bounded unacknowledged frames. */
910
- async function pushScreencastFrame(ctx, state, target) {
911
- if (!target.screencast || !target.screencastEmit) return;
912
- if (target.screencastBusy || target.screencastFrames.size >= MAX_SCREENCAST_FRAMES) {
913
- target.screencastPending = true;
950
+ // No owner means target destruction. An owner without a session means disconnect.
951
+ function stopScreencast(target, owner, sessionId) {
952
+ const owners = owner === undefined ? target.screencasts : new Map([[owner, target.screencasts.get(owner)]]);
953
+ for (const [key, sessions] of owners) {
954
+ if (!sessions) continue;
955
+ for (const [id, stream] of sessions) {
956
+ if (sessionId !== undefined && id !== sessionId) continue;
957
+ stream.active = false;
958
+ stream.pending = false;
959
+ stream.scrollOffset = null;
960
+ stream.frames.clear();
961
+ sessions.delete(id);
962
+ }
963
+ if (!sessions.size) target.screencasts.delete(key);
964
+ }
965
+ }
966
+
967
+ /** One capture at a time per viewer, with bounded unacknowledged frames. */
968
+ async function pushScreencastFrame(ctx, state, target, stream) {
969
+ if (!stream) {
970
+ await Promise.all([...screencasts(target)].map(s => pushScreencastFrame(ctx, state, target, s)));
971
+ return;
972
+ }
973
+ if (!stream.active) return;
974
+ if (stream.busy || stream.frames.size >= MAX_SCREENCAST_FRAMES) {
975
+ stream.pending = true;
914
976
  return;
915
977
  }
916
- target.screencastBusy = true;
978
+ stream.busy = true;
917
979
  try {
918
980
  do {
919
- target.screencastPending = false;
920
- await emitScreencastFrame(ctx, state, target);
921
- } while (target.screencastPending && target.screencast && target.screencastEmit
922
- && target.screencastFrames.size < MAX_SCREENCAST_FRAMES);
981
+ stream.pending = false;
982
+ await emitScreencastFrame(ctx, state, target, stream);
983
+ } while (stream.pending && stream.active && stream.frames.size < MAX_SCREENCAST_FRAMES);
923
984
  } catch (error) {
924
985
  serverError(`[CdpServer] screencast frame failed: ${error?.message ?? error}`);
925
986
  } finally {
926
- target.screencastBusy = false;
987
+ stream.busy = false;
927
988
  }
928
989
  }
929
990
 
930
- async function emitScreencastFrame(ctx, state, target) {
931
- const stream = target.screencast, emit = target.screencastEmit;
991
+ async function emitScreencastFrame(ctx, state, target, stream) {
992
+ const options = stream.params, emit = stream.emit;
932
993
  const loaderId = target.loaderId;
933
- // This belongs to one invalidation, not a lasting cache. Pending updates
934
- // replace it with the latest position; start/restart without one still query.
935
- const notifiedOffset = target.screencastScrollOffset;
936
- target.screencastScrollOffset = null;
937
- // Private zero-area screencast: signal invalidation without rendering pixels.
938
- // captureScreenshot keeps its ordinary clip validation.
939
- const zeroArea = stream.clip?.width === 0 && stream.clip?.height === 0;
994
+ // Pending updates replace this snapshot with the latest position.
995
+ const notifiedOffset = stream.scrollOffset;
996
+ stream.scrollOffset = null;
997
+ const zeroArea = options.clip?.width === 0 && options.clip?.height === 0;
940
998
  const data = zeroArea
941
999
  ? "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6SAAAAABJRU5ErkJggg=="
942
- : await screenshotData(ctx, target, stream);
943
- if (!data || stream !== target.screencast || emit !== target.screencastEmit || loaderId !== target.loaderId) return;
1000
+ : await screenshotData(ctx, target, options);
1001
+ if (!data || !stream.active || loaderId !== target.loaderId) return;
944
1002
  const scrollOffset = zeroArea && notifiedOffset ? notifiedOffset
945
1003
  : await ctx.scrollOffset?.() ?? { x: 0, y: 0 };
946
- if (stream !== target.screencast || emit !== target.screencastEmit || loaderId !== target.loaderId) return;
1004
+ if (!stream.active || loaderId !== target.loaderId) return;
947
1005
  const sessionId = state.nextScreencastFrameId++;
948
- target.screencastFrames.add(sessionId);
1006
+ stream.frames.add(sessionId);
949
1007
  emit("Page.screencastFrame", {
950
1008
  data,
951
1009
  metadata: {
@@ -972,7 +1030,7 @@ function getPendingInitialNavigationTarget(state) {
972
1030
 
973
1031
  function navigateTarget(state, target, url, emit, options = {}) {
974
1032
  const deferLifecycle = typeof state.context?.onLifecycle === "function";
975
- target.screencastScrollOffset = null;
1033
+ for (const stream of screencasts(target)) stream.scrollOffset = null;
976
1034
  target.lifecycleEmit = null;
977
1035
  target.url = url;
978
1036
  loadDocument(target, url);
@@ -1067,9 +1125,7 @@ function getImplicitTarget(state) {
1067
1125
 
1068
1126
  state.implicitTarget = {
1069
1127
  targetId: "implicit-target",
1070
- screencast: null,
1071
- screencastScrollOffset: null,
1072
- screencastFrames: new Set(),
1128
+ screencasts: new Map(),
1073
1129
  frameId: "implicit-target",
1074
1130
  loaderId: "implicit-loader",
1075
1131
  type: "page",
@@ -1089,8 +1145,8 @@ function getImplicitTarget(state) {
1089
1145
  ],
1090
1146
  historyIndex: 0,
1091
1147
  viewport: {
1092
- width: 800,
1093
- height: 600,
1148
+ width: state.context?.viewport?.width ?? 800,
1149
+ height: state.context?.viewport?.height ?? 600,
1094
1150
  deviceScaleFactor: 1,
1095
1151
  },
1096
1152
  };
@@ -0,0 +1,48 @@
1
+ import {readdir,stat,realpath} from 'node:fs/promises';
2
+ import {resolve,dirname} from 'node:path';
3
+ import {homedir} from 'node:os';
4
+ const esc=value=>Bun.escapeHTML(String(value));
5
+ export class FilePicker {
6
+ constructor(request){this.request=request;this.directory=homedir();this.selected=new Map();this.entries=[];this.error='';}
7
+ async browse(path){
8
+ try {const directory=await realpath(resolve(this.directory,path));const entries=await readdir(directory,{withFileTypes:true});
9
+ this.directory=directory;this.entries=[];
10
+ for(const e of entries){let folder=e.isDirectory();if(e.isSymbolicLink()){try{folder=(await stat(resolve(directory,e.name))).isDirectory();}catch{continue;}}
11
+ this.entries.push({name:e.name,path:resolve(directory,e.name),folder});}
12
+ this.entries.sort((a,b)=>Number(b.folder)-Number(a.folder)||a.name.localeCompare(b.name));this.error='';
13
+ }catch(e){this.error=e.message;}
14
+ }
15
+ async action(action){
16
+ if(action.kind==='browse'){await this.browse(action.path);return;}
17
+ if(action.kind==='up'){await this.browse(dirname(this.directory));return;}
18
+ if(action.kind==='select'){
19
+ const entry=this.entries[action.index];if(!entry)return;
20
+ if(entry.folder){await this.browse(entry.path);return;}
21
+ if(this.selected.has(entry.path))this.selected.delete(entry.path);
22
+ else {if(!this.request.multiple)this.selected.clear();this.selected.set(entry.path,entry.name);}return;
23
+ }
24
+ if(action.kind==='open'){
25
+ try{const files=[];let total=0;
26
+ for(const [path,name] of this.selected){const file=Bun.file(path);const info=await stat(path);if(!info.isFile())continue;
27
+ total+=info.size;if(total>32*1024*1024)throw Error('Please select at most 32 MiB at a time.');
28
+ const bytes=new Uint8Array(await file.slice(0,info.size+1).arrayBuffer());
29
+ if(bytes.length>info.size)throw Error('The selected file changed; please select it again.');
30
+ files.push({name,type:file.type,lastModified:info.mtimeMs,data:bytes.toBase64()});}
31
+ if(files.length)return {accept:true,files};
32
+ }catch(e){this.error=e.message;}
33
+ }
34
+ }
35
+ document({width,height},background=''){
36
+ const w=Math.max(1,Math.min(720,width-24)),h=Math.max(80,height-100);
37
+ const entries=this.entries.map((e,index)=>`<button class="entry" onclick="act({kind:'select',index:${index}})">${this.selected.has(e.path)?'☑ ':''}${e.folder?'📁':/\.(png|jpe?g|webp|gif|svg)$/i.test(e.name)?'🖼️':/\.(mp3|wav|ogg)$/i.test(e.name)?'🎵':'📄'} ${esc(e.name)}</button>`).join('');
38
+ return `<!doctype html><meta charset="utf-8"><style>
39
+ body{margin:0;font:16px sans-serif;color:#202124;background:#444}#background{position:fixed;inset:0;width:100%;height:100%;pointer-events:none}#shade{position:fixed;inset:0;background:rgba(0,0,0,.4)}
40
+ #dialog{position:absolute;left:${Math.max(0,(width-w)/2)}px;top:24px;width:${w}px;max-height:${height-32}px;box-sizing:border-box;overflow:auto;background:white;padding:16px;border:1px solid #aaa;border-radius:10px}
41
+ h2{font-size:18px;margin:0 0 12px}input{width:100%;box-sizing:border-box;font:16px sans-serif;padding:6px}button{font:16px sans-serif;padding:7px;margin:2px;border:1px solid #bbb;border-radius:4px;background:#fafafa;color:#111}
42
+ #message{max-height:${Math.max(50,h-220)}px;overflow:auto}.entry{display:block;width:98%;text-align:left;overflow-wrap:anywhere}.footer{margin-top:10px}#error{color:#a00}#chosen{font-size:13px;overflow-wrap:anywhere}</style>
43
+ ${background?`<img id="background" src="data:image/png;base64,${background}">`:''}<div id="shade"></div><section id="dialog" role="dialog" aria-modal="true"><h2>Choose ${this.request.multiple?'files':'a file'}</h2>
44
+ <input id="path" aria-label="Folder" value="${esc(this.directory)}"><button onclick="act({kind:'browse',path:document.getElementById('path').value})">Go</button><button onclick="act({kind:'up'})">📁 Up</button>
45
+ <div id="error">${esc(this.error)}</div><div id="message">${entries||'Empty folder'}</div><div id="chosen">${esc([...this.selected.values()].join(', '))}</div><div class="footer"><button onclick="window.__dialogAnswer={accept:false}">Cancel</button><button onclick="act({kind:'open'})">Open</button></div></section>
46
+ <script>window.__dialogAnswer=null;window.__fileAction=null;function act(a){window.__fileAction=a;}document.addEventListener('keydown',e=>{if(e.key==='Escape')window.__dialogAnswer={accept:false};else if(e.key==='Enter'){e.preventDefault();act(document.activeElement.id==='path'?{kind:'browse',path:document.getElementById('path').value}:{kind:'open'});}});</script>`;
47
+ }
48
+ }