buninu-browser 0.7.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 (142) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +90 -1
  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/lib/audio/biquad.js +25 -0
  16. package/lib/audio/piano.js +6 -3
  17. package/lib/audio/playback-buffer.js +5 -5
  18. package/lib/audio/pulse-client.js +24 -5
  19. package/lib/audio/web-audio.js +570 -0
  20. package/lib/browser-dialog.js +116 -0
  21. package/lib/cdp-server.js +43 -11
  22. package/lib/file-picker.js +48 -0
  23. package/lib/headless-shell.js +34 -12
  24. package/lib/layout/text-layout.js +112 -19
  25. package/lib/media/mp4-frame-times.js +93 -0
  26. package/lib/paint/direct-canvas.ts +39 -0
  27. package/lib/paint/display-list.js +37 -12
  28. package/lib/paint/raster-readback.js +16 -7
  29. package/lib/renderer/animation-frames.js +10 -5
  30. package/lib/renderer/bindings.js +69 -16
  31. package/lib/renderer/canvas-2d.js +265 -15
  32. package/lib/renderer/canvas-round-rect.js +36 -0
  33. package/lib/renderer/css-animations.js +173 -0
  34. package/lib/renderer/dialog-client.js +30 -0
  35. package/lib/renderer/dialog-worker.js +35 -0
  36. package/lib/renderer/eval-environment.js +28 -0
  37. package/lib/renderer/file-input.js +49 -0
  38. package/lib/renderer/host.js +33 -4
  39. package/lib/renderer/html-drag.js +77 -0
  40. package/lib/renderer/image-loading.js +45 -2
  41. package/lib/renderer/main.js +4 -0
  42. package/lib/renderer/module-transform.js +5 -3
  43. package/lib/renderer/page-builtins.js +59 -0
  44. package/lib/renderer/page-realm.js +177 -40
  45. package/lib/renderer/page-renderer.js +397 -49
  46. package/lib/renderer/piano-input.js +3 -1
  47. package/lib/renderer/raster-surface.js +18 -0
  48. package/lib/renderer/script-rewrite.js +165 -9
  49. package/lib/renderer/video-presentation.js +5 -4
  50. package/lib/renderer/webgl/clip.js +24 -0
  51. package/lib/renderer/webgl/context.js +158 -0
  52. package/lib/renderer/webgl/cpu-fragment.js +211 -0
  53. package/lib/renderer/webgl/draw-cache.js +22 -0
  54. package/lib/renderer/webgl/pipeline.js +70 -0
  55. package/lib/renderer/webgl/shaders.js +58 -0
  56. package/lib/renderer/webgl/textures.js +43 -0
  57. package/lib/renderer/window-named-properties.js +46 -0
  58. package/lib/screenshot-cli.js +4 -7
  59. package/lib/style/animation-values.js +29 -0
  60. package/lib/style/computed-style.js +109 -13
  61. package/lib/style/transform.js +44 -0
  62. package/lib/window-size.js +10 -0
  63. package/package.json +1 -1
  64. package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
  65. package/patches/happy-dom/README.md +16 -0
  66. package/test/animation-frames.test.js +15 -0
  67. package/test/canvas-2d.test.js +252 -4
  68. package/test/canvas-frame-pacing.test.js +7 -0
  69. package/test/cdp-viewers.test.js +36 -0
  70. package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
  71. package/test/classic-games-patches/README.md +156 -0
  72. package/test/classic-games-scenarios.js +133 -0
  73. package/test/clipboard.test.js +16 -0
  74. package/test/computed-style.test.js +18 -0
  75. package/test/css-animation-lifecycle.test.js +28 -0
  76. package/test/css-animations.test.js +83 -0
  77. package/test/direct-canvas.test.js +39 -0
  78. package/test/dynamic-inline-handlers.test.js +25 -0
  79. package/test/eval-declarations.test.js +22 -0
  80. package/test/file-picker.test.js +26 -0
  81. package/test/fixtures/canvas-damage.js +23 -0
  82. package/test/fixtures/canvas-frame-pacing.js +35 -0
  83. package/test/fixtures/direct-canvas.js +96 -0
  84. package/test/fixtures/javascript-dialogs.html +8 -0
  85. package/test/fixtures/media-audio-lifetime.js +65 -0
  86. package/test/fixtures/renderer-memory.js +7 -5
  87. package/test/fixtures/video-black-replay.js +8 -6
  88. package/test/fixtures/video-slow-end.js +39 -0
  89. package/test/fixtures/web-audio-renderer.js +45 -0
  90. package/test/flex.test.js +26 -0
  91. package/test/grid.test.js +13 -0
  92. package/test/headless-shell.test.js +9 -0
  93. package/test/headless-window-size.test.js +28 -0
  94. package/test/html-rcdata.test.js +35 -0
  95. package/test/image-decode.test.js +18 -0
  96. package/test/javascript-dialog.test.js +83 -0
  97. package/test/manual-breakout-paddle.js +42 -0
  98. package/test/manual-breakout-static.js +35 -0
  99. package/test/manual-classic-games.js +41 -0
  100. package/test/manual-clumsy-bird-three-pipes.js +81 -0
  101. package/test/manual-clumsy-bird.js +52 -0
  102. package/test/manual-hextris.js +30 -0
  103. package/test/manual-numbered-music.js +24 -0
  104. package/test/manual-t-rex-runner.js +49 -0
  105. package/test/manual-threejs-scene.js +64 -0
  106. package/test/manual-webgl-benchmark.js +27 -0
  107. package/test/manual-webgl-cpu.html +26 -0
  108. package/test/media-audio-lifetime.test.js +8 -0
  109. package/test/mp4-frame-times.test.js +33 -0
  110. package/test/page-scripts.test.js +96 -1
  111. package/test/piano-input.test.js +15 -2
  112. package/test/piano.test.js +11 -2
  113. package/test/playback-buffer.test.js +11 -0
  114. package/test/pointer-drag.test.js +33 -0
  115. package/test/positioning.test.js +82 -0
  116. package/test/pulse-client.test.js +51 -2
  117. package/test/raster-readback.test.js +14 -0
  118. package/test/script-rewrite.test.js +61 -3
  119. package/test/video-slow-end.test.js +6 -0
  120. package/test/viewport-keyboard-overflow.test.js +25 -0
  121. package/test/wayland-win32-resources.test.js +47 -0
  122. package/test/wayland-win32-wire.test.js +59 -0
  123. package/test/web-audio-renderer.test.js +8 -0
  124. package/test/web-audio.test.js +264 -0
  125. package/test/webgl-clip.test.js +9 -0
  126. package/test/webgl-cpu-fragment.test.js +43 -0
  127. package/test/window-named-properties.test.js +41 -0
  128. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
  129. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
  130. package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
  131. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
  132. package/vendor/land-renderer/LICENSE +21 -0
  133. package/vendor/land-renderer/README.md +14 -0
  134. package/vendor/land-renderer/direct-compositor.ts +443 -0
  135. package/vendor/land-renderer/frame.js +88 -0
  136. package/vendor/land-renderer/png-fast.js +85 -0
  137. package/vendor/land-renderer/raw-frames.js +66 -0
  138. package/vendor/land-renderer/render-types.ts +7 -0
  139. package/vendor/land-renderer/renderer.ts +134 -0
  140. package/vendor/land-renderer/snapshot.js +37 -0
  141. package/vendor/land-renderer/viewport.js +18 -0
  142. 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,
