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.
- package/AGENTS.md +5 -0
- package/README.md +94 -4
- 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/index.js +10 -6
- 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 +116 -0
- package/lib/cdp-server.js +128 -72
- 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 +79 -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 +26 -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/index.test.js +1 -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,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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
537
|
-
target.
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
902
|
-
target.
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
978
|
+
stream.busy = true;
|
|
917
979
|
try {
|
|
918
980
|
do {
|
|
919
|
-
|
|
920
|
-
await emitScreencastFrame(ctx, state, target);
|
|
921
|
-
} while (
|
|
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
|
-
|
|
987
|
+
stream.busy = false;
|
|
927
988
|
}
|
|
928
989
|
}
|
|
929
990
|
|
|
930
|
-
async function emitScreencastFrame(ctx, state, target) {
|
|
931
|
-
const
|
|
991
|
+
async function emitScreencastFrame(ctx, state, target, stream) {
|
|
992
|
+
const options = stream.params, emit = stream.emit;
|
|
932
993
|
const loaderId = target.loaderId;
|
|
933
|
-
//
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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,
|
|
943
|
-
if (!data || stream
|
|
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
|
|
1004
|
+
if (!stream.active || loaderId !== target.loaderId) return;
|
|
947
1005
|
const sessionId = state.nextScreencastFrameId++;
|
|
948
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|