buninu-browser 0.4.0 → 0.5.1

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 (99) 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/happy-dom/inert-link.js +26 -0
  9. package/lib/happy-dom/node-iterator-removal.js +36 -0
  10. package/lib/happy-dom/node-replacement.js +60 -0
  11. package/lib/happy-dom/parser.js +4 -1
  12. package/lib/headless-shell.js +7 -4
  13. package/lib/layout/text-layout.js +4 -2
  14. package/lib/paint/display-list.js +2 -1
  15. package/lib/qml/README.md +456 -0
  16. package/lib/qml/builtins.js +76 -5
  17. package/lib/qml/canvas.js +76 -0
  18. package/lib/qml/document.js +47 -24
  19. package/lib/qml/fonts.js +69 -0
  20. package/lib/qml/host.js +28 -0
  21. package/lib/qml/loader.js +7 -3
  22. package/lib/qml/models.js +1 -1
  23. package/lib/qml/reactive.js +26 -3
  24. package/lib/qml/runtime.js +263 -41
  25. package/lib/qml/shader/fragment.js +83 -0
  26. package/lib/qml/shader/qsb.js +27 -0
  27. package/lib/qml/shader/renderer.js +39 -0
  28. package/lib/qml/shader/runtime.js +61 -0
  29. package/lib/qml/shapes.js +9 -1
  30. package/lib/qml/shortcuts.js +65 -0
  31. package/lib/qml/textures.js +4 -1
  32. package/lib/qml/window-constraints.js +16 -0
  33. package/lib/renderer/bindings.js +110 -4
  34. package/lib/renderer/canvas-2d.js +16 -0
  35. package/lib/renderer/document-selection.js +19 -0
  36. package/lib/renderer/host.js +14 -11
  37. package/lib/renderer/main.js +2 -0
  38. package/lib/renderer/page-realm.js +4 -9
  39. package/lib/renderer/page-renderer.js +283 -39
  40. package/lib/renderer/pointer-move.js +5 -3
  41. package/lib/renderer/render-interval.js +9 -0
  42. package/lib/style/computed-style.js +8 -2
  43. package/package.json +1 -1
  44. package/test/audio-connection-lifetime.test.js +46 -0
  45. package/test/canvas-paint.test.js +8 -0
  46. package/test/compare-node-iterator.js +42 -0
  47. package/test/computed-style.test.js +12 -0
  48. package/test/document-selection.test.js +27 -0
  49. package/test/double-click.test.js +85 -0
  50. package/test/fixtures/canvas-damage.js +40 -0
  51. package/test/fixtures/fake-renderer.js +1 -0
  52. package/test/fixtures/fractional-text-scale.js +38 -0
  53. package/test/fixtures/node-iterator-cases.js +59 -0
  54. package/test/fixtures/node-iterator-chromium.json +315 -0
  55. package/test/fixtures/omastorm-helpers/Dockerfile +35 -0
  56. package/test/fixtures/omastorm-helpers/check.mjs +56 -0
  57. package/test/fixtures/omastorm-helpers/launch.mjs +24 -0
  58. package/test/fixtures/omastorm-helpers/prepare.mjs +17 -0
  59. package/test/fixtures/omastorm-helpers/runtime.mjs +32 -0
  60. package/test/fixtures/pointer-paint-batching.js +23 -0
  61. package/test/fixtures/qml-fonts/main.qml +10 -0
  62. package/test/fixtures/qml-locale/check.mjs +6 -0
  63. package/test/fixtures/qml-locale/main.qml +8 -0
  64. package/test/fixtures/qml-shader/grab.qml +14 -0
  65. package/test/fixtures/qml-shader/gradient.frag +5 -0
  66. package/test/fixtures/qml-shader/main.qml +14 -0
  67. package/test/fixtures/qml-shader/texture-grab.qml +25 -0
  68. package/test/fixtures/qml-shader/texture-size.frag +5 -0
  69. package/test/fixtures/qml-shader-check.mjs +24 -0
  70. package/test/fixtures/title-no-paint.js +20 -0
  71. package/test/fractional-text-scale.test.js +6 -0
  72. package/test/inert-link.test.js +37 -0
  73. package/test/manual-double-click.html +21 -0
  74. package/test/manual-mouse-buttons.html +18 -0
  75. package/test/manual-omastorm-engine.mjs +19 -0
  76. package/test/manual-omastorm-shaders.mjs +28 -0
  77. package/test/manual-omastorm-software.mjs +25 -0
  78. package/test/manual-pointer-scroll.html +43 -0
  79. package/test/node-iterator.test.js +81 -0
  80. package/test/page-scripts.test.js +116 -1
  81. package/test/pointer-drag.test.js +104 -0
  82. package/test/qml-canvas.test.js +21 -0
  83. package/test/qml-connections-timing.test.js +22 -0
  84. package/test/qml-controls-window.test.js +182 -0
  85. package/test/qml-fonts.test.js +31 -0
  86. package/test/qml-grid-multiline.test.js +48 -0
  87. package/test/qml-locale.test.js +45 -0
  88. package/test/qml-nonvisual-visible.test.js +19 -0
  89. package/test/qml-shader-runtime.test.js +6 -0
  90. package/test/qml-shortcuts.test.js +39 -0
  91. package/test/qml-socket.test.js +60 -0
  92. package/test/qml-software-shader.test.js +55 -0
  93. package/test/qml-window-update.test.js +36 -0
  94. package/test/render-interval.test.js +33 -0
  95. package/test/text-layout.test.js +15 -0
  96. package/test/title-no-paint.test.js +6 -0
  97. package/test/wayland-scroll.test.js +40 -0
  98. package/test/wayland-urlbar.test.js +23 -0
  99. package/test/window-resize.test.js +29 -0
