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,60 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {mkdtemp,rm} from 'node:fs/promises';
3
+ import {createServer} from 'node:net';
4
+ import {createBrowser} from '../lib/headless-shell.js';
5
+
6
+ test('local QML Socket connects, splits UTF-8 messages, writes and disconnects',async()=>{
7
+ const dir=await mkdtemp('/tmp/qml-socket-');
8
+ const peers=new Set(); let received='';
9
+ const server=createServer(socket=>{
10
+ peers.add(socket);socket.on('close',()=>peers.delete(socket));
11
+ socket.on('data',data=>{received+=data;});
12
+ const bytes=Buffer.from('你好\nsecond\n');
13
+ socket.write(bytes.subarray(0,2));setTimeout(()=>socket.write(bytes.subarray(2)),20);
14
+ });
15
+ await new Promise(r=>server.listen(dir+'/engine.sock',r));
16
+ const browser=await createBrowser({spareRenderer:false});
17
+ try{
18
+ await Bun.write(dir+'/main.qml',`import QtQuick\nimport QtQuick.Window\nimport Quickshell.Io
19
+ Item { Socket { id: socket; path: ${JSON.stringify(dir+'/engine.sock')}; connected: true
20
+ parser: SplitParser { onRead: data => { globalThis.messages.push(data); socket.write(data + "!"); socket.flush(); } }
21
+ onConnectedChanged: (globalThis.states ??= []).push(connected)
22
+ Component.onCompleted: { globalThis.messages=[]; globalThis.states=[]; globalThis.sock=socket; }
23
+ } }`);
24
+ await browser.context.navigate('file://'+dir+'/main.qml');
25
+ for(let i=0;i<100;i++){if(await browser.context.evaluate('globalThis.messages?.length===2'))break;await Bun.sleep(20);}
26
+ expect(await browser.context.evaluate('messages')).toEqual(['你好','second']);
27
+ expect(await browser.context.evaluate('sock.connected')).toBe(true);
28
+ for(let i=0;i<100&&!received.includes('second!');i++)await Bun.sleep(10);
29
+ expect(received).toBe('你好!second!');
30
+ await browser.context.evaluate('sock.connected=false');
31
+ for(let i=0;i<100&&peers.size;i++)await Bun.sleep(10);
32
+ expect(peers.size).toBe(0);
33
+ expect(await browser.context.evaluate('sock.connected')).toBe(false);
34
+ }finally{browser.close();for(const p of peers)p.destroy();await new Promise(r=>server.close(r));await rm(dir,{recursive:true,force:true});}
35
+ },20000);
36
+
37
+ test('Socket failure can reconnect and navigation closes the live connection',async()=>{
38
+ const dir=await mkdtemp('/tmp/qml-socket-retry-');
39
+ const peers=new Set();
40
+ const server=createServer(s=>{peers.add(s);s.on('close',()=>peers.delete(s));});
41
+ const browser=await createBrowser({spareRenderer:false});
42
+ try{
43
+ await Bun.write(dir+'/main.qml',`import QtQuick\nimport Quickshell.Io
44
+ Item { Socket {id:s; path:${JSON.stringify(dir+'/retry.sock')};connected:true
45
+ onError:error=>{globalThis.socketError=error;}
46
+ Component.onCompleted:globalThis.socketObject=s
47
+ } }`);
48
+ await browser.context.navigate('file://'+dir+'/main.qml');
49
+ for(let i=0;i<100;i++){if(await browser.context.evaluate('!!globalThis.socketError'))break;await Bun.sleep(10);}
50
+ expect(await browser.context.evaluate('socketObject.connected')).toBe(false);
51
+ expect(await browser.context.evaluate('socketError')).toBe('ENOENT');
52
+ await new Promise(r=>server.listen(dir+'/retry.sock',r));
53
+ await browser.context.evaluate('socketObject.connected=true');
54
+ for(let i=0;i<100;i++){if(await browser.context.evaluate('socketObject.connected'))break;await Bun.sleep(10);}
55
+ expect(await browser.context.evaluate('socketObject.connected')).toBe(true);
56
+ await browser.context.navigate('data:text/html,<p>ordinary page</p>');
57
+ for(let i=0;i<100&&peers.size;i++)await Bun.sleep(10);
58
+ expect(peers.size).toBe(0);
59
+ }finally{browser.close();for(const p of peers)p.destroy();await new Promise(r=>server.close(r));await rm(dir,{recursive:true,force:true});}
60
+ },20000);
@@ -0,0 +1,55 @@
1
+ import {test,expect,beforeAll} from 'bun:test';
2
+ import init from '../usr/lib/canvaskit/canvaskit.js';
3
+ import {compileFragment,makeFragmentShader,translateFragment} from '../lib/qml/shader/fragment.js';
4
+ let kit;
5
+ beforeAll(async()=>{kit=await init({wasmBinary:await Bun.file(new URL('../usr/lib/canvaskit/canvaskit.wasm',import.meta.url)).arrayBuffer()});});
6
+ const header=`#version 440
7
+ layout(location=0) in vec2 qt_TexCoord0;
8
+ layout(location=0) out vec4 fragColor;
9
+ layout(std140,binding=0) uniform buf {mat4 qt_Matrix;float qt_Opacity;};`;
10
+ function pixel(compiled,options,x=0,y=0){
11
+ const surface=kit.MakeSurface(options.width,options.height),paint=new kit.Paint();
12
+ const shader=makeFragmentShader(compiled,options);
13
+ try{
14
+ paint.setShader(shader);surface.getCanvas().drawRect(kit.XYWHRect(0,0,options.width,options.height),paint);surface.flush();
15
+ const image=surface.makeImageSnapshot();
16
+ try{return Array.from(image.readPixels(x,y,{width:1,height:1,colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB}));}finally{image.delete();}
17
+ }finally{paint.delete();shader.delete();surface.delete();}
18
+ }
19
+ test('Qt fragment uniform and coordinate mapping executes on CPU raster surface',()=>{
20
+ const c=compileFragment(kit,header+'uniform int mode; void main(){fragColor=mode==2?vec4(qt_TexCoord0,0,1):vec4(0);fragColor*=qt_Opacity;}');
21
+ try{const p=pixel(c,{width:16,height:16,uniforms:{mode:2}},8,8);expect(p).toEqual([135,135,0,255]);}finally{c.effect.delete();}
22
+ });
23
+ test('CPU child texture sampling and textureSize preserve source pixel dimensions',()=>{
24
+ const image=kit.MakeImage({width:2,height:1,colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Premul,colorSpace:kit.ColorSpace.SRGB},new Uint8Array([255,0,0,255,0,255,0,255]),8);
25
+ const child=image.makeShaderOptions(kit.TileMode.Clamp,kit.TileMode.Clamp,kit.FilterMode.Nearest,kit.MipmapMode.None);
26
+ const c=compileFragment(kit,header+'layout(binding=1) uniform sampler2D source;void main(){if(textureSize(source,0)!=ivec2(2,1)){fragColor=vec4(0,0,1,1);return;}fragColor=texture(source,qt_TexCoord0);}');
27
+ try{
28
+ const options={width:4,height:2,textures:{source:{shader:child,width:2,height:1}}};
29
+ expect(pixel(c,options,0,0)).toEqual([255,0,0,255]);
30
+ expect(pixel(c,options,3,0)).toEqual([0,255,0,255]);
31
+ expect(()=>makeFragmentShader(c,{width:1,height:1})).toThrow('Missing shader texture');
32
+ }finally{c.effect.delete();child.delete();image.delete();}
33
+ });
34
+ test('unsupported shader interfaces fail rather than rendering a success placeholder',()=>{
35
+ expect(()=>translateFragment('#include "missing"')).toThrow('preprocessor');
36
+ expect(()=>translateFragment(header+'void main(){discard;}')).toThrow('Unsupported');
37
+ expect(()=>compileFragment(kit,header+'void main(){fragColor=unknown();}')).toThrow('compilation failed');
38
+ });
39
+
40
+ import {deflateSync} from 'node:zlib';
41
+ import {fragmentSource} from '../lib/qml/shader/qsb.js';
42
+ function qsb(source){
43
+ const text=Buffer.from(source),head=Buffer.alloc(28),tail=Buffer.from([0,0,0,4,109,97,105,110]);
44
+ [9,4,1,150,0,0,text.length].forEach((n,i)=>head.writeUInt32BE(n,i*4));
45
+ const raw=Buffer.concat([head,text,tail]),size=Buffer.alloc(4);size.writeUInt32BE(raw.length);
46
+ return Buffer.concat([size,deflateSync(raw)]);
47
+ }
48
+ test('QSB source records are validated and generated struct uniforms compile',()=>{
49
+ const source='#version 150\nstruct buf {float gain;};uniform buf _12;out vec4 fragColor;void main(){fragColor=vec4(_12.gain,0,0,1);}';
50
+ expect(fragmentSource(qsb(source))).toBe(source);
51
+ const c=compileFragment(kit,fragmentSource(qsb(source)));
52
+ try{expect(pixel(c,{width:1,height:1,uniforms:{gain:1}})).toEqual([255,0,0,255]);}finally{c.effect.delete();}
53
+ expect(()=>fragmentSource(qsb(source).subarray(0,10))).toThrow();
54
+ expect(()=>fragmentSource(qsb(source.replace('#version 150','#version 100')))).toThrow('no supported');
55
+ });
@@ -0,0 +1,36 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {RendererHost} from '../lib/renderer/host.js';
3
+
4
+ test('Window.update coalesces requests and emits frame signals, including reentrant requests',async()=>{
5
+ const host=new RendererHost({timeout:20000});
6
+ try {
7
+ await host.start();
8
+ await host.call('loadDocument',{url:'data:text/x-qml,window-update',contentType:'text/x-qml',source:`import QtQuick
9
+ import Quickshell
10
+ FloatingWindow {width:200;height:100
11
+ Item {id:child;Component.onCompleted:globalThis.testWindow=child.Window.window}
12
+ }`});
13
+ await host.call('whenLoaded');
14
+ const result=await host.call('evaluate',`(()=>{
15
+ const original=requestAnimationFrame, cancel=cancelAnimationFrame;
16
+ const queue=new Map();let next=0,animated=0,synced=0;
17
+ globalThis.requestAnimationFrame=fn=>{queue.set(++next,fn);return next};
18
+ globalThis.cancelAnimationFrame=id=>queue.delete(id);
19
+ const w=testWindow;
20
+ const animate=()=>{animated++;if(animated===1)w.update()};
21
+ const sync=()=>synced++;
22
+ const step=()=>{const [id,fn]=queue.entries().next().value;queue.delete(id);fn(0)};
23
+ try {
24
+ w.update();w.update();const coalesced=queue.size;
25
+ w.afterAnimating.connect(animate);w.afterSynchronizing.connect(sync);
26
+ step();const reentrant=queue.size;
27
+ step();
28
+ w.afterAnimating.disconnect(animate);w.afterSynchronizing.disconnect(sync);
29
+ step();const idle=queue.size;
30
+ w.update();step();step();const idleAfterUnobserved=queue.size;
31
+ return {coalesced,reentrant,animated,synced,idle,idleAfterUnobserved};
32
+ }finally{globalThis.requestAnimationFrame=original;globalThis.cancelAnimationFrame=cancel}
33
+ })()`);
34
+ expect(result).toEqual({coalesced:1,reentrant:1,animated:2,synced:2,idle:0,idleAfterUnobserved:0});
35
+ }finally{host.close()}
36
+ },30000);
@@ -0,0 +1,33 @@
1
+ import {test, expect} from "bun:test";
2
+ import {renderMinInterval, RENDER_INTERVAL_FLAG} from "../lib/renderer/render-interval.js";
3
+ import {RendererHost} from "../lib/renderer/host.js";
4
+
5
+ test("render interval validates boundaries without silently accepting malformed values", () => {
6
+ for (const [value, expected] of [[undefined,16],["0",0],["8",8],["16",16],["60000",60000]]) {
7
+ const warnings=[];expect(renderMinInterval(value,s=>warnings.push(s))).toBe(expected);expect(warnings).toEqual([]);
8
+ }
9
+ for (const value of ["","-1","60001","1.5","8ms","Infinity","NaN"," 8 "]) {
10
+ const warnings=[];expect(renderMinInterval(value,s=>warnings.push(s))).toBe(16);expect(warnings.length).toBe(1);
11
+ }
12
+ });
13
+
14
+ test("renderer receives configured interval on launch without inheriting host environment", async () => {
15
+ const old=process.env.BUNINU_RENDER_MIN_INTERVAL, secret=process.env.BUNINU_TEST_PRIVATE_ENV;
16
+ let host;
17
+ try {
18
+ process.env.BUNINU_TEST_PRIVATE_ENV="must-not-leak";
19
+ for (const interval of ["8","0","60000"]) {
20
+ process.env.BUNINU_RENDER_MIN_INTERVAL=interval;
21
+ host=new RendererHost({entry:new URL("./fixtures/fake-renderer.js",import.meta.url).pathname});
22
+ await host.start();
23
+ const result=await host.call("configuration");
24
+ expect(result.args).toContain(RENDER_INTERVAL_FLAG+interval);
25
+ expect(result.secret).toBeUndefined();
26
+ host.close();
27
+ }
28
+ } finally {
29
+ host?.close();
30
+ if(old===undefined)delete process.env.BUNINU_RENDER_MIN_INTERVAL;else process.env.BUNINU_RENDER_MIN_INTERVAL=old;
31
+ if(secret===undefined)delete process.env.BUNINU_TEST_PRIVATE_ENV;else process.env.BUNINU_TEST_PRIVATE_ENV=secret;
32
+ }
33
+ });
@@ -0,0 +1,6 @@
1
+ import {test,expect} from 'bun:test';
2
+ test('live title reads do not flush pending layout and paint, including empty titles',async()=>{
3
+ const child=Bun.spawn([process.execPath,new URL('./fixtures/title-no-paint.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
4
+ const [exit,stderr]=await Promise.all([child.exited,new Response(child.stderr).text()]);
5
+ expect(stderr).toBe('');expect(exit).toBe(0);
6
+ },15000);
@@ -0,0 +1,40 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {mkdtempSync,rmSync} from 'node:fs';
3
+
4
+ // Opt-in sibling integration, with its own socket; never touches the user's Land.
5
+ test.skipIf(process.env.BUNINU_TEST_WAYLAND!=='1')('Casty SGR wheel -> Land -> browser scrolls both DOM axes',async()=>{
6
+ const {start}=await import('../../buninu-land/compositor.js');
7
+ const {mouseWheelEvent}=await import('../../casty/lib/input.js');
8
+ const {launch}=await import('../frontends/wayland/index.js');
9
+ const dir=mkdtempSync('/tmp/browser-wheel-'),old=process.env.XDG_RUNTIME_DIR;
10
+ process.env.XDG_RUNTIME_DIR=dir;let land,app;
11
+ const wait=async(fn)=>{for(let n=0;n<300;n++){if(await fn())return;await Bun.sleep(20)}throw Error('Scroll timed out')};
12
+ try{
13
+ land=await start(['--remote-debugging-port=0','--window-size=640,480']);
14
+ app=await launch({socket:dir+'/wayland-buninu',url:'data:text/html,'+encodeURIComponent('<style>body{margin:0}#box{width:400px;height:250px;overflow:auto}#content{width:1600px;height:1600px;background:linear-gradient(red,blue)}</style><div id="box"><div id="content"></div></div>')});
15
+ const state=async()=>JSON.parse(await app.browser.context.evaluate('JSON.stringify([document.getElementById("box").scrollLeft,document.getElementById("box").scrollTop])'));
16
+ await wait(()=>land.compositor.wm.renderSurfaces().some(s=>s.pixels));
17
+ const surface=land.compositor.wm.renderSurfaces().find(s=>s.pixels),origin=land.compositor.wm.origin(surface);
18
+ const wheel=code=>land.context.dispatchMouseEvent(mouseWheelEvent(code,origin.x+100,origin.y+130));
19
+ wheel(67);await wait(async()=>(await state())[0]>0);
20
+ const right=await state();expect(right).toEqual([100,0]);
21
+ wheel(65);await wait(async()=>(await state())[1]>0);
22
+ const down=await state();expect(down).toEqual([100,100]);
23
+ wheel(66);await wait(async()=>(await state())[0]<right[0]);
24
+ wheel(64);await wait(async()=>(await state())[1]<down[1]);
25
+ expect(await state()).toEqual([0,0]);
26
+ // Touchpad-like fractional deltas must not be rounded into wheel ticks.
27
+ land.context.dispatchMouseEvent({type:'mouseWheel',x:origin.x+100,y:origin.y+130,deltaX:12.5,deltaY:7.5});
28
+ await wait(async()=>(await state())[1]>0);
29
+ const fractional=await state();
30
+ expect(fractional[0]).toBeGreaterThan(0);expect(fractional[1]).toBeGreaterThan(0);
31
+ // Compare the same wheel input with the direct Browser/CDP scroll path.
32
+ await app.browser.context.scroll(-100,-100,{x:100,y:100});
33
+ expect(await state()).toEqual([0,0]);
34
+ await app.browser.context.scroll(12.5,7.5,{x:100,y:100});
35
+ expect(await state()).toEqual(fractional);
36
+ await app.browser.context.scroll(-100,-100,{x:100,y:100});
37
+ await app.browser.context.scroll(100,100,{x:100,y:100});
38
+ expect(await state()).toEqual(down);
39
+ }finally{app?.close();land?.close();if(old===undefined)delete process.env.XDG_RUNTIME_DIR;else process.env.XDG_RUNTIME_DIR=old;rmSync(dir,{recursive:true,force:true})}
40
+ },30000);
@@ -36,3 +36,26 @@ test('Toolbar raster cache and horizontal scrolling preserve page-independent re
36
36
  expect(bar.editor.selectedText.length).toBeGreaterThan(0);
37
37
  }finally{bar.dispose();}
38
38
  });
39
+
40
+ test('Wayland chrome forwards page click counts through zoom and bar offset',async()=>{
41
+ const clicks=[];
42
+ const bar=new WaylandUrlBar({url:'about:blank',click:(...args)=>clicks.push(args)},{history,close(){},clipboard:{read:async()=>'',write:async()=>{}}});
43
+ try{
44
+ bar.zoom=2;
45
+ await bar.pointerButton(40,bar.height+60,272,1,{clickCount:2});
46
+ await bar.pointerButton(40,bar.height+60,272,0,{clickCount:2});
47
+ expect(clicks).toEqual([[20,30,{clickCount:2}]]);
48
+ }finally{bar.dispose()}
49
+ });
50
+
51
+ test('Wayland toolbar forwards right/middle page clicks but ignores them on chrome',async()=>{
52
+ const clicks=[];const bar=new WaylandUrlBar({click:(...a)=>clicks.push(a)},{history});
53
+ try{
54
+ for(const code of [273,274]){
55
+ await bar.pointerButton(40,bar.height+60,code,1);
56
+ await bar.pointerButton(40,bar.height+60,code,0);
57
+ await bar.pointerButton(40,5,code,0);
58
+ }
59
+ expect(clicks.map(c=>c[2].button)).toEqual(['right','middle']);
60
+ }finally{bar.dispose()}
61
+ });