buninu-browser 0.4.0 → 0.5.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 (84) hide show
  1. package/README.md +22 -1
  2. package/frontends/wayland/README.md +50 -21
  3. package/frontends/wayland/index.js +14 -0
  4. package/frontends/wayland/input.js +24 -5
  5. package/frontends/wayland/urlbar.js +9 -5
  6. package/lib/audio/shared-connection.js +31 -0
  7. package/lib/cdp-server.js +15 -2
  8. package/lib/headless-shell.js +7 -4
  9. package/lib/paint/display-list.js +2 -1
  10. package/lib/qml/README.md +456 -0
  11. package/lib/qml/builtins.js +76 -5
  12. package/lib/qml/canvas.js +76 -0
  13. package/lib/qml/document.js +47 -24
  14. package/lib/qml/fonts.js +69 -0
  15. package/lib/qml/host.js +28 -0
  16. package/lib/qml/loader.js +7 -3
  17. package/lib/qml/models.js +1 -1
  18. package/lib/qml/reactive.js +26 -3
  19. package/lib/qml/runtime.js +263 -41
  20. package/lib/qml/shader/fragment.js +83 -0
  21. package/lib/qml/shader/qsb.js +27 -0
  22. package/lib/qml/shader/renderer.js +39 -0
  23. package/lib/qml/shader/runtime.js +61 -0
  24. package/lib/qml/shapes.js +9 -1
  25. package/lib/qml/shortcuts.js +65 -0
  26. package/lib/qml/textures.js +4 -1
  27. package/lib/qml/window-constraints.js +16 -0
  28. package/lib/renderer/bindings.js +5 -1
  29. package/lib/renderer/canvas-2d.js +16 -0
  30. package/lib/renderer/document-selection.js +19 -0
  31. package/lib/renderer/host.js +14 -11
  32. package/lib/renderer/main.js +2 -0
  33. package/lib/renderer/page-realm.js +4 -9
  34. package/lib/renderer/page-renderer.js +273 -35
  35. package/lib/renderer/pointer-move.js +5 -3
  36. package/lib/renderer/render-interval.js +9 -0
  37. package/lib/style/computed-style.js +8 -2
  38. package/package.json +1 -1
  39. package/test/audio-connection-lifetime.test.js +46 -0
  40. package/test/canvas-paint.test.js +8 -0
  41. package/test/computed-style.test.js +12 -0
  42. package/test/document-selection.test.js +27 -0
  43. package/test/double-click.test.js +85 -0
  44. package/test/fixtures/canvas-damage.js +40 -0
  45. package/test/fixtures/fake-renderer.js +1 -0
  46. package/test/fixtures/omastorm-helpers/Dockerfile +35 -0
  47. package/test/fixtures/omastorm-helpers/check.mjs +56 -0
  48. package/test/fixtures/omastorm-helpers/launch.mjs +24 -0
  49. package/test/fixtures/omastorm-helpers/prepare.mjs +17 -0
  50. package/test/fixtures/omastorm-helpers/runtime.mjs +32 -0
  51. package/test/fixtures/pointer-paint-batching.js +23 -0
  52. package/test/fixtures/qml-fonts/main.qml +10 -0
  53. package/test/fixtures/qml-locale/check.mjs +6 -0
  54. package/test/fixtures/qml-locale/main.qml +8 -0
  55. package/test/fixtures/qml-shader/grab.qml +14 -0
  56. package/test/fixtures/qml-shader/gradient.frag +5 -0
  57. package/test/fixtures/qml-shader/main.qml +14 -0
  58. package/test/fixtures/qml-shader/texture-grab.qml +25 -0
  59. package/test/fixtures/qml-shader/texture-size.frag +5 -0
  60. package/test/fixtures/qml-shader-check.mjs +24 -0
  61. package/test/fixtures/title-no-paint.js +20 -0
  62. package/test/manual-double-click.html +21 -0
  63. package/test/manual-mouse-buttons.html +18 -0
  64. package/test/manual-omastorm-engine.mjs +19 -0
  65. package/test/manual-omastorm-shaders.mjs +28 -0
  66. package/test/manual-omastorm-software.mjs +25 -0
  67. package/test/manual-pointer-scroll.html +43 -0
  68. package/test/pointer-drag.test.js +104 -0
  69. package/test/qml-canvas.test.js +21 -0
  70. package/test/qml-connections-timing.test.js +22 -0
  71. package/test/qml-controls-window.test.js +182 -0
  72. package/test/qml-fonts.test.js +31 -0
  73. package/test/qml-grid-multiline.test.js +48 -0
  74. package/test/qml-locale.test.js +45 -0
  75. package/test/qml-nonvisual-visible.test.js +19 -0
  76. package/test/qml-shader-runtime.test.js +6 -0
  77. package/test/qml-shortcuts.test.js +39 -0
  78. package/test/qml-socket.test.js +60 -0
  79. package/test/qml-software-shader.test.js +55 -0
  80. package/test/qml-window-update.test.js +36 -0
  81. package/test/render-interval.test.js +33 -0
  82. package/test/title-no-paint.test.js +6 -0
  83. package/test/wayland-scroll.test.js +40 -0
  84. package/test/wayland-urlbar.test.js +23 -0
