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.
- package/README.md +22 -1
- package/frontends/wayland/README.md +50 -21
- package/frontends/wayland/index.js +14 -0
- package/frontends/wayland/input.js +24 -5
- package/frontends/wayland/urlbar.js +9 -5
- package/lib/audio/shared-connection.js +31 -0
- package/lib/cdp-server.js +15 -2
- package/lib/headless-shell.js +7 -4
- package/lib/paint/display-list.js +2 -1
- package/lib/qml/README.md +456 -0
- package/lib/qml/builtins.js +76 -5
- package/lib/qml/canvas.js +76 -0
- package/lib/qml/document.js +47 -24
- package/lib/qml/fonts.js +69 -0
- package/lib/qml/host.js +28 -0
- package/lib/qml/loader.js +7 -3
- package/lib/qml/models.js +1 -1
- package/lib/qml/reactive.js +26 -3
- package/lib/qml/runtime.js +263 -41
- package/lib/qml/shader/fragment.js +83 -0
- package/lib/qml/shader/qsb.js +27 -0
- package/lib/qml/shader/renderer.js +39 -0
- package/lib/qml/shader/runtime.js +61 -0
- package/lib/qml/shapes.js +9 -1
- package/lib/qml/shortcuts.js +65 -0
- package/lib/qml/textures.js +4 -1
- package/lib/qml/window-constraints.js +16 -0
- package/lib/renderer/bindings.js +5 -1
- package/lib/renderer/canvas-2d.js +16 -0
- package/lib/renderer/document-selection.js +19 -0
- package/lib/renderer/host.js +14 -11
- package/lib/renderer/main.js +2 -0
- package/lib/renderer/page-realm.js +4 -9
- package/lib/renderer/page-renderer.js +273 -35
- package/lib/renderer/pointer-move.js +5 -3
- package/lib/renderer/render-interval.js +9 -0
- package/lib/style/computed-style.js +8 -2
- package/package.json +1 -1
- package/test/audio-connection-lifetime.test.js +46 -0
- package/test/canvas-paint.test.js +8 -0
- package/test/computed-style.test.js +12 -0
- package/test/document-selection.test.js +27 -0
- package/test/double-click.test.js +85 -0
- package/test/fixtures/canvas-damage.js +40 -0
- package/test/fixtures/fake-renderer.js +1 -0
- package/test/fixtures/omastorm-helpers/Dockerfile +35 -0
- package/test/fixtures/omastorm-helpers/check.mjs +56 -0
- package/test/fixtures/omastorm-helpers/launch.mjs +24 -0
- package/test/fixtures/omastorm-helpers/prepare.mjs +17 -0
- package/test/fixtures/omastorm-helpers/runtime.mjs +32 -0
- package/test/fixtures/pointer-paint-batching.js +23 -0
- package/test/fixtures/qml-fonts/main.qml +10 -0
- package/test/fixtures/qml-locale/check.mjs +6 -0
- package/test/fixtures/qml-locale/main.qml +8 -0
- package/test/fixtures/qml-shader/grab.qml +14 -0
- package/test/fixtures/qml-shader/gradient.frag +5 -0
- package/test/fixtures/qml-shader/main.qml +14 -0
- package/test/fixtures/qml-shader/texture-grab.qml +25 -0
- package/test/fixtures/qml-shader/texture-size.frag +5 -0
- package/test/fixtures/qml-shader-check.mjs +24 -0
- package/test/fixtures/title-no-paint.js +20 -0
- package/test/manual-double-click.html +21 -0
- package/test/manual-mouse-buttons.html +18 -0
- package/test/manual-omastorm-engine.mjs +19 -0
- package/test/manual-omastorm-shaders.mjs +28 -0
- package/test/manual-omastorm-software.mjs +25 -0
- package/test/manual-pointer-scroll.html +43 -0
- package/test/pointer-drag.test.js +104 -0
- package/test/qml-canvas.test.js +21 -0
- package/test/qml-connections-timing.test.js +22 -0
- package/test/qml-controls-window.test.js +182 -0
- package/test/qml-fonts.test.js +31 -0
- package/test/qml-grid-multiline.test.js +48 -0
- package/test/qml-locale.test.js +45 -0
- package/test/qml-nonvisual-visible.test.js +19 -0
- package/test/qml-shader-runtime.test.js +6 -0
- package/test/qml-shortcuts.test.js +39 -0
- package/test/qml-socket.test.js +60 -0
- package/test/qml-software-shader.test.js +55 -0
- package/test/qml-window-update.test.js +36 -0
- package/test/render-interval.test.js +33 -0
- package/test/title-no-paint.test.js +6 -0
- package/test/wayland-scroll.test.js +40 -0
- package/test/wayland-urlbar.test.js +23 -0
|
@@ -859,3 +859,15 @@ test("a linked style sheet's url()s resolve against the sheet", () => {
|
|
|
859
859
|
)).toBe(`a { background: url(https://site.test/css/img/a.png) } b { background: url("https://site.test/b.png") } c { background: url(data:image/png;base64,AA) }
|
|
860
860
|
d { background: url(https://cdn.test/d.png) } e { mask: url(#m) }`);
|
|
861
861
|
});
|
|
862
|
+
|
|
863
|
+
test("form controls default to readable dark text but author colors still win", () => {
|
|
864
|
+
const {document,window}=parseHTMLDocument(`<body style="color:lightblue"><textarea></textarea><input><button>Button</button><select><option>Option</option></select><textarea id="inherit" style="color:inherit"></textarea><input id="custom" style="color:red"></body>`);
|
|
865
|
+
try {
|
|
866
|
+
const styles=new StyleEngine().compute(document);
|
|
867
|
+
for(const e of document.querySelectorAll('textarea,input,button,select')) {
|
|
868
|
+
if(!e.id)expect(styles.get(e).color).toBe('rgba(0, 0, 0, 1)');
|
|
869
|
+
}
|
|
870
|
+
expect(styles.get(document.querySelector('#inherit')).color).toBe(styles.get(document.body).color);
|
|
871
|
+
expect(styles.get(document.querySelector('#custom')).color).toBe('rgba(255, 0, 0, 1)');
|
|
872
|
+
} finally {window.happyDOM.abort()}
|
|
873
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {createBrowser} from '../lib/headless-shell.js';
|
|
3
|
+
test('drag selects document text and controls without stealing canceled mouse gestures',async()=>{
|
|
4
|
+
let copied='';const b=await createBrowser({spareRenderer:false,clipboard:{read:async()=>copied,write:async text=>{copied=text}}});
|
|
5
|
+
const c=b.context,run=s=>c.evaluate(s);
|
|
6
|
+
const down=(x,y)=>c.pointerButton(x,y,272,1),up=(x,y)=>c.pointerButton(x,y,272,0);
|
|
7
|
+
try{
|
|
8
|
+
await c.resize(500,300,1);await c.navigate('data:text/html,'+encodeURIComponent('<style>body{margin:0;font:20px monospace}p{margin:0;height:30px}input{font:20px monospace;padding:0;border:0}</style><p id=a>abcdefghij</p><p id=b>klmnopqrst</p><input value="abcdefghij">'));
|
|
9
|
+
await down(12,12);await c.move(60,12);await up(60,12);
|
|
10
|
+
expect(await run('getSelection().toString()')).toBe('bcde');
|
|
11
|
+
await c.press('c',{modifiers:2});for(let i=0;i<50&&!copied;i++)await Bun.sleep(10);expect(copied).toBe('bcde');
|
|
12
|
+
await down(60,12);await c.move(12,12);await up(12,12);expect(await run('getSelection().toString()')).toBe('bcde');
|
|
13
|
+
await down(12,12);await c.move(36,42);await up(36,42);expect(await run('getSelection().toString()')).toContain('klm');
|
|
14
|
+
await down(12,72);await c.move(60,72);await up(60,72);
|
|
15
|
+
expect(await run('(()=>{const e=document.querySelector("input");return [e.selectionStart,e.selectionEnd]})()')).toEqual([1,5]);
|
|
16
|
+
await run('document.getElementById("a").onmousedown=e=>e.preventDefault();getSelection().removeAllRanges()');
|
|
17
|
+
await down(12,12);await c.move(60,12);await up(60,12);expect(await run('getSelection().toString()')).toBe('');
|
|
18
|
+
await run('getSelection().selectAllChildren(document.getElementById("a"))');expect(await run('getSelection().toString()')).toBe('abcdefghij');
|
|
19
|
+
expect(await run('document.getSelection()===window.getSelection()')).toBe(true);
|
|
20
|
+
await run('document.getElementById("a").onmousedown=null;document.getElementById("a").style.userSelect="none";getSelection().removeAllRanges()');
|
|
21
|
+
await down(12,12);await c.move(60,12);await up(60,12);expect(await run('getSelection().toString()')).toBe('');
|
|
22
|
+
await run('document.getElementById("a").style.userSelect="text";getSelection().selectAllChildren(document.getElementById("a"))');
|
|
23
|
+
await run('globalThis.selectedRange=getSelection().getRangeAt(0);getSelection().removeAllRanges();getSelection().addRange(selectedRange)');
|
|
24
|
+
expect(await run('getSelection().toString()')).toBe('abcdefghij');
|
|
25
|
+
await Bun.write('/tmp/document-selection.png' ,Buffer.from(await c.screenshot({format:'png'}),'base64'));
|
|
26
|
+
}finally{b.close()}
|
|
27
|
+
},30000);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {createBrowser} from '../lib/headless-shell.js';
|
|
3
|
+
import {CdpServer} from '../lib/cdp-server.js';
|
|
4
|
+
import {CDP} from '../tools/browser-lab.js';
|
|
5
|
+
import {attachInput} from '../frontends/wayland/input.js';
|
|
6
|
+
import {mkdtempSync,rmSync} from 'node:fs';
|
|
7
|
+
import {join} from 'node:path';
|
|
8
|
+
const html=`<body style="margin:0"><div id="box" style="width:200px;height:100px">Double click</div><script>globalThis.events=[];for(const type of ['mousedown','mouseup','click','dblclick'])document.getElementById('box').addEventListener(type,e=>{globalThis.events.push(type+':'+e.detail);e.preventDefault()})</script>`;
|
|
9
|
+
function pointer(context){
|
|
10
|
+
let seat,ptr;const errors=[];
|
|
11
|
+
const client={globals:new Map([['wl_seat',1]]),connection:{closed:false},bind:(n,v,cb)=>{seat=cb;return 1},object:(n,cb)=>{ptr=cb;return 2},request(){}};
|
|
12
|
+
attachInput(client,{surface:3,closed:false},context,e=>errors.push(e));seat('capabilities',[1]);ptr('enter',[1,3,20,20]);
|
|
13
|
+
return {event:ptr,errors,tap(time,x=20,y=20){ptr('motion',[time,x,y]);ptr('button',[1,time,272,1]);ptr('button',[2,time+30,272,0]);}};
|
|
14
|
+
}
|
|
15
|
+
const until=async(fn)=>{for(let i=0;i<100;i++){if(await fn())return;await Bun.sleep(20)}throw Error('input did not settle')};
|
|
16
|
+
|
|
17
|
+
test('CDP clickCount reaches DOM clicks and dblclick after cancelled click',async()=>{
|
|
18
|
+
const b=await createBrowser({spareRenderer:false});const server=CdpServer.create(b.context).listen(0);
|
|
19
|
+
const cdp=new CDP(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
|
|
20
|
+
try{
|
|
21
|
+
await cdp.ready;
|
|
22
|
+
const {targetId}=await cdp.send('Target.createTarget',{url:'data:text/html,'+encodeURIComponent(html)});
|
|
23
|
+
const {sessionId}=await cdp.send('Target.attachToTarget',{targetId,flatten:true});cdp.sessionId=sessionId;
|
|
24
|
+
await b.context.navigate('data:text/html,'+encodeURIComponent(html));
|
|
25
|
+
for(const clickCount of [1,2,3])for(const type of ['mousePressed','mouseReleased'])await cdp.send('Input.dispatchMouseEvent',{type,x:20,y:20,button:'left',clickCount});
|
|
26
|
+
expect(await b.context.evaluate('events')).toEqual(['mousedown:1','mouseup:1','click:1','mousedown:2','mouseup:2','click:2','dblclick:2','mousedown:3','mouseup:3','click:3']);
|
|
27
|
+
}finally{cdp.close();server.stop(true);b.close()}
|
|
28
|
+
},30000);
|
|
29
|
+
|
|
30
|
+
test('Wayland clicks reach HTML and QML double-tap handlers',async()=>{
|
|
31
|
+
const b=await createBrowser({spareRenderer:false});const dir=mkdtempSync('/tmp/double-click-');
|
|
32
|
+
try{
|
|
33
|
+
await b.context.navigate('data:text/html,'+encodeURIComponent(html));
|
|
34
|
+
const p=pointer(b.context);p.tap(1000);p.tap(1180,28,24);
|
|
35
|
+
await until(async()=>(await b.context.evaluate('events.length'))===7);
|
|
36
|
+
expect(await b.context.evaluate('events.at(-1)')).toBe('dblclick:2');expect(p.errors).toEqual([]);
|
|
37
|
+
await Bun.write(join(dir,'main.qml'),`import QtQuick
|
|
38
|
+
Rectangle {width:200;height:100;color:"white";property int doubles:0
|
|
39
|
+
Text {id:status;text:"doubles="+parent.doubles}
|
|
40
|
+
TapHandler {onDoubleTapped:parent.doubles++}
|
|
41
|
+
}`);
|
|
42
|
+
await b.context.navigate(Bun.pathToFileURL(join(dir,'main.qml')).href);
|
|
43
|
+
const q=pointer(b.context);q.tap(2000,60,60);q.tap(2180,65,63);
|
|
44
|
+
await until(async()=>(await b.context.evaluate('document.getElementById("status").textContent'))==='doubles=1');
|
|
45
|
+
expect(q.errors).toEqual([]);
|
|
46
|
+
}finally{b.close();rmSync(dir,{recursive:true,force:true})}
|
|
47
|
+
},30000);
|
|
48
|
+
|
|
49
|
+
test('Wayland count resets on timeout, distance and leave and handles timestamp wrap',async()=>{
|
|
50
|
+
const counts=[];const p=pointer({click:(x,y,o)=>counts.push(o.clickCount),move(){}});
|
|
51
|
+
p.tap(0xffffff00);p.tap(0x20);p.tap(1000);p.tap(1100,100,100);
|
|
52
|
+
p.event('leave',[]);p.event('enter',[1,3,100,100]);p.tap(1150,100,100);
|
|
53
|
+
await Bun.sleep(0);expect(counts).toEqual([1,2,1,1,1]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('CDP and Wayland preserve non-primary buttons without primary activation',async()=>{
|
|
57
|
+
const b=await createBrowser({spareRenderer:false});const server=CdpServer.create(b.context).listen(0);
|
|
58
|
+
const cdp=new CDP(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
|
|
59
|
+
const dir=mkdtempSync('/tmp/nonprimary-click-');
|
|
60
|
+
try {
|
|
61
|
+
await cdp.ready;
|
|
62
|
+
const url='data:text/html,'+encodeURIComponent(`<body style="margin:0"><a id="box" href="https://example.invalid/" style="display:block;width:200px;height:100px">link</a><script>globalThis.events=[];const box=document.getElementById("box");for(const t of ['mousedown','mouseup','click','auxclick','contextmenu'])box.addEventListener(t,e=>events.push([t,e.button,e.buttons]));box.onauxclick=e=>events.push(['property',e.button]);</script>`);
|
|
63
|
+
const {targetId}=await cdp.send('Target.createTarget',{url});
|
|
64
|
+
const {sessionId}=await cdp.send('Target.attachToTarget',{targetId,flatten:true});cdp.sessionId=sessionId;
|
|
65
|
+
await b.context.navigate(url);
|
|
66
|
+
for(const type of ['mousePressed','mouseReleased'])await cdp.send('Input.dispatchMouseEvent',{type,x:30,y:40,button:'right',clickCount:1});
|
|
67
|
+
expect(await b.context.evaluate('events')).toEqual([['mousedown',2,2],['contextmenu',2,2],['mouseup',2,0],['auxclick',2,0],['property',2]]);
|
|
68
|
+
expect(b.context.url).toBe(url);
|
|
69
|
+
await b.context.evaluate('events=[]');
|
|
70
|
+
const p=pointer(b.context);p.event('button',[1,1000,274,1]);p.event('button',[2,1030,274,0]);
|
|
71
|
+
await until(async()=>(await b.context.evaluate('events.length'))===4);
|
|
72
|
+
expect(await b.context.evaluate('events')).toEqual([['mousedown',1,4],['mouseup',1,0],['auxclick',1,0],['property',1]]);
|
|
73
|
+
await Bun.write(join(dir,'main.qml'),`import QtQuick
|
|
74
|
+
Rectangle {width:200;height:100;color:"white"
|
|
75
|
+
Text {id:status;text:"none"}
|
|
76
|
+
TapHandler {acceptedButtons:Qt.LeftButton | Qt.RightButton | Qt.MiddleButton;onTapped:(point,button)=>{status.text="button="+button}}
|
|
77
|
+
}`);
|
|
78
|
+
await b.context.navigate(Bun.pathToFileURL(join(dir,'main.qml')).href);
|
|
79
|
+
const q=pointer(b.context);
|
|
80
|
+
for(const [code,expected] of [[273,2],[274,4]]){
|
|
81
|
+
q.event('button',[1,2000,code,1]);q.event('button',[2,2030,code,0]);
|
|
82
|
+
await until(async()=>(await b.context.evaluate('document.getElementById("status").textContent'))==='button='+expected);
|
|
83
|
+
}
|
|
84
|
+
}finally{cdp.close();server.stop(true);b.close();rmSync(dir,{recursive:true,force:true})}
|
|
85
|
+
},30000);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {strict as assert} from 'node:assert';
|
|
2
|
+
import {createPageRenderer,loadGraphics} from '../../lib/renderer/page-renderer.js';
|
|
3
|
+
const {CanvasKit:kit}=await loadGraphics();
|
|
4
|
+
const traces=[];
|
|
5
|
+
const r=await createPageRenderer({onFrame:()=>{},trace:s=>traces.push(s)});
|
|
6
|
+
async function pixels(){
|
|
7
|
+
const image=kit.MakeImageFromEncoded(Buffer.from(await r.screenshot(),'base64'));
|
|
8
|
+
try{return image.readPixels(0,0,{width:image.width(),height:image.height(),colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB})}finally{image.delete()}
|
|
9
|
+
}
|
|
10
|
+
try{
|
|
11
|
+
r.resize(400,300,1);r.setScripting('host');
|
|
12
|
+
await r.loadDocument({url:'https://damage.test/',contentType:'text/html',source:`
|
|
13
|
+
<!doctype html><html><head><style>body{margin:0;background:#abc}.clip{position:absolute;left:30px;top:20px;width:90px;height:80px;overflow:hidden;border-radius:15px;background:yellow}canvas{width:120px;height:100px;opacity:.6}.over{position:absolute;left:50px;top:40px;width:30px;height:30px;background:green}</style></head><body>
|
|
14
|
+
<div class=clip><canvas width=120 height=100></canvas></div><div class=over></div><p style="position:absolute;top:160px">Preserve text outside canvas</p>
|
|
15
|
+
<script>globalThis.ctx=document.querySelector('canvas').getContext('2d');</script></body></html>`});
|
|
16
|
+
await r.whenLoaded();
|
|
17
|
+
await r.evaluate("ctx.fillRect(0,0,1,1)");
|
|
18
|
+
await pixels();await Bun.sleep(100);
|
|
19
|
+
for(const command of ["ctx.fillStyle='red';ctx.fillRect(0,0,120,100)","ctx.clearRect(0,0,120,100);ctx.fillStyle='blue';ctx.fillRect(15,15,20,20)","ctx.clearRect(0,0,120,100)"]){
|
|
20
|
+
traces.length=0;
|
|
21
|
+
await r.evaluate(command);
|
|
22
|
+
const partial=await pixels();
|
|
23
|
+
assert.ok(traces.some(s=>s.includes('canvas damage')),'exercise the partial paint path');
|
|
24
|
+
r.resize(401,300,1);r.resize(400,300,1);
|
|
25
|
+
assert.deepEqual(await pixels(),partial,'partial pixels must match a full repaint, including transparency, clipping and occlusion');
|
|
26
|
+
}
|
|
27
|
+
// Geometry/transform changes must fall back, then remain pixel-correct.
|
|
28
|
+
traces.length=0;
|
|
29
|
+
await r.evaluate("document.querySelector('.clip').style.transform='scale(1.2)';ctx.fillRect(0,0,80,80)");
|
|
30
|
+
const transformed=await pixels();
|
|
31
|
+
assert.ok(!traces.some(s=>s.includes('canvas damage')));
|
|
32
|
+
r.resize(401,300,1);r.resize(400,300,1);
|
|
33
|
+
assert.deepEqual(await pixels(),transformed);
|
|
34
|
+
traces.length=0;
|
|
35
|
+
await r.evaluate("ctx.clearRect(0,0,120,100)");
|
|
36
|
+
const cleared=await pixels();
|
|
37
|
+
assert.ok(!traces.some(s=>s.includes('canvas damage')),'transformed canvas stays on full paint');
|
|
38
|
+
r.resize(401,300,1);r.resize(400,300,1);
|
|
39
|
+
assert.deepEqual(await pixels(),cleared);
|
|
40
|
+
}finally{r.dispose()}
|
|
@@ -7,6 +7,7 @@ process.on("message", async (message) => {
|
|
|
7
7
|
if (message?.type !== "call") return;
|
|
8
8
|
const [argument] = message.params;
|
|
9
9
|
switch (message.method) {
|
|
10
|
+
case "configuration": return process.send({ type: "result", id: message.id, result: { args: process.argv, secret: process.env.BUNINU_TEST_PRIVATE_ENV } });
|
|
10
11
|
case "echo": return process.send({ type: "result", id: message.id, result: { pid: process.pid, argument } });
|
|
11
12
|
case "hang": for (;;) {}
|
|
12
13
|
case "fail": return process.send({ type: "result", id: message.id, error: "Error: page crashed\n at somewhere" });
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Build context: buninu-browser root. Single-stage, js-udocker-compatible syntax.
|
|
2
|
+
FROM oven/bun:1.4.4
|
|
3
|
+
ENV DEBIAN_FRONTEND=noninteractive
|
|
4
|
+
ENV HOME=/root
|
|
5
|
+
ENV LC_ALL=C.UTF-8
|
|
6
|
+
RUN set -eu; if [ -f /usr/bin/perl ] && [ ! -e /usr/bin/perl5.40.1 ]; then cp /usr/bin/perl /usr/bin/perl5.40.1; fi
|
|
7
|
+
RUN set -eu; apt-get update; apt-get install -y --no-install-recommends ca-certificates curl libgcc-s1; apt-get clean
|
|
8
|
+
RUN set -eu; mkdir -p /opt/omastorm; curl -fL --retry 3 https://codeload.github.com/wesleygrimes/omastorm/tar.gz/492e97841146bd9d6d9f7a3064e5e1ae9840cc68 -o /tmp/omastorm.tar.gz; tar -xzf /tmp/omastorm.tar.gz --strip-components=1 -C /opt/omastorm; rm /tmp/omastorm.tar.gz
|
|
9
|
+
RUN set -eu; cd /opt/omastorm; XDG_DATA_HOME=/usr/local/share bash scripts/fetch-engine.sh; ln -sfn /usr/local/share/omastorm/bin/omastorm-engine /usr/local/bin/omastorm-engine
|
|
10
|
+
RUN set -eu; mkdir -p /usr/local/share/fonts/omastorm; curl -fL --retry 3 https://raw.githubusercontent.com/ryanoasis/nerd-fonts/v3.4.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFontMono-Regular.ttf -o /usr/local/share/fonts/omastorm/JetBrainsMonoNerdFontMono-Regular.ttf; curl -fL --retry 3 https://raw.githubusercontent.com/ryanoasis/nerd-fonts/v3.4.0/patched-fonts/JetBrainsMono/Ligatures/Regular/OFL.txt -o /usr/local/share/fonts/omastorm/OFL.txt
|
|
11
|
+
COPY index.js /opt/buninu-browser/index.js
|
|
12
|
+
COPY buninu-browser.js /opt/buninu-browser/buninu-browser.js
|
|
13
|
+
COPY frontends /opt/buninu-browser/frontends
|
|
14
|
+
COPY usr /opt/buninu-browser/usr
|
|
15
|
+
COPY lib /opt/buninu-browser/lib
|
|
16
|
+
COPY vendor /opt/buninu-browser/vendor
|
|
17
|
+
COPY LICENSES /opt/buninu-browser/LICENSES
|
|
18
|
+
COPY LICENSE /opt/buninu-browser/LICENSE
|
|
19
|
+
COPY THIRD_PARTY_NOTICES.md /opt/buninu-browser/THIRD_PARTY_NOTICES.md
|
|
20
|
+
COPY package.json /opt/buninu-browser/package.json
|
|
21
|
+
COPY test/fixtures/omastorm-helpers /usr/local/lib/omastorm-helpers
|
|
22
|
+
ENV OMASTORM_ROOT=/opt/omastorm
|
|
23
|
+
ENV OMASTORM_ENGINE_BINARY=/usr/local/bin/omastorm-engine
|
|
24
|
+
ENV QT_QPA_FONTDIR=/usr/local/share/fonts/omastorm
|
|
25
|
+
ENV XDG_RUNTIME_DIR=/tmp
|
|
26
|
+
ENV WAYLAND_DISPLAY=/tmp/wayland-buninu
|
|
27
|
+
ENV XDG_CONFIG_HOME=/root/.config
|
|
28
|
+
ENV XDG_DATA_HOME=/root/.local/share
|
|
29
|
+
ENV XDG_STATE_HOME=/root/.local/state
|
|
30
|
+
ENV XDG_CACHE_HOME=/root/.cache
|
|
31
|
+
RUN bun /usr/local/lib/omastorm-helpers/prepare.mjs /opt/omastorm
|
|
32
|
+
RUN bun /usr/local/lib/omastorm-helpers/check.mjs
|
|
33
|
+
WORKDIR /root
|
|
34
|
+
ENTRYPOINT []
|
|
35
|
+
CMD ["bun", "/usr/local/lib/omastorm-helpers/launch.mjs"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Build-time smoke test: no Wayland socket, GPU, Rust compiler or native Qt.
|
|
2
|
+
import {strict as assert} from 'node:assert';
|
|
3
|
+
import {mkdtemp,rm} from 'node:fs/promises';
|
|
4
|
+
import {pathToFileURL} from 'node:url';
|
|
5
|
+
import {startEngine} from './runtime.mjs';
|
|
6
|
+
const root=process.env.OMASTORM_ROOT||'/opt/omastorm';
|
|
7
|
+
const browser=process.env.BUNINU_BROWSER_ROOT||'/opt/buninu-browser';
|
|
8
|
+
assert.equal(Bun.version,'1.4.4','Expected the pinned oven/bun:1.4.4 runtime');
|
|
9
|
+
for(const name of ['TextureCheck.qml','RadarMap.qml']) {
|
|
10
|
+
const source=await Bun.file(root+'/ui/'+name).text();
|
|
11
|
+
assert(!source.includes('GraphicsInfo.api === GraphicsInfo.Software'),'Software policy was not prepared');
|
|
12
|
+
assert(await Bun.file(root+'/ui/'+name+'.upstream').exists(),'Missing upstream backup');
|
|
13
|
+
}
|
|
14
|
+
const {loadGraphics}=await import(pathToFileURL(browser+'/lib/renderer/page-renderer.js').href);
|
|
15
|
+
const {fragmentSource}=await import(pathToFileURL(browser+'/lib/qml/shader/qsb.js').href);
|
|
16
|
+
const {compileFragment}=await import(pathToFileURL(browser+'/lib/qml/shader/fragment.js').href);
|
|
17
|
+
const {CanvasKit}=await loadGraphics();
|
|
18
|
+
for(const name of ['radar','tile','grid','texture-check']) {
|
|
19
|
+
const compiled=compileFragment(CanvasKit,fragmentSource(await Bun.file(root+'/ui/shaders/'+name+'.frag.qsb').bytes()));
|
|
20
|
+
compiled.effect.delete();
|
|
21
|
+
}
|
|
22
|
+
const font=CanvasKit.Typeface.MakeTypefaceFromData(await Bun.file(process.env.QT_QPA_FONTDIR+'/JetBrainsMonoNerdFontMono-Regular.ttf').bytes());
|
|
23
|
+
assert(font,'Font cannot be decoded');
|
|
24
|
+
assert([...font.getGlyphIDs('')].every(id=>id!==0),'Nerd icons missing');font.delete();
|
|
25
|
+
const help=Bun.spawn([process.env.OMASTORM_ENGINE_BINARY||'/usr/local/bin/omastorm-engine','--help'],{stdout:'pipe',stderr:'pipe'});
|
|
26
|
+
const [helpOut,helpErr,helpCode]=await Promise.all([new Response(help.stdout).text(),new Response(help.stderr).text(),help.exited]);
|
|
27
|
+
assert(helpCode===1 && (helpOut+helpErr).includes('Usage: omastorm-engine'),'Official engine could not execute');
|
|
28
|
+
// During js-udocker build the unbound ROOT/tmp path may exceed AF_UNIX's
|
|
29
|
+
// native path limit. Exercise the socket after binding host TMPDIR to /tmp.
|
|
30
|
+
if(process.argv.includes('--engine')) {
|
|
31
|
+
const engine=await startEngine();
|
|
32
|
+
try {
|
|
33
|
+
await new Promise((resolve,reject)=>{
|
|
34
|
+
let connection,buffer='';
|
|
35
|
+
const timer=setTimeout(()=>{connection?.end();reject(Error('No engine hello'));},10000);
|
|
36
|
+
Bun.connect({unix:engine.socket,socket:{
|
|
37
|
+
open(s){connection=s;},
|
|
38
|
+
data(s,data){buffer+=Buffer.from(data).toString();const i=buffer.indexOf('\n');if(i<0)return;
|
|
39
|
+
try{const hello=JSON.parse(buffer.slice(0,i));assert.equal(hello.type,'hello');assert(hello.sites?.length>0);clearTimeout(timer);s.end();resolve();}
|
|
40
|
+
catch(e){clearTimeout(timer);s.end();reject(e);}},
|
|
41
|
+
error(s,e){clearTimeout(timer);reject(e);},
|
|
42
|
+
}}).catch(e=>{clearTimeout(timer);reject(e);});
|
|
43
|
+
});
|
|
44
|
+
}finally{await engine.close();}
|
|
45
|
+
}
|
|
46
|
+
const scratch=await mkdtemp('/tmp/omastorm-build-check-');
|
|
47
|
+
const {createBrowser}=await import(pathToFileURL(browser+'/lib/headless-shell.js').href);
|
|
48
|
+
const page=await createBrowser({spareRenderer:false});
|
|
49
|
+
try {
|
|
50
|
+
const qml=scratch+'/main.qml';
|
|
51
|
+
await Bun.write(qml,'import QtQuick\nRectangle {width:320;height:120;color:"white";Text {text:"Omastorm ";font.family:"monospace";color:"black"}}');
|
|
52
|
+
await page.context.navigate(pathToFileURL(qml).href);
|
|
53
|
+
assert((await page.context.evaluate('document.body.innerText')).includes('Omastorm'),'QML did not render');
|
|
54
|
+
assert((await page.context.screenshot({format:'png'})).length>100,'Empty screenshot');
|
|
55
|
+
}finally{page.close();await rm(scratch,{recursive:true,force:true});}
|
|
56
|
+
console.log('PASS: Bun 1.4.4, official engine executable, four software shaders, Nerd Font icons and QML screenshot'+(process.argv.includes('--engine')?', engine hello/site list':''));
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {pathToFileURL} from 'node:url';
|
|
2
|
+
import {stat} from 'node:fs/promises';
|
|
3
|
+
import {startEngine,waylandPath} from './runtime.mjs';
|
|
4
|
+
const socket=waylandPath();
|
|
5
|
+
if(!await stat(socket).then(s=>s.isSocket(),()=>false))throw Error('Start Land and bind its socket to '+socket);
|
|
6
|
+
const root=process.env.OMASTORM_ROOT||'/opt/omastorm';
|
|
7
|
+
const browser=process.env.BUNINU_BROWSER_ROOT||'/opt/buninu-browser';
|
|
8
|
+
let engine,app,stopping=false;
|
|
9
|
+
const stop=()=>{stopping=true;app?.close();};
|
|
10
|
+
for(const signal of ['SIGINT','SIGTERM','SIGHUP'])process.on(signal,stop);
|
|
11
|
+
try {
|
|
12
|
+
engine=await startEngine();
|
|
13
|
+
// Set before importing the renderer/QML host, which snapshots environment.
|
|
14
|
+
Object.assign(process.env,engine.env,{WAYLAND_DISPLAY:socket,OMASTORM_ROOT:root});
|
|
15
|
+
const {launch}=await import(pathToFileURL(browser+'/frontends/wayland/index.js').href);
|
|
16
|
+
if(!stopping){
|
|
17
|
+
app=await launch({url:pathToFileURL(root+'/ui/shell.qml').href,socket,width:960,height:680,chrome:false});
|
|
18
|
+
if(stopping)app.close();
|
|
19
|
+
await Promise.race([app.done,engine.child.exited.then(code=>{throw Error('Omastorm engine stopped: '+code);})]);
|
|
20
|
+
}
|
|
21
|
+
}finally{
|
|
22
|
+
app?.close();await engine?.close();
|
|
23
|
+
for(const signal of ['SIGINT','SIGTERM','SIGHUP'])process.off(signal,stop);
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Explicit app experiment, never a browser/renderer special case.
|
|
2
|
+
import {readFile,writeFile,copyFile} from 'node:fs/promises';
|
|
3
|
+
import {resolve} from 'node:path';
|
|
4
|
+
export async function prepare(root) {
|
|
5
|
+
const replacements=[
|
|
6
|
+
['TextureCheck.qml', / if \(GraphicsInfo\.api === GraphicsInfo\.Software\) \{\n fail\("Radar requires GPU rendering \(OpenGL\/Vulkan\)\."\);\n return;\n \}\n/],
|
|
7
|
+
['RadarMap.qml', / Component\.onCompleted: if \(GraphicsInfo\.api === GraphicsInfo\.Software\) map\.graphicsError = "Radar requires GPU rendering \(OpenGL\/Vulkan\)\."\n/],
|
|
8
|
+
];
|
|
9
|
+
// Validate both before changing either. Keep originals for inspection.
|
|
10
|
+
const sources=await Promise.all(replacements.map(async([name,pattern])=>{
|
|
11
|
+
const path=resolve(root,'ui',name),source=await readFile(path,'utf8');
|
|
12
|
+
if(!pattern.test(source))throw Error('Upstream software policy changed; review '+name);
|
|
13
|
+
return {path,source,pattern};
|
|
14
|
+
}));
|
|
15
|
+
for(const {path,source,pattern} of sources){await copyFile(path,path+'.upstream');await writeFile(path,source.replace(pattern,''));}
|
|
16
|
+
}
|
|
17
|
+
if(import.meta.main)await prepare(resolve(process.argv[2]||'/opt/omastorm'));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {mkdtemp,rm,stat} from 'node:fs/promises';
|
|
2
|
+
import {resolve,isAbsolute} from 'node:path';
|
|
3
|
+
export async function startEngine() {
|
|
4
|
+
const runtime=await mkdtemp('/tmp/omastorm-buninu-');
|
|
5
|
+
const engine=process.env.OMASTORM_ENGINE_BINARY||'/usr/local/bin/omastorm-engine';
|
|
6
|
+
const env={...process.env,XDG_RUNTIME_DIR:runtime,OMASTORM_ENGINE_BINARY:engine};
|
|
7
|
+
let child;
|
|
8
|
+
const close=async()=>{
|
|
9
|
+
if(child && child.exitCode===null){
|
|
10
|
+
child.kill('SIGTERM');
|
|
11
|
+
const timer=setTimeout(()=>{if(child.exitCode===null)child.kill('SIGKILL');},3000);
|
|
12
|
+
try{await child.exited;}finally{clearTimeout(timer);}
|
|
13
|
+
}
|
|
14
|
+
await rm(runtime,{recursive:true,force:true});
|
|
15
|
+
};
|
|
16
|
+
try {
|
|
17
|
+
child=Bun.spawn([engine,'serve'],{env,stdin:'ignore',stdout:'inherit',stderr:'inherit'});
|
|
18
|
+
const socket=runtime+'/omastorm/engine.sock';
|
|
19
|
+
const deadline=Date.now()+15000;
|
|
20
|
+
while(true){
|
|
21
|
+
if(child.exitCode!==null)throw Error('Omastorm engine exited: '+child.exitCode);
|
|
22
|
+
if(await stat(socket).then(s=>s.isSocket(),()=>false))break;
|
|
23
|
+
if(Date.now()>deadline)throw Error('Omastorm engine socket did not become ready');
|
|
24
|
+
await Bun.sleep(50);
|
|
25
|
+
}
|
|
26
|
+
return {env,socket,child,close};
|
|
27
|
+
}catch(e){await close();throw e;}
|
|
28
|
+
}
|
|
29
|
+
export function waylandPath(env=process.env){
|
|
30
|
+
const path=env.WAYLAND_DISPLAY||'/tmp/wayland-buninu';
|
|
31
|
+
return isAbsolute(path)?path:resolve(env.XDG_RUNTIME_DIR||'/tmp',path);
|
|
32
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {strict as assert} from 'node:assert';
|
|
2
|
+
import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
|
|
3
|
+
let frames=0;
|
|
4
|
+
const r=await createPageRenderer({onFrame:()=>frames++});
|
|
5
|
+
try {
|
|
6
|
+
r.resize(400,300,1);
|
|
7
|
+
r.setScripting('host');
|
|
8
|
+
await r.loadDocument({url:'https://paint.test/',contentType:'text/html',source:`
|
|
9
|
+
<body style="margin:0"><canvas width="400" height="300"></canvas><script>
|
|
10
|
+
globalThis.moves=0;
|
|
11
|
+
const c=document.querySelector('canvas'),ctx=c.getContext('2d');
|
|
12
|
+
c.onmousemove=e=>{moves++;ctx.fillRect(e.clientX,e.clientY,2,2)};
|
|
13
|
+
</script>`});
|
|
14
|
+
await r.whenLoaded();
|
|
15
|
+
await Bun.sleep(100);
|
|
16
|
+
frames=0;
|
|
17
|
+
for(let i=0;i<50;i++)r.move(20+i,30);
|
|
18
|
+
assert.equal(frames,0,'input must not synchronously rasterize each motion');
|
|
19
|
+
await Bun.sleep(100);
|
|
20
|
+
assert.ok(frames>0 && frames<50,'queued canvas changes must reach the compositor');
|
|
21
|
+
assert.equal(await r.evaluate('moves'),50,'all drawing points must be delivered');
|
|
22
|
+
assert.equal(await r.evaluate("document.querySelector('canvas').getContext('2d').getImageData(69,30,1,1).data[3]"),255);
|
|
23
|
+
} finally {r.dispose()}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import QtQuick
|
|
2
|
+
import Quickshell
|
|
3
|
+
FloatingWindow {
|
|
4
|
+
width: 600; height: 200; color: "#18202a"
|
|
5
|
+
Text { x: 20; y: 20; text: "Text: "; font.family: "monospace"; font.pixelSize: 32; color: "white" }
|
|
6
|
+
Canvas {
|
|
7
|
+
x:20; y:90; width:560; height:80
|
|
8
|
+
onPaint: { let c=getContext("2d"); c.font="32px monospace"; c.fillStyle="white"; c.fillText("Canvas: ",0,40); }
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import {createBrowser} from '../../../lib/headless-shell.js';
|
|
2
|
+
const b=await createBrowser({spareRenderer:false});
|
|
3
|
+
try {
|
|
4
|
+
await b.context.navigate(new URL('main.qml',import.meta.url).href);
|
|
5
|
+
console.log(JSON.stringify(await b.context.evaluate('localeResult')));
|
|
6
|
+
} finally {b.close();}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import QtQuick
|
|
2
|
+
Rectangle {
|
|
3
|
+
id:root;width:64;height:64;color:"red"
|
|
4
|
+
Canvas {
|
|
5
|
+
id:reader;width:1;height:1;visible:false
|
|
6
|
+
onImageLoaded:{
|
|
7
|
+
var image=getContext("2d").createImageData(globalThis.grabUrl);
|
|
8
|
+
globalThis.grabPixels=Array.from(image.data).slice(0,4);
|
|
9
|
+
unloadImage(globalThis.grabUrl);
|
|
10
|
+
globalThis.unloaded=!isImageLoaded(globalThis.grabUrl);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
Component.onCompleted:Qt.callLater(function(){root.grabToImage(function(result){globalThis.grabUrl=result.url;reader.loadImage(result.url);},Qt.size(16,16));})
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import QtQuick
|
|
2
|
+
Rectangle {
|
|
3
|
+
width: 640; height: 420; color: "#161b22"
|
|
4
|
+
Text { x:20; y:12; text:"Software ShaderEffect / click the gradient"; color:"white"; font.pixelSize:20 }
|
|
5
|
+
ShaderEffect {
|
|
6
|
+
id: shader
|
|
7
|
+
x:20; y:52; width:600; height:320
|
|
8
|
+
property real phase:0.2
|
|
9
|
+
fragmentShader:"gradient.frag"
|
|
10
|
+
MouseArea { anchors.fill:parent; onClicked:shader.phase=shader.phase < 0.5 ? 0.9 : 0.2 }
|
|
11
|
+
Component.onCompleted:globalThis.testShader=shader
|
|
12
|
+
}
|
|
13
|
+
Text { x:20; y:385; text:shader.status===1 ? "CPU raster / blue = "+shader.phase : shader.log; color:"#58d5dd";font.pixelSize:16 }
|
|
14
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import QtQuick
|
|
2
|
+
Rectangle {
|
|
3
|
+
width:64;height:64;color:"black"
|
|
4
|
+
Image {
|
|
5
|
+
id:input;visible:false
|
|
6
|
+
source:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAEElEQVR4nGP4z8AARAwoFABE0AX7pM/egAAAAABJRU5ErkJggg=="
|
|
7
|
+
}
|
|
8
|
+
ShaderEffect {
|
|
9
|
+
id:probe;width:16;height:16
|
|
10
|
+
property var source:input
|
|
11
|
+
property vector2d expected:Qt.vector2d(2,3)
|
|
12
|
+
fragmentShader:"texture-size.frag"
|
|
13
|
+
}
|
|
14
|
+
Canvas {
|
|
15
|
+
id:reader;width:1;height:1;visible:false
|
|
16
|
+
onImageLoaded:{
|
|
17
|
+
globalThis.grabPixels=Array.from(getContext("2d").createImageData(globalThis.grabUrl).data).slice(0,4);
|
|
18
|
+
unloadImage(globalThis.grabUrl);
|
|
19
|
+
globalThis.unloaded=!isImageLoaded(globalThis.grabUrl);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
Timer {interval:100;running:true;repeat:true;onTriggered:{
|
|
23
|
+
if(input.status===Image.Ready && probe.status===ShaderEffect.Compiled){stop();probe.grabToImage(function(result){globalThis.grabUrl=result.url;reader.loadImage(result.url);},Qt.size(16,16));}
|
|
24
|
+
}}
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {strict as assert} from 'node:assert';
|
|
2
|
+
import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
|
|
3
|
+
const texture=process.argv[2]==='texture';
|
|
4
|
+
const grab=process.argv[2]==='grab'||texture;
|
|
5
|
+
const url=new URL(texture?'./qml-shader/texture-grab.qml':grab?'./qml-shader/grab.qml':'./qml-shader/main.qml',import.meta.url).href;
|
|
6
|
+
const errors=[];
|
|
7
|
+
const r=await createPageRenderer({reportError:e=>errors.push(String(e.stack||e)),fetchResource:async url=>({ok:true,status:200,url,body:url.startsWith('data:')?Buffer.from(url.slice(url.indexOf(',')+1),'base64'):await Bun.file(new URL(url)).bytes()})});
|
|
8
|
+
r.resize(640,420,1);r.setScripting('host');
|
|
9
|
+
try{
|
|
10
|
+
await r.loadDocument({url,contentType:'text/x-qml',source:await Bun.file(new URL(url)).text()});await r.whenLoaded();
|
|
11
|
+
await Bun.sleep(100);
|
|
12
|
+
if(!grab){
|
|
13
|
+
const pixel=()=>r.evaluate('Array.from(document.querySelector("canvas").getContext("2d").getImageData(300,160,1,1).data)');
|
|
14
|
+
assert.deepEqual(await pixel(),[128,128,51,255]);
|
|
15
|
+
await r.evaluate('testShader.phase=0.9');await Bun.sleep(100);
|
|
16
|
+
assert.deepEqual(await pixel(),[128,128,230,255]);
|
|
17
|
+
}else{
|
|
18
|
+
for(let i=0;i<100;i++){if(await r.evaluate('!!globalThis.grabPixels'))break;await Bun.sleep(10);}
|
|
19
|
+
assert.deepEqual(await r.evaluate('globalThis.grabPixels'),texture?[0,255,0,255]:[255,0,0,255]);
|
|
20
|
+
assert.equal(await r.evaluate('globalThis.unloaded'),true);
|
|
21
|
+
}
|
|
22
|
+
assert.deepEqual(errors,[]);
|
|
23
|
+
console.log('shader reactive pixels OK');
|
|
24
|
+
}finally{r.dispose();}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {strict as assert} from 'node:assert';
|
|
2
|
+
import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
|
|
3
|
+
const traces=[];
|
|
4
|
+
const r=await createPageRenderer({trace:s=>traces.push(s)});
|
|
5
|
+
try{
|
|
6
|
+
r.setScripting('host');
|
|
7
|
+
await r.loadDocument({url:'https://title.test/',contentType:'text/html',source:`<title>Initial</title><p>Before</p><script>globalThis.changeTitle=value=>{document.title=value;document.querySelector('p').textContent='Changed '+value};</script>`});
|
|
8
|
+
await r.whenLoaded();
|
|
9
|
+
assert.equal(r.title(),'Initial');
|
|
10
|
+
// Host scripting allows the fixture to mutate the DOM without evaluate()'s
|
|
11
|
+
// explicit refresh. No onFrame callback: pending work stays pending.
|
|
12
|
+
for(const title of ['Updated','']){
|
|
13
|
+
globalThis.changeTitle(title);
|
|
14
|
+
traces.length=0;
|
|
15
|
+
assert.equal(r.title(),title);
|
|
16
|
+
assert.equal(traces.length,0,'title reads must not rebuild or rasterize');
|
|
17
|
+
await r.screenshot();
|
|
18
|
+
assert.ok(traces.some(s=>s.includes('restyle and relayout')),'DOM mutations remain pending until rendering is requested');
|
|
19
|
+
}
|
|
20
|
+
}finally{delete globalThis.changeTitle;r.dispose()}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>雙擊測試 · ondblclick</title>
|
|
4
|
+
<style>
|
|
5
|
+
body{margin:0;padding:28px;background:#101c2c;color:#e9f2ff;font-family:sans-serif}
|
|
6
|
+
h1{font-size:26px}p{line-height:1.6;color:#bed0e8}
|
|
7
|
+
#target{display:block;width:100%;height:180px;background:#204763;color:white;border:2px solid #5ad5df;border-radius:12px;font-size:26px}
|
|
8
|
+
#result{padding:18px;font-size:22px;color:#71edcf}
|
|
9
|
+
#stats{padding:14px;background:#192b40;line-height:1.8}
|
|
10
|
+
</style>
|
|
11
|
+
<h1>雙擊才能開啟</h1>
|
|
12
|
+
<p>在下面區域快速點兩下。單擊只更新紀錄,雙擊才會切換開啟/關閉狀態。</p>
|
|
13
|
+
<button id="target" onclick="recordClick(event)" ondblclick="toggleDoor(event)">🔒 雙擊開啟</button>
|
|
14
|
+
<div id="result">等待 dblclick…</div>
|
|
15
|
+
<div id="stats">click:0 dblclick:0 最近 detail:0</div>
|
|
16
|
+
<script>
|
|
17
|
+
let clicks=0,doubles=0,opened=false;
|
|
18
|
+
function stats(detail){document.getElementById('stats').textContent='click:'+clicks+' dblclick:'+doubles+' 最近 detail:'+detail}
|
|
19
|
+
function recordClick(e){clicks++;stats(e.detail)}
|
|
20
|
+
function toggleDoor(e){doubles++;opened=!opened;document.getElementById('target').textContent=opened?'🔓 已開啟,雙擊關閉':'🔒 已關閉,雙擊開啟';document.getElementById('result').textContent='收到 ondblclick!第 '+doubles+' 次';stats(e.detail)}
|
|
21
|
+
</script>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-Hant"><meta charset="utf-8"><title>滑鼠按鍵測試</title>
|
|
3
|
+
<style>
|
|
4
|
+
body{margin:0;padding:28px;background:#101a28;color:#e1f2ff;font-family:sans-serif}
|
|
5
|
+
h1{font-size:28px}p{line-height:1.6}button{display:block;width:100%;height:220px;background:#146d83;color:white;border:3px solid #62dbe9;border-radius:18px;font-size:34px;font-weight:bold}
|
|
6
|
+
#result{margin-top:24px;font-size:27px;color:#70efce}#counts{font-size:22px}pre{white-space:pre-wrap;background:#19283b;padding:16px;font-size:17px}
|
|
7
|
+
</style>
|
|
8
|
+
<h1>左鍵・右鍵・中鍵測試</h1><p>直接點按測左鍵。長按圖片,選 Right click 測右鍵。右鍵不應增加左鍵次數。</p>
|
|
9
|
+
<button id="pad">在這裡點擊 / 長按</button><div id="result">等待按鍵…</div><p id="counts">左鍵 0 | 右鍵 0 | 中鍵 0</p><pre id="events">事件記錄</pre>
|
|
10
|
+
<script>
|
|
11
|
+
const pad=document.getElementById('pad'),result=document.getElementById('result'),counts=document.getElementById('counts'),log=document.getElementById('events');
|
|
12
|
+
const totals=[0,0,0],names=['左鍵','中鍵','右鍵'];let rows=[];
|
|
13
|
+
for(const type of ['mousedown','contextmenu','mouseup','click','auxclick'])pad.addEventListener(type,e=>{
|
|
14
|
+
if(type==='contextmenu')e.preventDefault();
|
|
15
|
+
rows.unshift(type+' button='+e.button+' buttons='+e.buttons);rows=rows.slice(0,10);log.textContent=rows.join('\n');
|
|
16
|
+
if(type==='click'||type==='auxclick'){totals[e.button]++;result.textContent='收到:'+names[e.button];counts.textContent='左鍵 '+totals[0]+' | 右鍵 '+totals[2]+' | 中鍵 '+totals[1];}
|
|
17
|
+
});
|
|
18
|
+
</script></html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Requires an already-running official engine; never starts or installs one.
|
|
2
|
+
import {mkdtemp,rm} from 'node:fs/promises';
|
|
3
|
+
import {resolve} from 'node:path';
|
|
4
|
+
import {pathToFileURL} from 'node:url';
|
|
5
|
+
import {createBrowser} from '../lib/headless-shell.js';
|
|
6
|
+
const root=resolve(process.argv[2] || '../omastorm');
|
|
7
|
+
const dir=await mkdtemp('/tmp/omastorm-engine-probe-');
|
|
8
|
+
await Bun.write(dir+'/probe.qml',`import QtQuick
|
|
9
|
+
import ${JSON.stringify(pathToFileURL(root+'/ui').href)} as Storm
|
|
10
|
+
Item {Storm.Engine {id:engine} Component.onCompleted:globalThis.engineProbe=engine}`);
|
|
11
|
+
const browser=await createBrowser({spareRenderer:false});
|
|
12
|
+
try {
|
|
13
|
+
await browser.context.navigate(pathToFileURL(dir+'/probe.qml').href);
|
|
14
|
+
for(let i=0;i<100;i++){
|
|
15
|
+
const value=await browser.context.evaluate('globalThis.engineProbe ? {error:engineProbe.error, sites:engineProbe.sites.length,state:engineProbe.state?.type,frame:engineProbe.state?.frame?.kind} : {text:document.body.textContent.slice(0,1000)}');
|
|
16
|
+
if(value?.state || value?.error || value?.text){console.log(JSON.stringify(value));break;}
|
|
17
|
+
await Bun.sleep(50);
|
|
18
|
+
}
|
|
19
|
+
}finally{browser.close();await rm(dir,{recursive:true,force:true});}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Offline, CPU-only compatibility probe. Reads upstream GLSL without modifying it.
|
|
2
|
+
import {resolve} from 'node:path';
|
|
3
|
+
import init from '../usr/lib/canvaskit/canvaskit.js';
|
|
4
|
+
import {compileFragment,makeFragmentShader} from '../lib/qml/shader/fragment.js';
|
|
5
|
+
import {fragmentSource} from '../lib/qml/shader/qsb.js';
|
|
6
|
+
const root=resolve(process.argv[2]||'../omastorm');
|
|
7
|
+
const kit=await init({wasmBinary:await Bun.file(new URL('../usr/lib/canvaskit/canvaskit.wasm',import.meta.url)).arrayBuffer()});
|
|
8
|
+
for(const name of ['tile','texture-check','radar','grid']){
|
|
9
|
+
const c=compileFragment(kit,fragmentSource(await Bun.file(root+'/ui/shaders/'+name+'.frag.qsb').bytes()));
|
|
10
|
+
try{
|
|
11
|
+
console.log(name+': CPU-compatible RuntimeEffect compiled');
|
|
12
|
+
if(!['tile','texture-check'].includes(name))continue;
|
|
13
|
+
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,255,0,0,255]),8);
|
|
14
|
+
const child=image.makeShaderOptions(kit.TileMode.Clamp,kit.TileMode.Clamp,kit.FilterMode.Nearest,kit.MipmapMode.None);
|
|
15
|
+
const shader=makeFragmentShader(c,{width:16,height:16,uniforms:{expected:[2,1],boundaries:[1,0,0,1],water:[0,0,0,0],minorRoads:[0,0,0,0],majorRoads:[0,0,0,0]},textures:{[name==='tile'?'mask':'sweep']:{shader:child,width:2,height:1}}});
|
|
16
|
+
const surface=kit.MakeSurface(16,16),paint=new kit.Paint();
|
|
17
|
+
try{
|
|
18
|
+
paint.setShader(shader);surface.getCanvas().drawRect(kit.XYWHRect(0,0,16,16),paint);surface.flush();
|
|
19
|
+
const result=surface.makeImageSnapshot();
|
|
20
|
+
try{
|
|
21
|
+
const pixel=Array.from(result.readPixels(0,0,{width:1,height:1,colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB}));
|
|
22
|
+
const expected=name==='tile'?[255,0,0,255]:[0,255,0,255];
|
|
23
|
+
if(JSON.stringify(pixel)!==JSON.stringify(expected))throw Error(name+' wrong pixel: '+pixel);
|
|
24
|
+
console.log(name+': CPU pixel '+pixel+' verified');
|
|
25
|
+
}finally{result.delete();}
|
|
26
|
+
}finally{paint.delete();surface.delete();shader.delete();child.delete();image.delete();}
|
|
27
|
+
}finally{c.effect.delete();}
|
|
28
|
+
}
|