buninu-browser 0.7.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/README.md +90 -1
- package/buninu-browser.js +1 -1
- package/frontends/wayland/README.md +35 -2
- package/frontends/wayland/client.js +8 -6
- package/frontends/wayland/clipboard.js +1 -1
- package/frontends/wayland/index.js +1 -1
- package/frontends/wayland/input.js +1 -1
- package/frontends/wayland/resource-io.js +6 -0
- package/frontends/wayland/transport-posix.js +120 -0
- package/frontends/wayland/transport-win32.js +9 -0
- package/frontends/wayland/transport.js +3 -120
- package/frontends/wayland/win32-resources.js +80 -0
- package/frontends/wayland/win32-wire.js +62 -0
- package/lib/audio/biquad.js +25 -0
- package/lib/audio/piano.js +6 -3
- package/lib/audio/playback-buffer.js +5 -5
- package/lib/audio/pulse-client.js +24 -5
- package/lib/audio/web-audio.js +570 -0
- package/lib/browser-dialog.js +126 -0
- package/lib/cdp-server.js +43 -11
- package/lib/file-picker.js +48 -0
- package/lib/headless-shell.js +34 -12
- package/lib/layout/text-layout.js +112 -19
- package/lib/media/mp4-frame-times.js +93 -0
- package/lib/paint/direct-canvas.ts +39 -0
- package/lib/paint/display-list.js +37 -12
- package/lib/paint/raster-readback.js +16 -7
- package/lib/renderer/animation-frames.js +10 -5
- package/lib/renderer/bindings.js +69 -16
- package/lib/renderer/canvas-2d.js +265 -15
- package/lib/renderer/canvas-round-rect.js +36 -0
- package/lib/renderer/css-animations.js +173 -0
- package/lib/renderer/dialog-client.js +30 -0
- package/lib/renderer/dialog-worker.js +35 -0
- package/lib/renderer/eval-environment.js +28 -0
- package/lib/renderer/file-input.js +49 -0
- package/lib/renderer/host.js +33 -4
- package/lib/renderer/html-drag.js +77 -0
- package/lib/renderer/image-loading.js +45 -2
- package/lib/renderer/main.js +4 -0
- package/lib/renderer/module-transform.js +5 -3
- package/lib/renderer/page-builtins.js +59 -0
- package/lib/renderer/page-realm.js +177 -40
- package/lib/renderer/page-renderer.js +397 -49
- package/lib/renderer/piano-input.js +3 -1
- package/lib/renderer/raster-surface.js +18 -0
- package/lib/renderer/script-rewrite.js +165 -9
- package/lib/renderer/video-presentation.js +5 -4
- package/lib/renderer/webgl/clip.js +24 -0
- package/lib/renderer/webgl/context.js +158 -0
- package/lib/renderer/webgl/cpu-fragment.js +211 -0
- package/lib/renderer/webgl/draw-cache.js +22 -0
- package/lib/renderer/webgl/pipeline.js +70 -0
- package/lib/renderer/webgl/shaders.js +58 -0
- package/lib/renderer/webgl/textures.js +43 -0
- package/lib/renderer/window-named-properties.js +46 -0
- package/lib/screenshot-cli.js +4 -7
- package/lib/style/animation-values.js +29 -0
- package/lib/style/computed-style.js +109 -13
- package/lib/style/transform.js +44 -0
- package/lib/window-size.js +10 -0
- package/package.json +1 -1
- package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
- package/patches/happy-dom/README.md +16 -0
- package/test/animation-frames.test.js +15 -0
- package/test/canvas-2d.test.js +252 -4
- package/test/canvas-frame-pacing.test.js +7 -0
- package/test/cdp-viewers.test.js +36 -0
- package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
- package/test/classic-games-patches/README.md +156 -0
- package/test/classic-games-scenarios.js +133 -0
- package/test/clipboard.test.js +16 -0
- package/test/computed-style.test.js +18 -0
- package/test/css-animation-lifecycle.test.js +28 -0
- package/test/css-animations.test.js +83 -0
- package/test/direct-canvas.test.js +39 -0
- package/test/dynamic-inline-handlers.test.js +25 -0
- package/test/eval-declarations.test.js +22 -0
- package/test/file-picker.test.js +34 -0
- package/test/fixtures/canvas-damage.js +23 -0
- package/test/fixtures/canvas-frame-pacing.js +35 -0
- package/test/fixtures/direct-canvas.js +96 -0
- package/test/fixtures/javascript-dialogs.html +8 -0
- package/test/fixtures/media-audio-lifetime.js +65 -0
- package/test/fixtures/renderer-memory.js +7 -5
- package/test/fixtures/video-black-replay.js +8 -6
- package/test/fixtures/video-slow-end.js +39 -0
- package/test/fixtures/web-audio-renderer.js +45 -0
- package/test/flex.test.js +26 -0
- package/test/grid.test.js +13 -0
- package/test/headless-shell.test.js +9 -0
- package/test/headless-window-size.test.js +28 -0
- package/test/html-rcdata.test.js +35 -0
- package/test/image-decode.test.js +18 -0
- package/test/javascript-dialog.test.js +83 -0
- package/test/manual-breakout-paddle.js +42 -0
- package/test/manual-breakout-static.js +35 -0
- package/test/manual-classic-games.js +41 -0
- package/test/manual-clumsy-bird-three-pipes.js +81 -0
- package/test/manual-clumsy-bird.js +52 -0
- package/test/manual-hextris.js +30 -0
- package/test/manual-numbered-music.js +24 -0
- package/test/manual-t-rex-runner.js +49 -0
- package/test/manual-threejs-scene.js +64 -0
- package/test/manual-webgl-benchmark.js +27 -0
- package/test/manual-webgl-cpu.html +26 -0
- package/test/media-audio-lifetime.test.js +8 -0
- package/test/mp4-frame-times.test.js +33 -0
- package/test/page-scripts.test.js +96 -1
- package/test/piano-input.test.js +15 -2
- package/test/piano.test.js +11 -2
- package/test/playback-buffer.test.js +11 -0
- package/test/pointer-drag.test.js +33 -0
- package/test/positioning.test.js +82 -0
- package/test/pulse-client.test.js +51 -2
- package/test/raster-readback.test.js +14 -0
- package/test/script-rewrite.test.js +61 -3
- package/test/video-slow-end.test.js +6 -0
- package/test/viewport-keyboard-overflow.test.js +25 -0
- package/test/wayland-win32-resources.test.js +47 -0
- package/test/wayland-win32-wire.test.js +59 -0
- package/test/web-audio-renderer.test.js +8 -0
- package/test/web-audio.test.js +264 -0
- package/test/webgl-clip.test.js +9 -0
- package/test/webgl-cpu-fragment.test.js +43 -0
- package/test/window-named-properties.test.js +41 -0
- package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
- package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
- package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
- package/vendor/land-renderer/LICENSE +21 -0
- package/vendor/land-renderer/README.md +14 -0
- package/vendor/land-renderer/direct-compositor.ts +443 -0
- package/vendor/land-renderer/frame.js +88 -0
- package/vendor/land-renderer/png-fast.js +85 -0
- package/vendor/land-renderer/raw-frames.js +66 -0
- package/vendor/land-renderer/render-types.ts +7 -0
- package/vendor/land-renderer/renderer.ts +134 -0
- package/vendor/land-renderer/snapshot.js +37 -0
- package/vendor/land-renderer/viewport.js +18 -0
- package/vendor/ses/dist/ses.mjs +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
invoke(method,...args) {
|
|
38
|
+
const entry=this.active;if(!entry)return;
|
|
39
|
+
const invoke=()=>this.invokeEntry(entry,method,...args);
|
|
40
|
+
if(!['press','type','click'].includes(method))return invoke();
|
|
41
|
+
// Modal input bypasses the page's blocked script turn. Serialize it here:
|
|
42
|
+
// overlapping down/up requests must not consume the same file action twice
|
|
43
|
+
// or reload the picker while another input still uses its old document.
|
|
44
|
+
const result=(entry.inputQueue??Promise.resolve()).then(invoke);
|
|
45
|
+
entry.inputQueue=result.then(()=>{},()=>{});
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
async invokeEntry(entry,method,...args) {
|
|
49
|
+
await entry.ready;
|
|
50
|
+
if(this.active!==entry||!entry.host)return;
|
|
51
|
+
if(method==='press' && (args[1]?.modifiers & 6) && String(args[0]).toLowerCase()==='c') {
|
|
52
|
+
const selected=await entry.host.call('evaluate','window.getSelection().toString()');
|
|
53
|
+
if(selected){if(this.clipboard)await this.clipboard.write(selected);else await writeClipboard(selected);return;}
|
|
54
|
+
}
|
|
55
|
+
const value=method==='screenshot'||method==='scrollOffset'||method==='scroll'
|
|
56
|
+
? await entry.host.composite(method,...args):await entry.host.call(method,...args);
|
|
57
|
+
if(['press','type','click'].includes(method)) {
|
|
58
|
+
if(entry.picker){const action=await entry.host.call('evaluate','window.__fileAction');
|
|
59
|
+
if(action){await entry.host.call('evaluate','window.__fileAction=null');
|
|
60
|
+
const directory=entry.picker.directory;
|
|
61
|
+
const scroll=await entry.host.call('evaluate','[document.getElementById("message").scrollTop,document.getElementById("dialog").scrollTop]');
|
|
62
|
+
const answer=await entry.picker.action(action);
|
|
63
|
+
if(answer){this.finish(answer,entry);return value;}
|
|
64
|
+
await entry.host.call('loadDocument',{url:'about:dialog',source:entry.picker.document(this.viewport(),entry.background),contentType:'text/html'});
|
|
65
|
+
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}`);
|
|
66
|
+
this.changed();}
|
|
67
|
+
}
|
|
68
|
+
const result=await entry.host.call('evaluate','window.__dialogAnswer');
|
|
69
|
+
if(result && typeof result.accept==='boolean')this.finish(result,entry);
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
async resize(width,height,dpr) {
|
|
74
|
+
const entry=this.active;if(!entry)return;
|
|
75
|
+
await entry.ready;if(this.active!==entry)return;
|
|
76
|
+
await entry.host.call('resize',width,height,dpr);
|
|
77
|
+
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));
|
|
78
|
+
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';`);
|
|
79
|
+
this.changed();
|
|
80
|
+
}
|
|
81
|
+
finish(result,entry=this.active) {
|
|
82
|
+
if(!entry||this.active!==entry)return;
|
|
83
|
+
this.active=null;
|
|
84
|
+
entry.signal?.removeEventListener('abort',entry.abort);
|
|
85
|
+
entry.host?.close();
|
|
86
|
+
entry.answer.resolve({accept:Boolean(result.accept),promptText:String(result.promptText??''),...(entry.picker?{files:result.files??[]}: {})});
|
|
87
|
+
this.closed(result);this.changed();
|
|
88
|
+
}
|
|
89
|
+
close(){this.finish({accept:false});}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function dialogDocument({type,message,defaultPrompt='',url=''}, {width,height}, background='') {
|
|
93
|
+
const escape=value=>Bun.escapeHTML(String(value));
|
|
94
|
+
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));
|
|
95
|
+
const maxMessage=Math.max(20,height-y-180);
|
|
96
|
+
let origin;try{origin=new URL(url).origin;}catch{origin='';}
|
|
97
|
+
if(!origin||origin==='null')origin='This page';
|
|
98
|
+
return `<!doctype html><meta charset="utf-8"><style>
|
|
99
|
+
html,body{margin:0;width:100%;height:100%;overflow:hidden;font:16px sans-serif;color:#202124}
|
|
100
|
+
body{background:#444}#background{position:fixed;left:0;top:0;width:100%;height:100%;pointer-events:none}
|
|
101
|
+
#shade{position:fixed;inset:0;background:rgba(0,0,0,.4)}
|
|
102
|
+
#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}
|
|
103
|
+
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}
|
|
104
|
+
input{display:block;box-sizing:border-box;width:100%;font:16px sans-serif;padding:8px;margin-bottom:16px}
|
|
105
|
+
#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}
|
|
106
|
+
</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">
|
|
107
|
+
<h2 id="heading">${escape(origin)} says</h2><div id="message" tabindex="0">${escape(message)}</div>
|
|
108
|
+
${type==='prompt'?`<input id="answer" aria-label="Response" value="${escape(defaultPrompt)}">`:''}
|
|
109
|
+
<div id="buttons">${type!=='alert'?'<button id="cancel" onclick="finish(false)">Cancel</button>':''}<button id="ok" onclick="finish(true)">OK</button></div></section>
|
|
110
|
+
<script>
|
|
111
|
+
window.__dialogAnswer=null;
|
|
112
|
+
function finish(accept){window.__dialogAnswer={accept:accept,promptText:document.getElementById('answer')?.value||''};}
|
|
113
|
+
document.addEventListener('keydown',function(e){
|
|
114
|
+
const active=document.activeElement;
|
|
115
|
+
if(e.key==='Escape'){e.preventDefault();finish(false);}
|
|
116
|
+
else if(e.key==='Enter'||(e.key===' '&&active?.tagName==='BUTTON')){e.preventDefault();finish(active?.id!=='cancel');}
|
|
117
|
+
else if(active?.tagName!=='INPUT'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){
|
|
118
|
+
const message=document.getElementById('message'),step=Math.max(20,message.clientHeight-20);
|
|
119
|
+
const delta={ArrowUp:-31,ArrowDown:31,PageUp:-step,PageDown:step,' ':step}[e.key];
|
|
120
|
+
if(delta!==undefined){e.preventDefault();message.scrollTop+=delta;}
|
|
121
|
+
else if(e.key==='Home'||e.key==='End'){e.preventDefault();message.scrollTop=e.key==='Home'?0:message.scrollHeight;}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
const input=document.getElementById('answer');if(input){input.focus();input.select();}else document.getElementById('message').focus();
|
|
125
|
+
</script>`;
|
|
126
|
+
}
|
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
|
-
|
|
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 (
|
|
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 (!
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/headless-shell.js
CHANGED
|
@@ -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 = {
|
|
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();
|