@@ -215,6 +219,7 @@ export class CdpServer {
215
219
  }
216
220
  },
217
221
  close(ws) {
222
+ state.dialogListeners.delete(ws);
218
223
  for (const target of state.targets.values()) {
219
224
  stopScreencast(target, ws);
220
225
  if (target.lifecycleEmit?.owner === ws) target.lifecycleEmit = null;
@@ -291,6 +296,7 @@ function createCdpState() {
291
296
  targets: new Map(),
292
297
  sessions: new Map(),
293
298
  sessionOwners: new Map(),
299
+ dialogListeners: new Map(),
294
300
  objects: new Map(),
295
301
  initialNavigations: new Map(),
296
302
  implicitTarget: null,
@@ -397,12 +403,27 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
397
403
  const target = state.targets.get(state.sessions.get(params.sessionId));
398
404
  if (state.sessionOwners.get(params.sessionId) !== emit.owner) throw Error("No session owned by this connection");
399
405
  if (target) stopScreencast(target, emit.owner, params.sessionId);
406
+ state.dialogListeners.get(emit.owner)?.delete(params.sessionId);
400
407
  state.sessions.delete(params.sessionId);
401
408
  state.sessionOwners.delete(params.sessionId);
402
409
  return {};
403
410
  }
404
- case "Page.enable":
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});
416
+ return {};
417
+ }
418
+ case "Page.disable": {
419
+ state.dialogListeners.get(emit.owner)?.delete(sessionId??null);
405
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
+ }
406
427
  case "Page.getResourceContent": {
407
428
  // The main document only: its bytes exactly as they arrived.
408
429
  const resource = await ctx.resourceContent?.(params.url);
@@ -765,21 +786,27 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
765
786
  }
