buninu-browser 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +90 -1
  3. package/buninu-browser.js +1 -1
  4. package/frontends/wayland/README.md +35 -2
  5. package/frontends/wayland/client.js +8 -6
  6. package/frontends/wayland/clipboard.js +1 -1
  7. package/frontends/wayland/index.js +1 -1
  8. package/frontends/wayland/input.js +1 -1
  9. package/frontends/wayland/resource-io.js +6 -0
  10. package/frontends/wayland/transport-posix.js +120 -0
  11. package/frontends/wayland/transport-win32.js +9 -0
  12. package/frontends/wayland/transport.js +3 -120
  13. package/frontends/wayland/win32-resources.js +80 -0
  14. package/frontends/wayland/win32-wire.js +62 -0
  15. package/lib/audio/biquad.js +25 -0
  16. package/lib/audio/piano.js +6 -3
  17. package/lib/audio/playback-buffer.js +5 -5
  18. package/lib/audio/pulse-client.js +24 -5
  19. package/lib/audio/web-audio.js +570 -0
  20. package/lib/browser-dialog.js +116 -0
  21. package/lib/cdp-server.js +43 -11
  22. package/lib/file-picker.js +48 -0
  23. package/lib/headless-shell.js +34 -12
  24. package/lib/layout/text-layout.js +112 -19
  25. package/lib/media/mp4-frame-times.js +93 -0
  26. package/lib/paint/direct-canvas.ts +39 -0
  27. package/lib/paint/display-list.js +37 -12
  28. package/lib/paint/raster-readback.js +16 -7
  29. package/lib/renderer/animation-frames.js +10 -5
  30. package/lib/renderer/bindings.js +69 -16
  31. package/lib/renderer/canvas-2d.js +265 -15
  32. package/lib/renderer/canvas-round-rect.js +36 -0
  33. package/lib/renderer/css-animations.js +173 -0
  34. package/lib/renderer/dialog-client.js +30 -0
  35. package/lib/renderer/dialog-worker.js +35 -0
  36. package/lib/renderer/eval-environment.js +28 -0
  37. package/lib/renderer/file-input.js +49 -0
  38. package/lib/renderer/host.js +33 -4
  39. package/lib/renderer/html-drag.js +77 -0
  40. package/lib/renderer/image-loading.js +45 -2
  41. package/lib/renderer/main.js +4 -0
  42. package/lib/renderer/module-transform.js +5 -3
  43. package/lib/renderer/page-builtins.js +59 -0
  44. package/lib/renderer/page-realm.js +177 -40
  45. package/lib/renderer/page-renderer.js +397 -49
  46. package/lib/renderer/piano-input.js +3 -1
  47. package/lib/renderer/raster-surface.js +18 -0
  48. package/lib/renderer/script-rewrite.js +165 -9
  49. package/lib/renderer/video-presentation.js +5 -4
  50. package/lib/renderer/webgl/clip.js +24 -0
  51. package/lib/renderer/webgl/context.js +158 -0
  52. package/lib/renderer/webgl/cpu-fragment.js +211 -0
  53. package/lib/renderer/webgl/draw-cache.js +22 -0
  54. package/lib/renderer/webgl/pipeline.js +70 -0
  55. package/lib/renderer/webgl/shaders.js +58 -0
  56. package/lib/renderer/webgl/textures.js +43 -0
  57. package/lib/renderer/window-named-properties.js +46 -0
  58. package/lib/screenshot-cli.js +4 -7
  59. package/lib/style/animation-values.js +29 -0
  60. package/lib/style/computed-style.js +109 -13
  61. package/lib/style/transform.js +44 -0
  62. package/lib/window-size.js +10 -0
  63. package/package.json +1 -1
  64. package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
  65. package/patches/happy-dom/README.md +16 -0
  66. package/test/animation-frames.test.js +15 -0
  67. package/test/canvas-2d.test.js +252 -4
  68. package/test/canvas-frame-pacing.test.js +7 -0
  69. package/test/cdp-viewers.test.js +36 -0
  70. package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
  71. package/test/classic-games-patches/README.md +156 -0
  72. package/test/classic-games-scenarios.js +133 -0
  73. package/test/clipboard.test.js +16 -0
  74. package/test/computed-style.test.js +18 -0
  75. package/test/css-animation-lifecycle.test.js +28 -0
  76. package/test/css-animations.test.js +83 -0
  77. package/test/direct-canvas.test.js +39 -0
  78. package/test/dynamic-inline-handlers.test.js +25 -0
  79. package/test/eval-declarations.test.js +22 -0
  80. package/test/file-picker.test.js +26 -0
  81. package/test/fixtures/canvas-damage.js +23 -0
  82. package/test/fixtures/canvas-frame-pacing.js +35 -0
  83. package/test/fixtures/direct-canvas.js +96 -0
  84. package/test/fixtures/javascript-dialogs.html +8 -0
  85. package/test/fixtures/media-audio-lifetime.js +65 -0
  86. package/test/fixtures/renderer-memory.js +7 -5
  87. package/test/fixtures/video-black-replay.js +8 -6
  88. package/test/fixtures/video-slow-end.js +39 -0
  89. package/test/fixtures/web-audio-renderer.js +45 -0
  90. package/test/flex.test.js +26 -0
  91. package/test/grid.test.js +13 -0
  92. package/test/headless-shell.test.js +9 -0
  93. package/test/headless-window-size.test.js +28 -0
  94. package/test/html-rcdata.test.js +35 -0
  95. package/test/image-decode.test.js +18 -0
  96. package/test/javascript-dialog.test.js +83 -0
  97. package/test/manual-breakout-paddle.js +42 -0
  98. package/test/manual-breakout-static.js +35 -0
  99. package/test/manual-classic-games.js +41 -0
  100. package/test/manual-clumsy-bird-three-pipes.js +81 -0
  101. package/test/manual-clumsy-bird.js +52 -0
  102. package/test/manual-hextris.js +30 -0
  103. package/test/manual-numbered-music.js +24 -0
  104. package/test/manual-t-rex-runner.js +49 -0
  105. package/test/manual-threejs-scene.js +64 -0
  106. package/test/manual-webgl-benchmark.js +27 -0
  107. package/test/manual-webgl-cpu.html +26 -0
  108. package/test/media-audio-lifetime.test.js +8 -0
  109. package/test/mp4-frame-times.test.js +33 -0
  110. package/test/page-scripts.test.js +96 -1
  111. package/test/piano-input.test.js +15 -2
  112. package/test/piano.test.js +11 -2
  113. package/test/playback-buffer.test.js +11 -0
  114. package/test/pointer-drag.test.js +33 -0
  115. package/test/positioning.test.js +82 -0
  116. package/test/pulse-client.test.js +51 -2
  117. package/test/raster-readback.test.js +14 -0
  118. package/test/script-rewrite.test.js +61 -3
  119. package/test/video-slow-end.test.js +6 -0
  120. package/test/viewport-keyboard-overflow.test.js +25 -0
  121. package/test/wayland-win32-resources.test.js +47 -0
  122. package/test/wayland-win32-wire.test.js +59 -0
  123. package/test/web-audio-renderer.test.js +8 -0
  124. package/test/web-audio.test.js +264 -0
  125. package/test/webgl-clip.test.js +9 -0
  126. package/test/webgl-cpu-fragment.test.js +43 -0
  127. package/test/window-named-properties.test.js +41 -0
  128. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
  129. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
  130. package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
  131. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
  132. package/vendor/land-renderer/LICENSE +21 -0
  133. package/vendor/land-renderer/README.md +14 -0
  134. package/vendor/land-renderer/direct-compositor.ts +443 -0
  135. package/vendor/land-renderer/frame.js +88 -0
  136. package/vendor/land-renderer/png-fast.js +85 -0
  137. package/vendor/land-renderer/raw-frames.js +66 -0
  138. package/vendor/land-renderer/render-types.ts +7 -0
  139. package/vendor/land-renderer/renderer.ts +134 -0
  140. package/vendor/land-renderer/snapshot.js +37 -0
  141. package/vendor/land-renderer/viewport.js +18 -0
  142. package/vendor/ses/dist/ses.mjs +1 -1
@@ -0,0 +1,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
+ }
@@ -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
- const timer = navigating || !(timeout > 0) ? null : setTimeout(() => {
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
- }, timeout);
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
- leadSeconds: name === "piano" ? 0.12 : undefined,
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,
@@ -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\s*[.(]/.test(source)) walk(program, (node) => {
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 `import(`; the arguments keep their own edits.
134
- edits.push({ start: node.start, end: node.source.start, text: `${MODULE}.importDynamic(` });
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
+ }