package/README.md CHANGED
@@ -17,7 +17,10 @@ directly as JavaScript and WebAssembly.
17
17
  `bun index.js --wayland URL` opens a standard Wayland SHM window using
18
18
  `WAYLAND_DISPLAY` and `XDG_RUNTIME_DIR`. Casty remains the default.
19
19
  See [frontend details and limitations](frontends/wayland/README.md).
20
- This does not yet provide full Quickshell/Flea/Omastorm compatibility.
20
+ See the [experimental QML runtime and Flea/Omastorm container recipes](lib/qml/README.md)
21
+ for local QML applications. This does not yet provide full Quickshell/Flea/Omastorm
22
+ compatibility. For input checks, open `test/manual-pointer-scroll.html` through
23
+ either frontend.
21
24
 
22
25
  ## Usage
23
26
 
@@ -435,6 +438,24 @@ additional unreported downstream latency, such as Bluetooth output latency.
435
438
  With `BUNINU_LOG`, `media sync` records reported audio progress, the latest
436
439
  rendered video position, and the output queue/latency for diagnosis.
437
440
 
441
+ ### Render scheduling
442
+
443
+ Set `BUNINU_RENDER_MIN_INTERVAL` to adjust the minimum delay before coalesced
444
+ DOM/Canvas redraws. The default is 16 ms; try 8 ms for more frequent updates:
445
+
446
+ ```sh
447
+ env BUNINU_RENDER_MIN_INTERVAL=8 bun index.js --wayland URL
448
+ env BUNINU_RENDER_MIN_INTERVAL=8 bun index.js URL
449
+ ```
450
+
451
+ Accepts integer milliseconds from 0 to 60000. Invalid values warn and fall back
452
+ to 16 ms. Zero removes the minimum delay, but retains asynchronous coalescing
453
+ and the adaptive delay based on the previous layout cost. This is not an FPS
454
+ guarantee or Casty's capture interval; shorter waits can increase CPU usage.
455
+ Restart the browser after changing it. The setting also applies to iframe
456
+ renderers, and only its validated numeric value is forwarded to the renderer
457
+ process; the ordinary renderer does not inherit the host environment.
458
+
438
459
  ### Diagnostics
439
460
 
440
461
  [`test/fixtures/browser-raw-demo.html`](test/fixtures/browser-raw-demo.html)
@@ -70,7 +70,16 @@ The producer's raw file is removed after consumption, including error paths.
70
70
 
71
71
  ## Input and scope / 輸入與範圍
72
72
 
73
- Pointer motion, left click, scrolling, basic XKB keymap symbols and modifiers.
73
+ Pointer motion, left/right/middle clicks, double clicks, scrolling on both axes,
74
+ basic XKB keymap symbols and modifiers. Right clicks dispatch `contextmenu`;
75
+ non-primary clicks dispatch `auxclick` without left-click navigation or form actions.
76
+ Press, movement and release are delivered separately; held-button state reaches
77
+ mousemove/pointermove so HTML canvas and QML drag handlers can draw before release.
78
+ Focus occurs on an uncancelled left press. Click/auxclick uses the common ancestor
79
+ of the press and release targets; removing the press target prevents activation.
80
+ The same page event handling is available through CDP/Casty. QML MouseArea and
81
+ TapHandler receive accepted non-primary buttons. Middle click does not yet paste
82
+ PRIMARY selection, and selecting text does not publish a PRIMARY clipboard.
74
83
  Keyboard input uses the controller's existing paired keydown/keyup operation.
75
84
  Not full XKB: no compose, IME, layout groups, repeat, or independently held keys.
76
85
  No client cursor, drag/drop, popup, layer-shell or fractional scaling yet.