766
787
  case "Input.dispatchKeyEvent": {
767
788
  const target = getSessionTarget(state, sessionId);
768
- const key = params.key ?? params.text ?? "";
769
- 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();
770
794
  if (params.type === "rawKeyDown" || params.type === "keyDown") {
771
- 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);
772
799
  await ctx.press?.(key, {
800
+ eventType: "keydown",
773
801
  modifiers: params.modifiers ?? 0,
774
802
  text: params.text ?? "",
775
803
  code: params.code ?? "",
776
804
  repeat: !!params.autoRepeat,
777
805
  windowsVirtualKeyCode: params.windowsVirtualKeyCode,
778
806
  });
779
- target.cdpKeyDown = signature;
780
807
  }
781
808
  } else if (params.type === "char") {
782
- if (!target.cdpKeyDown) {
809
+ if (!held.has(signature) && (params.code || ![...held.values()].includes(key))) {
783
810
  await ctx.press?.(key, {
784
811
  modifiers: params.modifiers ?? 0,
785
812
  text: params.text ?? "",
@@ -789,7 +816,12 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
789
816
  });
790
817
  }
791
818
  } else if (params.type === "keyUp") {
792
- 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
+ });
793
825
  }
794
826
  return {};
795
827
  }
@@ -863,8 +895,8 @@ function createTarget(state, url = "about:blank", options = {}) {
863
895
  history: [{ id: state.nextHistoryEntryId++, url, title: "" }],
864
896
  historyIndex: 0,
865
897
  viewport: {
866
- width: options.width ?? 800,
867
- height: options.height ?? 600,
898
+ width: options.width ?? state.context?.viewport?.width ?? 800,
899
+ height: options.height ?? state.context?.viewport?.height ?? 600,
868
900
  deviceScaleFactor: 1,
869
901
  },
870
902
  screencasts: new Map(),
@@ -1113,8 +1145,8 @@ function getImplicitTarget(state) {
1113
1145
  ],
1114
1146
  historyIndex: 0,
1115
1147
  viewport: {
1116
- width: 800,
1117
- height: 600,
1148
+ width: state.context?.viewport?.width ?? 800,
1149
+ height: state.context?.viewport?.height ?? 600,
1118
1150
  deviceScaleFactor: 1,
1119
1151
  },
1120
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
+ }
@@ -1,3 +1,4 @@
1
+ import {BrowserDialog} from "./browser-dialog.js";
1
2
  // qml handle begin: select a dedicated local application renderer
2
3
  import {isQmlDocument} from './qml/format.js';
3
4
  // Local QML uses a dedicated host renderer.
