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.
- 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 +116 -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 +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/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,30 @@
|
|
|
1
|
+
export async function createDialogClient(endpoint) {
|
|
2
|
+
const worker=new Worker(new URL('./dialog-worker.js',import.meta.url).href);
|
|
3
|
+
await new Promise((resolve,reject)=>{
|
|
4
|
+
worker.onmessage=({data})=>data.type==='ready'?resolve():reject(Error(data.message));
|
|
5
|
+
worker.onerror=event=>reject(Error(event.message));
|
|
6
|
+
worker.postMessage({type:'connect',endpoint});
|
|
7
|
+
}).catch(error=>{worker.terminate();throw error;});
|
|
8
|
+
let next=0,failed=false;const files=new Map();
|
|
9
|
+
worker.onerror=()=>{failed=true;for(const resolve of files.values())resolve({accept:false});files.clear();};
|
|
10
|
+
worker.onmessage=({data})=>{if(data.type==='files'){files.get(data.id)?.(data.result);files.delete(data.id);}};
|
|
11
|
+
const dialog = (type,message,defaultPrompt)=>{
|
|
12
|
+
if(failed)return type==='alert'?undefined:type==='confirm'?false:null;
|
|
13
|
+
const buffer=new SharedArrayBuffer(12,{maxByteLength:64*1024*1024}),state=new Int32Array(buffer,0,3);
|
|
14
|
+
worker.postMessage({type:'dialog',buffer,request:{type,message,defaultPrompt}});
|
|
15
|
+
while(Atomics.load(state,0)===0)Atomics.wait(state,0,0);
|
|
16
|
+
if(type==='alert')return undefined;
|
|
17
|
+
if(type==='confirm')return Boolean(state[2]);
|
|
18
|
+
if(!state[2])return null;
|
|
19
|
+
const text=new Uint16Array(buffer,12,state[1]);let value='';
|
|
20
|
+
for(let i=0;i<text.length;i+=8192)value+=String.fromCharCode(...text.subarray(i,i+8192));
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
dialog.pickFiles=options=>new Promise(resolve=>{
|
|
24
|
+
if(failed){resolve({accept:false});return;}
|
|
25
|
+
const id=++next;files.set(id,resolve);
|
|
26
|
+
try{worker.postMessage({type:'files',id,request:{type:'file',...options}});}
|
|
27
|
+
catch{files.delete(id);resolve({accept:false});}
|
|
28
|
+
});
|
|
29
|
+
return dialog;
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// This worker stays responsive while the page thread waits synchronously for
|
|
2
|
+
// alert/confirm/prompt. The authenticated socket is opened before seccomp.
|
|
3
|
+
import {connectCompositor} from './compositor-channel.js';
|
|
4
|
+
let pending=null,channel,fileId=null;
|
|
5
|
+
function finishSync(buffer,result={}) {
|
|
6
|
+
const state=new Int32Array(buffer,0,3);
|
|
7
|
+
let value=String(result.promptText??'');
|
|
8
|
+
try { buffer.grow(12+value.length*2); }
|
|
9
|
+
catch { value='';result={accept:false}; }
|
|
10
|
+
const text=new Uint16Array(buffer,12);
|
|
11
|
+
for(let i=0;i<value.length;i++)text[i]=value.charCodeAt(i);
|
|
12
|
+
state[1]=value.length;state[2]=result.accept?1:0;
|
|
13
|
+
Atomics.store(state,0,1);Atomics.notify(state,0);
|
|
14
|
+
}
|
|
15
|
+
function finish(result={}) {
|
|
16
|
+
if(fileId!==null){postMessage({type:'files',id:fileId,result});fileId=null;return;}
|
|
17
|
+
if(!pending)return;
|
|
18
|
+
const buffer=pending;pending=null;
|
|
19
|
+
finishSync(buffer,result);
|
|
20
|
+
}
|
|
21
|
+
self.onmessage=async({data})=>{
|
|
22
|
+
if(data.type==='connect') {
|
|
23
|
+
try{channel=await connectCompositor(data.endpoint,message=>finish(message),()=>{channel=null;finish();});postMessage({type:'ready'});}
|
|
24
|
+
catch(error){postMessage({type:'error',message:String(error)});}
|
|
25
|
+
} else if(data.type==='files') {
|
|
26
|
+
if(pending||fileId!==null){postMessage({type:'files',id:data.id,result:{accept:false}});return;}
|
|
27
|
+
fileId=data.id;try{if(!channel)throw Error('Dialog channel closed');channel.send(data.request);}catch{finish();}
|
|
28
|
+
} else if(data.type==='dialog') {
|
|
29
|
+
// A page timer may request a synchronous dialog while a file picker is
|
|
30
|
+
// open. Never replace either outstanding reply or leave its page blocked.
|
|
31
|
+
if(pending||fileId!==null){finishSync(data.buffer,{accept:false});return;}
|
|
32
|
+
pending=data.buffer;
|
|
33
|
+
try{if(!channel)throw Error('Dialog channel closed');channel.send(data.request);}catch{finish();}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Sloppy direct eval can add var/function bindings to its caller. A private
|
|
2
|
+
// environment per invocation keeps those bindings out of the page global.
|
|
3
|
+
const targets = new WeakMap();
|
|
4
|
+
export function evalEnvironment(global, parent = null) {
|
|
5
|
+
const bindings = Object.create(null);
|
|
6
|
+
const fallback = parent ?? global;
|
|
7
|
+
const proxy = new Proxy(bindings, {
|
|
8
|
+
get(target, key) {
|
|
9
|
+
if (Object.hasOwn(target, key)) return target[key];
|
|
10
|
+
if (key in fallback) return fallback[key];
|
|
11
|
+
if (typeof key === 'symbol') return undefined;
|
|
12
|
+
throw new ReferenceError(`${key} is not defined`);
|
|
13
|
+
},
|
|
14
|
+
has: (target, key) => Object.hasOwn(target, key) || key in fallback,
|
|
15
|
+
set(target, key, value) {
|
|
16
|
+
if (Object.hasOwn(target, key)) target[key] = value;
|
|
17
|
+
else fallback[key] = value;
|
|
18
|
+
return true;
|
|
19
|
+
},
|
|
20
|
+
deleteProperty(target, key) {return Object.hasOwn(target,key) ? delete target[key] : delete fallback[key];},
|
|
21
|
+
});
|
|
22
|
+
targets.set(proxy,bindings);
|
|
23
|
+
return proxy;
|
|
24
|
+
}
|
|
25
|
+
export function declareEvalBinding(environment, name) {
|
|
26
|
+
const target = targets.get(environment);
|
|
27
|
+
if (target && !Object.hasOwn(target,name)) target[name] = undefined;
|
|
28
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Selected files are capabilities granted by the browser UI, never host paths.
|
|
2
|
+
const selections=new WeakMap();
|
|
3
|
+
export function inputFiles(node){
|
|
4
|
+
if(node?.tagName!=='INPUT'||node.type!=='file')return null;
|
|
5
|
+
let list=selections.get(node);
|
|
6
|
+
// The DOM owns reset/value="" semantics. Do not keep a stale selection after
|
|
7
|
+
// it replaces its FileList (including form.reset()).
|
|
8
|
+
if(!list||list!==node.files){list=Object.freeze(Object.assign(Array.from(node.files??[]), {item(index){return this[index]??null;}}));node.files=list;selections.set(node,list);}
|
|
9
|
+
return list;
|
|
10
|
+
}
|
|
11
|
+
export async function chooseInputFiles(node,pick,window){
|
|
12
|
+
if(node?.tagName!=='INPUT'||node.type!=='file'||node.disabled||!pick)return;
|
|
13
|
+
const result=await pick({multiple:node.hasAttribute('multiple'),accept:node.getAttribute('accept')??''});
|
|
14
|
+
if(!result?.accept){node.dispatchEvent(new window.Event('cancel',{bubbles:true}));return;}
|
|
15
|
+
const files=(result.files??[]).map(f=>new File([Uint8Array.fromBase64(f.data)],f.name,{type:f.type,lastModified:f.lastModified}));
|
|
16
|
+
if(!node.hasAttribute('multiple'))files.splice(1);
|
|
17
|
+
const list=Object.freeze(Object.assign(files,{item(index){return this[index]??null;}}));
|
|
18
|
+
selections.set(node,list);
|
|
19
|
+
node.files=list;
|
|
20
|
+
node.dispatchEvent(new window.Event('input',{bubbles:true,composed:true}));
|
|
21
|
+
node.dispatchEvent(new window.Event('change',{bubbles:true}));
|
|
22
|
+
}
|
|
23
|
+
export function fileReaderInterface({EventTarget,realm,window,runTask}){
|
|
24
|
+
class FileReader extends EventTarget {
|
|
25
|
+
#serial=0; #result=null; #error=null; #state=0;
|
|
26
|
+
get result(){return this.#result;}get error(){return this.#error;}get readyState(){return this.#state;}
|
|
27
|
+
#emit(type){runTask(()=>this.dispatchEvent(realm.wrapEvent(new window.ProgressEvent(type))));}
|
|
28
|
+
#read(blob,kind,encoding){
|
|
29
|
+
if(!(blob instanceof Blob))throw new TypeError('Expected a Blob');
|
|
30
|
+
if(this.#state===1)throw new DOMException('A read is already in progress','InvalidStateError');
|
|
31
|
+
const serial=++this.#serial;this.#state=1;this.#result=null;this.#error=null;
|
|
32
|
+
setTimeout(async()=>{if(serial!==this.#serial)return;this.#emit('loadstart');
|
|
33
|
+
try {const bytes=new Uint8Array(await blob.arrayBuffer());if(serial!==this.#serial)return;
|
|
34
|
+
this.#result=kind==='array'?bytes.buffer:kind==='data'?`data:${blob.type||'application/octet-stream'};base64,${bytes.toBase64()}`:kind==='binary'?Array.from(bytes,b=>String.fromCharCode(b)).join(''):new TextDecoder(encoding).decode(bytes);
|
|
35
|
+
this.#state=2;this.#emit('progress');this.#emit('load');
|
|
36
|
+
}catch(e){this.#error=new DOMException(String(e),'NotReadableError');this.#state=2;this.#emit('error');}
|
|
37
|
+
if(serial===this.#serial)this.#emit('loadend');
|
|
38
|
+
},0);
|
|
39
|
+
}
|
|
40
|
+
readAsDataURL(blob){this.#read(blob,'data');}readAsText(blob,encoding='utf-8'){this.#read(blob,'text',encoding);}
|
|
41
|
+
readAsArrayBuffer(blob){this.#read(blob,'array');}readAsBinaryString(blob){this.#read(blob,'binary');}
|
|
42
|
+
abort(){const loading=this.#state===1;++this.#serial;this.#result=null;if(loading){this.#state=2;this.#emit('abort');this.#emit('loadend');}}
|
|
43
|
+
}
|
|
44
|
+
for(const [k,v] of Object.entries({EMPTY:0,LOADING:1,DONE:2})){Object.defineProperty(FileReader,k,{value:v});Object.defineProperty(FileReader.prototype,k,{value:v});}
|
|
45
|
+
for(const type of ['loadstart','progress','load','error','abort','loadend']){
|
|
46
|
+
const handlers=new WeakMap();Object.defineProperty(FileReader.prototype,'on'+type,{configurable:true,enumerable:true,get(){return handlers.get(this)??null;},set(fn){const old=handlers.get(this);if(old)this.removeEventListener(type,old);if(typeof fn==='function'){handlers.set(this,fn);this.addEventListener(type,fn);}else handlers.delete(this);}});
|
|
47
|
+
}
|
|
48
|
+
return FileReader;
|
|
49
|
+
}
|
package/lib/renderer/host.js
CHANGED
|
@@ -59,6 +59,10 @@ export class RendererHost {
|
|
|
59
59
|
#rawFrames = null;
|
|
60
60
|
#startEpoch = 0;
|
|
61
61
|
#closed = false;
|
|
62
|
+
#dialogChannel = null;
|
|
63
|
+
#dialogAbort = null;
|
|
64
|
+
#onDialog;
|
|
65
|
+
#dialogPending = false;
|
|
62
66
|
#rejectStartup = null;
|
|
63
67
|
#entry;
|
|
64
68
|
#args;
|
|
@@ -115,9 +119,11 @@ export class RendererHost {
|
|
|
115
119
|
network = new NetworkService({ fetch: fetchImpl }), onFrame = null, onLifecycle = null,
|
|
116
120
|
// A dedicated loopback socket works independently of the main thread on all platforms.
|
|
117
121
|
compositor = entry === DEFAULT_ENTRY,
|
|
122
|
+
onDialog = null,
|
|
118
123
|
clipboardRead = readClipboard,
|
|
119
124
|
clipboardWrite = writeClipboard,
|
|
120
125
|
} = {}) {
|
|
126
|
+
this.#onDialog = onDialog;
|
|
121
127
|
this.#clipboardRead = clipboardRead;
|
|
122
128
|
this.#clipboardWrite = clipboardWrite;
|
|
123
129
|
this.#onFrame = onFrame;
|
|
@@ -153,6 +159,19 @@ export class RendererHost {
|
|
|
153
159
|
()=>this.#closeChannel(channel));
|
|
154
160
|
if (epoch !== this.#startEpoch) { channel?.transport.close(); throw Error('Renderer startup cancelled'); }
|
|
155
161
|
this.#channel = channel;
|
|
162
|
+
const dialogAbort=new AbortController();
|
|
163
|
+
const dialogChannel = this.#onDialog ? await listenCompositor(async request => {
|
|
164
|
+
if (!['alert','confirm','prompt','file'].includes(request?.type)) return;
|
|
165
|
+
let result = {accept:false};
|
|
166
|
+
this.#dialogPending=true;
|
|
167
|
+
try { result = await this.#onDialog?.({type:request.type,multiple:!!request.multiple,accept:String(request.accept??''),message:String(request.message??''),defaultPrompt:String(request.defaultPrompt??'')},dialogAbort.signal) ?? result; }
|
|
168
|
+
catch {}
|
|
169
|
+
finally { this.#dialogPending=false; }
|
|
170
|
+
try { dialogChannel.send(result); } catch {}
|
|
171
|
+
}, () => dialogAbort.abort()) : null;
|
|
172
|
+
if (epoch !== this.#startEpoch) { dialogChannel?.close(); channel?.transport.close(); throw Error('Renderer startup cancelled'); }
|
|
173
|
+
this.#dialogChannel = dialogChannel;
|
|
174
|
+
this.#dialogAbort = dialogAbort;
|
|
156
175
|
// No inherited environment: nothing from the controller leaks in.
|
|
157
176
|
// Windows programs need SystemRoot to load system libraries.
|
|
158
177
|
let env = process.platform === "win32" && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {};
|
|
@@ -163,6 +182,7 @@ export class RendererHost {
|
|
|
163
182
|
|
|
164
183
|
const child = Bun.spawn({
|
|
165
184
|
cmd: [process.execPath, this.#entry, ...this.#args,
|
|
185
|
+
...(dialogChannel ? [`--dialog-endpoint=${JSON.stringify(dialogChannel.endpoint)}`] : []),
|
|
166
186
|
RENDER_INTERVAL_FLAG + renderMinInterval(process.env.BUNINU_RENDER_MIN_INTERVAL),
|
|
167
187
|
...(logging ? [RENDERER_LOG_FLAG] : []), ...(compositor ? [COMPOSITOR_FLAG, `--compositor-endpoint=${JSON.stringify(channel.transport.endpoint)}`] : []),
|
|
168
188
|
...(process.env.BUNINU_VERIFY_STYLE ? [VERIFY_STYLE_FLAG] : []), ...(process.env.BUNINU_FULL_STYLE ? [FULL_STYLE_FLAG] : [])],
|
|
@@ -179,6 +199,8 @@ export class RendererHost {
|
|
|
179
199
|
else this.#handleMessage(child, message);
|
|
180
200
|
},
|
|
181
201
|
onExit: (_process, exitCode, signalCode) => {
|
|
202
|
+
dialogAbort.abort();dialogChannel?.close();
|
|
203
|
+
if(this.#dialogChannel===dialogChannel)this.#dialogChannel=null;
|
|
182
204
|
this.#closeChannel(channel);
|
|
183
205
|
this.#closeSockets(child);
|
|
184
206
|
this.#closeAudio(child);
|
|
@@ -202,7 +224,7 @@ export class RendererHost {
|
|
|
202
224
|
pipeToLog(child.stdout, `renderer ${child.pid}`, (chunk) => process.stdout.write(chunk));
|
|
203
225
|
pipeToLog(child.stderr, `renderer ${child.pid}`, (chunk) => process.stderr.write(chunk));
|
|
204
226
|
}
|
|
205
|
-
})().catch(error=>{ if(epoch===this.#startEpoch){this.#closeChannel(this.#channel);this.#ready=null;} rejectReady(error); });
|
|
227
|
+
})().catch(error=>{ if(epoch===this.#startEpoch){this.#closeChannel(this.#channel);this.#dialogAbort?.abort();this.#dialogChannel?.close();this.#dialogChannel=null;this.#ready=null;} rejectReady(error); });
|
|
206
228
|
return this.#ready;
|
|
207
229
|
}
|
|
208
230
|
|
|
@@ -319,6 +341,8 @@ export class RendererHost {
|
|
|
319
341
|
|
|
320
342
|
close() {
|
|
321
343
|
this.#closed = true;
|
|
344
|
+
this.#dialogAbort?.abort();this.#dialogAbort=null;
|
|
345
|
+
this.#dialogChannel?.close();this.#dialogChannel=null;
|
|
322
346
|
this.#startEpoch++;
|
|
323
347
|
this.#rejectStartup?.(Error("Renderer closed during startup")); this.#rejectStartup=null;
|
|
324
348
|
this.#rawFrames?.close(); this.#rawFrames=null;
|
|
@@ -351,7 +375,9 @@ export class RendererHost {
|
|
|
351
375
|
const navigating = method === 'loadDocument' || method === 'showFragment';
|
|
352
376
|
// A watchdog is opt-in: elapsed time alone cannot distinguish a slow
|
|
353
377
|
// renderer from a hung one, including title queries during page loading.
|
|
354
|
-
|
|
378
|
+
let timer;
|
|
379
|
+
const watchdog = () => {
|
|
380
|
+
if(this.#dialogPending){timer=setTimeout(watchdog,timeout);return;}
|
|
355
381
|
this.#pending.delete(id);
|
|
356
382
|
// Watchdog: a renderer that misses its deadline is killed outright.
|
|
357
383
|
this.#child = null;
|
|
@@ -360,7 +386,8 @@ export class RendererHost {
|
|
|
360
386
|
this.#rawFrames?.close(); this.#rawFrames=null;
|
|
361
387
|
child.kill(9);
|
|
362
388
|
reject(new Error(`renderer ${method} timed out after ${timeout}ms`));
|
|
363
|
-
}
|
|
389
|
+
};
|
|
390
|
+
timer = navigating || !(timeout > 0) ? null : setTimeout(watchdog,timeout);
|
|
364
391
|
this.#pending.set(id, {
|
|
365
392
|
resolve: (value) => {
|
|
366
393
|
clearTimeout(timer);
|
|
@@ -571,7 +598,9 @@ export class RendererHost {
|
|
|
571
598
|
if (this.#audio.get(id) !== entry) throw Error("Audio owner closed");
|
|
572
599
|
return client.openPlayback({
|
|
573
600
|
rate, channels, name: String(name ?? "audio").slice(0, 200),
|
|
574
|
-
|
|
601
|
+
// Live synthesis must not pre-render the decoded-media PCM reserve.
|
|
602
|
+
leadSeconds: name === "piano" || name === "Web Audio" ? 0.12 : undefined,
|
|
603
|
+
adaptiveLead: name === "Web Audio",
|
|
575
604
|
onRequest: (bytes) => send({ event: "request", bytes }),
|
|
576
605
|
onTiming: name === "piano" ? null : (timing) => send({ event: "timing", ...timing }),
|
|
577
606
|
onTimingError: (error) => send({ event: "timing-error", message: String(error?.message ?? error) }),
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Text drag data stays in the binding realm; never expose Happy DOM objects.
|
|
2
|
+
const stores = new WeakMap();
|
|
3
|
+
const transfers = new WeakMap();
|
|
4
|
+
const normalize = type => ({text:'text/plain',url:'text/uri-list'}[String(type).toLowerCase()] ?? String(type).toLowerCase());
|
|
5
|
+
export class DataTransfer {
|
|
6
|
+
constructor() { stores.set(this, {mode:'readwrite', data:new Map(), drop:'none', effect:'uninitialized'}); }
|
|
7
|
+
get types() { return Object.freeze([...stores.get(this).data.keys()]); }
|
|
8
|
+
get files() { return Object.freeze([]); }
|
|
9
|
+
getData(type) { const s=stores.get(this); return ['readwrite','readonly'].includes(s.mode) ? s.data.get(normalize(type)) ?? '' : ''; }
|
|
10
|
+
setData(type, value) { const s=stores.get(this); if(s.mode==='readwrite')s.data.set(normalize(type),String(value)); }
|
|
11
|
+
clearData(type) { const s=stores.get(this); if(s.mode==='readwrite'){if(type===undefined)s.data.clear();else s.data.delete(normalize(type));} }
|
|
12
|
+
get dropEffect(){return stores.get(this).drop;}
|
|
13
|
+
set dropEffect(value){if(['none','copy','link','move'].includes(value))stores.get(this).drop=value;}
|
|
14
|
+
get effectAllowed(){return stores.get(this).effect;}
|
|
15
|
+
set effectAllowed(value){const s=stores.get(this);if(s.mode==='readwrite'&&['none','copy','copyLink','copyMove','link','linkMove','move','all','uninitialized'].includes(value))s.effect=value;}
|
|
16
|
+
}
|
|
17
|
+
export function dragData(event){return transfers.get(event);}
|
|
18
|
+
export function dragEvent(window,type,init,data){
|
|
19
|
+
const event=new window.MouseEvent(type,init);
|
|
20
|
+
transfers.set(event,data);
|
|
21
|
+
return event;
|
|
22
|
+
}
|
|
23
|
+
export function setDragMode(data,mode){stores.get(data).mode=mode;}
|
|
24
|
+
|
|
25
|
+
/** Pointer-driven HTML text drag lifecycle, separate from canvas mouse drawing. */
|
|
26
|
+
export class HTMLDrag {
|
|
27
|
+
constructor(window,realm,source,x,y){
|
|
28
|
+
Object.assign(this,{window,realm,source,x,y,startX:x,startY:y,active:false,target:null,accepted:false});
|
|
29
|
+
this.data=new DataTransfer();
|
|
30
|
+
const url=source.tagName==='A'?source.href:source.tagName==='IMG'?source.src:null;
|
|
31
|
+
if(url){this.data.setData('text/uri-list',url);this.data.setData('text/plain',url);}
|
|
32
|
+
}
|
|
33
|
+
emit(target,type,x,y,options={}){
|
|
34
|
+
const modifiers=options.modifiers??0;
|
|
35
|
+
return this.realm.dispatch(target,dragEvent(this.window,type,{bubbles:true,cancelable:type!=='dragleave'&&type!=='dragend',
|
|
36
|
+
clientX:x,clientY:y,button:0,buttons:type==='dragend'||type==='drop'?0:1,
|
|
37
|
+
altKey:!!(modifiers&1),ctrlKey:!!(modifiers&2),metaKey:!!(modifiers&4),shiftKey:!!(modifiers&8)},this.data));
|
|
38
|
+
}
|
|
39
|
+
move(target,x,y,options){
|
|
40
|
+
this.x=x;this.y=y;
|
|
41
|
+
if(this.cancelled)return false;
|
|
42
|
+
if(!this.active){
|
|
43
|
+
if(Math.hypot(x-this.startX,y-this.startY)<5)return false;
|
|
44
|
+
const cancelled=this.emit(this.source,'dragstart',this.startX,this.startY,options);
|
|
45
|
+
setDragMode(this.data,'protected');
|
|
46
|
+
if(cancelled||!this.source.isConnected){this.cancelled=true;setDragMode(this.data,'disabled');return false;}
|
|
47
|
+
this.active=true;
|
|
48
|
+
this.realm.dispatch(this.source,new this.window.PointerEvent('pointercancel',{bubbles:true,pointerId:1,pointerType:'mouse',isPrimary:true}));
|
|
49
|
+
}
|
|
50
|
+
this.emit(this.source,'drag',x,y,options);
|
|
51
|
+
if(target!==this.target){
|
|
52
|
+
if(target)this.emit(target,'dragenter',x,y,options);
|
|
53
|
+
if(this.target)this.emit(this.target,'dragleave',x,y,options);
|
|
54
|
+
this.target=target;
|
|
55
|
+
}
|
|
56
|
+
const allowed={none:[],copy:['copy'],link:['link'],move:['move'],copyLink:['copy','link'],copyMove:['copy','move'],linkMove:['link','move'],all:['copy','link','move'],uninitialized:['copy','link','move']}[this.data.effectAllowed];
|
|
57
|
+
this.data.dropEffect=allowed[0]??'none';
|
|
58
|
+
this.accepted=!!target&&this.emit(target,'dragover',x,y,options);
|
|
59
|
+
this.accepted=this.accepted&&allowed.includes(this.data.dropEffect);
|
|
60
|
+
if(!this.accepted)this.data.dropEffect='none';
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
end(x=this.x,y=this.y,options={},cancel=false){
|
|
64
|
+
if(!this.active){setDragMode(this.data,'disabled');return false;}
|
|
65
|
+
if(!cancel&&this.accepted&&this.target?.isConnected){setDragMode(this.data,'readonly');this.emit(this.target,'drop',x,y,options);}
|
|
66
|
+
else {this.data.dropEffect='none';if(this.target)this.emit(this.target,'dragleave',x,y,options);}
|
|
67
|
+
setDragMode(this.data,'protected');
|
|
68
|
+
this.emit(this.source,'dragend',x,y,options);
|
|
69
|
+
setDragMode(this.data,'disabled');this.active=false;return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isDraggable(element){
|
|
74
|
+
const value=element?.getAttribute?.('draggable')?.toLowerCase();
|
|
75
|
+
if(value==='true'||value==='false')return value==='true';
|
|
76
|
+
return element?.tagName==='IMG'||element?.tagName==='A'&&element.hasAttribute('href');
|
|
77
|
+
}
|
|
@@ -1,18 +1,61 @@
|
|
|
1
1
|
// Image bytes are decoded by our renderer, not Happy DOM's disabled loader.
|
|
2
2
|
// Share their state with HTMLImageElement's page-facing IDL attributes.
|
|
3
3
|
const states = new WeakMap();
|
|
4
|
+
const decodes = new WeakMap();
|
|
5
|
+
const encodingError = () => new DOMException('The image cannot be decoded.', 'EncodingError');
|
|
6
|
+
|
|
7
|
+
export function imageSourceChanged(element) {
|
|
8
|
+
const waiting=decodes.get(element);if(!waiting)return;
|
|
9
|
+
const src=element.getAttribute('src');
|
|
10
|
+
for(const item of waiting)if(item.src!==src){waiting.delete(item);item.reject(encodingError());}
|
|
11
|
+
if(!waiting.size)decodes.delete(element);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function decodeImage(element,request) {
|
|
15
|
+
return new Promise((resolve,reject)=>{
|
|
16
|
+
// Like HTML's decode algorithm, inspect the current request in a
|
|
17
|
+
// microtask: img.decode(); img.src=... in the same task is supported.
|
|
18
|
+
queueMicrotask(()=>{
|
|
19
|
+
const src=element.getAttribute('src');
|
|
20
|
+
if(!src){reject(encodingError());return;}
|
|
21
|
+
const state=states.get(element);
|
|
22
|
+
if(state?.src===src&&state.complete){state.decoded?resolve():reject(encodingError());return;}
|
|
23
|
+
const waiting=decodes.get(element)??new Set();
|
|
24
|
+
const item={src,resolve,reject};waiting.add(item);decodes.set(element,waiting);
|
|
25
|
+
try{request(element);}catch(error){waiting.delete(item);reject(error);}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
4
29
|
|
|
5
30
|
export function beginImageLoad(element, src) {
|
|
31
|
+
imageSourceChanged(element);
|
|
6
32
|
states.set(element, { src, complete: false, width: 0, height: 0 });
|
|
7
33
|
}
|
|
8
34
|
|
|
9
35
|
export function finishImageLoad(element, src, resource, dispatch = () => {}) {
|
|
10
36
|
if (element.getAttribute('src') !== src) return;
|
|
11
|
-
states.set(element, { src, complete: true, width: resource?.width ?? 0, height: resource?.height ?? 0 });
|
|
37
|
+
states.set(element, { src, complete: true, decoded:!!resource, width: resource?.width ?? 0, height: resource?.height ?? 0 });
|
|
38
|
+
const waiting=decodes.get(element);
|
|
39
|
+
if(waiting){decodes.delete(element);for(const item of waiting){
|
|
40
|
+
if(item.src===src&&resource)item.resolve();else item.reject(encodingError());
|
|
41
|
+
}}
|
|
12
42
|
dispatch(resource ? 'load' : 'error');
|
|
13
43
|
}
|
|
14
44
|
|
|
15
|
-
export function installImageLoadingAttributes(prototype, nodeOf) {
|
|
45
|
+
export function installImageLoadingAttributes(prototype, nodeOf, request = () => {}, renderedSize = () => null) {
|
|
46
|
+
Object.defineProperty(prototype,'decode',{configurable:true,enumerable:true,writable:true,
|
|
47
|
+
value:function decode(){return decodeImage(nodeOf(this),request);}});
|
|
48
|
+
for(const dimension of ['width','height'])Object.defineProperty(prototype,dimension,{
|
|
49
|
+
configurable:true,enumerable:true,
|
|
50
|
+
get(){
|
|
51
|
+
const node=nodeOf(this),rendered=node.isConnected?renderedSize(node):null;
|
|
52
|
+
if(rendered)return Math.max(0,Math.floor(rendered[dimension]));
|
|
53
|
+
const attribute=node.getAttribute(dimension);
|
|
54
|
+
if(attribute!==null){const n=/^\s*\+?(\d+)/.exec(attribute);return n?Math.min(2147483647,Number(n[1])):0;}
|
|
55
|
+
const state=states.get(node);return state?.src===node.getAttribute('src')?state[dimension]:0;
|
|
56
|
+
},
|
|
57
|
+
set(value){const node=nodeOf(this);node.setAttribute(dimension,String(Number(value)>>>0));request(node);},
|
|
58
|
+
});
|
|
16
59
|
for (const [property, field] of [['naturalWidth', 'width'], ['naturalHeight', 'height'], ['complete', 'complete']]) {
|
|
17
60
|
Object.defineProperty(prototype, property, {
|
|
18
61
|
configurable: true, enumerable: true,
|
package/lib/renderer/main.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createDialogClient } from "./dialog-client.js";
|
|
1
2
|
import { RENDER_INTERVAL_FLAG, renderMinInterval } from "./render-interval.js";
|
|
2
3
|
/**
|
|
3
4
|
* Renderer process entry point.
|
|
@@ -156,6 +157,8 @@ const audio = {
|
|
|
156
157
|
// Workers load their module from disk, so this happens before seccomp.
|
|
157
158
|
const frameBuffers = new SharedFramePool();
|
|
158
159
|
const compositor = process.argv.includes(COMPOSITOR_FLAG) ? await startCompositor() : null;
|
|
160
|
+
const dialogEndpoint=process.argv.find(arg=>arg.startsWith('--dialog-endpoint='));
|
|
161
|
+
const dialog=dialogEndpoint?await createDialogClient(JSON.parse(dialogEndpoint.slice('--dialog-endpoint='.length))):undefined;
|
|
159
162
|
// Started before the sandbox closes: the worker loads its module from disk.
|
|
160
163
|
const rewriteScript = await startScriptRewriter();
|
|
161
164
|
|
|
@@ -253,6 +256,7 @@ function clipboardWrite(text, type = 'clipboard-write') {
|
|
|
253
256
|
}
|
|
254
257
|
|
|
255
258
|
const renderer = await createPageRenderer({
|
|
259
|
+
dialog,
|
|
256
260
|
renderMinInterval: renderMinInterval(process.argv.find(arg => arg.startsWith(RENDER_INTERVAL_FLAG))?.slice(RENDER_INTERVAL_FLAG.length)),
|
|
257
261
|
fetchResource,
|
|
258
262
|
rewriteScript,
|
|
@@ -126,12 +126,14 @@ export function transformModule(source) {
|
|
|
126
126
|
|
|
127
127
|
// import.meta and import() can appear anywhere in the module; most
|
|
128
128
|
// modules have neither, and then the extra walk is skipped.
|
|
129
|
-
if (/\bimport\
|
|
129
|
+
if (/\bimport\b/.test(source)) walk(program, (node) => {
|
|
130
130
|
if (node.type === "MetaProperty" && node.meta.name === "import") {
|
|
131
131
|
edits.push({ start: node.start, end: node.end, text: `${MODULE}.meta` });
|
|
132
132
|
} else if (node.type === "ImportExpression") {
|
|
133
|
-
// Replace only
|
|
134
|
-
|
|
133
|
+
// Replace only the keyword. Acorn excludes grouping parentheses from
|
|
134
|
+
// node.source.start; consuming them would leave unmatched closing
|
|
135
|
+
// parentheses after property-read rewriting or author grouping.
|
|
136
|
+
edits.push({ start: node.start, end: node.start + 6, text: `${MODULE}.importDynamic` });
|
|
135
137
|
}
|
|
136
138
|
return true;
|
|
137
139
|
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Mutable boxed-primitive prototypes belong to one page, never SES's shared
|
|
2
|
+
// frozen intrinsics. This module receives only the page's tamed globals.
|
|
3
|
+
export function installPagePrimitiveBuiltins(compartment, pageGlobal) {
|
|
4
|
+
const prototypes = new Map(), boundMethods = new WeakMap();
|
|
5
|
+
for (const name of ['Number', 'String', 'Boolean']) {
|
|
6
|
+
const Base = pageGlobal[name];
|
|
7
|
+
const Local = compartment.evaluate(`((Base, construct) => function ${name}(...args) {
|
|
8
|
+
return new.target ? construct(Base, args, new.target) : Base(...args);
|
|
9
|
+
})(${name}, Reflect.construct)`);
|
|
10
|
+
const prototype = Reflect.construct(Base, [name === 'String' ? '' : name === 'Boolean' ? false : 0]);
|
|
11
|
+
for (const key of Reflect.ownKeys(Base.prototype)) {
|
|
12
|
+
if (key === 'constructor' || Object.hasOwn(prototype, key)) continue;
|
|
13
|
+
const descriptor = {...Object.getOwnPropertyDescriptor(Base.prototype, key), configurable: true};
|
|
14
|
+
if ('value' in descriptor) descriptor.writable = true;
|
|
15
|
+
Object.defineProperty(prototype, key, descriptor);
|
|
16
|
+
}
|
|
17
|
+
Object.setPrototypeOf(prototype, Object.getPrototypeOf(Base.prototype));
|
|
18
|
+
Object.defineProperty(prototype, 'constructor', {value: Local, writable: true, configurable: true});
|
|
19
|
+
Object.defineProperty(Local, 'prototype', {value: prototype});
|
|
20
|
+
Object.defineProperty(Local, 'length', {value: 1, configurable: true});
|
|
21
|
+
for (const key of Reflect.ownKeys(Base)) if (!['name', 'length', 'prototype'].includes(key))
|
|
22
|
+
Object.defineProperty(Local, key, {...Object.getOwnPropertyDescriptor(Base, key), configurable: true});
|
|
23
|
+
const ordinaryHasInstance=Function.prototype[Symbol.hasInstance];
|
|
24
|
+
Object.defineProperty(Local,Symbol.hasInstance,{value(value){
|
|
25
|
+
return Reflect.apply(ordinaryHasInstance,this,[value]) || this===Local && Reflect.apply(ordinaryHasInstance,Base,[value]);
|
|
26
|
+
},configurable:true});
|
|
27
|
+
pageGlobal[name] = Local;
|
|
28
|
+
prototypes.set(name.toLowerCase(), prototype);
|
|
29
|
+
}
|
|
30
|
+
// Return actual values for ordinary reads and retain the original receiver
|
|
31
|
+
// for method calls, including strict functions invoked on primitive values.
|
|
32
|
+
return (receiver, key, call = false) => {
|
|
33
|
+
key = typeof key === 'string' || typeof key === 'symbol' ? key : Reflect.ownKeys({[key]:0})[0];
|
|
34
|
+
const type = typeof receiver, primitive = prototypes.get(type);
|
|
35
|
+
let value;
|
|
36
|
+
if (primitive) {
|
|
37
|
+
const indexedString = type === 'string' && (key === 'length' || /^(0|[1-9]\d*)$/.test(String(key)));
|
|
38
|
+
value = indexedString ? Reflect.get(Object(receiver), key, receiver) : Reflect.get(primitive, key, receiver);
|
|
39
|
+
} else {
|
|
40
|
+
// Null/undefined throw normally. Never replace or mutate a host prototype.
|
|
41
|
+
if(receiver===null||receiver===undefined)throw new TypeError('Cannot read property '+String(key)+' of '+receiver);
|
|
42
|
+
value = receiver[key];
|
|
43
|
+
if (value === undefined && !Object.hasOwn(Object(receiver), key)) {
|
|
44
|
+
const local = Array.isArray(receiver) ? pageGlobal.Array.prototype
|
|
45
|
+
: type === 'function' ? pageGlobal.Function.prototype : null;
|
|
46
|
+
if (local && Object.hasOwn(local, key)) value = Reflect.get(local, key, receiver);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!call || typeof value !== 'function') return value;
|
|
50
|
+
if (receiver !== null && (type === 'object' || type === 'function')) {
|
|
51
|
+
let methods=boundMethods.get(receiver);
|
|
52
|
+
if(!methods){methods=new WeakMap();boundMethods.set(receiver,methods);}
|
|
53
|
+
let bound=methods.get(value);
|
|
54
|
+
if(!bound){const fn=value;bound=(...args)=>Reflect.apply(fn,receiver,args);methods.set(fn,bound);}
|
|
55
|
+
return bound;
|
|
56
|
+
}
|
|
57
|
+
return (...args)=>Reflect.apply(value,receiver,args);
|
|
58
|
+
};
|
|
59
|
+
}
|