@@ -82,37 +91,45 @@ Clipboard API security/user-activation checks remain in effect.
82
91
  才讀主機工具,成功取得的非空文字也送回 Wayland,讓 Land 可以快取。
83
92
  網頁 API 原本的安全來源與使用者操作檢查仍保留。
84
93
  Connection and window are separate objects, but the launcher currently opens one
85
- browser document/window. QML `ShellRoot` and multiple windows are not implemented.
86
-
87
- 支援基本滑鼠、左鍵、捲動與鍵盤;尚非完整 XKB/IME。按鍵沿用 controller 的成對
94
+ browser document/window. QML `ShellRoot` can group logical objects, but multiple independent native windows
95
+ are not implemented.
96
+
97
+ 支援滑鼠移動、左/右/中鍵、雙擊及雙軸捲動。按下、移動與放開分別即時傳遞,
98
+ mousemove/pointermove 保留按住狀態,讓 Canvas 與 QML 可在放開前處理拖曳。
99
+ 未取消的左鍵按下會取得焦點;click/auxclick 以按下與放開元素的共同祖先為目標,
100
+ 按下元素已被移除時不觸發點擊。右鍵送出 contextmenu,非左鍵送出
101
+ auxclick,不執行左鍵導航或表單動作;CDP/Casty 也共用這些事件。QML MouseArea
102
+ 與 TapHandler 可收到允許的非左鍵按鈕。選取文字尚不發布 PRIMARY,中鍵也不會
103
+ 自動貼上 PRIMARY。尚非完整 XKB/IME;按鍵沿用 controller 的成對
88
104
  按下/放開,不支援獨立長按。啟動器目前只有單一文件/視窗。
89
105
 
90
106
  ## Compatibility targets / 相容性目標
91
107
 
92
- Neither Flea nor Omastorm runs unmodified on the current QML subset.
93
- Flea 和 Omastorm 目前都還不能直接以現有 QML 子集執行。
94
-
95
- * Flea: ShellRoot, custom components/imports, models, controls/layouts, Quickshell.Io
96
- process/IPC and multiple windows. Tab tearoff also uses PanelWindow/WlrLayershell.
97
- * Omastorm (`../omastorm`, MIT reference checkout): its manifest exposes bar-widget
98
- and panel entry points. Requires custom components, Controls/Layouts, FileView,
99
- Process, Socket/SplitParser, IPC, timers, Image/Canvas and ShaderEffect.
100
- `docs/protocol.md` defines newline-delimited JSON v2 over
101
- `$XDG_RUNTIME_DIR/omastorm/engine.sock`, served by the separate Rust engine.
102
- GPU texture files and shaders require rendering compatibility beyond DOM parsing.
103
-
104
- Flea 需要多視窗、元件、模型與程序/IPC;分離分頁另需 layer-shell。
105
- Omastorm 還需要 Unix socket JSON 橋接及雷達 shader/貼圖支援;只有 parser
106
- 與標準 Wayland 視窗不足以適配。不會為一般網頁開放主機檔案或程序權限。
108
+ The official Flea binary can launch the experimental QML wrapper; directory
109
+ navigation, previews and selected file operations have been exercised. Omastorm
110
+ has a software ShaderEffect experiment and a reproducible container recipe.
111
+ See [QML setup and limitations](../../lib/qml/README.md) for required backends,
112
+ fonts, external tools and the explicit Omastorm software-path adaptation.
113
+ Neither result implies complete Qt/Quickshell compatibility: independent native
114
+ windows, tab tearoff/layer-shell and arbitrary shaders remain outside this scope.
115
+ Ordinary web pages do not acquire the local QML host's file/process permissions.
116
+
117
+ 官方 Flea binary 已可啟動實驗性 QML wrapper,並測試目錄導航、預覽與部分檔案操作。
118
+ Omastorm 已有軟體 ShaderEffect 實驗及容器教學;所需後端、字型、外部工具與
119
+ Omastorm 的軟體路徑調整,見 [QML 說明](../../lib/qml/README.md)。這不代表完整
120
+ Qt/Quickshell 相容;獨立原生多視窗、分離分頁/layer-shell、任意 shader 仍有限制。
121
+ 一般網頁不會因此取得本地 QML 的檔案或程序權限。
107
122
 
108
123
  References: https://github.com/thisisgm/flea and https://github.com/wesleygrimes/omastorm
109
124
 
110
125
  ## Verification / 測試
111
126
 
112
127
  ```sh
113
- bun test test/wayland-frontend.test.js
128
+ bun test test/wayland-frontend.test.js test/double-click.test.js test/wayland-urlbar.test.js test/pointer-drag.test.js
114
129
  # Optional integration with the existing sibling buninu-land checkout:
115
- BUNINU_TEST_WAYLAND=1 bun test test/wayland-frontend.test.js
130
+ BUNINU_TEST_WAYLAND=1 bun test test/wayland-frontend.test.js test/wayland-scroll.test.js test/pointer-drag.test.js
131
+ # Optional reference comparison with /usr/local/bin/chromium-headless-shell:
132
+ BUNINU_TEST_CHROMIUM=1 bun test test/pointer-drag.test.js
116
133
  ```