@@ -0,0 +1,83 @@
1
+ // Experimental Qt-style GLSL fragment subset -> SkSL. No app-specific shaders.
2
+ // This is not a general GLSL compiler: unsupported syntax is rejected by SkSL,
3
+ // and unsupported interfaces/preprocessor features fail before compilation.
4
+ export function translateFragment(source) {
5
+ if (/\b_qml_\w*/.test(String(source))) throw Error('Reserved shader adapter identifier');
6
+ source=String(source).replace(/\/\*[\s\S]*?\*\//g,' ').replace(/\/\/[^\n]*/g,'');
7
+ source=source.replace(/^\s*#version[^\n]*$/gm,'');
8
+ if(/^\s*#/m.test(source))throw Error('Shader preprocessor directives are not implemented');
9
+ if(/\b(?:gl_FragDepth|discard|samplerCube|sampler3D|image2D)\b/.test(source))throw Error('Unsupported fragment shader feature');
10
+ source=source.replace(/layout\s*\([^)]*\)\s*/g,'');
11
+ source=source.replace(/\bprecision\s+(?:lowp|mediump|highp)\s+\w+\s*;/g,'').replace(/\b(?:highp|mediump|lowp)\s+/g,'');
12
+ const blocks=[];
13
+ for(const m of source.matchAll(/struct\s+(\w+)\s*\{([^{}]*)\}\s*;/g)) {
14
+ const matches=[...source.matchAll(new RegExp('uniform\\s+'+m[1]+'\\s+(\\w+)\\s*;','g'))];
15
+ if(matches.length===1)blocks.push({declaration:m[0],body:m[2],uniform:matches[0][0],instance:matches[0][1]});
16
+ }
17
+ for(const block of blocks){
18
+ source=source.replace(block.declaration,'').replace(block.uniform,block.body.split(';').filter(s=>s.trim()).map(s=>'uniform '+s.trim()+';').join('\n'));
19
+ source=source.replace(new RegExp('\\b'+block.instance+'\\s*\\.\\s*','g'),'');
20
+ }
21
+ const samplers=[];
22
+ source=source.replace(/uniform\s+sampler2D\s+(\w+)\s*;/g,(_,name)=>{
23
+ samplers.push(name);
24
+ return `uniform shader ${name}; uniform float2 _qml_size_${name};`;
25
+ });
26
+ source=source.replace(/uniform\s+\w+\s*\{([^{}]*)\}\s*;/g,(_,body)=>body.split(';').filter(s=>s.trim()).map(s=>'uniform '+s.trim()+';').join('\n'));
27
+ let output=null,coordinates=false;
28
+ source=source.replace(/\bin\s+vec2\s+qt_TexCoord0\s*;/g,()=>{coordinates=true;return 'float2 qt_TexCoord0;';});
29
+ source=source.replace(/\bout\s+vec4\s+(\w+)\s*;/g,(_,name)=>{
30
+ if(output)throw Error('Multiple fragment outputs are not supported');
31
+ output=name;return `float4 ${name};`;
32
+ });
33
+ if(!output)throw Error('Fragment shader requires a vec4 output');
34
+ if(/\b(?:in|out)\s+\w+\s+\w+\s*;/.test(source))throw Error('Unsupported fragment varying');
35
+ const types={vec2:'float2',vec3:'float3',vec4:'float4',ivec2:'int2',ivec3:'int3',ivec4:'int4',bvec2:'bool2',bvec3:'bool3',bvec4:'bool4',mat2:'float2x2',mat3:'float3x3',mat4:'float4x4'};
36
+ source=source.replace(/\b(?:[ib]?vec[234]|mat[234])\b/g,type=>types[type]);
37
+ for (const call of source.matchAll(/\btextureSize\s*\(\s*\w+\s*,([^)]*)\)/g)) if (call[1].trim()!=='0') throw Error('Only base-level textureSize is supported');
38
+ source=source.replace(/\b(texture|textureSize)\s*\(\s*(\w+)\s*,/g,(_,operation,name)=>{
39
+ if(!samplers.includes(name))throw Error('Sampler expressions are not supported');
40
+ return `_qml_${operation}_${name}(`;
41
+ });
42
+ if(!/\bvoid\s+main\s*\(\s*(?:void)?\s*\)/.test(source))throw Error('Missing fragment main');
43
+ source=source.replace(/\bvoid\s+main\s*\(\s*(?:void)?\s*\)/,'void _qml_fragment()');
44
+ source=source.replace(/\bround\s*\(/g,'_qml_round(');
45
+ const rounding=['float','float2','float3','float4'].map(type=>`${type} _qml_round(${type} x) {return sign(x)*floor(abs(x)+0.5);}`).join('\n');
46
+ const helpers=rounding+'\n'+samplers.map(name=>`float4 _qml_texture_${name}(float2 uv) {return ${name}.eval(uv * _qml_size_${name});}\nint2 _qml_textureSize_${name}(int lod) {return int2(_qml_size_${name});}`).join('\n');
47
+ // Functions must follow their uniforms and precede their call sites.
48
+ const mainAt=source.search(/\b(?:void|float\w*|int\w*|bool\w*)\s+\w+\s*\(/);
49
+ source=source.slice(0,mainAt)+helpers+'\n'+source.slice(mainAt);
50
+ return {samplers,source:`uniform float2 _qml_viewport;\n${source}\nhalf4 main(float2 p) {${coordinates?'qt_TexCoord0 = p / _qml_viewport;':''}${output}=float4(0);_qml_fragment();return half4(${output});}`};
51
+ }
52
+
53
+ export function compileFragment(kit, source) {
54
+ const translated=translateFragment(source);
55
+ let diagnostic='';
56
+ const effect=kit.RuntimeEffect.Make(translated.source,message=>{diagnostic+=message;});
57
+ if(!effect)throw Error('Software fragment compilation failed: '+diagnostic);
58
+ return {...translated,effect};
59
+ }
60
+
61
+ // Reuse child shaders from the renderer's existing CanvasKit instance. The
62
+ // caller owns the returned shader, effect and children; no pixel readback here.
63
+ export function makeFragmentShader(compiled, {width,height,uniforms={},textures={}}) {
64
+ const values=new Float32Array(compiled.effect.getUniformFloatCount());
65
+ // CanvasKit converts integer uniforms from numeric float slots internally.
66
+ const supplied={qt_Opacity:1,qt_Matrix:[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],...uniforms,_qml_viewport:[width,height]};
67
+ const children=compiled.samplers.map(name=>{
68
+ const texture=textures[name];
69
+ if(!texture?.shader || !(texture.width>0) || !(texture.height>0))throw Error('Missing shader texture: '+name);
70
+ supplied['_qml_size_'+name]=[texture.width,texture.height];
71
+ return texture.shader;
72
+ });
73
+ for(let i=0;i<compiled.effect.getUniformCount();i++){
74
+ const name=compiled.effect.getUniformName(i),info=compiled.effect.getUniform(i);
75
+ const count=info.columns*info.rows;
76
+ const value=supplied[name] ?? Array(count).fill(0);
77
+ const parts=typeof value==='number' ? [value] : Array.from(value);
78
+ if(parts.length!==count)throw Error('Wrong shader uniform size: '+name);
79
+ if(parts.some(v=>!Number.isFinite(v)))throw Error('Nonfinite shader uniform: '+name);
80
+ values.set(parts,info.slot);
81
+ }
82
+ return compiled.effect.makeShaderWithChildren(values,children);
83
+ }
@@ -0,0 +1,27 @@
1
+ import {inflateSync} from 'node:zlib';
2
+
3
+ // QSB v9 stores QDataStream shader keys followed by length-prefixed source and
4
+ // entrypoint QByteArrays. Reflection is intentionally opaque; validate complete
5
+ // GLSL records rather than depending on its changing private schema.
6
+ export function fragmentSource(bytes) {
7
+ bytes=Buffer.from(bytes);
8
+ if(bytes.subarray(0,256).toString().trimStart().startsWith('#version'))return bytes.toString();
9
+ const limit=8*1024*1024;
10
+ if(bytes.length<8||bytes.readUInt32BE(0)>limit)throw Error('Invalid or oversized QSB');
11
+ const data=inflateSync(bytes.subarray(4),{maxOutputLength:limit});
12
+ if(data.length!==bytes.readUInt32BE(0)||data.length<8||data.readUInt32BE(0)!==9||data.readUInt32BE(4)!==4)throw Error('Only QSB v9 fragment packages are supported');
13
+ const candidates=[];
14
+ for(let p=20;p<data.length;p++) {
15
+ if(data[p]!==35||data.subarray(p,p+8).toString()!=='#version')continue;
16
+ const size=data.readUInt32BE(p-4),end=p+size;
17
+ if(data.readUInt32BE(p-20)!==1||data.readUInt32BE(p-8)!==0||size>limit||end+8>data.length)continue;
18
+ const version=data.readUInt32BE(p-16),flags=data.readUInt32BE(p-12);
19
+ if(flags!==0||version<150||version>460)continue;
20
+ if(data.readUInt32BE(end)!==4||data.subarray(end+4,end+8).toString()!=='main')continue;
21
+ const source=data.subarray(p,end).toString();
22
+ if(!source.startsWith(`#version ${version}`)||source.includes('\0'))continue;
23
+ candidates.push({version,source});p=end+7;
24
+ }
25
+ if(!candidates.length)throw Error('QSB has no supported standard desktop GLSL fragment');
26
+ return candidates.sort((a,b)=>a.version-b.version)[0].source;
27
+ }
@@ -0,0 +1,39 @@
1
+ import {readFileSync} from 'node:fs';
2
+ import {fragmentSource} from './qsb.js';
3
+ import {compileFragment,makeFragmentShader} from './fragment.js';
4
+
5
+ // Local-QML capability. Native handles never enter the document realm.
6
+ export function createShaderRenderer({kit,unwrap,valid,resource,paint,track}) {
7
+ function node(value) {const n=unwrap(value)??value;if(!valid(n))throw Error('Invalid QML shader element');return n;}
8
+ return url=>{
9
+ const address=new URL(String(url));
10
+ if(address.protocol!=='file:')throw Error('ShaderEffect requires a local shader file');
11
+ const compiled=compileFragment(kit,fragmentSource(readFileSync(address)));
12
+ let disposed=false;
13
+ const stop=track(()=>{disposed=true;compiled.effect.delete();});
14
+ const names=Array.from({length:compiled.effect.getUniformCount()},(_,i)=>compiled.effect.getUniformName(i)).filter(n=>!n.startsWith('_qml_')&&!['qt_Matrix','qt_Opacity'].includes(n));
15
+ return {
16
+ uniforms:names,samplers:compiled.samplers,
17
+ dispose:stop,
18
+ draw(target,uniforms,inputs,smooth=true) {
19
+ if(disposed)throw Error('ShaderEffect is disposed');
20
+ target=node(target);
21
+ if(target.tagName!=='CANVAS')throw Error('ShaderEffect target must be a canvas');
22
+ const children=[],textures={};let shader,p;
23
+ try {
24
+ for(const name of compiled.samplers){
25
+ if(!inputs[name]?.element)return false;
26
+ const r=resource(node(inputs[name].element));
27
+ if(!r?.image)return false;
28
+ const child=r.image.makeShaderOptions(kit.TileMode.Clamp,kit.TileMode.Clamp,inputs[name].smooth?kit.FilterMode.Linear:kit.FilterMode.Nearest,kit.MipmapMode.None);
29
+ children.push(child);textures[name]={shader:child,width:r.width,height:r.height};
30
+ }
31
+ shader=makeFragmentShader(compiled,{width:target.width,height:target.height,uniforms,textures});
32
+ p=new kit.Paint();p.setShader(shader);p.setBlendMode(kit.BlendMode.Src);
33
+ paint(target,canvas=>canvas.drawRect(kit.XYWHRect(0,0,target.width,target.height),p));
34
+ return true;
35
+ }finally{p?.delete();shader?.delete();for(const child of children)child.delete();}
36
+ },
37
+ };
38
+ };
39
+ }
@@ -0,0 +1,61 @@
1
+ // Serialized local QML adapter. Rendering remains an ordinary HTML canvas.
2
+ export function createQmlShaderEffects(api) {
3
+ const {effect,untrack,elements,host,cleanup}=api;
4
+ function make(v,type,element){
5
+ if(type!=='ShaderEffect')return;
6
+ Object.assign(v,{fragmentShader:'',vertexShader:'',status:0,log:'',blending:true,cullMode:0});
7
+ const canvas=document.createElement('canvas');
8
+ canvas.style.cssText='position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none';
9
+ element.appendChild(canvas);
10
+ let program=null,source=null,frame,disposed=false;
11
+ const fail=e=>{v.status=2;v.log=String(e.message||e);};
12
+ effect(()=>{
13
+ const url=v.fragmentShader,vertex=v.vertexShader;
14
+ untrack(()=>{
15
+ if(url===source&&!vertex)return;
16
+ source=url;program?.dispose();program=null;v.status=0;v.log='';
17
+ if(!url)return;
18
+ try{
19
+ if(vertex)throw Error('Custom vertex shaders are not implemented');
20
+ if(!host?.shader)throw Error('ShaderEffect requires a local QML renderer');
21
+ program=host.shader(api.resolve(v,url));v.status=1;
22
+ }catch(e){fail(e);}
23
+ });
24
+ });
25
+ effect(()=>{
26
+ v.status;v.fragmentShader;
27
+ const width=Math.ceil(v.width),height=Math.ceil(v.height),smooth=v.smooth!==false;
28
+ const uniforms={},inputs={};
29
+ if(program){
30
+ for(const name of program.uniforms){
31
+ const value=v[name];
32
+ uniforms[name]=value&&typeof value==='object' ? ('r' in value?[value.r,value.g,value.b,value.a]: 'x' in value?['x','y','z','w'].filter(k=>k in value).map(k=>value[k]):Array.from(value)):value;
33
+ }
34
+ for(const name of program.samplers){
35
+ const value=v[name];
36
+ if(!value)continue;
37
+ value.status;value.source;
38
+ const element=elements.get(value);
39
+ inputs[name]=element?.querySelector('img,canvas');
40
+ if(value.sourceItem!==undefined)inputs[name]=api.texture(value);
41
+ inputs[name]={element:inputs[name],smooth:value.smooth!==false};
42
+ }
43
+ }
44
+ untrack(()=>{
45
+ cancelAnimationFrame(frame);
46
+ if(!program||!(width>0&&height>0))return;
47
+ if(width>8192||height>8192||width*height>16777216){fail(Error('ShaderEffect size limit'));return;}
48
+ frame=requestAnimationFrame(()=>{
49
+ if(disposed||!program)return;
50
+ try{
51
+ if(canvas.width!==width)canvas.width=width;
52
+ if(canvas.height!==height)canvas.height=height;
53
+ program.draw(canvas,uniforms,inputs,smooth);
54
+ }catch(e){fail(e);}
55
+ });
56
+ });
57
+ });
58
+ cleanup(()=>{disposed=true;cancelAnimationFrame(frame);program?.dispose();canvas.remove();});
59
+ }
60
+ return {make};
61
+ }
package/lib/qml/shapes.js CHANGED
@@ -5,6 +5,13 @@ export function createQmlShapes(api) {
5
5
  const { elements, parents, owners, children, cssColor } = api;
6
6
  const roots = new WeakMap(),
7
7
  canvases = new WeakMap();
8
+ function multiline(paths) {
9
+ return (paths || []).map(points => points.map((p,i) => {
10
+ const x = Number(p.x ?? p[0]), y = Number(p.y ?? p[1]);
11
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw Error('Invalid PathMultiline point');
12
+ return `${i ? 'L' : 'M'}${x} ${y}`;
13
+ }).join(' ')).join(' ');
14
+ }
8
15
  function path(ctx, source) {
9
16
  const tokens =
10
17
  String(source).match(
@@ -189,6 +196,7 @@ export function createQmlShapes(api) {
189
196
  for (const child of children(v)) {
190
197
  const type = owners.get(child)?.type;
191
198
  if (type === 'PathSvg') path(context, child.path);
199
+ else if (type === 'PathMultiline') path(context, multiline(child.paths));
192
200
  else if (type === 'PathRectangle') {
193
201
  const radii = [
194
202
  'topLeftRadius',
@@ -239,5 +247,5 @@ export function createQmlShapes(api) {
239
247
  context.stroke();
240
248
  }
241
249
  }
242
- return { make, paint };
250
+ return { make, paint, multiline };
243
251
  }
@@ -0,0 +1,65 @@
1
+ // QML Shortcut adapter over the existing DOM keyboard events.
2
+ export function createQmlShortcuts(api) {
3
+ const entries = new Set();
4
+ const names = {escape:'Escape',esc:'Escape',space:'Space',tab:'Tab',backtab:'Backtab',return:'Return',enter:'Enter',left:'Left',right:'Right',up:'Up',down:'Down',home:'Home',end:'End',pgup:'PgUp',pageup:'PgUp',pgdown:'PgDown',pagedown:'PgDown',delete:'Del',del:'Del',insert:'Ins',ins:'Ins',backspace:'Backspace'};
5
+ const keyName = key => names[String(key).toLowerCase()] || (/^f(?:[1-9]|[12]\d|3[0-5])$/i.test(key) ? key.toUpperCase() : [...String(key)].length===1 ? String(key).toUpperCase() : '');
6
+ function parse(value) {
7
+ if (typeof value !== 'string' || !value.trim()) return [];
8
+ const strokes = value.trim()===',' ? [','] : value.split(/,\s*/);
9
+ if (strokes.length>4) return [];
10
+ const result=[];
11
+ for(const stroke of strokes){
12
+ let rest=stroke.trim(), mods=[];
13
+ while(/^(ctrl|alt|shift|meta)\+/i.test(rest)){
14
+ const at=rest.indexOf('+');mods.push(rest.slice(0,at).toLowerCase());rest=rest.slice(at+1);
15
+ }
16
+ const key=keyName(rest);if(!key)return [];
17
+ result.push({key,mods:new Set(mods)});
18
+ }
19
+ return result;
20
+ }
21
+ const text = value => parse(value).map(({key,mods})=>['ctrl','alt','shift','meta'].filter(m=>mods.has(m)).map(m=>m[0].toUpperCase()+m.slice(1)).concat(key).join('+')).join(', ');
22
+ function matches(stroke,event){
23
+ const key=event.key===' ' ? 'Space' : event.key==='Enter' ? 'Return' : event.key?.replace(/^Arrow/,'');
24
+ if(stroke.key!==keyName(key) && !(stroke.key==='Enter' && key==='Return'))return false;
25
+ for(const name of ['ctrl','alt','meta'])if(stroke.mods.has(name)!==!!event[name+'Key'])return false;
26
+ // Shift is intrinsic to printable punctuation such as '+' and '?'.
27
+ if(stroke.mods.has('shift')!==!!event.shiftKey && !(event.shiftKey && !stroke.mods.has('shift') && key?.length===1 && !/[a-z0-9]/i.test(key)))return false;
28
+ return true;
29
+ }
30
+ function keydown(event){
31
+ if(event.defaultPrevented || event.isComposing)return;
32
+ const complete=[], partial=[];
33
+ for(const entry of entries){
34
+ const v=entry.v, window=v.Window?.window;
35
+ if(!v.enabled || (!v.autoRepeat && event.repeat) || (window && (window.visible===false || (v.context!==2 && api.activeWindow() && api.activeWindow()!==window)))){entry.pending=[];continue;}
36
+ const sequences=(v.sequences?.length?v.sequences:[v.sequence]).map(parse).filter(s=>s.length);
37
+ const candidates=entry.pending.length && Date.now()-entry.time<1000 ? entry.pending : sequences.map(s=>({s,at:0}));
38
+ entry.pending=[];
39
+ for(const candidate of candidates){
40
+ if(!matches(candidate.s[candidate.at],event))continue;
41
+ if(candidate.at+1===candidate.s.length)complete.push(entry);
42
+ else entry.pending.push({s:candidate.s,at:candidate.at+1});
43
+ }
44
+ if(entry.pending.length){entry.time=Date.now();partial.push(entry);}
45
+ }
46
+ if(!complete.length&&!partial.length)return;
47
+ event.preventDefault();event.stopImmediatePropagation();
48
+ const unique=[...new Set(complete)];
49
+ if(unique.length){
50
+ for(const entry of entries)entry.pending=[];
51
+ for(const entry of unique)api.emit(entry.v, unique.length>1||partial.length ? 'onActivatedAmbiguously':'onActivated');
52
+ }
53
+ }
54
+ function make(v,type){
55
+ if(type!=='Shortcut')return;
56
+ Object.assign(v,{sequence:'',sequences:[],autoRepeat:true,context:1});
57
+ Object.defineProperty(v,'portableText',{get:()=>text(v.sequences?.length?v.sequences[0]:v.sequence)});
58
+ Object.defineProperty(v,'nativeText',{get:()=>v.portableText});
59
+ const entry={v,pending:[],time:0};
60
+ if(!entries.size)document.addEventListener('keydown',keydown,true);
61
+ entries.add(entry);
62
+ api.cleanup(()=>{entries.delete(entry);if(!entries.size)document.removeEventListener('keydown',keydown,true);});
63
+ }
64
+ return {make};
65
+ }
@@ -24,7 +24,7 @@ export function createQmlTextures(api) {
24
24
  if (type !== 'ShaderEffectSource') return;
25
25
  if (!api.host) throw Error('ShaderEffectSource requires a local QML host renderer');
26
26
  Object.assign(v, { sourceItem: null, sourceRect: { x: 0, y: 0, width: 0, height: 0 },
27
- textureSize: { width: 0, height: 0 }, hideSource: false, live: true,
27
+ mipmap:false, smooth:true, textureSize: { width: 0, height: 0 }, hideSource: false, live: true,
28
28
  scheduledUpdateCompleted: signal() });
29
29
  const canvas = document.createElement('canvas');
30
30
  canvas.width = canvas.height = 1;
@@ -55,6 +55,7 @@ export function createQmlTextures(api) {
55
55
  if (canvas.width !== w) canvas.width = w;
56
56
  if (canvas.height !== h) canvas.height = h;
57
57
  const ctx = canvas.getContext('2d');
58
+ ctx.imageSmoothingEnabled=v.smooth!==false;
58
59
  ctx.clearRect(0, 0, w, h);
59
60
  ctx.drawImage(input, rect.width > 0 ? rect.x : 0, rect.height > 0 ? rect.y : 0, sw, sh, 0, 0, w, h);
60
61
  entry.ready = true;
@@ -65,6 +66,8 @@ export function createQmlTextures(api) {
65
66
  v.scheduleUpdate = () => { requested = true; queue(); };
66
67
  effect(() => {
67
68
  const next = v.sourceItem, hide = v.hideSource;
69
+ v.smooth;
70
+ if(v.mipmap)throw Error("ShaderEffectSource mipmaps are not implemented");
68
71
  const live = v.live;
69
72
  const size = v.textureSize, rect = v.sourceRect;
70
73
  size.width; size.height; rect.x; rect.y; rect.width; rect.height;
@@ -0,0 +1,16 @@
1
+ // Optional frontend adapter; xdg_toplevel size hints, not an application check.
2
+ export function qmlWindowConstraints(client,window,context) {
3
+ let previous='';
4
+ return async()=>{
5
+ if(!previous&&!/\.qml(?:[?#]|$)/i.test(context.url||''))return;
6
+ const hints=await context.evaluate(`(()=>{const elements=[...document.querySelectorAll('[data-qml-window-min]')];const e=elements.find(e=>e.style.display!=='none');return e?[e.getAttribute('data-qml-window-min'),e.getAttribute('data-qml-window-max')]:null;})()`);
7
+ const key=JSON.stringify(hints);if(previous===key)return;
8
+ const parse=value=>{try{return JSON.parse(value).map(n=>Number.isFinite(n)?Math.max(0,Math.min(8192,Math.ceil(n))):0);}catch{return [0,0];}};
9
+ const min=parse(hints?.[0]),max=parse(hints?.[1]);
10
+ for(let i=0;i<2;i++)if(max[i]&&max[i]<min[i])max[i]=min[i];
11
+ client.request(window.toplevel,'set_min_size',...min);
12
+ client.request(window.toplevel,'set_max_size',...max);
13
+ client.request(window.surface,'commit');
14
+ previous=key;
15
+ };
16
+ }
@@ -205,6 +205,8 @@ export class BindingRealm {
205
205
  }
206
206
 
207
207
  /** The page's Range for an engine Range (one wrapper each). */
208
+ unwrapRange(range) { return RangeBinding.rangeOf(range); }
209
+
208
210
  wrapRange(range) {
209
211
  if (!range) return null;
210
212
  let wrapper = this.#ranges.get(range);
@@ -1372,7 +1374,7 @@ function isMedia(wrapper) {
1372
1374
 
1373
1375
  /** on* event handler properties (element.onclick = fn, script.onload = fn). */
1374
1376
  export const EVENT_HANDLER_NAMES = Object.freeze([
1375
- "click", "dblclick", "mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "mouseenter", "mouseleave",
1377
+ "click", "auxclick", "dblclick", "mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "mouseenter", "mouseleave",
1376
1378
  "keydown", "keyup", "keypress", "beforeinput", "input", "change", "submit", "reset", "focus", "blur", "load", "error",
1377
1379
  "abort", "scroll", "resize", "wheel", "contextmenu", "touchstart", "touchend", "touchmove",
1378
1380
  "pointerdown", "pointerup", "pointermove", "animationend", "transitionend", "readystatechange", "slotchange", "toggle", "invalid",
@@ -1696,6 +1698,8 @@ class DocumentBinding extends ParentBinding {
1696
1698
  return realmOf(this).wrap(nodeOf(this).createDocumentFragment());
1697
1699
  }
1698
1700
 
1701
+ getSelection() { return realmOf(this).selection?.() ?? null; }
1702
+
1699
1703
  createRange() {
1700
1704
  return realmOf(this).wrapRange(nodeOf(this).createRange());
1701
1705
  }
@@ -346,3 +346,19 @@ function copyImageData(image) {
346
346
  }
347
347
  return { width: image.width, height: image.height, data, colorSpace: "srgb" };
348
348
  }
349
+
350
+ // qml handle begin: software fragment effects draw into the existing canvas surface
351
+ export function paintQmlCanvas(node, draw) {
352
+ if (!canvasContext(node, "2d")) throw Error('Canvas renderer is unavailable');
353
+ const state = surfaces.get(node).state;
354
+ if (!state.raster) throw Error('Canvas surface is unavailable');
355
+ const canvas = state.raster.getCanvas();
356
+ canvas.save();
357
+ try {
358
+ const inverse = CanvasKit.Matrix.invert(canvas.getTotalMatrix());
359
+ if (!inverse) throw Error("Noninvertible QML canvas transform");
360
+ canvas.concat(inverse); draw(canvas);
361
+ }
362
+ finally { canvas.restore(); changed(state); }
363
+ }
364
+ // qml handle end
@@ -0,0 +1,19 @@
1
+ // Keep engine nodes/ranges behind the same DOM wrappers as the rest of the realm.
2
+ export function selectionBinding(window, realm, nodeOf, changed) {
3
+ const native=()=>window.getSelection();
4
+ const api={};
5
+ for(const key of ['anchorOffset','focusOffset','isCollapsed','rangeCount','type'])Object.defineProperty(api,key,{get:()=>native()[key]});
6
+ for(const key of ['anchorNode','focusNode'])Object.defineProperty(api,key,{get:()=>realm.wrap(native()[key])});
7
+ api.toString=()=>native().toString();
8
+ api.getRangeAt=i=>realm.wrapRange(native().getRangeAt(i));
9
+ const mutate=(name,args)=>{const result=native()[name](...args);changed();return result;};
10
+ for(const name of ['removeAllRanges','empty','collapseToStart','collapseToEnd','deleteFromDocument'])api[name]=()=>mutate(name,[]);
11
+ api.collapse=api.setPosition=(node,offset=0)=>mutate('collapse',[nodeOf(node),offset]);
12
+ api.extend=(node,offset=0)=>mutate('extend',[nodeOf(node),offset]);
13
+ api.setBaseAndExtent=(a,ao,f,fo)=>mutate('setBaseAndExtent',[nodeOf(a),ao,nodeOf(f),fo]);
14
+ api.selectAllChildren=node=>mutate('selectAllChildren',[nodeOf(node)]);
15
+ api.containsNode=(node,partial=false)=>native().containsNode(nodeOf(node),partial);
16
+ api.addRange=range=>mutate('addRange',[realm.unwrapRange(range)]);
17
+ api.removeRange=range=>mutate('removeRange',[realm.unwrapRange(range)]);
18
+ return api;
19
+ }
@@ -1,3 +1,4 @@
1
+ import { RENDER_INTERVAL_FLAG, renderMinInterval } from "./render-interval.js";
1
2
  import {writeClipboard, readClipboard} from '../clipboard.js';
2
3
  /**
3
4
  * Controller-side handle for a renderer process.
@@ -16,6 +17,7 @@ import {writeClipboard, readClipboard} from '../clipboard.js';
16
17
  import {listenCompositor} from "./compositor-channel.js";
17
18
  import {RawFrames} from "../paint/raw-frames.js";
18
19
  import { PulseClient } from "../audio/pulse-client.js";
20
+ import { SharedAudioConnection } from "../audio/shared-connection.js";
19
21
  import { logEnabled, pipeToLog, RENDERER_LOG_FLAG, writeLog } from "../log.js";
20
22
  import { NetworkService } from "../network/network-service.js";
21
23
  import { COMPOSITOR_FLAG, FULL_STYLE_FLAG, VERIFY_STYLE_FLAG } from "./flags.js";
@@ -33,14 +35,9 @@ const MAX_RESOURCE_BYTES = 32 * 1024 * 1024;
33
35
  // Audio output: one PulseAudio native-protocol connection (jspulse or a
34
36
  // PulseAudio daemon on 127.0.0.1:4713) shared by every renderer, opened on
35
37
  // first use. Renderers cannot open sockets, so their audio comes through here.
36
- let pulse = null;
37
- function pulseClient() {
38
- pulse ??= PulseClient.connect().catch((error) => {
39
- pulse = null;
40
- throw new Error(`no PulseAudio server at 127.0.0.1:4713 (start jspulse): ${error.message}`);
41
- });
42
- return pulse;
43
- }
38
+ const pulse = new SharedAudioConnection(() => PulseClient.connect().catch((error) => {
39
+ throw new Error(`no PulseAudio server at 127.0.0.1:4713 (start jspulse): ${error.message}`);
40
+ }));
44
41
  const MAX_AUDIO_STREAMS = 8;
45
42
  const MAX_AUDIO_CHUNK = 4 * 1024 * 1024;
46
43
 
@@ -164,6 +161,7 @@ export class RendererHost {
164
161
 
165
162
  const child = Bun.spawn({
166
163
  cmd: [process.execPath, this.#entry, ...this.#args,
164
+ RENDER_INTERVAL_FLAG + renderMinInterval(process.env.BUNINU_RENDER_MIN_INTERVAL),
167
165
  ...(logging ? [RENDERER_LOG_FLAG] : []), ...(compositor ? [COMPOSITOR_FLAG, `--compositor-endpoint=${JSON.stringify(channel.transport.endpoint)}`] : []),
168
166
  ...(process.env.BUNINU_VERIFY_STYLE ? [VERIFY_STYLE_FLAG] : []), ...(process.env.BUNINU_FULL_STYLE ? [FULL_STYLE_FLAG] : [])],
169
167
  env,
@@ -543,20 +541,24 @@ export class RendererHost {
543
541
  return;
544
542
  }
545
543
  const entry = { child, stream: null, early: [] };
546
- entry.opened = pulseClient()
547
- .then((client) => client.openPlayback({
544
+ entry.opened = pulse.acquire(child)
545
+ .then((client) => {
546
+ if (this.#audio.get(id) !== entry) throw Error("Audio owner closed");
547
+ return client.openPlayback({
548
548
  rate, channels, name: String(name ?? "audio").slice(0, 200),
549
549
  leadSeconds: name === "piano" ? 0.12 : undefined,
550
550
  onRequest: (bytes) => send({ event: "request", bytes }),
551
551
  onTiming: name === "piano" ? null : (timing) => send({ event: "timing", ...timing }),
552
552
  onTimingError: (error) => send({ event: "timing-error", message: String(error?.message ?? error) }),
553
- }))
553
+ });
554
+ })
554
555
  .then((stream) => {
555
556
  if (this.#audio.get(id) !== entry) return stream.close();
556
557
  entry.stream = stream;
557
558
  for (const data of entry.early.splice(0)) stream.write(data);
558
559
  send({ event: "ready" });
559
560
  }, (error) => {
561
+ if (this.#audio.get(id) !== entry) return;
560
562
  this.#audio.delete(id);
561
563
  send({ event: "error", message: String(error?.message ?? error) });
562
564
  });
@@ -564,6 +566,7 @@ export class RendererHost {
564
566
  }
565
567
 
566
568
  #closeAudio(child) {
569
+ pulse.release(child);
567
570
  for (const [id, entry] of this.#audio) {
568
571
  if (entry.child !== child) continue;
569
572
  this.#audio.delete(id);
@@ -1,3 +1,4 @@
1
+ import { RENDER_INTERVAL_FLAG, renderMinInterval } from "./render-interval.js";
1
2
  /**
2
3
  * Renderer process entry point.
3
4
  *
@@ -252,6 +253,7 @@ function clipboardWrite(text, type = 'clipboard-write') {
252
253
  }
253
254
 
254
255
  const renderer = await createPageRenderer({
256
+ renderMinInterval: renderMinInterval(process.argv.find(arg => arg.startsWith(RENDER_INTERVAL_FLAG))?.slice(RENDER_INTERVAL_FLAG.length)),
255
257
  fetchResource,
256
258
  rewriteScript,
257
259
  requestNavigation: (url, download = null, fragmentFrom = null, post = null) => process.send({ type: "navigate", url, download, fragmentFrom, post }),
@@ -1,3 +1,4 @@
1
+ import {selectionBinding} from './document-selection.js';
1
2
  /**
2
3
  * The JavaScript environment of one document.
3
4
  *
@@ -352,6 +353,7 @@ export function createPageRealm({ window, document, mode, hooks }) {
352
353
  }
353
354
  };
354
355
 
356
+ let documentSelection;
355
357
  const realm = new BindingRealm(window, {
356
358
  touch: (node, features) => {
357
359
  if (!node || node === document || node.ownerDocument === document) hooks.touch(node, features);
@@ -567,6 +569,7 @@ export function createPageRealm({ window, document, mode, hooks }) {
567
569
  // HTMLCanvasElement width/height are the bitmap size (default 300×150), and
568
570
  // getContext("2d") is how pages sample colours and draw. Other elements do
569
571
  // not inherit these.
572
+ realm.selection=()=>documentSelection ??= selectionBinding(window,realm,nodeOf,()=>hooks.selectionChanged?.());
570
573
  const canvasPrototype = elementInterfacePrototypes.get("HTMLCanvasElement");
571
574
  for (const name of ["width", "height"]) {
572
575
  Object.defineProperty(canvasPrototype, name, {
@@ -1785,15 +1788,7 @@ export function createPageRealm({ window, document, mode, hooks }) {
1785
1788
  element: node,
1786
1789
  });
1787
1790
  },
1788
- getSelection: () => ({
1789
- anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0,
1790
- isCollapsed: true, rangeCount: 0, type: "None",
1791
- addRange() {}, collapse() {}, collapseToEnd() {}, collapseToStart() {},
1792
- containsNode: () => false, deleteFromDocument() {}, empty() {}, extend() {},
1793
- getRangeAt: () => { throw new DOMException("Index out of range", "IndexSizeError"); },
1794
- modify() {}, removeAllRanges() {}, removeRange() {}, selectAllChildren() {}, setBaseAndExtent() {},
1795
- toString: () => "",
1796
- }),
1791
+ getSelection: () => realm.selection(),
1797
1792
  matchMedia: (query) => {
1798
1793
  const { width, height, mobile } = hooks.viewport();
1799
1794
  return {