buninu-browser 0.3.0 → 0.4.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 (239) hide show
  1. package/LICENSES/jsmdcui.txt +22 -0
  2. package/README.md +19 -4
  3. package/frontends/wayland/LICENSE-land +21 -0
  4. package/frontends/wayland/README.md +123 -0
  5. package/frontends/wayland/casty/LICENSE +21 -0
  6. package/frontends/wayland/casty/bookmarks.js +32 -0
  7. package/frontends/wayland/casty/click.js +96 -0
  8. package/frontends/wayland/casty/commands.js +61 -0
  9. package/frontends/wayland/casty/config.js +45 -0
  10. package/frontends/wayland/casty/download-directory.js +28 -0
  11. package/frontends/wayland/casty/download.js +49 -0
  12. package/frontends/wayland/casty/hints.js +334 -0
  13. package/frontends/wayland/casty/keys.js +56 -0
  14. package/frontends/wayland/casty/page-commands.js +80 -0
  15. package/frontends/wayland/casty/prompt-width.js +148 -0
  16. package/frontends/wayland/casty/urlbar.js +606 -0
  17. package/frontends/wayland/client.js +88 -0
  18. package/frontends/wayland/clipboard.js +80 -0
  19. package/frontends/wayland/index.js +116 -0
  20. package/frontends/wayland/input.js +56 -0
  21. package/frontends/wayland/protocol.js +55 -0
  22. package/frontends/wayland/protocols/wayland.xml +3248 -0
  23. package/frontends/wayland/protocols/xdg-shell.xml +1415 -0
  24. package/frontends/wayland/transport.js +120 -0
  25. package/frontends/wayland/urlbar.js +158 -0
  26. package/index.js +28 -1
  27. package/lib/clipboard-command.js +10 -0
  28. package/lib/clipboard-powershell.js +22 -0
  29. package/lib/clipboard.js +37 -2
  30. package/lib/document-types.js +11 -0
  31. package/lib/headless-shell.js +47 -8
  32. package/lib/layout/text-layout.js +17 -9
  33. package/lib/paint/display-list.js +5 -1
  34. package/lib/qml/README.md +971 -0
  35. package/lib/qml/builtins.js +700 -0
  36. package/lib/qml/canvas-dom.js +115 -0
  37. package/lib/qml/document.js +1176 -0
  38. package/lib/qml/format.js +9 -0
  39. package/lib/qml/host.js +324 -0
  40. package/lib/qml/images.js +199 -0
  41. package/lib/qml/launcher.js +132 -0
  42. package/lib/qml/loader.js +517 -0
  43. package/lib/qml/media-renderer.js +49 -0
  44. package/lib/qml/media.js +86 -0
  45. package/lib/qml/models.js +622 -0
  46. package/lib/qml/parser.js +381 -0
  47. package/lib/qml/qs +5 -0
  48. package/lib/qml/qscasty +3 -0
  49. package/lib/qml/qsheadless +3 -0
  50. package/lib/qml/qswayland +3 -0
  51. package/lib/qml/reactive.js +159 -0
  52. package/lib/qml/runtime.js +3381 -0
  53. package/lib/qml/shapes.js +243 -0
  54. package/lib/qml/text.js +109 -0
  55. package/lib/qml/textures.js +123 -0
  56. package/lib/qml/wayland-clipboard.js +32 -0
  57. package/lib/qml/worker-thread.js +70 -0
  58. package/lib/render-tree/index.js +16 -1
  59. package/lib/renderer/bindings.js +11 -0
  60. package/lib/renderer/canvas-2d.js +13 -0
  61. package/lib/renderer/host.js +31 -6
  62. package/lib/renderer/main.js +19 -6
  63. package/lib/renderer/page-realm.js +16 -1
  64. package/lib/renderer/page-renderer.js +230 -13
  65. package/lib/renderer/text-input-paint.js +54 -0
  66. package/lib/renderer/text-input.js +57 -0
  67. package/lib/style/computed-style.js +2 -2
  68. package/package.json +1 -1
  69. package/test/clipboard.test.js +19 -0
  70. package/test/fixtures/flea-helpers/Dockerfile +36 -0
  71. package/test/fixtures/flea-helpers/bat.mjs +43 -0
  72. package/test/fixtures/flea-helpers/bin/bat +3 -0
  73. package/test/fixtures/flea-helpers/bin/bwrap +10 -0
  74. package/test/fixtures/flea-helpers/bin/bwrap-real-filter +9 -0
  75. package/test/fixtures/flea-helpers/bin/glow +3 -0
  76. package/test/fixtures/flea-helpers/bin/qs +3 -0
  77. package/test/fixtures/flea-helpers/bin/unsupported +3 -0
  78. package/test/fixtures/flea-helpers/check-highlighting.mjs +47 -0
  79. package/test/fixtures/flea-helpers/check-media.mjs +29 -0
  80. package/test/fixtures/flea-helpers/check-preview.mjs +27 -0
  81. package/test/fixtures/flea-helpers/check.mjs +36 -0
  82. package/test/fixtures/flea-helpers/highlight-cases.json +227 -0
  83. package/test/fixtures/flea-helpers/source-mime.xml +28 -0
  84. package/test/fixtures/flea-helpers/syntax-compat.mjs +23 -0
  85. package/test/fixtures/flea-thumbnail-stub/flea-placeholder.thumbnailer +4 -0
  86. package/test/fixtures/flea-thumbnail-stub/flea-thumbnail-stub +3 -0
  87. package/test/fixtures/qml/flea/EscapeHint.qml +11 -0
  88. package/test/fixtures/qml/flea/LICENSE +21 -0
  89. package/test/fixtures/qml/flea/README.md +25 -0
  90. package/test/fixtures/qml/flea/Theme.qml +8 -0
  91. package/test/fixtures/qml/flea/demo.qml +24 -0
  92. package/test/fixtures/qml/flea/qmldir +3 -0
  93. package/test/fixtures/qml/flea-theme/LICENSE-Flea +21 -0
  94. package/test/fixtures/qml/flea-theme/LICENSE-Omarchy +20 -0
  95. package/test/fixtures/qml/flea-theme/README.md +70 -0
  96. package/test/fixtures/qml/flea-theme/button.qml +17 -0
  97. package/test/fixtures/qml/flea-theme/demo.qml +27 -0
  98. package/test/fixtures/qml/flea-theme/field.qml +21 -0
  99. package/test/fixtures/qml/flea-theme/imports/qs/Commons/BorderGeometry.js +373 -0
  100. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Color.qml +258 -0
  101. package/test/fixtures/qml/flea-theme/imports/qs/Commons/I18n.qml +118 -0
  102. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Numbers.js +19 -0
  103. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Plural.js +91 -0
  104. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Style.qml +561 -0
  105. package/test/fixtures/qml/flea-theme/imports/qs/Commons/Util.qml +188 -0
  106. package/test/fixtures/qml/flea-theme/imports/qs/Commons/qmldir +13 -0
  107. package/test/fixtures/qml/flea-theme/segment.qml +17 -0
  108. package/test/fixtures/qml/flea-theme/settings.qml +58 -0
  109. package/test/fixtures/qml/flea-theme/text-metrics.qml +10 -0
  110. package/test/fixtures/qml/flea-theme/ui/DialogButton.qml +150 -0
  111. package/test/fixtures/qml/flea-theme/ui/DialogField.qml +147 -0
  112. package/test/fixtures/qml/flea-theme/ui/EscapeHint.qml +11 -0
  113. package/test/fixtures/qml/flea-theme/ui/Glass.qml +114 -0
  114. package/test/fixtures/qml/flea-theme/ui/Glyph.qml +54 -0
  115. package/test/fixtures/qml/flea-theme/ui/GlyphSolid.qml +22 -0
  116. package/test/fixtures/qml/flea-theme/ui/Reveal.qml +54 -0
  117. package/test/fixtures/qml/flea-theme/ui/SettingsRuler.qml +85 -0
  118. package/test/fixtures/qml/flea-theme/ui/SettingsSegment.qml +85 -0
  119. package/test/fixtures/qml/flea-theme/ui/StableModel.qml +89 -0
  120. package/test/fixtures/qml/flea-theme/ui/Theme.qml +460 -0
  121. package/test/fixtures/qml/flea-theme/ui/ThemeColors.qml +43 -0
  122. package/test/fixtures/qml/flea-theme/ui/ViewState.qml +565 -0
  123. package/test/fixtures/qml/flea-theme/ui/js/Anchor.js +379 -0
  124. package/test/fixtures/qml/flea-theme/ui/js/AnchorHold.js +162 -0
  125. package/test/fixtures/qml/flea-theme/ui/js/Archive.js +157 -0
  126. package/test/fixtures/qml/flea-theme/ui/js/BackReturn.js +110 -0
  127. package/test/fixtures/qml/flea-theme/ui/js/Buttons.js +54 -0
  128. package/test/fixtures/qml/flea-theme/ui/js/ColumnMenu.js +111 -0
  129. package/test/fixtures/qml/flea-theme/ui/js/Columns.js +298 -0
  130. package/test/fixtures/qml/flea-theme/ui/js/Contrast.js +157 -0
  131. package/test/fixtures/qml/flea-theme/ui/js/Density.js +44 -0
  132. package/test/fixtures/qml/flea-theme/ui/js/DirSizes.js +129 -0
  133. package/test/fixtures/qml/flea-theme/ui/js/ExtThumbs.js +115 -0
  134. package/test/fixtures/qml/flea-theme/ui/js/Filter.js +284 -0
  135. package/test/fixtures/qml/flea-theme/ui/js/FolderSorts.js +108 -0
  136. package/test/fixtures/qml/flea-theme/ui/js/Format.js +305 -0
  137. package/test/fixtures/qml/flea-theme/ui/js/Icons.js +206 -0
  138. package/test/fixtures/qml/flea-theme/ui/js/Input.js +39 -0
  139. package/test/fixtures/qml/flea-theme/ui/js/KeyName.js +64 -0
  140. package/test/fixtures/qml/flea-theme/ui/js/Keymap.js +370 -0
  141. package/test/fixtures/qml/flea-theme/ui/js/Kinds.js +145 -0
  142. package/test/fixtures/qml/flea-theme/ui/js/Lists.js +11 -0
  143. package/test/fixtures/qml/flea-theme/ui/js/MakeDefault.js +186 -0
  144. package/test/fixtures/qml/flea-theme/ui/js/Match.js +27 -0
  145. package/test/fixtures/qml/flea-theme/ui/js/Material.js +109 -0
  146. package/test/fixtures/qml/flea-theme/ui/js/Menu.js +513 -0
  147. package/test/fixtures/qml/flea-theme/ui/js/Motion.js +16 -0
  148. package/test/fixtures/qml/flea-theme/ui/js/Mounts.js +320 -0
  149. package/test/fixtures/qml/flea-theme/ui/js/MouseNav.js +13 -0
  150. package/test/fixtures/qml/flea-theme/ui/js/Nav.js +328 -0
  151. package/test/fixtures/qml/flea-theme/ui/js/Palette.js +33 -0
  152. package/test/fixtures/qml/flea-theme/ui/js/Places.js +236 -0
  153. package/test/fixtures/qml/flea-theme/ui/js/Protocols.js +296 -0
  154. package/test/fixtures/qml/flea-theme/ui/js/RecentMode.js +154 -0
  155. package/test/fixtures/qml/flea-theme/ui/js/Search.js +396 -0
  156. package/test/fixtures/qml/flea-theme/ui/js/Settings.js +476 -0
  157. package/test/fixtures/qml/flea-theme/ui/js/SettingsAbout.js +99 -0
  158. package/test/fixtures/qml/flea-theme/ui/js/SettingsShelf.js +52 -0
  159. package/test/fixtures/qml/flea-theme/ui/js/Sort.js +127 -0
  160. package/test/fixtures/qml/flea-theme/ui/js/StateWords.js +42 -0
  161. package/test/fixtures/qml/flea-theme/ui/js/Swap.js +166 -0
  162. package/test/fixtures/qml/flea-theme/ui/js/TextSize.js +107 -0
  163. package/test/fixtures/qml/flea-theme/ui/js/Thumbs.js +158 -0
  164. package/test/fixtures/qml/flea-theme/ui/js/UiState.js +438 -0
  165. package/test/fixtures/qml/flea-theme/ui/js/Update.js +132 -0
  166. package/test/fixtures/qml/flea-theme/ui/js/UserDirs.js +56 -0
  167. package/test/fixtures/qml/flea-theme/ui/qmldir +227 -0
  168. package/test/fixtures/qml/hello.qml +26 -0
  169. package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabel.qml +2 -0
  170. package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabelNew.qml +2 -0
  171. package/test/fixtures/qml/imports/Demo/Toolkit/qmldir +3 -0
  172. package/test/fixtures/qml/imports/components/Badge.qml +15 -0
  173. package/test/fixtures/qml/imports/logic/base.js +2 -0
  174. package/test/fixtures/qml/imports/logic/counter.js +4 -0
  175. package/test/fixtures/qml/imports/logic/math.js +3 -0
  176. package/test/fixtures/qml/imports/logic/shared.js +3 -0
  177. package/test/fixtures/qml/imports/main.qml +21 -0
  178. package/test/fixtures/qml/imports/theme/Theme.qml +8 -0
  179. package/test/fixtures/qml/imports/theme/qmldir +2 -0
  180. package/test/fixtures/qml/multiply.qml +61 -0
  181. package/test/fixtures/qml-canvas-dom.js +90 -0
  182. package/test/fixtures/qml-media-audio.js +39 -0
  183. package/test/helpers/flea-sandbox.js +48 -0
  184. package/test/helpers/quickshell-fixture.js +125 -0
  185. package/test/html-clipboard-wayland.test.js +47 -0
  186. package/test/index.test.js +13 -0
  187. package/test/manual-casty-without-qml.js +87 -0
  188. package/test/manual-flea-application.js +49 -0
  189. package/test/manual-flea-binary.js +62 -0
  190. package/test/manual-flea-qml.js +9 -0
  191. package/test/manual-flea-settings.js +40 -0
  192. package/test/manual-flea-theme.js +40 -0
  193. package/test/manual-qml-removal.js +46 -0
  194. package/test/manual-without-qml.js +45 -0
  195. package/test/qml-canvas-dom.test.js +37 -0
  196. package/test/qml-clipboard-bridge.test.js +25 -0
  197. package/test/qml-clipboard-wayland.test.js +40 -0
  198. package/test/qml-connections-timing.test.js +73 -0
  199. package/test/qml-dynamic.test.js +401 -0
  200. package/test/qml-flea-app.test.js +146 -0
  201. package/test/qml-flea-backend.test.js +78 -0
  202. package/test/qml-flea-columns-backend.test.js +38 -0
  203. package/test/qml-flea-compile.test.js +78 -0
  204. package/test/qml-flea-launcher.test.js +88 -0
  205. package/test/qml-flea-markdown-backend.test.js +31 -0
  206. package/test/qml-flea-media.test.js +28 -0
  207. package/test/qml-flea-navigation-backend.test.js +35 -0
  208. package/test/qml-flea-navigation-timing.test.js +147 -0
  209. package/test/qml-flea-preview-backend.test.js +53 -0
  210. package/test/qml-flea-previews.test.js +163 -0
  211. package/test/qml-flea-rows.test.js +101 -0
  212. package/test/qml-flea-textures.test.js +59 -0
  213. package/test/qml-flea-view-switch.test.js +33 -0
  214. package/test/qml-flea-wayland.test.js +68 -0
  215. package/test/qml-flea.test.js +34 -0
  216. package/test/qml-host.test.js +238 -0
  217. package/test/qml-imports.test.js +102 -0
  218. package/test/qml-inherited-handlers.test.js +51 -0
  219. package/test/qml-input-selection.test.js +66 -0
  220. package/test/qml-interactions.test.js +309 -0
  221. package/test/qml-language.test.js +167 -0
  222. package/test/qml-launcher.test.js +54 -0
  223. package/test/qml-layout-text.test.js +205 -0
  224. package/test/qml-media.test.js +63 -0
  225. package/test/qml-persisted-delegates.test.js +45 -0
  226. package/test/qml-quickshell-controls.test.js +65 -0
  227. package/test/qml-quickshell-settings.test.js +62 -0
  228. package/test/qml-repeater.test.js +72 -0
  229. package/test/qml-style-updates.test.js +124 -0
  230. package/test/qml-textures.test.js +66 -0
  231. package/test/qml-views.test.js +332 -0
  232. package/test/qml-worker.test.js +69 -0
  233. package/test/qml.test.js +83 -0
  234. package/test/renderer-host.test.js +11 -0
  235. package/test/text-input-pointer.test.js +31 -0
  236. package/test/text-input-scroll.test.js +29 -0
  237. package/test/text-input.test.js +47 -0
  238. package/test/wayland-frontend.test.js +83 -0
  239. package/test/wayland-urlbar.test.js +38 -0