117
134
 
118
135
  The integration test creates its own compositor/socket; it does not interrupt the
@@ -121,3 +138,15 @@ user's compositor. Checks SHM presentation, pointer click, keyboard and resize.
121
138
 
122
139
  Protocol XML retains upstream license notices. Transport/protocol code adapted
123
140
  from Buninu Land (MIT), see LICENSE-land. XML is interpreted at runtime with Bun.XML.
141
+
142
+ Manual mouse/scroll test / 手動滑鼠與捲動測試:
143
+
144
+ ```sh
145
+ bun index.js --wayland file://"$PWD"/test/manual-pointer-scroll.html
146
+ # Omit --wayland to use the default Casty frontend.
147
+ ```
148
+
149
+ The page includes vertical/horizontal/two-axis scroll boxes, button and double-click
150
+ counters, a drag canvas, text controls and bounded event logs. It reports received
151
+ modifiers without swapping wheel axes itself. 頁面不自行轉換滾輪方向,可從紀錄
152
+ 確認外層是否真的送來水平軸與修飾鍵;預設 Casty 路徑可省略 `--wayland`。
@@ -9,6 +9,7 @@ import {WaylandUrlBar,BAR_HEIGHT} from './urlbar.js';
9
9
  import {readClipboard,writeClipboard} from '../../lib/clipboard.js';
10
10
 
11
11
  // qml handle begin: local QML clipboard uses this frontend's Wayland seat
12
+ import {qmlWindowConstraints} from '../../lib/qml/window-constraints.js';
12
13
  import {attachQmlClipboard} from '../../lib/qml/wayland-clipboard.js';
13
14
  // qml handle end
14
15
 
@@ -47,6 +48,11 @@ export async function launch({url='about:blank',width=800,height=600,socket,chro
47
48
  }).initialize();
48
49
  window=new WaylandWindow(client,{width,height:height+(toolbar?BAR_HEIGHT:0)});
49
50
  client.onClose=close;window.onClose=close;
51
+
52
+ // qml handle begin: forward declarative window constraints to xdg_toplevel
53
+ const syncQmlConstraints=qmlWindowConstraints(client,window,browser.context);
54
+ // qml handle end
55
+
50
56
  const draw=async()=>{
51
57
  if(closed||!ready||navigating||busy||!dirty||!window.available)return;
52
58
  busy=true;dirty=false;const generation=navigation;let path;
@@ -90,6 +96,10 @@ export async function launch({url='about:blank',width=800,height=600,socket,chro
90
96
  const title=String(await browser.context.title()||'Buninu Browser');
91
97
  if(closed||generation!==navigation)return;
92
98
  toolbar?.updateURL(browser.context.url);
99
+ // qml handle begin: refresh size hints alongside existing window metadata
100
+ await syncQmlConstraints();
101
+ // qml handle end
102
+
93
103
  if(title!==lastTitle){lastTitle=title;client.request(window.toplevel,'set_title',title);}
94
104
  }catch(e){if(!closed&&generation===navigation)fail(e);}finally{readingTitle=false;}
95
105
  },500);
@@ -102,6 +112,10 @@ export async function launch({url='about:blank',width=800,height=600,socket,chro
102
112
  }:browser.context;
103
113
  attachInput(client,window,input,fail);
104
114
  await browser.context.resize(width,height,1);await browser.context.navigate(url);ready=true;draw();
115
+ // qml handle begin: send initial window size hints
116
+ await syncQmlConstraints();
117
+ // qml handle end
118
+
105
119
  return {browser,client,window,toolbar,close,done};
106
120
  }catch(e){close();throw e;}
107
121
  }
