buninu-browser 0.2.4 → 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.
- package/LICENSES/jsmdcui.txt +22 -0
- package/README.md +102 -2
- package/frontends/wayland/LICENSE-land +21 -0
- package/frontends/wayland/README.md +123 -0
- package/frontends/wayland/casty/LICENSE +21 -0
- package/frontends/wayland/casty/bookmarks.js +32 -0
- package/frontends/wayland/casty/click.js +96 -0
- package/frontends/wayland/casty/commands.js +61 -0
- package/frontends/wayland/casty/config.js +45 -0
- package/frontends/wayland/casty/download-directory.js +28 -0
- package/frontends/wayland/casty/download.js +49 -0
- package/frontends/wayland/casty/hints.js +334 -0
- package/frontends/wayland/casty/keys.js +56 -0
- package/frontends/wayland/casty/page-commands.js +80 -0
- package/frontends/wayland/casty/prompt-width.js +148 -0
- package/frontends/wayland/casty/urlbar.js +606 -0
- package/frontends/wayland/client.js +88 -0
- package/frontends/wayland/clipboard.js +80 -0
- package/frontends/wayland/index.js +116 -0
- package/frontends/wayland/input.js +56 -0
- package/frontends/wayland/protocol.js +55 -0
- package/frontends/wayland/protocols/wayland.xml +3248 -0
- package/frontends/wayland/protocols/xdg-shell.xml +1415 -0
- package/frontends/wayland/transport.js +120 -0
- package/frontends/wayland/urlbar.js +158 -0
- package/index.js +28 -1
- package/lib/clipboard-command.js +10 -0
- package/lib/clipboard-powershell.js +22 -0
- package/lib/clipboard.js +57 -0
- package/lib/document-types.js +11 -0
- package/lib/headless-shell.js +78 -13
- package/lib/layout/text-layout.js +17 -9
- package/lib/paint/display-list.js +5 -1
- package/lib/paint/frame.js +35 -3
- package/lib/paint/raw-frames.js +47 -0
- package/lib/qml/README.md +971 -0
- package/lib/qml/builtins.js +700 -0
- package/lib/qml/canvas-dom.js +115 -0
- package/lib/qml/document.js +1176 -0
- package/lib/qml/format.js +9 -0
- package/lib/qml/host.js +324 -0
- package/lib/qml/images.js +199 -0
- package/lib/qml/launcher.js +132 -0
- package/lib/qml/loader.js +517 -0
- package/lib/qml/media-renderer.js +49 -0
- package/lib/qml/media.js +86 -0
- package/lib/qml/models.js +622 -0
- package/lib/qml/parser.js +381 -0
- package/lib/qml/qs +5 -0
- package/lib/qml/qscasty +3 -0
- package/lib/qml/qsheadless +3 -0
- package/lib/qml/qswayland +3 -0
- package/lib/qml/reactive.js +159 -0
- package/lib/qml/runtime.js +3381 -0
- package/lib/qml/shapes.js +243 -0
- package/lib/qml/text.js +109 -0
- package/lib/qml/textures.js +123 -0
- package/lib/qml/wayland-clipboard.js +32 -0
- package/lib/qml/worker-thread.js +70 -0
- package/lib/render-tree/index.js +16 -1
- package/lib/renderer/bindings.js +11 -0
- package/lib/renderer/canvas-2d.js +13 -0
- package/lib/renderer/compositor-channel.js +103 -0
- package/lib/renderer/compositor.js +25 -33
- package/lib/renderer/flags.js +1 -1
- package/lib/renderer/host.js +128 -92
- package/lib/renderer/main.js +38 -4
- package/lib/renderer/page-realm.js +32 -1
- package/lib/renderer/page-renderer.js +250 -21
- package/lib/renderer/text-input-paint.js +54 -0
- package/lib/renderer/text-input.js +57 -0
- package/lib/style/computed-style.js +2 -2
- package/package.json +1 -1
- package/test/clipboard.test.js +54 -0
- package/test/fixtures/browser-raw-demo.html +36 -0
- package/test/fixtures/flea-helpers/Dockerfile +36 -0
- package/test/fixtures/flea-helpers/bat.mjs +43 -0
- package/test/fixtures/flea-helpers/bin/bat +3 -0
- package/test/fixtures/flea-helpers/bin/bwrap +10 -0
- package/test/fixtures/flea-helpers/bin/bwrap-real-filter +9 -0
- package/test/fixtures/flea-helpers/bin/glow +3 -0
- package/test/fixtures/flea-helpers/bin/qs +3 -0
- package/test/fixtures/flea-helpers/bin/unsupported +3 -0
- package/test/fixtures/flea-helpers/check-highlighting.mjs +47 -0
- package/test/fixtures/flea-helpers/check-media.mjs +29 -0
- package/test/fixtures/flea-helpers/check-preview.mjs +27 -0
- package/test/fixtures/flea-helpers/check.mjs +36 -0
- package/test/fixtures/flea-helpers/highlight-cases.json +227 -0
- package/test/fixtures/flea-helpers/source-mime.xml +28 -0
- package/test/fixtures/flea-helpers/syntax-compat.mjs +23 -0
- package/test/fixtures/flea-thumbnail-stub/flea-placeholder.thumbnailer +4 -0
- package/test/fixtures/flea-thumbnail-stub/flea-thumbnail-stub +3 -0
- package/test/fixtures/qml/flea/EscapeHint.qml +11 -0
- package/test/fixtures/qml/flea/LICENSE +21 -0
- package/test/fixtures/qml/flea/README.md +25 -0
- package/test/fixtures/qml/flea/Theme.qml +8 -0
- package/test/fixtures/qml/flea/demo.qml +24 -0
- package/test/fixtures/qml/flea/qmldir +3 -0
- package/test/fixtures/qml/flea-theme/LICENSE-Flea +21 -0
- package/test/fixtures/qml/flea-theme/LICENSE-Omarchy +20 -0
- package/test/fixtures/qml/flea-theme/README.md +70 -0
- package/test/fixtures/qml/flea-theme/button.qml +17 -0
- package/test/fixtures/qml/flea-theme/demo.qml +27 -0
- package/test/fixtures/qml/flea-theme/field.qml +21 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/BorderGeometry.js +373 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/Color.qml +258 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/I18n.qml +118 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/Numbers.js +19 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/Plural.js +91 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/Style.qml +561 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/Util.qml +188 -0
- package/test/fixtures/qml/flea-theme/imports/qs/Commons/qmldir +13 -0
- package/test/fixtures/qml/flea-theme/segment.qml +17 -0
- package/test/fixtures/qml/flea-theme/settings.qml +58 -0
- package/test/fixtures/qml/flea-theme/text-metrics.qml +10 -0
- package/test/fixtures/qml/flea-theme/ui/DialogButton.qml +150 -0
- package/test/fixtures/qml/flea-theme/ui/DialogField.qml +147 -0
- package/test/fixtures/qml/flea-theme/ui/EscapeHint.qml +11 -0
- package/test/fixtures/qml/flea-theme/ui/Glass.qml +114 -0
- package/test/fixtures/qml/flea-theme/ui/Glyph.qml +54 -0
- package/test/fixtures/qml/flea-theme/ui/GlyphSolid.qml +22 -0
- package/test/fixtures/qml/flea-theme/ui/Reveal.qml +54 -0
- package/test/fixtures/qml/flea-theme/ui/SettingsRuler.qml +85 -0
- package/test/fixtures/qml/flea-theme/ui/SettingsSegment.qml +85 -0
- package/test/fixtures/qml/flea-theme/ui/StableModel.qml +89 -0
- package/test/fixtures/qml/flea-theme/ui/Theme.qml +460 -0
- package/test/fixtures/qml/flea-theme/ui/ThemeColors.qml +43 -0
- package/test/fixtures/qml/flea-theme/ui/ViewState.qml +565 -0
- package/test/fixtures/qml/flea-theme/ui/js/Anchor.js +379 -0
- package/test/fixtures/qml/flea-theme/ui/js/AnchorHold.js +162 -0
- package/test/fixtures/qml/flea-theme/ui/js/Archive.js +157 -0
- package/test/fixtures/qml/flea-theme/ui/js/BackReturn.js +110 -0
- package/test/fixtures/qml/flea-theme/ui/js/Buttons.js +54 -0
- package/test/fixtures/qml/flea-theme/ui/js/ColumnMenu.js +111 -0
- package/test/fixtures/qml/flea-theme/ui/js/Columns.js +298 -0
- package/test/fixtures/qml/flea-theme/ui/js/Contrast.js +157 -0
- package/test/fixtures/qml/flea-theme/ui/js/Density.js +44 -0
- package/test/fixtures/qml/flea-theme/ui/js/DirSizes.js +129 -0
- package/test/fixtures/qml/flea-theme/ui/js/ExtThumbs.js +115 -0
- package/test/fixtures/qml/flea-theme/ui/js/Filter.js +284 -0
- package/test/fixtures/qml/flea-theme/ui/js/FolderSorts.js +108 -0
- package/test/fixtures/qml/flea-theme/ui/js/Format.js +305 -0
- package/test/fixtures/qml/flea-theme/ui/js/Icons.js +206 -0
- package/test/fixtures/qml/flea-theme/ui/js/Input.js +39 -0
- package/test/fixtures/qml/flea-theme/ui/js/KeyName.js +64 -0
- package/test/fixtures/qml/flea-theme/ui/js/Keymap.js +370 -0
- package/test/fixtures/qml/flea-theme/ui/js/Kinds.js +145 -0
- package/test/fixtures/qml/flea-theme/ui/js/Lists.js +11 -0
- package/test/fixtures/qml/flea-theme/ui/js/MakeDefault.js +186 -0
- package/test/fixtures/qml/flea-theme/ui/js/Match.js +27 -0
- package/test/fixtures/qml/flea-theme/ui/js/Material.js +109 -0
- package/test/fixtures/qml/flea-theme/ui/js/Menu.js +513 -0
- package/test/fixtures/qml/flea-theme/ui/js/Motion.js +16 -0
- package/test/fixtures/qml/flea-theme/ui/js/Mounts.js +320 -0
- package/test/fixtures/qml/flea-theme/ui/js/MouseNav.js +13 -0
- package/test/fixtures/qml/flea-theme/ui/js/Nav.js +328 -0
- package/test/fixtures/qml/flea-theme/ui/js/Palette.js +33 -0
- package/test/fixtures/qml/flea-theme/ui/js/Places.js +236 -0
- package/test/fixtures/qml/flea-theme/ui/js/Protocols.js +296 -0
- package/test/fixtures/qml/flea-theme/ui/js/RecentMode.js +154 -0
- package/test/fixtures/qml/flea-theme/ui/js/Search.js +396 -0
- package/test/fixtures/qml/flea-theme/ui/js/Settings.js +476 -0
- package/test/fixtures/qml/flea-theme/ui/js/SettingsAbout.js +99 -0
- package/test/fixtures/qml/flea-theme/ui/js/SettingsShelf.js +52 -0
- package/test/fixtures/qml/flea-theme/ui/js/Sort.js +127 -0
- package/test/fixtures/qml/flea-theme/ui/js/StateWords.js +42 -0
- package/test/fixtures/qml/flea-theme/ui/js/Swap.js +166 -0
- package/test/fixtures/qml/flea-theme/ui/js/TextSize.js +107 -0
- package/test/fixtures/qml/flea-theme/ui/js/Thumbs.js +158 -0
- package/test/fixtures/qml/flea-theme/ui/js/UiState.js +438 -0
- package/test/fixtures/qml/flea-theme/ui/js/Update.js +132 -0
- package/test/fixtures/qml/flea-theme/ui/js/UserDirs.js +56 -0
- package/test/fixtures/qml/flea-theme/ui/qmldir +227 -0
- package/test/fixtures/qml/hello.qml +26 -0
- package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabel.qml +2 -0
- package/test/fixtures/qml/imports/Demo/Toolkit/VersionLabelNew.qml +2 -0
- package/test/fixtures/qml/imports/Demo/Toolkit/qmldir +3 -0
- package/test/fixtures/qml/imports/components/Badge.qml +15 -0
- package/test/fixtures/qml/imports/logic/base.js +2 -0
- package/test/fixtures/qml/imports/logic/counter.js +4 -0
- package/test/fixtures/qml/imports/logic/math.js +3 -0
- package/test/fixtures/qml/imports/logic/shared.js +3 -0
- package/test/fixtures/qml/imports/main.qml +21 -0
- package/test/fixtures/qml/imports/theme/Theme.qml +8 -0
- package/test/fixtures/qml/imports/theme/qmldir +2 -0
- package/test/fixtures/qml/multiply.qml +61 -0
- package/test/fixtures/qml-canvas-dom.js +90 -0
- package/test/fixtures/qml-media-audio.js +39 -0
- package/test/fixtures/raw-capture-benchmark.json +76 -0
- package/test/helpers/flea-sandbox.js +48 -0
- package/test/helpers/quickshell-fixture.js +125 -0
- package/test/html-clipboard-wayland.test.js +47 -0
- package/test/index.test.js +13 -0
- package/test/manual-casty-without-qml.js +87 -0
- package/test/manual-flea-application.js +49 -0
- package/test/manual-flea-binary.js +62 -0
- package/test/manual-flea-qml.js +9 -0
- package/test/manual-flea-settings.js +40 -0
- package/test/manual-flea-theme.js +40 -0
- package/test/manual-qml-removal.js +46 -0
- package/test/manual-without-qml.js +45 -0
- package/test/qml-canvas-dom.test.js +37 -0
- package/test/qml-clipboard-bridge.test.js +25 -0
- package/test/qml-clipboard-wayland.test.js +40 -0
- package/test/qml-connections-timing.test.js +73 -0
- package/test/qml-dynamic.test.js +401 -0
- package/test/qml-flea-app.test.js +146 -0
- package/test/qml-flea-backend.test.js +78 -0
- package/test/qml-flea-columns-backend.test.js +38 -0
- package/test/qml-flea-compile.test.js +78 -0
- package/test/qml-flea-launcher.test.js +88 -0
- package/test/qml-flea-markdown-backend.test.js +31 -0
- package/test/qml-flea-media.test.js +28 -0
- package/test/qml-flea-navigation-backend.test.js +35 -0
- package/test/qml-flea-navigation-timing.test.js +147 -0
- package/test/qml-flea-preview-backend.test.js +53 -0
- package/test/qml-flea-previews.test.js +163 -0
- package/test/qml-flea-rows.test.js +101 -0
- package/test/qml-flea-textures.test.js +59 -0
- package/test/qml-flea-view-switch.test.js +33 -0
- package/test/qml-flea-wayland.test.js +68 -0
- package/test/qml-flea.test.js +34 -0
- package/test/qml-host.test.js +238 -0
- package/test/qml-imports.test.js +102 -0
- package/test/qml-inherited-handlers.test.js +51 -0
- package/test/qml-input-selection.test.js +66 -0
- package/test/qml-interactions.test.js +309 -0
- package/test/qml-language.test.js +167 -0
- package/test/qml-launcher.test.js +54 -0
- package/test/qml-layout-text.test.js +205 -0
- package/test/qml-media.test.js +63 -0
- package/test/qml-persisted-delegates.test.js +45 -0
- package/test/qml-quickshell-controls.test.js +65 -0
- package/test/qml-quickshell-settings.test.js +62 -0
- package/test/qml-repeater.test.js +72 -0
- package/test/qml-style-updates.test.js +124 -0
- package/test/qml-textures.test.js +66 -0
- package/test/qml-views.test.js +332 -0
- package/test/qml-worker.test.js +69 -0
- package/test/qml.test.js +83 -0
- package/test/raw-capture.test.js +152 -0
- package/test/renderer-host.test.js +11 -0
- package/test/text-input-pointer.test.js +31 -0
- package/test/text-input-scroll.test.js +29 -0
- package/test/text-input.test.js +47 -0
- package/test/wayland-frontend.test.js +83 -0
- package/test/wayland-urlbar.test.js +38 -0
- package/test/window-resize.test.js +36 -0
- package/tools/bench-raw-capture.js +32 -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
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {powershellClipboard} from './clipboard-powershell.js';
|
|
2
|
+
// Host-side text clipboard. No shell interpolation; page bytes go only to stdin.
|
|
3
|
+
export function clipboardCommands(platform = process.platform) {
|
|
4
|
+
return [
|
|
5
|
+
['xclip','-selection','clipboard'],
|
|
6
|
+
...(platform === 'win32' ? [['powershell.exe']]
|
|
7
|
+
: platform === 'darwin' ? [['pbcopy']] : [['wl-copy'],['termux-clipboard-set']]),
|
|
8
|
+
];
|
|
9
|
+
}
|
|
10
|
+
export async function writeClipboard(text, {which = Bun.which, spawn = Bun.spawn, platform = process.platform} = {}) {
|
|
11
|
+
if(typeof text !== 'string' || Buffer.byteLength(text)>1024*1024)throw Error('Clipboard text exceeds limit');
|
|
12
|
+
for(const command of clipboardCommands(platform)) {
|
|
13
|
+
const executable=which(command[0]); if(!executable)continue;
|
|
14
|
+
let child,timer;
|
|
15
|
+
try {
|
|
16
|
+
if(command[0]==='powershell.exe') {
|
|
17
|
+
if(powershellClipboard(executable).write(text))return;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
child=spawn([executable,...command.slice(1)],{stdin:Buffer.from(text),stdout:'ignore',stderr:'ignore'});
|
|
21
|
+
timer=setTimeout(()=>child.kill(9),3000);
|
|
22
|
+
if(await child.exited === 0)return;
|
|
23
|
+
} catch {} finally {clearTimeout(timer);}
|
|
24
|
+
}
|
|
25
|
+
throw Error('No working clipboard command (xclip preferred)');
|
|
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
|
+
}
|
package/lib/document-types.js
CHANGED
|
@@ -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
|
}
|
package/lib/headless-shell.js
CHANGED
|
@@ -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.
|
|
11
|
-
* the renderer sandbox
|
|
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
|
|
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;
|
|
@@ -101,6 +108,7 @@ export async function createBrowser({
|
|
|
101
108
|
let closed = false;
|
|
102
109
|
// Settles when the first renderer shows about:blank (assigned below).
|
|
103
110
|
let rendererReady = null;
|
|
111
|
+
const documentReady = new WeakMap();
|
|
104
112
|
// Renderer-independent state replayed into every new renderer.
|
|
105
113
|
const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
|
|
106
114
|
let userAgent = webkit ? initialUserAgent : null;
|
|
@@ -157,10 +165,18 @@ export async function createBrowser({
|
|
|
157
165
|
}
|
|
158
166
|
|
|
159
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
|
+
|
|
160
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)}:{}),
|
|
161
177
|
network,
|
|
162
178
|
timeout: rendererTimeout,
|
|
163
|
-
args
|
|
179
|
+
args,
|
|
164
180
|
// A replacement renderer starts blank; bring the current page back.
|
|
165
181
|
onRestart: async (invoke) => {
|
|
166
182
|
await replayState(invoke);
|
|
@@ -224,13 +240,27 @@ export async function createBrowser({
|
|
|
224
240
|
|
|
225
241
|
/** The renderer for a URL's origin, swapping processes on an origin change. */
|
|
226
242
|
async function rendererFor(url) {
|
|
227
|
-
|
|
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
|
+
|
|
228
257
|
if (current && key === currentKey) return current;
|
|
229
|
-
const next =
|
|
230
|
-
spare = null;
|
|
258
|
+
const next = acquireHost();
|
|
231
259
|
await next.start();
|
|
232
260
|
await replayState((method, ...params) => next.call(method, ...params));
|
|
233
261
|
const previous = current;
|
|
262
|
+
const ready=Promise.withResolvers();
|
|
263
|
+
documentReady.set(next,ready);
|
|
234
264
|
current = next;
|
|
235
265
|
currentKey = key;
|
|
236
266
|
previous?.close();
|
|
@@ -304,8 +334,22 @@ export async function createBrowser({
|
|
|
304
334
|
writeLog("navigation", `${url}: ${document.error ?? `HTTP ${document.status ?? 200}`}`
|
|
305
335
|
+ `${document.url === url ? "" : ` -> ${document.url}`} in ${Math.round(fetched - started)}ms`);
|
|
306
336
|
if (!invoke) await rendererReady;
|
|
307
|
-
|
|
308
|
-
|
|
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);
|
|
349
|
+
let generation;
|
|
350
|
+
try {
|
|
351
|
+
generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
|
|
352
|
+
} finally { documentReady.get(host)?.resolve(); }
|
|
309
353
|
committed = { host, generation };
|
|
310
354
|
for (const name of reachedLifecycle(host, generation)) notify(lifecycleListeners, name);
|
|
311
355
|
writeLog("navigation", `${document.url}: first paint in ${Math.round(performance.now() - fetched)}ms`);
|
|
@@ -315,7 +359,20 @@ export async function createBrowser({
|
|
|
315
359
|
// answered even while the renderer's main thread is busy.
|
|
316
360
|
const composite = async (method, ...params) => {
|
|
317
361
|
await rendererReady;
|
|
318
|
-
|
|
362
|
+
// An origin change retires the old compositor. Read-only requests may
|
|
363
|
+
// follow that swap once; scrolling must never be applied twice.
|
|
364
|
+
const readOnly = method === "screenshot" || method === "scrollOffset";
|
|
365
|
+
for (let attempt=0; ; attempt++) {
|
|
366
|
+
const host=current;
|
|
367
|
+
await documentReady.get(host)?.promise;
|
|
368
|
+
if (closed) throw Error("Browser is closed");
|
|
369
|
+
try {
|
|
370
|
+
if (host !== current) throw Error("Capture superseded by navigation");
|
|
371
|
+
return await host.composite(method, ...params);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (closed || !readOnly || host === current || attempt >= 1) throw error;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
319
376
|
};
|
|
320
377
|
|
|
321
378
|
async function rememberScroll() {
|
|
@@ -370,6 +427,7 @@ export async function createBrowser({
|
|
|
370
427
|
}
|
|
371
428
|
|
|
372
429
|
const context = {
|
|
430
|
+
get url() { return currentUrl; },
|
|
373
431
|
navigate,
|
|
374
432
|
get userAgent() { return userAgent ?? initialUserAgent; },
|
|
375
433
|
setDownloadBehavior({ behavior = "allow", downloadPath = DEFAULT_DOWNLOAD_DIR, eventsEnabled = false } = {}) {
|
|
@@ -405,9 +463,10 @@ export async function createBrowser({
|
|
|
405
463
|
applyState("setInitScripts", () => [[...initScripts.values()]]);
|
|
406
464
|
},
|
|
407
465
|
screenshot: (options = {}) => composite("screenshot", {
|
|
408
|
-
format:
|
|
466
|
+
format: ["jpeg", "webp", "rgba", "bgra"].includes(options.format) ? options.format : "png",
|
|
409
467
|
quality: Number.isFinite(options.quality) ? options.quality : 80,
|
|
410
468
|
optimizeForSpeed: options.optimizeForSpeed === true,
|
|
469
|
+
clip: options.clip ? {...options.clip,x:options.clip.x/pageScale(),y:options.clip.y/pageScale(),width:options.clip.width/pageScale(),height:options.clip.height/pageScale()} : undefined,
|
|
411
470
|
}),
|
|
412
471
|
title: () => call("title"),
|
|
413
472
|
/**
|
|
@@ -473,7 +532,12 @@ export async function createBrowser({
|
|
|
473
532
|
return result === null ? null : { url: href, title: await call("title") };
|
|
474
533
|
},
|
|
475
534
|
evaluate: (expression) => call("evaluate", String(expression)),
|
|
476
|
-
cdp() {
|
|
535
|
+
async cdp(method) {
|
|
536
|
+
if(method==='Page.getLayoutMetrics') {
|
|
537
|
+
const offset=await composite('scrollOffset');
|
|
538
|
+
const visual={pageX:offset.x*pageScale(),pageY:offset.y*pageScale(),offsetX:0,offsetY:0,clientWidth:viewport.width,clientHeight:viewport.height,scale:1,zoom:1};
|
|
539
|
+
return {cssVisualViewport:visual,cssLayoutViewport:visual,visualViewport:visual,layoutViewport:visual};
|
|
540
|
+
}
|
|
477
541
|
return {};
|
|
478
542
|
},
|
|
479
543
|
};
|
|
@@ -484,7 +548,8 @@ export async function createBrowser({
|
|
|
484
548
|
const first = rendererFor("about:blank");
|
|
485
549
|
prepareSpare();
|
|
486
550
|
await first;
|
|
487
|
-
await current.call("loadDocument", { url: "about:blank" });
|
|
551
|
+
try { await current.call("loadDocument", { url: "about:blank" }); }
|
|
552
|
+
finally { documentReady.get(current)?.resolve(); }
|
|
488
553
|
})();
|
|
489
554
|
if (waitForRenderer) await rendererReady;
|
|
490
555
|
else rendererReady.catch(() => {}); // the caller awaits `ready`
|