@@ -0,0 +1,120 @@
1
+ // Adapted from buninu-land/lib/transport.js (MIT; LICENSE-land).
2
+ // Linux 64-bit ABI. Only libc is loaded. recvmsg is required for SCM_RIGHTS;
3
+ // Bun's stream sockets do not expose ancillary file descriptors.
4
+ import { dlopen, ptr } from 'bun:ffi';
5
+
6
+ if (!['arm64', 'x64'].includes(process.arch) || !['linux', 'android'].includes(process.platform)) throw Error('Requires 64-bit Linux or Android');
7
+ const signature = (args, returns = 'i32') => ({ args, returns });
8
+ let library;
9
+ let errnoSymbol;
10
+ // Same architecture-specific libc names as buninu-linux's /lib/dlopen.js.
11
+ const muslLibc = `libc.musl-${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}.so.1`;
12
+ const candidates = process.platform === 'android'
13
+ ? [['libc.so', '__errno']]
14
+ : [
15
+ ['libc.so.6', '__errno_location'],
16
+ [muslLibc, '__errno_location'],
17
+ [`/lib/${muslLibc}`, '__errno_location'],
18
+ ['libc.so', '__errno_location'],
19
+ ['libc.so', '__errno'],
20
+ ];
21
+ const failures = [];
22
+ for (const [name, symbol] of candidates) {
23
+ try { library = dlopen(name, {
24
+ connect: signature(['i32','ptr','u32']), socket: signature(['i32','i32','i32']),
25
+ recvmsg: signature(['i32','ptr','i32'], 'i64'), sendmsg: signature(['i32','ptr','i32'], 'i64'),
26
+ dup: signature(['i32']), send: signature(['i32','ptr','u64','i32'], 'i64'), close: signature(['i32']),
27
+
28
+ // Standard Wayland clipboard FD transfer.
29
+ pipe2: signature(['ptr','i32']), fcntl: signature(['i32','i32','i32']),
30
+
31
+ [symbol]: signature([], 'ptr'),
32
+ }); errnoSymbol = symbol; break; }
33
+ catch (error) { failures.push(`${name} (${symbol}): ${error.message}`); }
34
+ }
35
+ if (!library) throw Error(`Cannot load libc on ${process.platform}:\n${failures.join('\n')}`);
36
+ export const libc = library.symbols;
37
+ import { toArrayBuffer } from 'bun:ffi';
38
+ export const errno = () => new Int32Array(toArrayBuffer(libc[errnoSymbol](), 0, 4))[0];
39
+ const retry = () => [4,11].includes(errno());
40
+ export class Connection {
41
+ constructor(fd, removed) {
42
+ this.fd = fd; this.removed = removed; this.fds = []; this.input = Buffer.alloc(0); this.output = []; this.queuedBytes = 0;
43
+ this.data = Buffer.alloc(65536); this.control = Buffer.alloc(4096); this.iov = Buffer.alloc(16); this.msg = Buffer.alloc(56);
44
+ this.iov.writeBigUInt64LE(BigInt(ptr(this.data))); this.iov.writeBigUInt64LE(BigInt(this.data.length), 8);
45
+ this.msg.writeBigUInt64LE(BigInt(ptr(this.iov)), 16); this.msg.writeBigUInt64LE(1n, 24);
46
+ this.msg.writeBigUInt64LE(BigInt(ptr(this.control)), 32);
47
+ }
48
+ send(bytes, fds = []) {
49
+ if (this.closed) return;
50
+ const owned = [];
51
+ try { for (const fd of fds) { const copy = libc.dup(fd); if (copy < 0) throw Error('Cannot duplicate outgoing FD'); owned.push(copy); } }
52
+ catch (e) { for (const fd of owned) libc.close(fd); throw e; }
53
+ this.output.push({ bytes, fds: owned }); this.queuedBytes += bytes.length;
54
+ if (this.queuedBytes > 4*1024*1024) this.close();
55
+ }
56
+ flush() {
57
+ for (let round = 0; round < 64 && this.output.length; round++) {
58
+ const item = this.output[0];
59
+ let n;
60
+ if (item.fds.length) {
61
+ const iov = Buffer.alloc(16), msg = Buffer.alloc(56);
62
+ const size = 16 + item.fds.length * 4, control = Buffer.alloc(Math.ceil(size / 8) * 8);
63
+ iov.writeBigUInt64LE(BigInt(ptr(item.bytes))); iov.writeBigUInt64LE(BigInt(item.bytes.length), 8);
64
+ control.writeBigUInt64LE(BigInt(size)); control.writeInt32LE(1,8); control.writeInt32LE(1,12);
65
+ item.fds.forEach((fd,i) => control.writeInt32LE(fd,16+i*4));
66
+ msg.writeBigUInt64LE(BigInt(ptr(iov)),16); msg.writeBigUInt64LE(1n,24);
67
+ msg.writeBigUInt64LE(BigInt(ptr(control)),32); msg.writeBigUInt64LE(BigInt(control.length),40);
68
+ n = Number(libc.sendmsg(this.fd,msg,0x4000));
69
+ } else n = Number(libc.send(this.fd,item.bytes,item.bytes.length,0x4000));
70
+ if (n < 0) { if (!retry()) this.close(); return; }
71
+ if (!n) return this.close();
72
+ // SCM_RIGHTS is delivered with the first successfully sent byte, even on partial writes.
73
+ for (const fd of item.fds) libc.close(fd); item.fds = [];
74
+ this.queuedBytes -= n;
75
+ if (n === item.bytes.length) this.output.shift(); else item.bytes = item.bytes.subarray(n);
76
+ }
77
+ }
78
+ poll() {
79
+ if (this.closed) return;
80
+ try {
81
+ for (let round = 0; round < 16; round++) {
82
+ this.msg.writeBigUInt64LE(BigInt(this.control.length), 40); this.msg.writeUInt32LE(0, 48);
83
+ const n = Number(libc.recvmsg(this.fd, this.msg, 0x40000000));
84
+ if (n < 0) { if (retry()) break; return this.close(); }
85
+ if (!n) return this.close();
86
+ const end = Number(this.msg.readBigUInt64LE(40));
87
+ for (let p = 0; p + 16 <= end;) {
88
+ const size = Number(this.control.readBigUInt64LE(p)); if (size < 16 || p + size > end) throw Error('Invalid ancillary data');
89
+ if (this.control.readInt32LE(p+8) === 1 && this.control.readInt32LE(p+12) === 1)
90
+ for (let q = p + 16; q + 4 <= p + size; q += 4) this.fds.push(this.control.readInt32LE(q));
91
+ p += Math.ceil(size / 8) * 8;
92
+ }
93
+ if (this.msg.readUInt32LE(48) & 8) throw Error('Truncated ancillary data');
94
+ this.input = Buffer.concat([this.input, this.data.subarray(0,n)]);
95
+ if (this.input.length > 1024*1024 || this.fds.length > 256) throw Error('Client queue limit');
96
+ this.onData();
97
+ if (this.closed) return;
98
+ }
99
+ this.flush();
100
+ } catch (e) { console.error(`Wayland client: ${e.message}`);
101
+ // Flush queued requests before disconnecting.
102
+ this.flush();
103
+ this.close(); }
104
+ }
105
+ close() {
106
+ if (this.closed) return; this.closed = true; libc.close(this.fd);
107
+ for (const item of this.output) for (const fd of item.fds) libc.close(fd);
108
+ this.output = []; this.queuedBytes = 0;
109
+ for (const fd of this.fds) libc.close(fd); this.fds = []; this.onClose?.(); this.removed();
110
+ }
111
+ }
112
+
113
+ export function connectUnix(path) {
114
+ if (Buffer.byteLength(path)>107 || path.includes('\0')) throw Error('Invalid Wayland socket path');
115
+ const fd=libc.socket(1,1|0x800|0x80000,0);
116
+ if(fd<0)throw Error(`socket: errno ${errno()}`);
117
+ const address=Buffer.alloc(110); address.writeUInt16LE(1);address.write(path,2);
118
+ if(libc.connect(fd,address,Buffer.byteLength(path)+3)<0){const code=errno();libc.close(fd);throw Error(`connect ${path}: errno ${code}`);}
119
+ return new Connection(fd,()=>{});
120
+ }
@@ -0,0 +1,158 @@
1
+ // Casty's editor is vendored beside this adapter. Only rendering and browser
2
+ // actions live here; selection, history, dragging and double clicks stay there.
3
+ import {UrlBar,PromptHistory,toURL} from './casty/urlbar.js';
4
+ import {COMMANDS,parseCommand,parseZoom} from './casty/commands.js';
5
+ import {loadKeyBindings,toKeyName} from './casty/keys.js';
6
+ import {loadConfig} from './casty/config.js';
7
+ import {searchBookmarks} from './casty/bookmarks.js';
8
+ import {displayWidth,fitWidth} from './casty/prompt-width.js';
9
+ import {downloadPage} from './casty/download.js';
10
+ import {HintMode} from './casty/hints.js';
11
+ import {findInPage,toggleEruda} from './casty/page-commands.js';
12
+ import {readClipboard,writeClipboard} from '../../lib/clipboard.js';
13
+
14
+ export const BAR_HEIGHT=32;
15
+ export const WAYLAND_COMMANDS=COMMANDS.filter(c=>!['cdp','kitty','pixelmouse'].includes(c.name));
16
+ export function editorKey(key,modifiers=0) {
17
+ const letters={ArrowUp:'A',ArrowDown:'B',ArrowRight:'C',ArrowLeft:'D',Home:'H',End:'F'};
18
+ if(letters[key])return `\x1b[${modifiers?`1;${1+(modifiers&8?1:0)+(modifiers&1?2:0)+(modifiers&2?4:0)}`:''}${letters[key]}`;
19
+ if(key==='Delete')return '\x1b[3~';
20
+ if(key==='Backspace')return '\x7f';
21
+ if(key==='Enter')return '\r';if(key==='Escape')return '\x1b';if(key==='Tab')return '\t';
22
+ if(modifiers&2&&/^[a-z]$/i.test(key))return String.fromCharCode(key.toLowerCase().charCodeAt(0)-96);
23
+ return key.length===1||[...key].length===1?key:'';
24
+ }
25
+ class PixelEditor extends UrlBar {
26
+ constructor(onChange,history) {super({history,commandEntry:true});this.changed=onChange;this.columns=80;}
27
+ render(){this._dirty=false;this.changed?.();}
28
+ }
29
+ export class WaylandUrlBar {
30
+ constructor(context,{changed=()=>{},close=()=>{},clipboard=null,history=new PromptHistory()}={}) {
31
+ this.context=context;this.changed=changed;this.quit=close;this.height=BAR_HEIGHT;
32
+ this.clipboard=clipboard||{read:readClipboard,write:writeClipboard};
33
+ this.editor=new PixelEditor(()=>{this.dirty=true;this.caret=true;changed();},history);
34
+ this.bindings=loadKeyBindings();this.zoom=1;this.width=0;this.cellWidth=10;this.cache=new Map();
35
+ this.client={send:(method,params)=>this.send(method,params)};
36
+ this.hints=new HintMode(changed);
37
+ this.timer=setInterval(()=>{if(this.editor.editing){this.caret=!this.caret;this.dirty=true;changed();}},500);
38
+ this.timer.unref?.();this.dirty=true;
39
+ }
40
+ async initialize() {
41
+ const {loadGraphics}=await import('../../lib/renderer/page-renderer.js');
42
+ this.graphics=await loadGraphics();
43
+ const paragraph=this.paragraph('M','#ffffff');this.cellWidth=paragraph.getMaxIntrinsicWidth();
44
+ return this;
45
+ }
46
+ updateURL(url){if(url!==this.editor.currentUrl)this.editor.setUrl(url||'about:blank');}
47
+ flash(text){clearTimeout(this.statusTimer);this.editor.setStatus(String(text));this.editor.render();this.statusTimer=setTimeout(()=>{this.editor.clearStatus();this.editor.render();},5000);}
48
+ open(command=false,text=null){if(this.editor.editing)this.editor.cancelEditing();this.editor.startEditing({command,text}).then(result=>{
49
+ if(result?.mode==='cmd')return this.command(result.line);
50
+ if(result?.url)return this.navigate(result.url);
51
+ }).catch(error=>this.flash(error.message));}
52
+ async navigate(url){this.editor.loading=true;this.editor.render();try{await this.context.navigate(url);this.updateURL(this.context.url);}finally{this.editor.loading=false;this.editor.render();this.changed();}}
53
+ async press(key,options={}) {
54
+ const m=options.modifiers||0,name=toKeyName({key,modifiers:m});
55
+ if(this.editor.editing) {
56
+ if(name==='ctrl+c'||name==='alt+c')await this.clipboard.write(this.editor.selectedText||this.editor.text);
57
+ else if(name==='ctrl+x') {if(this.editor.selectedText){await this.clipboard.write(this.editor.selectedText);this.editor._clearIfSelected();this.editor.render();}}
58
+ else if(name==='ctrl+v')this.editor.insertText((await this.clipboard.read()).replace(/[\r\n]+/g,' '));
59
+ else this.editor.handleInput(editorKey(key,m));
60
+ return;
61
+ }
62
+ if(this.hints.active){await this.hints.handleInput(editorKey(key,m));return;}
63
+ const action=this.bindings[name];
64
+ // Native page editing must receive Ctrl-V itself (including preventDefault,
65
+ // selection replacement and beforeinput), using the frontend clipboard.
66
+ if(action==='paste')return this.context.press(key,options);
67
+ if(action){await this.action(action);return;}
68
+ return this.context.press(key,options);
69
+ }
70
+ async pointerButton(x,y,button,state) {
71
+ if(button!==272)return;
72
+ if(y<this.height||this.editor.dragging) {
73
+ const col=Math.max(1,Math.floor(x/this.cellWidth)+1);
74
+ if(state) {
75
+ if(!this.editor.editing){this.open(col<=3);if(col>3)this.editor.armDrag(col);}
76
+ else this.editor.pointerDown(col);
77
+ }else this.editor.pointerUp();
78
+ return;
79
+ }
80
+ if(state&&this.editor.editing)this.editor.cancelEditing();
81
+ if(!state)return this.context.click(x/this.zoom,(y-this.height)/this.zoom);
82
+ }
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);}
84
+ 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
+ async command(line) {
86
+ const {name,arg,error}=parseCommand(line);if(error){this.flash(error);return;}
87
+ if(!WAYLAND_COMMANDS.some(c=>c.name===name)){this.flash(`${name} is not used by the Wayland frontend`);return;}
88
+ if(name==='open'){const url=toURL(arg);if(url)await this.navigate(url);else this.flash('Bookmark not found');}
89
+ else if(name==='bookmark'){const match=searchBookmarks(arg)[0];if(match)await this.navigate(match.url);else this.flash('Bookmark not found');}
90
+ else if(name==='help')this.flash(WAYLAND_COMMANDS.map(c=>c.usage).join(' · '));
91
+ else if(name==='find'){const result=await findInPage(this.client,arg);this.flash(result.error||`${result.text}: ${result.total?result.index+1:0}/${result.total}`);}
92
+ else if(name==='zoom'){if(!arg)this.flash(`Zoom ${Math.round(this.zoom*100)}%`);else{const zoom=parseZoom(arg,this.zoom);if(zoom===null)this.flash('Invalid zoom');else{this.zoom=zoom;this.changed();}}}
93
+ else await this.action({links:'hints',copyurl:'copy_url',pasteurl:'paste_url'}[name]||name);
94
+ }
95
+ async action(action) {
96
+ if(action==='quit'){this.quit();return;}
97
+ if(action==='url_bar'){this.open();return;}
98
+ if(action==='command'){this.open(true);return;}
99
+ if(action==='paste_url'){this.open(false,(await this.clipboard.read()).replace(/[\r\n]+/g,' '));return;}
100
+ if(action==='back')await this.context.goBack();
101
+ else if(action==='forward')await this.context.goForward();
102
+ else if(action==='reload')await this.context.reload();
103
+ else if(action==='home')await this.navigate(toURL(loadConfig().homeUrl));
104
+ else if(action==='copy_url'){await this.clipboard.write(this.context.url);this.flash('Copied URL');}
105
+ else if(action==='copy'){
106
+ const text=await this.context.evaluate(`(()=>{const e=document.activeElement;return e&&typeof e.selectionStart==='number'?e.value.slice(e.selectionStart,e.selectionEnd):String(getSelection())})()`);
107
+ if(text)await this.clipboard.write(text);
108
+ }else if(action==='paste')await this.context.type(await this.clipboard.read());
109
+ else if(action==='download')this.flash('Saved: '+await downloadPage(this.client));
110
+ else if(action==='hints'){void this.hints.start(this.client).catch(e=>this.flash(e.message));}
111
+ else if(action==='eruda')this.flash(await toggleEruda(this.client));
112
+ else if(['zoom_in','zoom_out','zoom_reset'].includes(action))this.zoom=parseZoom({zoom_in:'+',zoom_out:'-',zoom_reset:'reset'}[action],this.zoom);
113
+ this.updateURL(this.context.url);this.changed();
114
+ }
115
+ // Existing Casty helpers expect send(); route their small subset directly to
116
+ // the engine. There is no CDP socket or extra remote debugging server here.
117
+ async send(method,p={}) {
118
+ if(method==='Runtime.evaluate')return {result:{value:await this.context.evaluate(p.expression)}};
119
+ if(method==='Input.dispatchMouseEvent'){if(p.type==='mouseReleased')await this.context.click(p.x,p.y);else if(p.type==='mouseMoved')await this.context.move(p.x,p.y);return {};}
120
+ if(method==='Input.insertText'){await this.context.type(p.text);return {};}
121
+ if(method==='Page.getFrameTree'){const r=this.context.resourceContent(this.context.url);return {frameTree:{frame:{id:'main',url:this.context.url,mimeType:r?.contentType||''}}};}
122
+ if(method==='Page.getResourceContent'){const r=this.context.resourceContent(p.url);if(!r)throw Error('Page source unavailable');return {content:Buffer.from(r.body).toString('base64'),base64Encoded:true};}
123
+ throw Error(`Unsupported frontend helper method: ${method}`);
124
+ }
125
+ paragraph(text,color) {
126
+ const key=color+'\n'+text;let p=this.cache.get(key);if(p)return p;
127
+ const {CanvasKit:k,fontCollection,monospaceFamily,fontFamilies}=this.graphics;
128
+ const builder=k.ParagraphBuilder.MakeFromFontCollection(new k.ParagraphStyle({textStyle:{color:k.parseColorString(color),fontSize:16,fontFamilies:[monospaceFamily,...fontFamilies]},maxLines:1}),fontCollection);
129
+ builder.addText(text);p=builder.build();builder.delete();p.layout(100000);
130
+ if(this.cache.size>=256){const first=this.cache.keys().next().value;this.cache.get(first).delete();this.cache.delete(first);}
131
+ this.cache.set(key,p);return p;
132
+ }
133
+ pixels(width) {
134
+ if(width!==this.width){this.width=width;this.dirty=true;this.editor.columns=Math.max(1,Math.floor(width/this.cellWidth));}
135
+ if(!this.dirty&&this.bytes)return this.bytes;
136
+ const {CanvasKit:k}=this.graphics;if(!this.surface||this.surface.width()!==width){this.surface?.delete();this.surface=k.MakeSurface(width,this.height);}
137
+ const canvas=this.surface.getCanvas(),paint=new k.Paint();
138
+ canvas.clear(k.parseColorString(this.editor.editing?'#005f87':'#303030'));
139
+ const cols=this.editor.columns;
140
+ const ansi=this.editor.editing?this.editor._editLine(cols):this.editor._displayLine(cols);
141
+ let inverse=false,x=0;
142
+ for(const part of ansi.split(/(\x1b\[[\d;]*m)/)) {
143
+ if(part.startsWith('\x1b')){if(part==='\x1b[7m')inverse=true;else if(part==='\x1b[27m')inverse=false;continue;}
144
+ for(const char of part){const w=displayWidth(char)*this.cellWidth;
145
+ if(inverse){paint.setColor(k.parseColorString('#f4f8ff'));canvas.drawRect(k.XYWHRect(x,0,w,this.height),paint);}
146
+ if(char.trim()){const p=this.paragraph(char,inverse?'#005f87':'#f4f8ff');canvas.drawParagraph(p,x,(this.height-p.getHeight())/2);}
147
+ x+=w;if(x>=width)break;
148
+ }
149
+ }
150
+ if(this.editor.editing&&!this.editor.selectionRange&&this.caret){const view=this.editor._editView(cols);paint.setColor(k.parseColorString('#72e0e8'));canvas.drawRect(k.XYWHRect(view.cursorCol*this.cellWidth,6,1,20),paint);}
151
+ paint.delete();this.surface.flush();const image=this.surface.makeImageSnapshot();
152
+ try{this.bytes=Buffer.from(image.readPixels(0,0,{width,height:this.height,colorType:k.ColorType.RGBA_8888,alphaType:k.AlphaType.Unpremul,colorSpace:k.ColorSpace.SRGB}));}finally{image.delete();}
153
+ // Only the small toolbar strip is converted; page BGRA bytes remain untouched.
154
+ for(let i=0;i<this.bytes.length;i+=4){const red=this.bytes[i];this.bytes[i]=this.bytes[i+2];this.bytes[i+2]=red;}
155
+ this.dirty=false;return this.bytes;
156
+ }
157
+ dispose(){clearInterval(this.timer);clearTimeout(this.statusTimer);clearInterval(this.editor._spinTimer);this.editor.cancelEditing();for(const p of this.cache.values())p.delete();this.cache.clear();this.surface?.delete();}
158
+ }
package/index.js CHANGED
@@ -43,6 +43,14 @@ const INFORMATION = new Map([
43
43
  /** Splits the command line into a frontend and the arguments it receives. */
44
44
  export function chooseFrontend(argv) {
45
45
  const [first, ...rest] = argv;
46
+
47
+ // wayland handle begin: independent native frontend
48
+ if (first === "--wayland") return { frontend: "wayland", args: rest };
49
+ // wayland handle end
50
+
51
+ if (["--quickshell-wayland", "--quickshell-headless", "--quickshell-casty"].includes(first))
52
+ return { frontend: first.slice(2), args: rest };
53
+
46
54
  if (first === "--casty") return { frontend: "casty", args: rest };
47
55
  if (first === "--headless") return { frontend: "headless", args: rest };
48
56
  if (RESERVED_FRONTENDS.has(first)) return { frontend: first.slice(2), args: rest, reserved: true };
@@ -66,13 +74,17 @@ export function castyCommand(args, which = Bun.which) {
66
74
  }
67
75
 
68
76
  export function usage(name = packageInfo.name) {
77
+ // wayland handle begin: frontend help
78
+ const waylandHelp = ' --wayland\n Experimental standard Wayland SHM window; --wayland --help for options\n';
79
+ // wayland handle end
80
+
69
81
  return `Usage:
70
82
  ${name} [URL] [CASTY_OPTIONS...] [-- BROWSER_FLAGS...]
71
83
  ${name} --casty [CASTY_ARGS...]
72
84
  ${name} --headless [ENGINE_OPTIONS...]
73
85
 
74
86
  Frontends (first argument only):
75
- --casty
87
+ ${typeof waylandHelp === "undefined" ? "" : waylandHelp} --casty
76
88
  Browse in the terminal with casty (the default)
77
89
  Needs a terminal with the Kitty graphics protocol
78
90
  Any first argument not listed here also starts casty,
@@ -81,6 +93,9 @@ Frontends (first argument only):
81
93
  --headless
82
94
  Start the engine only, as a CDP endpoint
83
95
  The engine's options: ${name} --headless --help
96
+ --quickshell-wayland, --quickshell-headless, --quickshell-casty
97
+ Experimental Quickshell CLI (-p FILE_OR_DIRECTORY / -c CONFIG)
98
+ Use -- before backend arguments; each entry supports --help
84
99
  --win32, --gtk, --appkit, --qt
85
100
  Native windowed frontends (reserved, not implemented yet)
86
101
 
@@ -147,6 +162,18 @@ if (import.meta.main) {
147
162
  await printReadme();
148
163
  process.exit(0);
149
164
  }
165
+
166
+ if (frontend.startsWith("quickshell-")) {
167
+ const entry = join(import.meta.dir, "lib/qml/qs" + frontend.slice("quickshell-".length));
168
+ process.exit(await run([process.execPath, entry, ...args]) ?? 1);
169
+ }
170
+
171
+ // wayland handle begin: launch the isolated frontend
172
+ if (frontend === "wayland") {
173
+ process.exit(await run([process.execPath, join(import.meta.dir, "frontends/wayland/index.js"), ...args]) ?? 1);
174
+ }
175
+ // wayland handle end
176
+
150
177
  const code = frontend === "headless"
151
178
  ? await run([process.execPath, ENGINE, ...args])
152
179
  : await run(castyCommand(args), { ...process.env, CASTY_BROWSER: ENGINE });
@@ -0,0 +1,10 @@
1
+ // Adapter for jsmdcui's unchanged PowerShell clipboard functions.
2
+ export function runSync(command, options = {}) {
3
+ try {
4
+ const p = Bun.spawnSync(command, {
5
+ stdin: options.stdin == null ? 'ignore' : Buffer.from(options.stdin),
6
+ stdout: options.stdout ?? 'pipe', stderr: 'pipe', timeout: options.timeout ?? 2000,
7
+ });
8
+ return {ok:p.success, stdout:p.stdout?.toString() ?? '', stderr:p.stderr?.toString() ?? ''};
9
+ } catch (error) { return {ok:false, stdout:'', stderr:String(error)}; }
10
+ }
@@ -0,0 +1,22 @@
1
+ // Functions copied verbatim from jsmdcui/src/platform/clipboard.js (MIT).
2
+ // Keep command spelling, quoting and trailing-newline handling unchanged.
3
+ import { runSync } from "./clipboard-command.js";
4
+
5
+ function powershellClipboard(shell) {
6
+ return {
7
+ name: "powershell",
8
+ // Get-Clipboard -Raw appends \r\n to stdout; strip exactly one trailing line ending.
9
+ read: () => outputOrThrow(runSync([shell, "-NoProfile", "-Command", "Get-Clipboard -Raw"], {})).replace(/\r?\n$/, ""),
10
+ write: (text) => {
11
+ text=(text+'').replaceAll("'","''") ;
12
+ return runSync([shell, "-NoProfile", "-Command", `Set-Clipboard '${text}'`], { stdout: "ignore" }).ok ;
13
+ },
14
+ };
15
+ }
16
+
17
+ function outputOrThrow(result) {
18
+ if (!result.ok) throw new Error(result.stderr || result.stdout || "clipboard command failed");
19
+ return result.stdout;
20
+ }
21
+
22
+ export { powershellClipboard };
package/lib/clipboard.js CHANGED
@@ -1,9 +1,9 @@
1
+ import {powershellClipboard} from './clipboard-powershell.js';
1
2
  // Host-side text clipboard. No shell interpolation; page bytes go only to stdin.
2
3
  export function clipboardCommands(platform = process.platform) {
3
4
  return [
4
5
  ['xclip','-selection','clipboard'],
5
- ...(platform === 'win32' ? [['powershell.exe','-NoProfile','-NonInteractive','-Command',
6
- '[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())']]
6
+ ...(platform === 'win32' ? [['powershell.exe']]
7
7
  : platform === 'darwin' ? [['pbcopy']] : [['wl-copy'],['termux-clipboard-set']]),
8
8
  ];
9
9
  }
@@ -13,6 +13,10 @@ export async function writeClipboard(text, {which = Bun.which, spawn = Bun.spawn
13
13
  const executable=which(command[0]); if(!executable)continue;
14
14
  let child,timer;
15
15
  try {
16
+ if(command[0]==='powershell.exe') {
17
+ if(powershellClipboard(executable).write(text))return;
18
+ continue;
19
+ }
16
20
  child=spawn([executable,...command.slice(1)],{stdin:Buffer.from(text),stdout:'ignore',stderr:'ignore'});
17
21
  timer=setTimeout(()=>child.kill(9),3000);
18
22
  if(await child.exited === 0)return;
@@ -20,3 +24,34 @@ export async function writeClipboard(text, {which = Bun.which, spawn = Bun.spawn
20
24
  }
21
25
  throw Error('No working clipboard command (xclip preferred)');
22
26
  }
27
+
28
+ // Read only on an explicit paste request; never poll the system clipboard.
29
+ export async function readClipboard({which = Bun.which, spawn = Bun.spawn, platform = process.platform} = {}) {
30
+ const commands = [['xclip','-selection','clipboard','-o'],
31
+ ...(platform === 'win32' ? [['powershell.exe','-NoProfile','-Command','Get-Clipboard -Raw']]
32
+ : platform === 'darwin' ? [['pbpaste']] : [['wl-paste','--no-newline'],['termux-clipboard-get']])];
33
+ for (const command of commands) {
34
+ const executable = which(command[0]); if (!executable) continue;
35
+ let child, timer;
36
+ try {
37
+ if(command[0]==='powershell.exe') {
38
+ const text=powershellClipboard(executable).read();
39
+ if(Buffer.byteLength(text)>1024*1024)throw Error('Clipboard text exceeds limit');
40
+ return text;
41
+ }
42
+ child = spawn([executable,...command.slice(1)], {stdin:'ignore',stdout:'pipe',stderr:'ignore'});
43
+ timer = setTimeout(()=>child.kill(9),3000);
44
+ const chunks=[]; let size=0;
45
+ for await (const chunk of child.stdout) {
46
+ size += chunk.length;
47
+ if (size > 1024*1024) { child.kill(9); throw Error('Clipboard text exceeds limit'); }
48
+ chunks.push(Buffer.from(chunk));
49
+ }
50
+ if (await child.exited === 0) {
51
+ const text=Buffer.concat(chunks).toString();
52
+ return command[0]==='powershell.exe' ? text.replace(/\r?\n$/, '') : text;
53
+ }
54
+ } catch {} finally {clearTimeout(timer);}
55
+ }
56
+ throw Error('No working clipboard read command (xclip preferred)');
57
+ }
@@ -1,3 +1,7 @@
1
+ // qml handle begin: response classification
2
+ import {isQmlDocument, QML_MIME} from "./qml/format.js";
3
+ // qml handle end
4
+
1
5
  /**
2
6
  * What a top-level response looks like in the viewport. HTML is shown as
3
7
  * itself; anything else gets a small generated HTML document, as browsers do
@@ -73,6 +77,13 @@ function previewKind(name, type) {
73
77
  export function displayDocument({ url, contentType = "", body }) {
74
78
  const { type, charset } = parseContentType(contentType);
75
79
  const name = fileName(url);
80
+
81
+ // qml handle begin: preserve QML source for the renderer
82
+ if (body.byteLength <= DISPLAY_LIMIT && isQmlDocument(url, contentType)) {
83
+ return {source: decode(body, charset), contentType: QML_MIME};
84
+ }
85
+ // qml handle end
86
+
76
87
  if (HTML_TYPES.has(type) || (type === "" && looksLikeHtml(body))) {
77
88
  return { source: decode(body, charset), contentType: contentType || "text/html" };
78
89
  }
@@ -1,3 +1,8 @@
1
+ // qml handle begin: select a dedicated local application renderer
2
+ import {isQmlDocument} from './qml/format.js';
3
+ // Local QML uses a dedicated host renderer.
4
+ // qml handle end
5
+
1
6
  /**
2
7
  * Headless shell controller.
3
8
  *
@@ -7,8 +12,8 @@
7
12
  * runs page content itself.
8
13
  *
9
14
  * Sandbox flags: `--no-sandbox` is accepted for Chromium compatibility (casty
10
- * always passes it) but ignored. Only `--dangerously-allow-host-js` relaxes
11
- * the renderer sandbox, for every page.
15
+ * always passes it) but ignored. `--dangerously-allow-host-js` relaxes
16
+ * the renderer sandbox for every page.
12
17
  */
13
18
  import { CdpServer } from "./cdp-server.js";
14
19
  import { DEFAULT_DOWNLOAD_DIR, isAttachment, saveDownload, suggestedFilename } from "./download.js";
@@ -80,12 +85,14 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
80
85
  * anything that needs a renderer waits for it.
81
86
  *
82
87
  * @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number,
83
- * waitForRenderer?: boolean, mobile?: boolean, webkit?: boolean }} options
88
+ * waitForRenderer?: boolean, mobile?: boolean, webkit?: boolean,
89
+ * clipboard?: {read: Function, write: Function} }} options
84
90
  * WebKit identity defaults on; webkit: false selects Chromium. Mobile takes priority.
85
91
  */
86
92
  export async function createBrowser({
87
93
  allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
88
94
  waitForRenderer = true, webkit = true,
95
+ clipboard = null,
89
96
  } = {}) {
90
97
  // Explicit presentation profiles fix the identity for requests and scripts.
91
98
  const initialUserAgent = mobile ? MOBILE_USER_AGENT : webkit ? WEBKIT_USER_AGENT : DESKTOP_USER_AGENT;
@@ -158,10 +165,18 @@ export async function createBrowser({
158
165
  }
159
166
 
160
167
  function createHost() {
168
+ let args = [...(allowHostJs ? [DANGEROUSLY_ALLOW_HOST_JS] : []), ...(mobile ? [MOBILE_FLAG] : [])];
169
+
170
+ // qml handle begin: internal options for a dedicated application renderer
171
+ if (arguments[0]?.qmlHost) args = ["--buninu-qml-host", ...args];
172
+ // qml handle end
173
+
161
174
  const host = new RendererHost({
175
+ // Native frontends supply one clipboard for page APIs and input defaults.
176
+ ...(clipboard?{clipboardRead:()=>clipboard.read(),clipboardWrite:text=>clipboard.write(text)}:{}),
162
177
  network,
163
178
  timeout: rendererTimeout,
164
- args: [...(allowHostJs ? [DANGEROUSLY_ALLOW_HOST_JS] : []), ...(mobile ? [MOBILE_FLAG] : [])],
179
+ args,
165
180
  // A replacement renderer starts blank; bring the current page back.
166
181
  onRestart: async (invoke) => {
167
182
  await replayState(invoke);
@@ -225,10 +240,22 @@ export async function createBrowser({
225
240
 
226
241
  /** The renderer for a URL's origin, swapping processes on an origin change. */
227
242
  async function rendererFor(url) {
228
- const key = originKey(url);
243
+ let key = originKey(url);
244
+ let acquireHost = () => {
245
+ const next = spare ?? createHost();
246
+ spare = null;
247
+ return next;
248
+ };
249
+
250
+ // qml handle begin: never reuse application realms or take an ordinary spare
251
+ if (arguments[1]?.qmlHost) {
252
+ key = Symbol("qml document");
253
+ acquireHost = () => createHost({ qmlHost: true });
254
+ }
255
+ // qml handle end
256
+
229
257
  if (current && key === currentKey) return current;
230
- const next = spare ?? createHost();
231
- spare = null;
258
+ const next = acquireHost();
232
259
  await next.start();
233
260
  await replayState((method, ...params) => next.call(method, ...params));
234
261
  const previous = current;
@@ -307,7 +334,18 @@ export async function createBrowser({
307
334
  writeLog("navigation", `${url}: ${document.error ?? `HTTP ${document.status ?? 200}`}`
308
335
  + `${document.url === url ? "" : ` -> ${document.url}`} in ${Math.round(fetched - started)}ms`);
309
336
  if (!invoke) await rendererReady;
310
- const host = invoke ? current : await rendererFor(document.url);
337
+ let selectRenderer = rendererFor;
338
+
339
+ // qml handle begin: host privileges require a local top-level QML document
340
+ const qmlHost = !document.error && new URL(document.url).protocol === "file:"
341
+ && isQmlDocument(document.url, document.contentType);
342
+ // A restarted renderer may restore a response of a different type.
343
+ const restoreChangedType = invoke && (typeof currentKey === "symbol") !== qmlHost;
344
+ if (restoreChangedType) invoke = null;
345
+ selectRenderer = url => rendererFor(url, { qmlHost });
346
+ // qml handle end
347
+
348
+ const host = invoke ? current : await selectRenderer(document.url);
311
349
  let generation;
312
350
  try {
313
351
  generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
@@ -389,6 +427,7 @@ export async function createBrowser({
389
427
  }
390
428
 
391
429
  const context = {
430
+ get url() { return currentUrl; },
392
431
  navigate,
393
432
  get userAgent() { return userAgent ?? initialUserAgent; },
394
433
  setDownloadBehavior({ behavior = "allow", downloadPath = DEFAULT_DOWNLOAD_DIR, eventsEnabled = false } = {}) {
@@ -236,9 +236,9 @@ function blockChildren(node, flags) {
236
236
  children.push(buildBox(child, inheritedFlags(child, childFlags)));
237
237
  for (const open of openInlines) items.push({ kind: "open", node: open, style: open.style ?? null });
238
238
  } else if (child.type === "image") {
239
- items.push({ kind: "image", node: child });
239
+ items.push({ kind: "image", node: child, wrap:childFlags.wrap });
240
240
  } else if (isAtomic(child)) {
241
- items.push({ kind: "atomic", node: child, box: buildBox(child, inheritedFlags(child, childFlags)) });
241
+ items.push({ kind: "atomic", node: child, wrap:childFlags.wrap, box: buildBox(child, inheritedFlags(child, childFlags)) });
242
242
  } else {
243
243
  items.push({ kind: "open", node: child, style: child.style ?? null });
244
244
  openInlines.push(child);
@@ -2117,11 +2117,13 @@ function buildParagraph(ctx, ifc) {
2117
2117
  let value = String(item.text).replace(/\r\n?/g, "\n");
2118
2118
  if (item.preserve) {
2119
2119
  const pieces = value.split("\n");
2120
+ let sourceStart=0;
2120
2121
  pieces.forEach((piece, index) => {
2121
2122
  if (index > 0) forced.set(text.length, (forced.get(text.length) ?? 0) + 1);
2122
2123
  if (piece) {
2123
- pushRun({ kind: "text", nodeId: item.node.id, style: item.style, collapsible: false, wrap: item.wrap }, piece);
2124
+ pushRun({ kind: "text", nodeId: item.node.id, style: item.style, collapsible: false, wrap: item.wrap, sourceStart }, piece);
2124
2125
  }
2126
+ sourceStart+=piece.length+1;
2125
2127
  });
2126
2128
  afterSpace = false;
2127
2129
  } else {
@@ -2137,7 +2139,7 @@ function buildParagraph(ctx, ifc) {
2137
2139
  forced.set(text.length, (forced.get(text.length) ?? 0) + 1);
2138
2140
  afterSpace = true;
2139
2141
  } else if (item.kind === "image" || item.kind === "atomic") {
2140
- pushRun({ kind: item.kind, nodeId: item.node.id, style: item.node.style ?? null, node: item.node, box: item.box, wrap: true }, "");
2142
+ pushRun({ kind: item.kind, nodeId: item.node.id, style: item.node.style ?? null, node: item.node, box: item.box, wrap: item.wrap!==false }, "");
2141
2143
  afterSpace = false;
2142
2144
  }
2143
2145
  }
@@ -2158,7 +2160,7 @@ function buildParagraph(ctx, ifc) {
2158
2160
  const end = index + segment.length;
2159
2161
  if (!run) continue;
2160
2162
  if (run.collapsible && run.wrap && segment === " ") opportunities.add(end);
2161
- else if (run.kind === "image" || run.kind === "atomic") {
2163
+ else if (run.wrap && (run.kind === "image" || run.kind === "atomic")) {
2162
2164
  opportunities.add(index);
2163
2165
  opportunities.add(end);
2164
2166
  } else if (run.wrap && run.kind === "text" && (Bun.stringWidth(segment) > 1 || (!run.collapsible && segment === " "))) {
@@ -2404,7 +2406,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2404
2406
  ascent = Math.max(ascent, atomAscent);
2405
2407
  descent = Math.max(descent, atom.marginTop + atom.height + atom.marginBottom - atomAscent);
2406
2408
  } else {
2407
- segment.metrics = textMetrics(ctx, run.style);
2409
+ segment.metrics = textMetrics(ctx, run.style, paragraph.text.slice(segment.start,segment.end));
2408
2410
  ascent = Math.max(ascent, segment.metrics.ascent);
2409
2411
  descent = Math.max(descent, segment.metrics.descent);
2410
2412
  }
@@ -2461,6 +2463,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2461
2463
  const common = {
2462
2464
  nodeId: run.nodeId,
2463
2465
  text: run.kind === "text" ? paragraph.text.slice(segment.start, segment.end) : "",
2466
+ sourceStart:(run.sourceStart??0)+segment.start-run.start,
2464
2467
  start: segment.start - line.start,
2465
2468
  end: segment.end - line.start,
2466
2469
  style: run.style ?? style,
@@ -2598,8 +2601,8 @@ function measureAtomic(ctx, box, availableWidth) {
2598
2601
  };
2599
2602
  }
2600
2603
 
2601
- function textMetrics(ctx, style) {
2602
- const font = ctx.metrics(style);
2604
+ function textMetrics(ctx, style, text = null) {
2605
+ const font = ctx.metrics(style,text);
2603
2606
  // `line-height: normal` (null) uses the font's own content height.
2604
2607
  const lineHeight = ctx.lineHeight ?? (style ? style.lineHeight ?? font.height : ctx.fallbackLineHeight);
2605
2608
  const halfLeading = (lineHeight - font.height) / 2;
@@ -2668,7 +2671,12 @@ function cachedMetrics(fontMetrics) {
2668
2671
  }
2669
2672
  return metrics;
2670
2673
  };
2671
- return (style) => {
2674
+ return (style,text=null) => {
2675
+ // Fallback fonts can be taller than the Latin strut. Use the same glyphs
2676
+ // measured for this run rather than assuming every character uses "Hg".
2677
+ if(text && /[^\x00-\x7f]/.test(text) && typeof fontMetrics==='function') {
2678
+ const measured=fontMetrics(style,text);if(measured?.height>0)return measured;
2679
+ }
2672
2680
  if (style === null || typeof style !== "object") return metricsFor(style);
2673
2681
  let metrics = byStyle.get(style);
2674
2682
  if (!metrics) byStyle.set(style, metrics = metricsFor(style));