@@ -19,6 +19,8 @@ export function keysym(symbol){
19
19
  export function attachInput(client,window,context,onError) {
20
20
  if(!client.globals.has('wl_seat'))return;
21
21
  let pointer,keyboard,x=0,y=0,focused=false,keys=new Map(),mods=0,locked=0;
22
+ let lastClick=null, clickCount=0;
23
+ const modifiers=()=>({modifiers:(mods&8?1:0)|(mods&4?2:0)|(mods&64?4:0)|(mods&1?8:0)});
22
24
  const active=()=>!window.closed&&!client.connection.closed;
23
25
  const invoke=(method,...args)=>{
24
26
  if(!active())return;
@@ -31,15 +33,32 @@ export function attachInput(client,window,context,onError) {
31
33
  if(event!=='capabilities')return;
32
34
  if((caps&1)&&!pointer){pointer=client.object('wl_pointer',(name,args)=>{
33
35
  if(name==='enter'){focused=args[1]===window.surface;[x,y]=args.slice(2);}
34
- if(name==='leave')focused=false;
35
- if(name==='motion'){[x,y]=args.slice(1);if(focused)invoke('move',x,y);}
36
+ if(name==='leave'){focused=false;lastClick=null;clickCount=0;}
37
+ if(name==='motion'){[x,y]=args.slice(1);if(focused)invoke('move',x,y,modifiers());}
36
38
 
37
39
  // Mouse-triggered copy also has a valid Wayland input serial.
38
40
  if(name==='button'&&focused&&client.clipboard)client.clipboard.serial=args[0];
39
41
 
40
- if(name==='button'&&focused&&context.pointerButton)invoke('pointerButton',x,y,args[2],args[3]);
41
- else if(name==='button'&&focused&&args[2]===272&&args[3]===0)invoke('click',x,y);
42
- if(name==='axis'&&focused)invoke('scroll',args[1]===1?args[2]:0,args[1]===0?args[2]:0,{x,y});
42
+ if(name==='button'&&focused){
43
+ const [,time,button,state]=args;
44
+ if(state){
45
+ // Wayland supplies monotonic uint32 milliseconds, not a click count.
46
+ // Allow modest touch jitter in surface coordinates; reset on leave.
47
+ clickCount=lastClick&&lastClick.button===button&&((time-lastClick.time)>>>0)<400
48
+ &&Math.abs(x-lastClick.x)<=12&&Math.abs(y-lastClick.y)<=12 ? clickCount+1 : 1;
49
+ lastClick={time,button,x,y};
50
+ }
51
+ const options={clickCount:clickCount||1,...modifiers()};
52
+ if(context.pointerButton)invoke('pointerButton',x,y,button,state,options);
53
+ else if(state===0&&button>=272&&button<=274)invoke('click',x,y,{...options,button:button===273?'right':button===274?'middle':'left'});
54
+ }
55
+ if(name==='axis'&&focused){
56
+ // Browser scrolling consumes pixel deltas. Land maps 100 CDP pixels
57
+ // to 10 Wayland axis units; restore that scale on the client side.
58
+ // Preserve fractional movement and apply page zoom later in the toolbar.
59
+ const delta=args[2]*10;
60
+ invoke('scroll',args[1]===1?delta:0,args[1]===0?delta:0,{x,y});
61
+ }
43
62
  });client.request(seat,'get_pointer',pointer);}
44
63
  if((caps&2)&&!keyboard){keyboard=client.object('wl_keyboard',(name,args)=>{
45
64
 
@@ -67,9 +67,10 @@ export class WaylandUrlBar {
67
67
  if(action){await this.action(action);return;}
68
68
  return this.context.press(key,options);
69
69
  }
70
- async pointerButton(x,y,button,state) {
71
- if(button!==272)return;
72
- if(y<this.height||this.editor.dragging) {
70
+ async pointerButton(x,y,button,state,options={}) {
71
+ if(button<272||button>274)return;
72
+ if((y<this.height&&!this.pageButtons)||this.editor.dragging) {
73
+ if(button!==272)return;
73
74
  const col=Math.max(1,Math.floor(x/this.cellWidth)+1);
74
75
  if(state) {
75
76
  if(!this.editor.editing){this.open(col<=3);if(col>3)this.editor.armDrag(col);}
@@ -78,9 +79,12 @@ export class WaylandUrlBar {
78
79
  return;
79
80
  }
80
81
  if(state&&this.editor.editing)this.editor.cancelEditing();
81
- if(!state)return this.context.click(x/this.zoom,(y-this.height)/this.zoom);
82
+ const mask=button===272?1:button===273?2:4;
83
+ this.pageButtons=state?(this.pageButtons||0)|mask:(this.pageButtons||0)&~mask;
84
+ if(this.context.pointerButton)return this.context.pointerButton(x/this.zoom,(y-this.height)/this.zoom,button,state,options);
85
+ if(!state)return this.context.click(x/this.zoom,(y-this.height)/this.zoom,button===272?options:{...options,button:button===273?"right":"middle"});
82
86
  }
83
- move(x,y){if(this.editor.dragging){this.editor.pointerMove(Math.max(1,Math.floor(x/this.cellWidth)+1));return;}if(y>=this.height)return this.context.move(x/this.zoom,(y-this.height)/this.zoom);}
87
+ move(x,y,options={}){if(this.editor.dragging){this.editor.pointerMove(Math.max(1,Math.floor(x/this.cellWidth)+1));return;}if(y>=this.height||this.pageButtons)return this.context.move(x/this.zoom,(y-this.height)/this.zoom,options);}
84
88
  scroll(dx,dy,at){if(at?.y<this.height){if(this.editor.editing){if(dy<0)this.editor.historyUp();else this.editor.historyDown();}return;}return this.context.scroll(dx/this.zoom,dy/this.zoom,at?{x:at.x/this.zoom,y:(at.y-this.height)/this.zoom}:null);}
85
89
  async command(line) {
86
90
  const {name,arg,error}=parseCommand(line);if(error){this.flash(error);return;}
@@ -0,0 +1,31 @@
1
+ // Keep one connection while audio-owning renderer processes are alive. A
2
+ // completed stream does not release its owner: the page may play again.
3
+ export class SharedAudioConnection {
4
+ #connect;
5
+ #pending = null;
6
+ #owners = new Set();
7
+
8
+ constructor(connect) { this.#connect = connect; }
9
+
10
+ acquire(owner) {
11
+ this.#owners.add(owner);
12
+ if (!this.#pending) {
13
+ const pending = Promise.resolve().then(this.#connect).catch(error => {
14
+ if (this.#pending === pending) this.#pending = null;
15
+ throw error;
16
+ });
17
+ this.#pending = pending;
18
+ }
19
+ return this.#pending;
20
+ }
21
+
22
+ release(owner) {
23
+ this.#owners.delete(owner);
24
+ if (this.#owners.size || !this.#pending) return;
25
+ const pending = this.#pending;
26
+ this.#pending = null;
27
+ // Also close connections whose authentication is still in flight. A new
28
+ // owner gets a separate connection and cannot inherit this closing one.
29
+ void pending.then(client => client.close()).catch(() => {});
30
+ }
31
+ }
package/lib/cdp-server.js CHANGED
@@ -701,7 +701,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
701
701
  case "Input.dispatchMouseEvent": {
702
702
  const target = getSessionTarget(state, sessionId);
703
703
  if (params.type === "mouseMoved") {
704
- await ctx.move?.(params.x, params.y);
704
+ await ctx.move?.(params.x, params.y, {modifiers:params.modifiers ?? 0});
705
705
  return {};
706
706
  }
707
707
  if (params.type === "mouseWheel") {
@@ -709,9 +709,22 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
709
709
  return {};
710
710
  }
711
711
 
712
+ if (ctx.pointerButton && (params.type === 'mousePressed' || params.type === 'mouseReleased')) {
713
+ const navigation = await ctx.pointerButton(params.x,params.y,
714
+ params.button==='right'?273:params.button==='middle'?274:272,
715
+ params.type==='mousePressed'?1:0,
716
+ {clickCount:params.clickCount,modifiers:params.modifiers});
717
+ if (navigation?.url) {
718
+ navigateTarget(state,target,navigation.url,emit,{addHistory:true});
719
+ target.title=navigation.title ?? await getContextTitle(ctx,target);
720
+ target.history[target.historyIndex].title=target.title;
721
+ if(target.screencast)await pushScreencastFrame(ctx,state,target);
722
+ }
723
+ return {};
724
+ }
712
725
  if (params.type === "mouseReleased") {
713
726
  target.focusedSelector = target.pendingSelector;
714
- applyClick(target, target.pendingSelector);
727
+ if (!params.button || params.button === "left") applyClick(target, target.pendingSelector);
715
728
  const navigation = await ctx.click?.(params.x, params.y, {
716
729
  button: params.button,
717
730
  clickCount: params.clickCount,
@@ -0,0 +1,26 @@
1
+ import * as PropertySymbol from '../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts';
2
+
3
+ // Happy DOM shares HTMLLinkElement.prototype between windows. Install one
4
+ // dispatch hook, but bypass its loader only for windows owned by our renderer.
5
+ const inertWindows = new WeakSet();
6
+ let installed = false;
7
+ export function makeLinksInert(window) {
8
+ inertWindows.add(window);
9
+ if (installed) return;
10
+ installed = true;
11
+ const base = window.HTMLElement.prototype;
12
+ const prototype = window.HTMLLinkElement.prototype;
13
+ const connected = prototype[PropertySymbol.connectedToDocument];
14
+ const setAttribute = prototype[PropertySymbol.onSetAttribute];
15
+ prototype[PropertySymbol.connectedToDocument] = function() {
16
+ if (!inertWindows.has(this[PropertySymbol.window])) return connected.call(this);
17
+ // The renderer owns load/error dispatch; the disabled parser loader must
18
+ // not emit a premature error when a dynamic stylesheet is connected.
19
+ base[PropertySymbol.connectedToDocument].call(this);
20
+ this.ownerDocument[PropertySymbol.clearComputedStyleCache]();
21
+ };
22
+ prototype[PropertySymbol.onSetAttribute] = function(attribute, replacedAttribute) {
23
+ if (!inertWindows.has(this[PropertySymbol.window])) return setAttribute.call(this, attribute, replacedAttribute);
24
+ base[PropertySymbol.onSetAttribute].call(this, attribute, replacedAttribute);
25
+ };
26
+ }
@@ -0,0 +1,36 @@
1
+ import Node from '../../vendor/happy-dom/packages/happy-dom/src/nodes/node/Node.ts';
2
+ import * as PropertySymbol from '../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts';
3
+
4
+ // NodeIterator's pre-removing steps must run synchronously, including removals
5
+ // by innerHTML, normalize and reparenting. All reach Happy DOM's internal hook.
6
+ const roots = new WeakMap();
7
+ const cleanup = new FinalizationRegistry(({ entries, ref }) => entries.delete(ref));
8
+ let installed = false;
9
+ export function observeIteratorRemoval(root, iterator) {
10
+ let entries = roots.get(root);
11
+ if (!entries) roots.set(root, entries = new Set());
12
+ const ref = new WeakRef(iterator);
13
+ entries.add(ref);
14
+ cleanup.register(iterator, { entries, ref });
15
+ if (installed) return;
16
+ installed = true;
17
+ const remove = Node.prototype[PropertySymbol.removeChild];
18
+ Node.prototype[PropertySymbol.removeChild] = function(node) {
19
+ node = node?.[PropertySymbol.proxy] ?? node;
20
+ // select/form methods are bound to the raw target, while parentNode and
21
+ // iterator roots expose its proxy. Compare and walk the public identity.
22
+ const parent = this[PropertySymbol.proxy] ?? this;
23
+ if (node?.parentNode === parent) {
24
+ for (let ancestor = parent; ancestor; ancestor = ancestor.parentNode) {
25
+ const entries = roots.get(ancestor);
26
+ if (!entries) continue;
27
+ for (const ref of entries) {
28
+ const iterator = ref.deref();
29
+ if (iterator) iterator.preRemove(node);
30
+ else entries.delete(ref);
31
+ }
32
+ }
33
+ }
34
+ return remove.call(this, node);
35
+ };
36
+ }
@@ -0,0 +1,60 @@
1
+ /** DOM replace: validate first, detach the incoming node, remove the outgoing
2
+ * node, then insert. Happy DOM's insert-then-remove order skips replacements
3
+ * in live NodeIterators. Keep this on the renderer bindings' mutation path.
4
+ * https://dom.spec.whatwg.org/#concept-node-replace */
5
+ function validate(parent, node, child) {
6
+ const fail = (name) => { throw new DOMException('Invalid node replacement', name); };
7
+ if (![1,9,11].includes(parent.nodeType)) fail('HierarchyRequestError');
8
+ for (let ancestor = parent; ancestor; ancestor = ancestor.parentNode ?? ancestor.host) {
9
+ if (ancestor === node) fail('HierarchyRequestError');
10
+ }
11
+ if (child.parentNode !== parent) fail('NotFoundError');
12
+ if (![1,3,4,7,8,10,11].includes(node.nodeType)) fail('HierarchyRequestError');
13
+ if (node.nodeType === 10 && parent.nodeType !== 9) fail('HierarchyRequestError');
14
+ if (parent.nodeType !== 9) return;
15
+ // Validate the final document, allowing the outgoing child and an incoming
16
+ // sibling to be replaced/moved without counting either twice.
17
+ const added = node.nodeType === 11 ? [...node.childNodes] : [node];
18
+ const final = [];
19
+ for (const current of parent.childNodes) {
20
+ if (current === child) final.push(...added);
21
+ else if (current !== node) final.push(current);
22
+ }
23
+ let element = false, doctype = false;
24
+ for (const current of final) {
25
+ if (current.nodeType === 3 || current.nodeType === 4) fail('HierarchyRequestError');
26
+ if (current.nodeType === 1) {
27
+ if (element) fail('HierarchyRequestError');
28
+ element = true;
29
+ } else if (current.nodeType === 10) {
30
+ if (doctype || element) fail('HierarchyRequestError');
31
+ doctype = true;
32
+ }
33
+ }
34
+ }
35
+
36
+ export function replaceNode(parent, node, child) {
37
+ validate(parent, node, child);
38
+ let reference = child.nextSibling;
39
+ if (reference === node) reference = node.nextSibling;
40
+ if (node.parentNode) node.parentNode.removeChild(node);
41
+ if (child.parentNode) child.parentNode.removeChild(child);
42
+ parent.insertBefore(node, reference);
43
+ return child;
44
+ }
45
+
46
+ export function replaceWithNodes(child, nodes) {
47
+ const parent = child.parentNode;
48
+ if (!parent) return;
49
+ let reference = child.nextSibling;
50
+ while (reference && nodes.includes(reference)) reference = reference.nextSibling;
51
+ // DOM's convert-nodes algorithm moves multiple arguments into a fragment
52
+ // first, including the receiver if it occurs among the arguments.
53
+ let node = nodes[0];
54
+ if (nodes.length !== 1) {
55
+ node = child.ownerDocument.createDocumentFragment();
56
+ for (const item of nodes) node.appendChild(item);
57
+ }
58
+ if (child.parentNode === parent) replaceNode(parent, node, child);
59
+ else parent.insertBefore(node, reference);
60
+ }
@@ -1,3 +1,4 @@
1
+ import { makeLinksInert } from './inert-link.js';
1
2
  import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
2
3
  import Window from "../../vendor/happy-dom/packages/happy-dom/src/window/Window.ts";
3
4
  import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
@@ -26,7 +27,7 @@ export function parseHTMLDocument(html, url = "about:blank", referrer = "") {
26
27
 
27
28
  /** An isolated parser context: no script execution, resource loads or page custom elements. */
28
29
  export function createInertWindow(url = "about:blank") {
29
- return new Window({
30
+ const window = new Window({
30
31
  url,
31
32
  settings: {
32
33
  disableJavaScriptEvaluation: true,
@@ -40,6 +41,8 @@ export function createInertWindow(url = "about:blank") {
40
41
  navigation: { disableFallbackToSetURL: true },
41
42
  },
42
43
  });
44
+ makeLinksInert(window);
45
+ return window;
43
46
  }
44
47
 
45
48
  /**
@@ -516,11 +516,14 @@ export async function createBrowser({
516
516
  lifecycleState() {
517
517
  return [...reachedLifecycle(committed.host, committed.generation)];
518
518
  },
519
- move(x, y) {
520
- return call("move", x / pageScale(), y / pageScale());
519
+ move(x, y, options = {}) {
520
+ return call("move", x / pageScale(), y / pageScale(), options);
521
521
  },
522
- async click(x, y) {
523
- const clicked = await call("click", x / pageScale(), y / pageScale());
522
+ pointerButton(x, y, button, state, options = {}) {
523
+ return this.click(x, y, {...options, button:button===273?'right':button===274?'middle':'left', phase:state?'down':'up'});
524
+ },
525
+ async click(x, y, options = {}) {
526
+ const clicked = await call("click", x / pageScale(), y / pageScale(), options);
524
527
  const href = typeof clicked === "string" ? clicked : clicked?.url;
525
528
  if (typeof href !== "string") return null;
526
529
  if (clicked.fragmentFrom) {
@@ -1858,8 +1858,10 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, perce
1858
1858
  return { x: containingX + marginLeft, width, marginLeft, marginRight };
1859
1859
  }
1860
1860
 
1861
+ // Anonymous flex/grid items share their parent RenderNode, but measure only
1862
+ // their own text run. Never reuse the parent's intrinsic width for these boxes.
1861
1863
  function maxContentWidth(ctx, box) {
1862
- const cacheKey = box.node ?? box;
1864
+ const cacheKey = box.anonymous ? box : box.node ?? box;
1863
1865
  const cached = ctx.maxContent.get(cacheKey);
1864
1866
  if (cached != null) return cached;
1865
1867
  const style = box.style;
@@ -1902,7 +1904,7 @@ function maxContentWidth(ctx, box) {
1902
1904
  }
1903
1905
 
1904
1906
  function minContentWidth(ctx, box) {
1905
- const cacheKey = box.node ?? box;
1907
+ const cacheKey = box.anonymous ? box : box.node ?? box;
1906
1908
  const cached = ctx.minContent.get(cacheKey);
1907
1909
  if (cached != null) return cached;
1908
1910
  const style = box.style;
@@ -195,7 +195,7 @@ export function buildDisplayList(layout, tree = null, resources = null, backgrou
195
195
  fixed: effect.fixed,
196
196
  // Preserve text geometry for form-control caret hit testing, including
197
197
  // whitespace runs which intentionally have no painted glyphs.
198
- ...(tree?.nodesById.get(run.nodeId)?.pseudo==='value'?{textRun:run,transform:effect.transform}:{}),
198
+ ...(run.type !== 'image'?{textRun:run,transform:effect.transform}:{}),
199
199
  });
200
200
  reach(effect, hitRect);
201
201
  paintRun(fragment, run);
@@ -238,6 +238,7 @@ export function buildDisplayList(layout, tree = null, resources = null, backgrou
238
238
  nodeId: run.nodeId,
239
239
  text: run.text,
240
240
  textWidth: run.width,
241
+ sourceStart: run.sourceStart ?? 0,
241
242
  x: run.x,
242
243
  y,
243
244
  font: {