@@ -15,6 +16,7 @@ import {isQmlDocument} from './qml/format.js';
15
16
  * always passes it) but ignored. `--dangerously-allow-host-js` relaxes
16
17
  * the renderer sandbox for every page.
17
18
  */
19
+ import { parseWindowSize } from "./window-size.js";
18
20
  import { CdpServer } from "./cdp-server.js";
19
21
  import { DEFAULT_DOWNLOAD_DIR, isAttachment, saveDownload, suggestedFilename } from "./download.js";
20
22
  import { audioDemoDocument, displayDocument } from "./document-types.js";
@@ -31,6 +33,7 @@ export const DANGEROUSLY_ALLOW_HOST_JS = "--dangerously-allow-host-js";
31
33
 
32
34
  export async function runHeadlessShell(args = Bun.argv.slice(2)) {
33
35
  installLog(logFileFromEnv());
36
+ const initialViewport = parseWindowSize(args);
34
37
  const allowHostJs = args.includes(DANGEROUSLY_ALLOW_HOST_JS);
35
38
  if (args.includes("--no-sandbox")) {
36
39
  console.error(`buninu-browser: --no-sandbox is ignored; use ${DANGEROUSLY_ALLOW_HOST_JS} to disable the page sandbox`);
@@ -44,7 +47,7 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
44
47
 
45
48
  // The endpoint opens while the first renderer starts: a client's first
46
49
  // navigation fetches its document meanwhile.
47
- const browser = await createBrowser({ allowHostJs, mobile, webkit: webkitFromArgs(args), waitForRenderer: false });
50
+ const browser = await createBrowser({ initialViewport, allowHostJs, mobile, webkit: webkitFromArgs(args), waitForRenderer: false });
48
51
  const portArgument = args.find((argument) => argument.startsWith("--remote-debugging-port="));
49
52
  const requestedPort = Number(portArgument?.slice("--remote-debugging-port=".length) ?? 9222);
50
53
  const server = CdpServer.create(browser.context).listen(requestedPort, "127.0.0.1");
@@ -85,14 +88,14 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
85
88
  * anything that needs a renderer waits for it.
86
89
  *
87
90
  * @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number,
88
- * waitForRenderer?: boolean, mobile?: boolean, webkit?: boolean,
91
+ * waitForRenderer?: boolean, mobile?: boolean, webkit?: boolean, initialViewport?: {width: number, height: number},
89
92
  * clipboard?: {read: Function, write: Function} }} options
90
93
  * WebKit identity defaults on; webkit: false selects Chromium. Mobile takes priority.
91
94
  */
92
95
  export async function createBrowser({
93
96
  allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 0, mobile = false,
94
97
  waitForRenderer = true, webkit = true,
95
- clipboard = null,
98
+ clipboard = null, initialViewport = { width: 800, height: 600 },
96
99
  } = {}) {
97
100
  // Explicit presentation profiles fix the identity for requests and scripts.
98
101
  const initialUserAgent = mobile ? MOBILE_USER_AGENT : webkit ? WEBKIT_USER_AGENT : DESKTOP_USER_AGENT;
@@ -111,12 +114,18 @@ export async function createBrowser({
111
114
  let rendererReady = null;
112
115
  const documentReady = new WeakMap();
113
116
  // Renderer-independent state replayed into every new renderer.
114
- const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
117
+ const viewport = { ...initialViewport, deviceScaleFactor: 1 };
115
118
  let userAgent = webkit ? initialUserAgent : null;
116
119
  // Page.addScriptToEvaluateOnNewDocument: source by identifier, run in every new document before its scripts.
117
120
  const initScripts = new Map();
118
121
  let nextInitScript = 1;
119
122
  // Listeners for frame and lifecycle notifications (the CDP server).
123
+ let dialogHandler = null;
124
+ const dialogs = new BrowserDialog({viewport:()=>({...viewport}),
125
+ background:()=>current?.composite('screenshot',{format:'png'}),
126
+ changed:()=>notify(frameListeners),
127
+ opened:request=>dialogHandler?.(request),
128
+ closed:result=>dialogHandler?.(null,result),clipboard});
120
129
  const frameListeners = new Set();
121
130
  const lifecycleListeners = new Set();
122
131
  const downloadListeners = new Set();
@@ -173,6 +182,7 @@ export async function createBrowser({
173
182
  // qml handle end
174
183
 
175
184
  const host = new RendererHost({
185
+ onDialog: (request,signal) => host===current ? dialogs.open({...request,url:host.documentUrl},signal) : {accept:false},
176
186
  // Native frontends supply one clipboard for page APIs and input defaults.
177
187
  ...(clipboard?{clipboardRead:()=>clipboard.read(),clipboardWrite:text=>clipboard.write(text)}:{}),
178
188
  network,
@@ -327,6 +337,7 @@ export async function createBrowser({
327
337
  * restarted renderer).
328
338
  */
329
339
  async function load(url, invoke = null, downloadName = null, post = null, referrer = null) {
340
+ dialogs.close();
330
341
  const started = performance.now();
331
342
  if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
332
343
  return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
@@ -428,6 +439,7 @@ export async function createBrowser({
428
439
  await call("runJavaScriptURL", resolved);
429
440
  return;
430
441
  }
442
+ dialogs.close();
431
443
  await rememberScroll();
432
444
  let shown = resolved;
433
445
  if (!post && downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
@@ -458,6 +470,7 @@ export async function createBrowser({
458
470
 
459
471
  async function traverseHistory(index) {
460
472
  if (index < 0 || index >= history.length || index === historyIndex) return;
473
+ dialogs.close();
461
474
  await rememberScroll();
462
475
  historyIndex = index;
463
476
  const entry = history[index];
@@ -466,6 +479,10 @@ export async function createBrowser({
466
479
  }
467
480
 
468
481
  const context = {
482
+ onDialog(handler) { dialogHandler = handler; },
483
+ get dialogState() { return dialogs.active?.request ?? null; },
484
+ handleDialog(result) { dialogs.finish(result); },
485
+ get viewport() { return { ...viewport }; },
469
486
  get url() { return currentUrl; },
470
487
  navigate,
471
488
  get userAgent() { return userAgent ?? initialUserAgent; },
@@ -482,6 +499,7 @@ export async function createBrowser({
482
499
  goForward: () => traverseHistory(historyIndex + 1),
483
500
  resize(width, height, deviceScaleFactor = 1) {
484
501
  Object.assign(viewport, { width, height, deviceScaleFactor });
502
+ if(dialogs.active){Promise.resolve(applyState("resize",resizeArguments)).catch(()=>{});return dialogs.resize(width,height,deviceScaleFactor);}
485
503
  return applyState("resize", resizeArguments);
486
504
  },
487
505
  setUserAgent(value) {
@@ -501,13 +519,13 @@ export async function createBrowser({
501
519
  if (!initScripts.delete(String(identifier))) return;
502
520
  applyState("setInitScripts", () => [[...initScripts.values()]]);
503
521
  },
504
- screenshot: (options = {}) => composite("screenshot", {
522
+ screenshot: (options = {}) => (dialogs.active ? dialogs.invoke.bind(dialogs) : composite)("screenshot", {
505
523
  format: ["jpeg", "webp", "rgba", "bgra"].includes(options.format) ? options.format : "png",
506
524
  quality: Number.isFinite(options.quality) ? options.quality : 80,
507
525
  optimizeForSpeed: options.optimizeForSpeed === true,
508
- clip: options.clip ? {...options.clip,x:options.clip.x/pageScale(),y:options.clip.y/pageScale(),width:options.clip.width/pageScale(),height:options.clip.height/pageScale()} : undefined,
526
+ clip: options.clip ? {...options.clip,x:options.clip.x/(dialogs.active?1:pageScale()),y:options.clip.y/(dialogs.active?1:pageScale()),width:options.clip.width/(dialogs.active?1:pageScale()),height:options.clip.height/(dialogs.active?1:pageScale())} : undefined,
509
527
  }),
510
- title: () => call("title"),
528
+ title: () => dialogs.active ? currentUrl : call("title"),
511
529
  /**
512
530
  * The document's response body as it arrived (CDP Page.getResourceContent),
513
531
  * for the current document's URL with or without its fragment; else null.
@@ -530,6 +548,7 @@ export async function createBrowser({
530
548
  * first; the page scrolls when nothing there takes it.
531
549
  */
532
550
  async scroll(deltaX, deltaY, at = null) {
551
+ if(dialogs.active)return at?dialogs.invoke("wheel",at.x,at.y,deltaX,deltaY):dialogs.invoke("scroll",deltaX,deltaY);
533
552
  const scale = pageScale();
534
553
  let left = { x: deltaX / scale, y: deltaY / scale };
535
554
  if (at && Number.isFinite(at.x) && Number.isFinite(at.y)) {
@@ -538,9 +557,9 @@ export async function createBrowser({
538
557
  }
539
558
  return composite("scroll", left.x, left.y);
540
559
  },
541
- press: (key, options) => call("press", key, options),
542
- type: (text) => call("type", text),
543
- scrollOffset: () => composite("scrollOffset"),
560
+ press: (key, options) => dialogs.active ? dialogs.invoke("press",key,options) : call("press", key, options),
561
+ type: (text) => dialogs.active ? dialogs.invoke("type",text) : call("type", text),
562
+ scrollOffset: () => dialogs.active ? Promise.resolve({x:0,y:0}) : composite("scrollOffset"),
544
563
  /** Calls `listener(frame?)` when the viewport changes, with its measured scroll offset when available. */
545
564
  onFrame(listener) {
546
565
  frameListeners.add(listener);
@@ -556,12 +575,14 @@ export async function createBrowser({
556
575
  return [...reachedLifecycle(committed.host, committed.generation)];
557
576
  },
558
577
  move(x, y, options = {}) {
578
+ if(dialogs.active)return dialogs.invoke("move",x,y,options);
559
579
  return call("move", x / pageScale(), y / pageScale(), options);
560
580
  },
561
581
  pointerButton(x, y, button, state, options = {}) {
562
582
  return this.click(x, y, {...options, button:button===273?'right':button===274?'middle':'left', phase:state?'down':'up'});
563
583
  },
564
584
  async click(x, y, options = {}) {
585
+ if(dialogs.active){await dialogs.invoke("click",x,y,options);return null;}
565
586
  const clicked = await call("click", x / pageScale(), y / pageScale(), options);
566
587
  const href = typeof clicked === "string" ? clicked : clicked?.url;
567
588
  if (typeof href !== "string") return null;
@@ -573,10 +594,10 @@ export async function createBrowser({
573
594
  referrer: current.documentUrl });
574
595
  return result === null ? null : { url: href, title: await call("title") };
575
596
  },
576
- evaluate: (expression) => call("evaluate", String(expression)),
597
+ evaluate: (expression) => dialogs.active ? dialogs.invoke("evaluate",String(expression)) : call("evaluate", String(expression)),
577
598
  async cdp(method) {
578
599
  if(method==='Page.getLayoutMetrics') {
579
- const offset=await composite('scrollOffset');
600
+ const offset=dialogs.active?{x:0,y:0}:await composite('scrollOffset');
580
601
  const visual={pageX:offset.x*pageScale(),pageY:offset.y*pageScale(),offsetX:0,offsetY:0,clientWidth:viewport.width,clientHeight:viewport.height,scale:1,zoom:1};
581
602
  return {cssVisualViewport:visual,cssLayoutViewport:visual,visualViewport:visual,layoutViewport:visual};
582
603
  }
@@ -612,6 +633,7 @@ export async function createBrowser({
612
633
  },
613
634
  close() {
614
635
  closed = true;
636
+ dialogs.close();
615
637
  cancelSparePreparation();
616
638
  current?.close();
617
639
  spare?.close();