Initial commit

This commit is contained in:
Stephen Hellings 2026-03-08 23:04:02 -04:00
commit 33359b814e
47 changed files with 17347 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
target/
storage/
snapshots/
.qdrant*

3301
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

73
Cargo.toml Normal file
View file

@ -0,0 +1,73 @@
[package]
name = "wo"
version = "0.1.0"
edition = "2021"
description = "Electron-centered Smithay Wayland compositor with DMABUF offscreen rendering"
[[bin]]
name = "wo"
path = "src/main.rs"
[[bin]]
name = "wo-portal"
path = "src/bin/wo-portal.rs"
[dependencies]
# Smithay compositor framework
smithay = { version = "0.7", default-features = false, features = ["backend_drm", "backend_gbm", "backend_egl", "backend_libinput", "backend_udev", "backend_session_libseat", "backend_winit", "renderer_gl", "wayland_frontend", "desktop", "xwayland", "use_system_lib", "wayland-client", "backend_session"] }
# Event loop
calloop = { version = "0.14", features = ["executor"] }
calloop-wayland-source = "0.4"
# DRM/GBM
drm = "0.14"
gbm = "0.18"
# EGL / GL
gl = "0.14"
# Wayland
wayland-server = "0.31"
wayland-protocols = { version = "0.32", features = ["server", "staging", "unstable"] }
wayland-backend = { version = "0.3", features = ["server_system"] }
# Async / IPC
tokio = { version = "1", features = ["full"] }
nix = { version = "0.31", features = ["socket", "uio", "fs", "net", "time"] }
# D-Bus and PipeWire for portal
zbus = { version = "5", default-features = false, features = ["tokio"] }
pipewire = { version = "0.9", default-features = false }
# Config
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.0"
# Platform syscalls (TIOCOUTQ ioctl for socket buffer checks)
libc = "0.2"
# Utils
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
shellexpand = "3.1"
ctrlc = "3.5"
# Input
input = "0.9"
udev = "0.9"
xkbcommon = "0.9"
# Rendering
pixman = "0.2"
drm-fourcc = "2.2.0"
[build-dependencies]
# nothing yet
[profile.release]
opt-level = 3
lto = true
codegen-units = 1

57
PKGBUILD Normal file
View file

@ -0,0 +1,57 @@
# Maintainer: Stephen E. Hellings <stephen@hellings.cc>
pkgname=wo-git
pkgver=0.1.0
pkgrel=1
pkgdesc='The Electron Wayland Compositor'
arch=('x86_64')
url='https://github.com/hellings/wo'
license=('MIT')
depends=(
'libdrm'
'libinput'
'libseat'
'libxkbcommon'
'mesa'
'wayland'
'xorg-xwayland'
'pipewire'
'electron'
)
makedepends=(
'cargo'
'rust'
'clang'
'pkgconf'
)
options=('!lto')
source=(
"git+https://github.com/wo-wl/wo.git"
"git+https://github.com/wo-wl/comraw.git"
"config.example.toml"
"packaging/wayland-sessions/wo.desktop"
"electron"
)
build() {
cd "$startdir"
cargo build --release --locked --bins
}
package() {
cd "$startdir"
install -Dm755 target/release/wo "$pkgdir/usr/bin/wo"
install -Dm755 target/release/wo-portal "$pkgdir/usr/bin/wo-portal"
install -Dm644 packaging/wayland-sessions/wo.desktop \
"$pkgdir/usr/share/wayland-sessions/wo.desktop"
install -Dm644 config.example.toml \
"$pkgdir/usr/share/doc/$pkgname/config.example.toml"
if [[ -d electron ]]; then
cp -a electron "$pkgdir/usr/lib/$pkgname/"
fi
}

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# wo compositor
don't bother (woe)

21
config.example.toml Normal file
View file

@ -0,0 +1,21 @@
[compositor]
socket_name = "wo-0"
ipc_socket = "/run/user/1000/wo-ipc.sock"
electron_path = "electron"
background = [0.08, 0.10, 0.12, 1.0]
enable_portal = true
portal_socket = "/run/user/1000/wo-portal.sock"
enable_syscalls = true
focus_notify = true
[[root]]
name = "main"
url = "http://localhost:5173"
fps = 60
focusable = false
interactive = false
[[autostart]]
name = "init-portal"
command = "wo://portal/start"
delay = 100

7
electron/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
dist/
build/
out/
*.log
*.tsbuildinfo
*.map

368
electron/bun.lock Normal file
View file

@ -0,0 +1,368 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "wo-electron",
"dependencies": {
"@sakun/system.css": "^0.1.11",
"node-addon-api": "^8.6.0",
},
"devDependencies": {
"@types/bun": "latest",
"@types/electron": "^latest",
"@types/node": "^latest",
"electron": "^29.0.0",
"node-gyp": "^10.0.0",
"typescript": "^latest",
},
},
},
"packages": {
"@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@npmcli/agent": ["@npmcli/agent@2.2.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og=="],
"@npmcli/fs": ["@npmcli/fs@3.1.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@sakun/system.css": ["@sakun/system.css@0.1.11", "", { "dependencies": { "draggabilly": "^3.0.0" } }, "sha512-Zpl6Uoq7J3A/77knLBqgcdAcmyeZo38O7WKJMT1KVIxCVzSDulJnSKWNXF/7EHUXCTjYuOWE9D5DkxmJe533tg=="],
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
"@types/electron": ["@types/electron@1.6.12", "", { "dependencies": { "electron": "*" } }, "sha512-NIJokDkGv9h+MStCL1IuiL1FOHYVkszoWeNxJtSI5dcEKRGbX83JcVYNAgk019qOQgJkHtz9WdP0CDXvrArrGg=="],
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
"@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="],
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"cacache": ["cacache@18.0.4", "", { "dependencies": { "@npmcli/fs": "^3.1.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^4.0.0", "ssri": "^10.0.0", "tar": "^6.1.11", "unique-filename": "^3.0.0" } }, "sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ=="],
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
"cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="],
"chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="],
"clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="],
"clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
"draggabilly": ["draggabilly@3.0.0", "", { "dependencies": { "get-size": "^3.0.0", "unidragger": "^3.0.0" } }, "sha512-aEs+B6prbMZQMxc9lgTpCBfyCUhRur/VFucHhIOvlvvdARTj7TcDmX/cdOUtqbjJJUh7+agyJXR5Z6IFe1MxwQ=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"electron": ["electron@29.4.6", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-fz8ndj8cmmf441t4Yh2FDP3Rn0JhLkVGvtUf2YVMbJ5SdJPlc0JWll9jYkhh60jDKVVCr/tBAmfxqRnXMWJpzg=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"ev-emitter": ["ev-emitter@2.1.2", "", {}, "sha512-jQ5Ql18hdCQ4qS+RCrbLfz1n+Pags27q5TwMKvZyhp5hh2UULUYZUy1keqj6k6SYsdqIYjnmz7xyyEY0V67B8Q=="],
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="],
"get-size": ["get-size@3.0.0", "", {}, "sha512-Y8aiXLq4leR7807UY0yuKEwif5s3kbVp1nTv+i4jBeoUzByTLKkLWu/HorS6/pB+7gsB0o7OTogC8AoOOeT0Hw=="],
"get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"make-fetch-happen": ["make-fetch-happen@13.0.1", "", { "dependencies": { "@npmcli/agent": "^2.0.0", "cacache": "^18.0.0", "http-cache-semantics": "^4.1.1", "is-lambda": "^1.0.1", "minipass": "^7.0.2", "minipass-fetch": "^3.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "proc-log": "^4.2.0", "promise-retry": "^2.0.1", "ssri": "^10.0.0" } }, "sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA=="],
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="],
"minipass-fetch": ["minipass-fetch@3.0.5", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^2.1.2" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg=="],
"minipass-flush": ["minipass-flush@1.0.5", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="],
"minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="],
"minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="],
"minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="],
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
"node-addon-api": ["node-addon-api@8.6.0", "", {}, "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q=="],
"node-gyp": ["node-gyp@10.3.1", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^10.3.10", "graceful-fs": "^4.2.6", "make-fetch-happen": "^13.0.0", "nopt": "^7.0.0", "proc-log": "^4.1.0", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^4.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ=="],
"nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
"normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
"p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="],
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
"promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"ssri": ["ssri@10.0.6", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ=="],
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
"tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unidragger": ["unidragger@3.0.1", "", { "dependencies": { "ev-emitter": "^2.0.0" } }, "sha512-RngbGSwBFmqGBWjkaH+yB677uzR95blSQyxq6hYbrQCejH3Mx1nm8DVOuh3M9k2fQyTstWUG5qlgCnNqV/9jVw=="],
"unique-filename": ["unique-filename@3.0.0", "", { "dependencies": { "unique-slug": "^4.0.0" } }, "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g=="],
"unique-slug": ["unique-slug@4.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}

View file

@ -0,0 +1,30 @@
{
"targets": [
{
"target_name": "wo_dmabuf",
"sources": [
"wo_dmabuf.cc"
],
"include_dirs": [
"<!@(node -p \"require('node-addon-api').include\")"
],
"defines": [
"NAPI_DISABLE_CPP_EXCEPTIONS"
],
"cflags_cc": [
"-std=c++17",
"-Wall",
"-Wextra"
],
"libraries": [
"-lgbm",
"-ldrm",
"-lEGL",
"-lGL"
],
"include_dirs+": [
"/usr/include/libdrm"
]
}
]
}

View file

@ -0,0 +1,524 @@
#include <napi.h>
#include <cstdint>
#include <cstring>
#include <map>
#include <mutex>
#include <stdexcept>
#include <string>
// Linux / GBM / DRM
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include <GLES2/gl2.h>
#include <GLES2/gl2ext.h>
#include <drm/drm_fourcc.h>
#include <gbm.h>
static int g_drm_fd = -1;
static gbm_device *g_gbm = nullptr;
static std::mutex g_gbm_mutex;
static EGLDisplay g_egl_display = EGL_NO_DISPLAY;
static EGLContext g_egl_context = EGL_NO_CONTEXT;
static std::mutex g_egl_mutex;
typedef EGLImageKHR (*PFNEGLCREATEIMAGEKHRPROC)(EGLDisplay, EGLContext, EGLenum, EGLClientBuffer, const EGLint*);
typedef EGLBoolean (*PFNEGLDESTROYIMAGEKHRPROC)(EGLDisplay, EGLImageKHR);
typedef void (*PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)(GLenum, GLeglImageOES);
static PFNEGLCREATEIMAGEKHRPROC eglCreateImageKHR_fn = nullptr;
static PFNEGLDESTROYIMAGEKHRPROC eglDestroyImageKHR_fn = nullptr;
static PFNGLEGLIMAGETARGETTEXTURE2DOESPROC glEGLImageTargetTexture2DOES_fn = nullptr;
struct TextureEntry {
GLuint texture;
EGLImageKHR image;
int width;
int height;
};
static std::map<std::string, TextureEntry> g_texture_cache;
static std::mutex g_texture_cache_mutex;
static bool ensure_gbm(const std::string &drm_device = "/dev/dri/renderD128") {
std::lock_guard<std::mutex> lk(g_gbm_mutex);
if (g_gbm)
return true;
g_drm_fd = ::open(drm_device.c_str(), O_RDWR | O_CLOEXEC);
if (g_drm_fd < 0)
return false;
g_gbm = gbm_create_device(g_drm_fd);
return g_gbm != nullptr;
}
static bool ensure_egl() {
std::lock_guard<std::mutex> lk(g_egl_mutex);
if (g_egl_display != EGL_NO_DISPLAY)
return true;
if (!ensure_gbm())
return false;
g_egl_display = eglGetDisplay((EGLNativeDisplayType)g_gbm);
if (g_egl_display == EGL_NO_DISPLAY)
return false;
EGLint major, minor;
if (!eglInitialize(g_egl_display, &major, &minor)) {
g_egl_display = EGL_NO_DISPLAY;
return false;
}
eglCreateImageKHR_fn = (PFNEGLCREATEIMAGEKHRPROC)eglGetProcAddress("eglCreateImageKHR");
eglDestroyImageKHR_fn = (PFNEGLDESTROYIMAGEKHRPROC)eglGetProcAddress("eglDestroyImageKHR");
glEGLImageTargetTexture2DOES_fn = (PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)eglGetProcAddress("glEGLImageTargetTexture2DOES");
if (!eglCreateImageKHR_fn || !eglDestroyImageKHR_fn || !glEGLImageTargetTexture2DOES_fn)
return false;
if (!eglBindAPI(EGL_OPENGL_ES_API))
return false;
EGLint config_attribs[] = {
EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
EGL_RED_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8,
EGL_ALPHA_SIZE, 8,
EGL_NONE
};
EGLConfig config;
EGLint num_configs;
if (!eglChooseConfig(g_egl_display, config_attribs, &config, 1, &num_configs) || num_configs < 1)
return false;
EGLint context_attribs[] = {
EGL_CONTEXT_CLIENT_VERSION, 2,
EGL_NONE
};
g_egl_context = eglCreateContext(g_egl_display, config, EGL_NO_CONTEXT, context_attribs);
if (g_egl_context == EGL_NO_CONTEXT)
return false;
EGLint pbuffer_attribs[] = {
EGL_WIDTH, 1,
EGL_HEIGHT, 1,
EGL_NONE
};
EGLSurface pbuffer = eglCreatePbufferSurface(g_egl_display, config, pbuffer_attribs);
if (pbuffer == EGL_NO_SURFACE) {
eglDestroyContext(g_egl_display, g_egl_context);
g_egl_context = EGL_NO_CONTEXT;
return false;
}
if (!eglMakeCurrent(g_egl_display, pbuffer, pbuffer, g_egl_context)) {
eglDestroySurface(g_egl_display, pbuffer);
eglDestroyContext(g_egl_display, g_egl_context);
g_egl_context = EGL_NO_CONTEXT;
return false;
}
return true;
}
struct BufEntry {
gbm_bo *bo;
int dmabuf_fd;
};
static std::map<uint32_t, BufEntry> g_bufs;
static std::mutex g_bufs_mutex;
static uint32_t g_next_token = 1;
static uint32_t store_buf(gbm_bo *bo, int fd) {
std::lock_guard<std::mutex> lk(g_bufs_mutex);
uint32_t tok = g_next_token++;
g_bufs[tok] = {bo, fd};
return tok;
}
static void release_buf(uint32_t tok) {
std::lock_guard<std::mutex> lk(g_bufs_mutex);
auto it = g_bufs.find(tok);
if (it == g_bufs.end())
return;
::close(it->second.dmabuf_fd);
gbm_bo_destroy(it->second.bo);
g_bufs.erase(it);
}
Napi::Value Init(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
std::string dev = "/dev/dri/renderD128";
if (info.Length() >= 1 && info[0].IsString())
dev = info[0].As<Napi::String>().Utf8Value();
if (!ensure_gbm(dev)) {
Napi::Error::New(env, "Failed to open GBM device: " + dev)
.ThrowAsJavaScriptException();
}
return env.Undefined();
}
Napi::Value ImportRgba(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
if (info.Length() < 4) {
Napi::TypeError::New(env, "importRgba(buf, w, h, stride)")
.ThrowAsJavaScriptException();
return env.Null();
}
auto pixel_buf = info[0].As<Napi::Buffer<uint8_t>>();
uint32_t w = info[1].As<Napi::Number>().Uint32Value();
uint32_t h = info[2].As<Napi::Number>().Uint32Value();
uint32_t src_stride = info[3].As<Napi::Number>().Uint32Value();
ensure_gbm();
if (!g_gbm) {
Napi::Error::New(env, "GBM device not initialised")
.ThrowAsJavaScriptException();
return env.Null();
}
gbm_bo *bo = nullptr;
bo = gbm_bo_create_with_modifiers2(g_gbm, w, h, GBM_FORMAT_ARGB8888,
(const uint64_t[]){DRM_FORMAT_MOD_LINEAR},
1,
GBM_BO_USE_RENDERING | GBM_BO_USE_LINEAR);
if (!bo) {
bo = gbm_bo_create(g_gbm, w, h, GBM_FORMAT_ARGB8888,
GBM_BO_USE_RENDERING | GBM_BO_USE_LINEAR);
}
if (!bo) {
bo = gbm_bo_create(g_gbm, w, h, GBM_FORMAT_ARGB8888, GBM_BO_USE_WRITE);
}
if (!bo) {
Napi::Error::New(
env, "gbm_bo_create failed: all buffer creation methods exhausted")
.ThrowAsJavaScriptException();
return env.Null();
}
void *map_data = nullptr;
uint32_t dst_stride = 0;
void *ptr =
gbm_bo_map(bo, 0, 0, w, h, GBM_BO_TRANSFER_WRITE, &dst_stride, &map_data);
if (!ptr) {
gbm_bo_destroy(bo);
Napi::Error::New(env, "gbm_bo_map failed").ThrowAsJavaScriptException();
return env.Null();
}
const uint8_t *src = pixel_buf.Data();
uint8_t *dst = static_cast<uint8_t *>(ptr);
uint32_t row_bytes = std::min(src_stride, dst_stride);
for (uint32_t y = 0; y < h; ++y)
std::memcpy(dst + y * dst_stride, src + y * src_stride, row_bytes);
gbm_bo_unmap(bo, map_data);
int owned_fd = gbm_bo_get_fd(bo);
if (owned_fd < 0) {
gbm_bo_destroy(bo);
Napi::Error::New(env, "gbm_bo_get_fd failed").ThrowAsJavaScriptException();
return env.Null();
}
uint64_t mod = gbm_bo_get_modifier(bo);
uint32_t mod_hi = static_cast<uint32_t>(mod >> 32);
uint32_t mod_lo = static_cast<uint32_t>(mod & 0xFFFFFFFF);
uint32_t token = store_buf(bo, owned_fd);
Napi::Object result = Napi::Object::New(env);
result.Set("fd", Napi::Number::New(env, owned_fd));
result.Set("offset", Napi::Number::New(env, 0));
result.Set("stride", Napi::Number::New(env, dst_stride));
result.Set("modifier_hi", Napi::Number::New(env, mod_hi));
result.Set("modifier_lo", Napi::Number::New(env, mod_lo));
result.Set("token", Napi::Number::New(env, token));
return result;
}
Napi::Value ReleaseBuffer(const Napi::CallbackInfo &info) {
if (info.Length() >= 1 && info[0].IsNumber()) {
uint32_t tok = info[0].As<Napi::Number>().Uint32Value();
release_buf(tok);
}
return info.Env().Undefined();
}
Napi::Value SendFd(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
if (info.Length() < 2) {
Napi::TypeError::New(env, "sendFd(socketFd, dmabufFd)")
.ThrowAsJavaScriptException();
return env.Undefined();
}
int sock_fd = info[0].As<Napi::Number>().Int32Value();
int dmabuf_fd = info[1].As<Napi::Number>().Int32Value();
char dummy = 0;
struct iovec iov = {&dummy, 1};
// control message for SCM_RIGHTS.
char cmsg_buf[CMSG_SPACE(sizeof(int))];
std::memset(cmsg_buf, 0, sizeof(cmsg_buf));
struct msghdr msg = {};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf;
msg.msg_controllen = sizeof(cmsg_buf);
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
std::memcpy(CMSG_DATA(cmsg), &dmabuf_fd, sizeof(int));
ssize_t sent = ::sendmsg(sock_fd, &msg, MSG_NOSIGNAL);
if (sent < 0) {
Napi::Error::New(env, std::string("sendmsg failed: ") + ::strerror(errno))
.ThrowAsJavaScriptException();
}
return env.Undefined();
}
Napi::Value RecvFd(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
if (info.Length() < 1) {
Napi::TypeError::New(env, "recvFd(socketFd)").ThrowAsJavaScriptException();
return env.Undefined();
}
int sock_fd = info[0].As<Napi::Number>().Int32Value();
char dummy = 0;
struct iovec iov = {&dummy, 1};
char cmsg_buf[CMSG_SPACE(sizeof(int))];
std::memset(cmsg_buf, 0, sizeof(cmsg_buf));
struct msghdr msg = {};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg_buf;
msg.msg_controllen = sizeof(cmsg_buf);
ssize_t n = ::recvmsg(sock_fd, &msg, MSG_CMSG_CLOEXEC);
if (n < 0) {
Napi::Error::New(env, std::string("recvmsg failed: ") + ::strerror(errno))
.ThrowAsJavaScriptException();
return Napi::Number::New(env, -1);
}
for (struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); cmsg != nullptr;
cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) {
int received_fd = -1;
std::memcpy(&received_fd, CMSG_DATA(cmsg), sizeof(int));
return Napi::Number::New(env, received_fd);
}
}
return Napi::Number::New(env, -1);
}
Napi::Value ImportDmabufTexture(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
if (info.Length() < 5) {
Napi::TypeError::New(env, "importDmabufTexture(windowName, fd, width, height, format)")
.ThrowAsJavaScriptException();
return env.Undefined();
}
std::string windowName = info[0].As<Napi::String>().Utf8Value();
int fd = info[1].As<Napi::Number>().Int32Value();
int width = info[2].As<Napi::Number>().Int32Value();
int height = info[3].As<Napi::Number>().Int32Value();
uint32_t format = info[4].As<Napi::Number>().Uint32Value();
if (!ensure_egl()) {
Napi::Error::New(env, "Failed to initialize EGL").ThrowAsJavaScriptException();
return env.Undefined();
}
std::lock_guard<std::mutex> lk(g_texture_cache_mutex);
auto it = g_texture_cache.find(windowName);
if (it != g_texture_cache.end()) {
if (it->second.image != EGL_NO_IMAGE_KHR) {
eglDestroyImageKHR_fn(g_egl_display, it->second.image);
}
if (it->second.texture != 0) {
glDeleteTextures(1, &it->second.texture);
}
g_texture_cache.erase(it);
}
EGLint attribs[] = {
EGL_WIDTH, width,
EGL_HEIGHT, height,
EGL_LINUX_DRM_FOURCC_EXT, (EGLint)format,
EGL_DMA_BUF_PLANE0_FD_EXT, fd,
EGL_DMA_BUF_PLANE0_OFFSET_EXT, 0,
EGL_DMA_BUF_PLANE0_PITCH_EXT, width * 4,
EGL_NONE
};
EGLImageKHR image = eglCreateImageKHR_fn(
g_egl_display,
EGL_NO_CONTEXT,
EGL_LINUX_DMA_BUF_EXT,
(EGLClientBuffer)nullptr,
attribs
);
if (image == EGL_NO_IMAGE_KHR) {
Napi::Error::New(env, "eglCreateImageKHR failed").ThrowAsJavaScriptException();
return env.Undefined();
}
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glEGLImageTargetTexture2DOES_fn(GL_TEXTURE_2D, (GLeglImageOES)image);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
g_texture_cache[windowName] = {texture, image, width, height};
Napi::Object result = Napi::Object::New(env);
result.Set("texture", Napi::Number::New(env, texture));
result.Set("width", Napi::Number::New(env, width));
result.Set("height", Napi::Number::New(env, height));
return result;
}
Napi::Value GetTexture(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
if (info.Length() < 1) {
Napi::TypeError::New(env, "getTexture(windowName)").ThrowAsJavaScriptException();
return env.Undefined();
}
std::string windowName = info[0].As<Napi::String>().Utf8Value();
std::lock_guard<std::mutex> lk(g_texture_cache_mutex);
auto it = g_texture_cache.find(windowName);
if (it == g_texture_cache.end()) {
return env.Null();
}
Napi::Object result = Napi::Object::New(env);
result.Set("texture", Napi::Number::New(env, it->second.texture));
result.Set("width", Napi::Number::New(env, it->second.width));
result.Set("height", Napi::Number::New(env, it->second.height));
return result;
}
Napi::Value ReleaseTexture(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
if (info.Length() < 1) {
Napi::TypeError::New(env, "releaseTexture(windowName)").ThrowAsJavaScriptException();
return env.Undefined();
}
std::string windowName = info[0].As<Napi::String>().Utf8Value();
std::lock_guard<std::mutex> lk(g_texture_cache_mutex);
auto it = g_texture_cache.find(windowName);
if (it != g_texture_cache.end()) {
if (it->second.image != EGL_NO_IMAGE_KHR) {
eglDestroyImageKHR_fn(g_egl_display, it->second.image);
}
if (it->second.texture != 0) {
glDeleteTextures(1, &it->second.texture);
}
g_texture_cache.erase(it);
}
return env.Undefined();
}
Napi::Value MmapFd(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
if (info.Length() < 2) {
Napi::TypeError::New(env, "mmapFd(fd, size)").ThrowAsJavaScriptException();
return env.Undefined();
}
int fd = info[0].As<Napi::Number>().Int32Value();
size_t size = static_cast<size_t>(info[1].As<Napi::Number>().Int64Value());
int dup_fd = ::dup(fd);
if (dup_fd < 0) {
Napi::Error::New(env, std::string("dup failed: ") + ::strerror(errno))
.ThrowAsJavaScriptException();
return env.Undefined();
}
void *ptr = ::mmap(nullptr, size, PROT_READ, MAP_SHARED, dup_fd, 0);
if (ptr == MAP_FAILED) {
::close(dup_fd);
Napi::Error::New(env, std::string("mmap failed: ") + ::strerror(errno))
.ThrowAsJavaScriptException();
return env.Undefined();
}
struct MapInfo {
size_t size;
int fd;
};
MapInfo *hint = new MapInfo{size, dup_fd};
return Napi::Buffer<uint8_t>::New(
env, static_cast<uint8_t *>(ptr), size,
[](Napi::Env, uint8_t *data, MapInfo *info) {
::munmap(data, info->size);
::close(info->fd);
delete info;
},
hint);
}
Napi::Object ModuleInit(Napi::Env env, Napi::Object exports) {
exports.Set("init", Napi::Function::New(env, Init));
exports.Set("importRgba", Napi::Function::New(env, ImportRgba));
exports.Set("releaseBuffer", Napi::Function::New(env, ReleaseBuffer));
exports.Set("sendFd", Napi::Function::New(env, SendFd));
exports.Set("recvFd", Napi::Function::New(env, RecvFd));
exports.Set("mmapFd", Napi::Function::New(env, MmapFd));
exports.Set("importDmabufTexture", Napi::Function::New(env, ImportDmabufTexture));
exports.Set("getTexture", Napi::Function::New(env, GetTexture));
exports.Set("releaseTexture", Napi::Function::New(env, ReleaseTexture));
return exports;
}
NODE_API_MODULE(wo_dmabuf, ModuleInit)

30
electron/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "wo-electron",
"version": "0.1.0",
"description": "Electron offscreen renderer for the wo compositor",
"type": "module",
"main": "dist/main.js",
"private": true,
"scripts": {
"start": "electron dist/main.js",
"dev": "bun src/main.ts",
"build": "bun build src/main.ts src/preload.ts --outdir dist --target node --format esm --external electron",
"build-native": "node-gyp rebuild --directory native",
"postinstall": "bun run build && bun run build-native"
},
"dependencies": {
"node-addon-api": "^8.6.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/electron": "^latest",
"@types/node": "^latest",
"electron": "^29.0.0",
"node-gyp": "^10.0.0",
"typescript": "^latest"
},
"engines": {
"bun": ">=1.0.0",
"node": ">=18"
}
}

View file

@ -0,0 +1,292 @@
import { Socket } from 'net';
import { MAGIC } from './protocol.js';
export interface DamageRect {
x: number;
y: number;
width: number;
height: number;
}
export interface DamagePatch {
rect: DamageRect;
rgba: Uint8Array;
stride?: number;
}
export interface DamageFramePayload {
width: number;
height: number;
fullFrame?: Uint8Array;
fullStride?: number;
patches?: DamagePatch[];
}
export class DamageRegionTracker {
private rects: DamageRect[] = [];
add(rect: DamageRect): void {
this.rects.push(rect);
}
addAll(rects: DamageRect[]): void {
for (const rect of rects) {
this.add(rect);
}
}
take(maxRects = 8): DamageRect[] {
const merged = coalesceDamageRects(this.rects, maxRects);
this.rects = [];
return merged;
}
}
type ImportedDmabuf = {
fd: number;
offset: number;
stride: number;
modifier_hi: number;
modifier_lo: number;
token: number;
};
type NativeDmabufModule = {
init: (drmDevice?: string) => void;
importRgba: (buf: Buffer, width: number, height: number, stride: number) => ImportedDmabuf;
releaseBuffer: (token: number) => void;
sendFd: (socketFd: number, dmabufFd: number) => void;
recvFd: (socketFd: number) => number;
mmapFd: (fd: number, size: number) => Buffer;
importDmabufTexture: (windowName: string, fd: number, width: number, height: number, format: number) => { texture: number; width: number; height: number };
getTexture: (windowName: string) => { texture: number; width: number; height: number } | null;
releaseTexture: (windowName: string) => void;
};
const ARGB8888 = fourccCode('A', 'R', '2', '4');
export class DamageBuffer {
private width: number;
private height: number;
private stride: number;
private frame: Buffer;
constructor(width: number, height: number, stride: number = width * 4) {
this.width = width;
this.height = height;
this.stride = stride;
this.frame = Buffer.alloc(this.stride * this.height);
}
getWidth(): number {
return this.width;
}
getHeight(): number {
return this.height;
}
getStride(): number {
return this.stride;
}
getFrameBuffer(): Buffer {
return this.frame;
}
reset(width: number, height: number, stride: number = width * 4): void {
this.width = width;
this.height = height;
this.stride = stride;
this.frame = Buffer.alloc(this.stride * this.height);
}
applyFullFrame(frame: Uint8Array, frameStride: number = this.width * 4): void {
if (frameStride === this.stride) {
Buffer.from(frame).copy(this.frame, 0, 0, this.frame.length);
return;
}
const rowBytes = Math.min(frameStride, this.stride);
for (let y = 0; y < this.height; y += 1) {
const srcStart = y * frameStride;
const dstStart = y * this.stride;
Buffer.from(frame).copy(this.frame, dstStart, srcStart, srcStart + rowBytes);
}
}
applyPatch(patch: DamagePatch): void {
const rect = clampRect(patch.rect, this.width, this.height);
if (rect.width <= 0 || rect.height <= 0) {
return;
}
const patchStride = patch.stride ?? rect.width * 4;
const dstStartX = rect.x * 4;
const rowBytes = rect.width * 4;
for (let row = 0; row < rect.height; row += 1) {
const srcStart = row * patchStride;
const dstStart = (rect.y + row) * this.stride + dstStartX;
Buffer.from(patch.rgba).copy(this.frame, dstStart, srcStart, srcStart + rowBytes);
}
}
applyPatches(patches: DamagePatch[]): void {
for (const patch of patches) {
this.applyPatch(patch);
}
}
}
export class WoDmabufSender {
private readonly socket: Socket;
private readonly windowName: string;
private readonly native: NativeDmabufModule;
private seq = 1n;
constructor(socket: Socket, windowName: string, native: NativeDmabufModule) {
this.socket = socket;
this.windowName = windowName;
this.native = native;
}
send(buffer: DamageBuffer): bigint {
const imported = this.native.importRgba(
buffer.getFrameBuffer(),
buffer.getWidth(),
buffer.getHeight(),
buffer.getStride(),
);
try {
const socketFd = getSocketFd(this.socket);
const nameBuf = Buffer.from(this.windowName, 'utf8');
const header = Buffer.alloc(4 + 4 + nameBuf.length + 8 + 4 + 4 + 4 + 4);
let offset = 0;
header.writeUInt32LE(MAGIC.FRAME, offset); offset += 4;
header.writeUInt32LE(nameBuf.length, offset); offset += 4;
nameBuf.copy(header, offset); offset += nameBuf.length;
header.writeBigUInt64LE(this.seq, offset); offset += 8;
header.writeUInt32LE(buffer.getWidth(), offset); offset += 4;
header.writeUInt32LE(buffer.getHeight(), offset); offset += 4;
header.writeUInt32LE(ARGB8888, offset); offset += 4;
header.writeUInt32LE(1, offset);
const plane = Buffer.alloc(16);
plane.writeUInt32LE(imported.offset, 0);
plane.writeUInt32LE(imported.stride, 4);
plane.writeUInt32LE(imported.modifier_hi, 8);
plane.writeUInt32LE(imported.modifier_lo, 12);
this.socket.write(header);
this.socket.write(plane);
this.native.sendFd(socketFd, imported.fd);
const sentSeq = this.seq;
this.seq += 1n;
return sentSeq;
} finally {
this.native.releaseBuffer(imported.token);
}
}
}
export function applyDamagePayload(buffer: DamageBuffer, payload: DamageFramePayload): void {
if (payload.width !== buffer.getWidth() || payload.height !== buffer.getHeight()) {
buffer.reset(payload.width, payload.height);
}
if (payload.fullFrame) {
buffer.applyFullFrame(payload.fullFrame, payload.fullStride ?? payload.width * 4);
}
if (payload.patches && payload.patches.length > 0) {
buffer.applyPatches(payload.patches);
}
}
export function loadNativeDmabufModule(requireFn: NodeRequire, nativePath: string): NativeDmabufModule {
const native = requireFn(nativePath) as NativeDmabufModule;
return native;
}
function getSocketFd(socket: Socket): number {
const fd = (socket as any)?._handle?.fd;
if (typeof fd !== 'number' || !Number.isFinite(fd) || fd < 0) {
throw new Error('Unable to read underlying socket file descriptor');
}
return fd;
}
function clampRect(rect: DamageRect, width: number, height: number): DamageRect {
const x = Math.max(0, Math.min(width, rect.x));
const y = Math.max(0, Math.min(height, rect.y));
const right = Math.max(x, Math.min(width, rect.x + rect.width));
const bottom = Math.max(y, Math.min(height, rect.y + rect.height));
return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
}
function coalesceDamageRects(input: DamageRect[], maxRects: number): DamageRect[] {
if (input.length <= 1) {
return input.slice();
}
const rects = input
.map((r) => ({ ...r }))
.filter((r) => r.width > 0 && r.height > 0);
const out: DamageRect[] = [];
while (rects.length > 0) {
let current = rects.pop()!;
let mergedAny = true;
while (mergedAny) {
mergedAny = false;
for (let i = rects.length - 1; i >= 0; i -= 1) {
const candidate = rects[i];
if (intersectsOrTouches(current, candidate)) {
current = unionRect(current, candidate);
rects.splice(i, 1);
mergedAny = true;
}
}
}
out.push(current);
}
if (out.length <= maxRects) {
return out;
}
return [out.reduce((acc, rect) => unionRect(acc, rect))];
}
function intersectsOrTouches(a: DamageRect, b: DamageRect): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
);
}
function unionRect(a: DamageRect, b: DamageRect): DamageRect {
const x1 = Math.min(a.x, b.x);
const y1 = Math.min(a.y, b.y);
const x2 = Math.max(a.x + a.width, b.x + b.width);
const y2 = Math.max(a.y + a.height, b.y + b.height);
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
function fourccCode(a: string, b: string, c: string, d: string): number {
return (
a.charCodeAt(0) |
(b.charCodeAt(0) << 8) |
(c.charCodeAt(0) << 16) |
(d.charCodeAt(0) << 24)
) >>> 0;
}

1766
electron/src/main.ts Normal file

File diff suppressed because it is too large Load diff

127
electron/src/preload.ts Normal file
View file

@ -0,0 +1,127 @@
/**
* Wo Electron Preload Script
*/
import { contextBridge, ipcRenderer } from 'electron';
import type {
CompositorAPI,
WoClientAPI,
WindowMetadataEvent,
FocusChangeEvent,
WoWindow,
DamageFramePayload,
RendererDamageHelper,
RendererDamageHelperOptions,
} from 'wo-types';
import { createRendererDamageHelper } from './renderer-damage-helper.js';
const compositorAPI: CompositorAPI = {
action: async (name: string, payload?: Record<string, unknown>) => {
return await ipcRenderer.invoke('action', name, payload ?? {});
},
actionSync: (name: string, payload?: Record<string, unknown>) => {
ipcRenderer.send('action-sync', name, payload ?? {});
},
quit: (code?: number) => ipcRenderer.invoke('action', 'quit', { code: code ?? 0 }),
reload: () => ipcRenderer.invoke('action', 'reload', {}),
toggleDevTools: () => ipcRenderer.invoke('action', 'devtools', {}),
// New: Focus change notifications
onFocusChange: (callback: (window: string, focused: boolean) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: FocusChangeEvent) =>
callback(data.window, data.focused);
ipcRenderer.on('wo:focus-change', handler);
return () => ipcRenderer.removeListener('wo:focus-change', handler);
},
// New: Window metadata
onWindowMetadata: (callback: (metadata: WindowMetadataEvent) => void) => {
const handler = (_event: Electron.IpcRendererEvent, metadata: WindowMetadataEvent) => callback(metadata);
ipcRenderer.on('wo:window-metadata', handler);
return () => ipcRenderer.removeListener('wo:window-metadata', handler);
},
// New: Syscall API
syscall: async (type: string, params: Record<string, unknown>) => {
return await ipcRenderer.invoke('wo:syscall', type, params);
},
submitDamageFrame: async (payload: DamageFramePayload) => {
return await ipcRenderer.invoke('wo:submit-damage-frame', payload);
},
// Surface buffer: Wayland window pixel content from compositor
onSurfaceBuffer: (callback: (data: { name: string; width: number; height: number; stride: number; pixels: Buffer }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { name: string; width: number; height: number; stride: number; pixels: Buffer }) =>
callback(data);
ipcRenderer.on('wo:surface-buffer', handler);
return () => ipcRenderer.removeListener('wo:surface-buffer', handler);
},
createDamageHelper: (options: RendererDamageHelperOptions): RendererDamageHelper => {
return createRendererDamageHelper(
async (payload: DamageFramePayload) => ipcRenderer.invoke('wo:submit-damage-frame', payload),
options,
);
},
// Notification events from compositor
onNotification: (callback: (data: { id?: string; title: string; body: string; icon?: string; timeout?: number; timestamp?: number }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { id?: string; title: string; body: string; icon?: string; timeout?: number; timestamp?: number }) =>
callback(data);
ipcRenderer.on('wo:notification', handler);
return () => ipcRenderer.removeListener('wo:notification', handler);
},
// Forward keyboard event from canvas to compositor (evdev keycode)
forwardKeyboard: (windowName: string, evdevKey: number, pressed: boolean, time: number) => {
ipcRenderer.send('wo:forward-keyboard', windowName, evdevKey, pressed, time);
},
// Forward relative mouse motion
forwardRelativePointer: (windowName: string, dx: number, dy: number) => {
ipcRenderer.send('wo:forward-relative-pointer', windowName, dx, dy);
},
// Pointer lock request from compositor
onPointerLockRequest: (callback: (data: { window: string; lock: boolean }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { window: string; lock: boolean }) =>
callback(data);
ipcRenderer.on('wo:pointer-lock-request', handler);
return () => ipcRenderer.removeListener('wo:pointer-lock-request', handler);
},
// Generic portal request event for client-provided popup/approval handlers
onPortalRequest: (callback: (data: {
requestId: string;
kind: string;
appName?: string;
sessionId?: string;
}) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: {
requestId: string;
kind: string;
appName?: string;
sessionId?: string;
}) => callback(data);
ipcRenderer.on('wo:portal-request', handler);
return () => ipcRenderer.removeListener('wo:portal-request', handler);
},
};
const woClientAPI: WoClientAPI = {
getWindows: async () => ipcRenderer.invoke('wo:get-windows') as Promise<WoWindow[]>,
onWindows: (callback: (windows: WoWindow[]) => void) => {
const handler = (_event: Electron.IpcRendererEvent, windows: WoWindow[]) => callback(windows);
ipcRenderer.on('wo:windows', handler);
return () => ipcRenderer.removeListener('wo:windows', handler);
},
sendAction: (action: string) => {
ipcRenderer.send('wo:keybind-action', action);
},
};
contextBridge.exposeInMainWorld('compositor', compositorAPI);
contextBridge.exposeInMainWorld('woClient', woClientAPI);
export type { CompositorAPI, WoClientAPI };

237
electron/src/protocol.ts Normal file
View file

@ -0,0 +1,237 @@
/**
* Wo Compositor - Shared Types and Protocol
*
* This file defines the protocol messages and types used for communication
* between the Wo compositor and Electron/Wayland clients.
*
* These types are shared across the Rust compositor and TypeScript servers/clients.
*/
/**
* IPC Protocol Magic Numbers (little-endian u32)
*/
export const MAGIC = {
HELLO: 0x574F484C, // "WOHL"
FRAME: 0x574F4652, // "WOFR"
MOUSE_MOVE: 0x574F4D4D, // "WOMM"
MOUSE_BUTTON: 0x574F4D42, // "WOMB"
KEYBOARD: 0x574F4B42, // "WOKB"
SCROLL: 0x574F5343, // "WOSC"
ACTION: 0x574F4341, // "WOAC"
FOCUS_CHANGE: 0x574F4643, // "WOFC"
WINDOW_META: 0x574F574D, // "WOWM"
WINDOW_POS: 0x574F5750, // "WOWP"
SYSCALL: 0x574F5359, // "WOSY"
FRAME_ACK: 0x574F4641, // "WOFA"
SURFACE_BUFFER: 0x574F5342, // "WOSB" — Wayland surface pixels from compositor
SHM_BUFFER: 0x574F534D, // "WOSM" — Wayland SHM surface sent to Electron via process FD
DMABUF_FRAME: 0x574F4446, // "WODF" — Wayland surface DMABUF FDs from compositor
FORWARD_POINTER: 0x574F5045, // "WOPE" — forwarded pointer from web UI canvas
FORWARD_KEYBOARD: 0x574F4B45, // "WOKE" — forwarded keyboard from web UI canvas
FORWARD_RELATIVE_POINTER: 0x574F5245, // "WORE" — forwarded relative pointer (dx/dy)
POINTER_LOCK_REQUEST: 0x574F504C, // "WOPL" — server-to-client pointer lock request
} as const;
/**
* Mouse button codes
*/
export const MOUSE_BUTTON = {
LEFT: 1,
MIDDLE: 2,
RIGHT: 3,
BACK: 4,
FORWARD: 5,
} as const;
/**
* Input Events from Compositor → Client
*/
export interface MouseMoveEvent {
type: 'mouseMove';
x: number;
y: number;
}
export interface MouseButtonEvent {
type: 'mouseButton';
button: number;
pressed: boolean;
time: number;
}
export interface KeyboardEvent {
type: 'keyboard';
key: number;
pressed: boolean;
time: number;
}
export interface ScrollEvent {
type: 'scroll';
vertical: number;
horizontal: number;
time: number;
}
export interface FocusChangeEvent {
type: 'focusChange';
window: string;
focused: boolean;
}
export interface WindowMetadataEvent {
type: 'windowMetadata';
metadata: string; // JSON string
}
export interface PointerLockRequestEvent {
type: 'pointerLockRequest';
window: string;
lock: boolean;
}
export type InputEvent =
| MouseMoveEvent
| MouseButtonEvent
| KeyboardEvent
| ScrollEvent
| FocusChangeEvent
| WindowMetadataEvent
| PointerLockRequestEvent;
/**
* Compositor Actions from Client → Compositor
*/
export interface QuitAction {
type: 'quit';
code?: number;
}
export interface CustomAction {
type: 'custom';
action: string;
payload?: Record<string, any>;
}
export type CompositorAction = QuitAction | CustomAction;
/**
* Frame information for rendering
*/
export interface FrameInfo {
seq: number;
width: number;
height: number;
format: string; // DRM fourcc as string
planes: PlaneInfo[];
}
export interface PlaneInfo {
offset: number;
stride: number;
modifier: bigint;
}
/**
* DMABUF window frame information (metadata-only; FDs passed via ancillary data)
*/
export interface DmabufFrameInfo {
name: string;
width: number;
height: number;
format: number; // DRM fourcc as u32
numPlanes: number;
planes: DmabufPlaneInfo[];
fds: number[]; // File descriptors passed with this message
}
export interface DmabufPlaneInfo {
offset: number;
stride: number;
modifier: bigint;
}
/**
* Window configuration
*/
export interface WindowConfig {
name: string;
width: number;
height: number;
x?: number;
y?: number;
z_order?: number;
}
/**
* Utility functions for working with the protocol
*/
export function numberToMagic(magic: number): string {
return String.fromCharCode(
magic & 0xff,
(magic >> 8) & 0xff,
(magic >> 16) & 0xff,
(magic >> 24) & 0xff
);
}
export function bufferToU32LE(buf: Buffer, offset: number = 0): number {
return (
buf[offset] |
(buf[offset + 1] << 8) |
(buf[offset + 2] << 16) |
(buf[offset + 3] << 24)
);
}
export function bufferToU64LE(buf: Buffer, offset: number = 0): bigint {
const lo = BigInt(bufferToU32LE(buf, offset));
const hi = BigInt(bufferToU32LE(buf, offset + 4));
return (hi << BigInt(32)) | lo;
}
export function bufferToF64LE(buf: Buffer, offset: number = 0): number {
return buf.readDoubleLE(offset);
}
export function bufferToI32LE(buf: Buffer, offset: number = 0): number {
const val = bufferToU32LE(buf, offset);
// Convert unsigned to signed
return val > 0x7fffffff ? val - 0x100000000 : val;
}
export function u32ToBufferLE(val: number): Buffer {
const buf = Buffer.alloc(4);
buf.writeUInt32LE(val, 0);
return buf;
}
export function u64ToBufferLE(val: bigint): Buffer {
const buf = Buffer.alloc(8);
const lo = Number(val & BigInt(0xffffffff));
const hi = Number((val >> BigInt(32)) & BigInt(0xffffffff));
buf.writeUInt32LE(lo, 0);
buf.writeUInt32LE(hi, 4);
return buf;
}
export function f64ToBufferLE(val: number): Buffer {
const buf = Buffer.alloc(8);
buf.writeDoubleLE(val, 0);
return buf;
}
export function i32ToBufferLE(val: number): Buffer {
const buf = Buffer.alloc(4);
buf.writeInt32LE(val, 0);
return buf;
}
export function stringToBuffer(str: string): Buffer {
return Buffer.from(str, 'utf-8');
}
export function bufferToString(buf: Buffer, offset: number = 0, length?: number): string {
return buf.toString('utf-8', offset, length !== undefined ? offset + length : undefined);
}

View file

@ -0,0 +1,219 @@
import type { DamageFramePayload, DamagePatch, DamageRect } from 'wo-types';
type SubmitFn = (payload: DamageFramePayload) => Promise<{ ok: boolean; seq?: string; reason?: string }>;
export interface DamageHelperOptions {
width: number;
height: number;
maxRects?: number;
fullFrameThreshold?: number;
}
export interface RendererDamageHelper {
resize(width: number, height: number): void;
markDirty(rect: DamageRect): void;
updateFullFrame(rgba: Uint8Array, stride?: number): void;
updatePatch(rect: DamageRect, rgba: Uint8Array, stride?: number): void;
captureFromCanvas(canvasLike: CanvasLike, dirtyRects?: DamageRect[]): void;
flush(): Promise<{ ok: boolean; seq?: string; reason?: string; skipped?: boolean }>;
}
interface CanvasLike {
width: number;
height: number;
getContext(type: '2d'): Canvas2DLike | null;
}
interface Canvas2DLike {
getImageData(x: number, y: number, width: number, height: number): { data: Uint8ClampedArray };
}
export function createRendererDamageHelper(
submit: SubmitFn,
options: DamageHelperOptions,
): RendererDamageHelper {
let width = options.width;
let height = options.height;
let stride = width * 4;
let frame = new Uint8Array(stride * height);
const maxRects = options.maxRects ?? 8;
const fullFrameThreshold = options.fullFrameThreshold ?? 0.6;
const dirtyRects: DamageRect[] = [];
function resize(nextWidth: number, nextHeight: number): void {
width = nextWidth;
height = nextHeight;
stride = width * 4;
frame = new Uint8Array(stride * height);
dirtyRects.length = 0;
dirtyRects.push({ x: 0, y: 0, width, height });
}
function markDirty(rect: DamageRect): void {
dirtyRects.push(clampRect(rect, width, height));
}
function updateFullFrame(rgba: Uint8Array, srcStride = width * 4): void {
if (srcStride === stride && rgba.length >= frame.length) {
frame.set(rgba.subarray(0, frame.length));
} else {
const rowBytes = Math.min(srcStride, stride);
for (let y = 0; y < height; y += 1) {
const srcStart = y * srcStride;
const dstStart = y * stride;
frame.set(rgba.subarray(srcStart, srcStart + rowBytes), dstStart);
}
}
dirtyRects.length = 0;
dirtyRects.push({ x: 0, y: 0, width, height });
}
function updatePatch(rect: DamageRect, rgba: Uint8Array, srcStride = rect.width * 4): void {
const clamped = clampRect(rect, width, height);
if (clamped.width <= 0 || clamped.height <= 0) {
return;
}
const rowBytes = clamped.width * 4;
const startX = clamped.x * 4;
for (let row = 0; row < clamped.height; row += 1) {
const srcStart = row * srcStride;
const dstStart = (clamped.y + row) * stride + startX;
frame.set(rgba.subarray(srcStart, srcStart + rowBytes), dstStart);
}
dirtyRects.push(clamped);
}
function captureFromCanvas(canvasLike: CanvasLike, rects?: DamageRect[]): void {
if (!canvasLike || typeof canvasLike.getContext !== 'function') {
throw new Error('Invalid canvas object');
}
if (canvasLike.width !== width || canvasLike.height !== height) {
resize(canvasLike.width, canvasLike.height);
}
const context = canvasLike.getContext('2d');
if (!context) {
throw new Error('2D canvas context unavailable');
}
const targets = rects && rects.length > 0 ? rects : [{ x: 0, y: 0, width, height }];
for (const target of targets) {
const clamped = clampRect(target, width, height);
if (clamped.width <= 0 || clamped.height <= 0) {
continue;
}
const imageData = context.getImageData(clamped.x, clamped.y, clamped.width, clamped.height);
const rgba = new Uint8Array(
imageData.data.buffer,
imageData.data.byteOffset,
imageData.data.byteLength,
);
updatePatch(clamped, rgba, clamped.width * 4);
}
}
async function flush(): Promise<{ ok: boolean; seq?: string; reason?: string; skipped?: boolean }> {
if (dirtyRects.length === 0) {
return { ok: true, skipped: true };
}
const merged = coalesceDamageRects(dirtyRects, maxRects);
dirtyRects.length = 0;
const damagedPixels = merged.reduce((acc, rect) => acc + rect.width * rect.height, 0);
const totalPixels = width * height;
if (damagedPixels / Math.max(1, totalPixels) >= fullFrameThreshold) {
return submit({ width, height, fullFrame: frame, fullStride: stride });
}
const patches: DamagePatch[] = merged.map((rect) => ({
rect,
rgba: slicePatch(frame, stride, rect),
stride: rect.width * 4,
}));
return submit({ width, height, patches });
}
return {
resize,
markDirty,
updateFullFrame,
updatePatch,
captureFromCanvas,
flush,
};
}
function slicePatch(frame: Uint8Array, frameStride: number, rect: DamageRect): Uint8Array {
const patchStride = rect.width * 4;
const out = new Uint8Array(patchStride * rect.height);
for (let row = 0; row < rect.height; row += 1) {
const srcStart = (rect.y + row) * frameStride + rect.x * 4;
const dstStart = row * patchStride;
out.set(frame.subarray(srcStart, srcStart + patchStride), dstStart);
}
return out;
}
function clampRect(rect: DamageRect, width: number, height: number): DamageRect {
const x = Math.max(0, Math.min(width, rect.x));
const y = Math.max(0, Math.min(height, rect.y));
const right = Math.max(x, Math.min(width, rect.x + rect.width));
const bottom = Math.max(y, Math.min(height, rect.y + rect.height));
return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
}
function coalesceDamageRects(input: DamageRect[], maxRects: number): DamageRect[] {
if (input.length <= 1) {
return input.slice();
}
const rects = input
.map((r) => ({ ...r }))
.filter((r) => r.width > 0 && r.height > 0);
const out: DamageRect[] = [];
while (rects.length > 0) {
let current = rects.pop()!;
let mergedAny = true;
while (mergedAny) {
mergedAny = false;
for (let i = rects.length - 1; i >= 0; i -= 1) {
const candidate = rects[i];
if (intersectsOrTouches(current, candidate)) {
current = unionRect(current, candidate);
rects.splice(i, 1);
mergedAny = true;
}
}
}
out.push(current);
}
if (out.length <= maxRects) {
return out;
}
return [out.reduce((acc, rect) => unionRect(acc, rect))];
}
function intersectsOrTouches(a: DamageRect, b: DamageRect): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
);
}
function unionRect(a: DamageRect, b: DamageRect): DamageRect {
const x1 = Math.min(a.x, b.x);
const y1 = Math.min(a.y, b.y);
const x2 = Math.max(a.x + a.width, b.x + b.width);
const y2 = Math.max(a.y + a.height, b.y + b.height);
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}

View file

@ -0,0 +1,199 @@
/**
* Wo Composer Input Event Receiver for Electron
*
* This module provides utilities for Electron apps to receive input events
* from the Wo compositor via the IPC protocol.
*
* Usage in your Electron renderer process:
*
* const { WoInputReceiver } = require('./wo-input-receiver');
* const receiver = new WoInputReceiver();
*
* receiver.on('mouseMove', (x, y) => {
* console.log(`Mouse moved to: ${x}, ${y}`);
* });
*
* receiver.on('mouseButton', (button, pressed, time) => {
* console.log(`Button ${button} ${pressed ? 'pressed' : 'released'}`);
* });
*
* receiver.on('keyboard', (key, pressed, time) => {
* console.log(`Key ${key} ${pressed ? 'pressed' : 'released'}`);
* });
*
* receiver.on('scroll', (vertical, horizontal, time) => {
* console.log(`Scrolled V:${vertical} H:${horizontal}`);
* });
*
* receiver.startListening();
*
* Or in the client app:
*
* // Access via window.__WO_INPUT_RECEIVER__
* window.__WO_INPUT_RECEIVER__.on('mouseMove', (x, y) => {
* // Handle mouse move
* });
*/
import { EventEmitter } from 'events';
import net from 'net';
const MAGIC_MOUSE_MOVE = 0x574F4D4D; // "WOMM"
const MAGIC_MOUSE_BUTTON = 0x574F4D42; // "WOMB"
const MAGIC_KEYBOARD = 0x574F4B42; // "WOKB"
const MAGIC_SCROLL = 0x574F5343; // "WOSC"
/**
* Receives input events from the Wo compositor via IPC socket.
*
* Events:
* - mouseMove(x, y) - Pointer moved
* - mouseButton(button, pressed, time) - Mouse button event
* - keyboard(key, pressed, time) - Keyboard event
* - scroll(vertical, horizontal, time) - Scroll wheel event
* - connected() - Connected to compositor
* - disconnected(reason) - Disconnected from compositor
* - error(error) - Error occurred
*/
class WoInputReceiver extends EventEmitter {
private socket: net.Socket | null;
private ipcSocket: string;
private windowName: string;
private buffer: Buffer;
private connected: boolean;
constructor(ipcSocket?: string, windowName?: string) {
super();
this.socket = null;
this.ipcSocket = ipcSocket || process.env.WO_IPC_SOCKET!;
this.windowName = windowName || process.env.WO_WINDOW_NAME || 'unknown';
this.buffer = Buffer.alloc(0);
this.connected = false;
}
/**
* Start listening for input events from the Wo compositor.
* This is typically called automatically when connecting to the compositor.
*/
startListening() {
if (this.socket) {
this.socket.removeAllListeners();
this.socket.destroy();
}
// Create a new socket connection just for receiving input events
// This reuses the DMABUF connection through parameter passing
this.socket = net.createConnection(this.ipcSocket);
this.socket.on('connect', () => {
this.connected = true;
console.log('[Wo] Input event receiver connected');
this.emit('connected');
});
this.socket.on('data', (chunk: Buffer) => {
this.buffer = Buffer.concat([this.buffer, chunk]);
this.processEventBuffer();
});
this.socket.on('error', (error: Error) => {
console.error('[Wo] Input receiver error:', error);
this.emit('error', error);
});
this.socket.on('close', () => {
this.connected = false;
console.log('[Wo] Input event receiver disconnected');
this.emit('disconnected');
});
}
/**
* Process all complete events in the buffer.
* Events are variable-length, so we parse them as they arrive.
*/
processEventBuffer() {
while (this.buffer.length >= 4) {
const magic = this.buffer.readUInt32LE(0);
let eventSize = 0;
let parsed = false;
// Mouse move: 4 (magic) + 8 (x) + 8 (y) = 20 bytes
if (magic === MAGIC_MOUSE_MOVE) { // "WOMM"
if (this.buffer.length >= 20) {
const x = this.buffer.readDoubleLE(4);
const y = this.buffer.readDoubleLE(12);
this.emit('mouseMove', x, y);
this.buffer = this.buffer.slice(20);
parsed = true;
}
}
// Mouse button: 4 (magic) + 4 (button) + 4 (pressed) + 4 (time) = 16 bytes
else if (magic === MAGIC_MOUSE_BUTTON) { // "WOMB"
if (this.buffer.length >= 16) {
const button = this.buffer.readUInt32LE(4);
const pressed = this.buffer.readUInt32LE(8) !== 0;
const time = this.buffer.readUInt32LE(12);
this.emit('mouseButton', button, pressed, time);
this.buffer = this.buffer.slice(16);
parsed = true;
}
}
// Keyboard: 4 (magic) + 4 (key) + 4 (pressed) + 4 (time) = 16 bytes
else if (magic === MAGIC_KEYBOARD) { // "WOKB"
if (this.buffer.length >= 16) {
const key = this.buffer.readUInt32LE(4);
const pressed = this.buffer.readUInt32LE(8) !== 0;
const time = this.buffer.readUInt32LE(12);
this.emit('keyboard', key, pressed, time);
this.buffer = this.buffer.slice(16);
parsed = true;
}
}
// Scroll: 4 (magic) + 4 (vertical) + 4 (horizontal) + 4 (time) = 16 bytes
else if (magic === MAGIC_SCROLL) { // "WOSC"
if (this.buffer.length >= 16) {
const vertical = this.buffer.readInt32LE(4);
const horizontal = this.buffer.readInt32LE(8);
const time = this.buffer.readUInt32LE(12);
this.emit('scroll', vertical, horizontal, time);
this.buffer = this.buffer.slice(16);
parsed = true;
}
}
else {
// Unknown magic, skip this byte and try again
console.warn('[Wo] Unknown message magic: 0x' + magic.toString(16));
this.buffer = this.buffer.slice(1);
parsed = true;
}
// If we couldn't parse a complete event, wait for more data
if (!parsed) {
break;
}
}
}
/**
* Stop listening for input events.
*/
stop() {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
}
/**
* Destroy the receiver and clean up resources.
*/
destroy() {
this.stop();
this.removeAllListeners();
}
}
export { WoInputReceiver };

31
electron/tsconfig.json Normal file
View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "esnext",
"module": "ESNext",
"lib": ["ES2020"],
"moduleResolution": "node",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"wo-types": ["types/index.ts"],
"wo-types/*": ["types/*"]
},
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["bun-types", "node"]
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}

34
electron/types/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

106
electron/types/CLAUDE.md Normal file
View file

@ -0,0 +1,106 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

186
electron/types/README.md Normal file
View file

@ -0,0 +1,186 @@
# wo-types
Type definitions for the Wo Wayland compositor Electron integration.
This package provides fully-typed interfaces for the compositor APIs exposed to Electron windows running within the Wo compositor.
## Installation
```bash
bun install wo-types
# or
npm install wo-types
```
## Usage
### In Preload Scripts
```typescript
import type { CompositorAPI, WoClientAPI } from 'wo-types';
// These are already exposed as window.compositor and window.woClient
const compositorAPI: CompositorAPI = window.compositor;
const clientAPI: WoClientAPI = window.woClient;
```
### In React Components
```typescript
import type { WoWindow, WindowMetadataEvent } from 'wo-types';
import { useEffect, useState } from 'react';
function MyComponent() {
const [windows, setWindows] = useState<WoWindow[]>([]);
const [metadata, setMetadata] = useState<WindowMetadataEvent | null>(null);
useEffect(() => {
// Subscribe to window updates
const unsubWindows = window.woClient?.onWindows((windowList) => {
setWindows(windowList);
});
// Subscribe to metadata updates
const unsubMetadata = window.compositor?.onWindowMetadata((meta) => {
setMetadata(meta);
});
return () => {
unsubWindows?.();
unsubMetadata?.();
};
}, []);
return (
<div>
<p>Active windows: {metadata?.count ?? 0}</p>
</div>
);
}
```
## API Reference
### `CompositorAPI`
Exposed as `window.compositor` in Electron windows.
#### Methods
- `action(name: string, payload?: Record<string, unknown>): Promise<unknown>`
- Send an action to the compositor
- `actionSync(name: string, payload?: Record<string, unknown>): void`
- Send an action synchronously (fire and forget)
- `quit(code?: number): Promise<unknown>`
- Request the compositor to quit
- `reload(): Promise<unknown>`
- Reload the current window
- `toggleDevTools(): Promise<unknown>`
- Toggle DevTools for the current window
- `onFocusChange(callback: (window: string, focused: boolean) => void): () => void`
- Register focus change notification callback
- `onWindowMetadata(callback: (metadata: WindowMetadataEvent) => void): () => void`
- Register window metadata update callback
- `syscall(type: string, params: Record<string, unknown>): Promise<unknown>`
- Execute a syscall in the compositor
### `WoClientAPI`
Exposed as `window.woClient` in Electron windows.
#### Methods
- `getWindows(): Promise<WoWindow[]>`
- Get list of all windows
- `onWindows(callback: (windows: WoWindow[]) => void): () => void`
- Register window list update callback
- `sendAction(action: string): void`
- Send a keybind action to the compositor
## Types
### `WindowMetadata`
Describes a Wayland surface or application window.
```typescript
interface WindowMetadata {
name: string;
title?: string;
width: number;
height: number;
x: number;
y: number;
z_order: number;
fps: number;
focused?: boolean;
focusable: boolean;
interactive: boolean;
}
```
### `WoWindow`
Window information returned by the client API.
```typescript
interface WoWindow {
name: string;
title?: string;
x: number;
y: number;
width: number;
height: number;
z_order: number;
fps: number;
focused: boolean;
}
```
### `WindowMetadataEvent`
Event data for window metadata updates.
```typescript
interface WindowMetadataEvent {
windows: WindowMetadata[];
count: number;
}
```
### `FocusChangeEvent`
Event data for focus change notifications.
```typescript
interface FocusChangeEvent {
window: string;
focused: boolean;
}
```
### `S2CAction`
Enum of server-to-client actions.
```typescript
enum S2CAction {
WindowMetadataUpdate = 'window-metadata-update',
FocusChange = 'focus-change',
SyscallResponse = 'syscall-response',
InputEvent = 'input-event',
}
```
## License
MIT

26
electron/types/bun.lock Normal file
View file

@ -0,0 +1,26 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "types",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View file

@ -0,0 +1,91 @@
import type { Compositor, CompositorActionType, CompositorActionConfig } from './types.js';
/**
* A strongly-typed dispatcher for interacting with windows via the Compositor API.
* Provides convenience methods for all supported compositor actions.
*/
export class InteractionDispatcher {
private compositor: Compositor;
constructor(compositor: Compositor) {
this.compositor = compositor;
}
/**
* Send a raw action to the compositor
*/
public action(type: CompositorActionType, config: CompositorActionConfig): void {
this.compositor.action(type, config);
}
/**
* Focus a window by name
*/
public focusWindow(windowName: string): void {
this.action('focus', { window: windowName });
}
/**
* Minimize a window by name
*/
public minimizeWindow(windowName: string): void {
this.action('minimize', { window: windowName });
}
/**
* Maximize a window by name
*/
public maximizeWindow(windowName: string): void {
this.action('maximize', { window: windowName });
}
/**
* Close a window by name
*/
public closeWindow(windowName: string): void {
this.action('close', { window: windowName });
}
/**
* Resize a window by name
*/
public resizeWindow(windowName: string, width: number, height: number): void {
this.action('resize', { window: windowName, width, height });
}
/**
* Move a window by name
*/
public moveWindow(windowName: string, x: number, y: number): void {
this.action('move', { window: windowName, x, y });
}
/**
* Send pointer motion (mouse move) to a window
*/
public pointerMotion(windowName: string, x: number, y: number): void {
this.action('pointer_motion', { window: windowName, x, y });
}
/**
* Send pointer button (mouse click) to a window
* @param button Evdev button code (e.g. 272 for BTN_LEFT)
*/
public pointerButton(windowName: string, x: number, y: number, button: number, pressed: boolean): void {
this.action('pointer_button', { window: windowName, x, y, button, pressed });
}
/**
* Send pointer scroll (mouse wheel) to a window
*/
public pointerScroll(windowName: string, dx: number, dy: number): void {
this.action('pointer_scroll', { window: windowName, dx, dy });
}
/**
* Indicate the pointer has left the window
*/
public pointerLeave(windowName: string): void {
this.action('pointer_leave', { window: windowName });
}
}

24
electron/types/index.ts Normal file
View file

@ -0,0 +1,24 @@
export type {
WindowsCallback,
WoClient,
WoClientAPI,
WoWindow,
ApplicationIcon,
ApplicationInfo,
CompositorActionConfig,
Compositor,
CompositorAPI,
CompositorActionType,
FocusChangeCallback,
PixelBufferCallback,
NotificationData,
SurfaceBufferData,
Unsubscribe,
WindowMetadataEvent,
FocusChangeEvent,
DamageFramePayload,
RendererDamageHelper,
RendererDamageHelperOptions,
} from './types';
export { InteractionDispatcher } from './dispatcher';

View file

@ -0,0 +1,12 @@
{
"name": "wo-types",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

402
electron/types/types.d.ts vendored Normal file
View file

@ -0,0 +1,402 @@
/**
* Window types and interfaces from the Wayland compositor
*/
/**
* Represents a single window managed by the compositor
*/
export interface WoWindow {
/** Unique identifier for this window */
name: string;
/** Window title/label */
title?: string;
/** X coordinate of window in pixels */
x: number;
/** Y coordinate of window in pixels */
y: number;
/** Width of window in pixels */
width: number;
/** Height of window in pixels */
height: number;
/** Z-order (stacking) of the window (higher = on top) */
z_order?: number;
/** Whether this window is currently mapped/visible */
mapped?: boolean;
/** Whether this window is currently focused */
focused?: boolean;
/** Frames per second being rendered */
fps?: number;
/** Process ID of the application (if available) */
pid?: number;
/** Window source: 'electron', 'wayland', or 'x11' (XWayland clients). */
source?: 'electron' | 'wayland' | 'x11';
/** True when server-side decorations/chrome should be drawn by comraw. */
ssd?: boolean;
/** Application ID (for Wayland windows) */
app_id?: string;
/** True if this is a dialog window (xdg_toplevel with set_parent) */
dialog?: boolean;
/** Name of the parent window for dialog windows (e.g. "wayland-0") */
parent_name?: string;
}
/**
* Action types for compositor window control
*/
export type CompositorActionType =
| 'focus' | 'minimize' | 'close' | 'resize' | 'move' | 'maximize'
| 'pointer_motion' | 'pointer_button' | 'pointer_leave' | 'pointer_scroll' | 'keyboard_key';
/**
* Configuration for compositor actions
*/
export interface CompositorActionConfig {
window?: string;
x?: number;
y?: number;
width?: number;
height?: number;
[key: string]: unknown;
}
// Stronger syscall typing
export interface SyscallListApplicationsParams { [key: string]: any; }
export interface SyscallLaunchParams { command: string; [key: string]: any; }
export interface SyscallExecParams { command: string; [key: string]: any; }
export interface SyscallListDirParams { path?: string; [key: string]: any; }
export interface SyscallReadParams { path: string; [key: string]: any; }
export interface SyscallWriteParams { path: string; content: string; [key: string]: any; }
export interface SyscallShutdownParams { [key: string]: any; }
export interface SyscallRestartParams { [key: string]: any; }
export interface SyscallLogoutParams { [key: string]: any; }
export interface SyscallLockParams { [key: string]: any; }
export interface SyscallSleepParams { [key: string]: any; }
export interface SyscallNotifyParams { id?: string; title: string; body?: string; icon?: string; timeout?: number; [key: string]: any; }
export interface SyscallPortalRespondParams { requestId: string; allowed: boolean; type?: string; windowName?: string | null; [key: string]: any; }
export interface SyscallLaunchResult { ok: boolean; pid?: number; error?: string; }
export interface SyscallExecResult { ok: boolean; pid?: number; error?: string; }
export interface SyscallListDirResult { ok: boolean; path?: string; entries?: { name: string; type: 'file'|'dir' }[]; error?: string; }
export interface SyscallReadResult { ok: boolean; content?: string; size?: number; error?: string; }
export interface SyscallWriteResult { ok: boolean; bytesWritten?: number; error?: string; }
export interface SyscallShutdownResult { ok: boolean; error?: string; }
export interface SyscallRestartResult { ok: boolean; error?: string; }
export interface SyscallLogoutResult { ok: boolean; error?: string; }
export interface SyscallLockResult { ok: boolean; error?: string; }
export interface SyscallSleepResult { ok: boolean; error?: string; }
export interface SyscallNotifyResult { ok: boolean; error?: string; }
export interface SyscallPortalRespondResult { ok: boolean; error?: string; }
export interface PortalRequestEvent {
requestId: string;
kind: string;
appName?: string;
sessionId?: string;
}
/**
* Application information for launching
*/
export interface ApplicationInfo {
name: string;
command: string;
multi_instance?: boolean;
icon?: ApplicationIcon;
}
/**
* Icon data for applications - supports multiple formats
*/
export interface ApplicationIcon {
/** Type of icon: 'iconify', 'base64', 'url', or 'path' */
type: 'iconify' | 'base64' | 'url' | 'path';
/** Icon data or reference */
data: string;
/** Optional MIME type (e.g., 'image/png', 'image/svg+xml') */
mimeType?: string;
/** Optional fallback icon if primary fails */
fallback?: ApplicationIcon;
}
/**
* Focus change callback signature
*/
export type FocusChangeCallback = (windowName: string, focused: boolean) => void;
/**
* Window list update callback signature
*/
export type WindowsCallback = (windows: WoWindow[]) => void;
/**
* Pixel buffer update callback signature
*/
export type PixelBufferCallback = (windowName: string, buffer: Uint8Array, width: number, height: number) => void;
/**
* Unsubscribe function type
*/
export type Unsubscribe = () => void;
/**
* Wayland client interface for receiving window updates
*/
export interface WoClient {
/**
* Get the current window list once
*/
getWindows(): Promise<WoWindow[]>;
/**
* Subscribe to window list updates
*/
onWindows(callback: WindowsCallback): Unsubscribe;
/**
* Subscribe to pixel buffer updates (DMABUF transfers, if supported)
*/
onPixelBuffer?: (callback: PixelBufferCallback) => Unsubscribe;
/**
* Send a keybind/management action to the compositor
*/
sendAction(action: string): void;
}
/** Alias for WoClient to match preload.ts imports */
export type WoClientAPI = WoClient;
/**
* Compositor interface for controlling windows and the compositor
*/
export interface Compositor {
/**
* Send an action to modify a window
*/
action(type: CompositorActionType, config: CompositorActionConfig): void;
/**
* Send an action synchronously
*/
actionSync(type: string, payload?: Record<string, unknown>): void;
/**
* Execute a syscall (filesystem, process, etc.) with strong typings
*/
syscall(type: 'list_applications', params?: SyscallListApplicationsParams): Promise<ApplicationInfo[]>;
syscall(type: 'launch', params: SyscallLaunchParams): Promise<SyscallLaunchResult>;
syscall(type: 'exec', params: SyscallExecParams): Promise<SyscallExecResult>;
syscall(type: 'listdir', params?: SyscallListDirParams): Promise<SyscallListDirResult>;
syscall(type: 'read', params: SyscallReadParams): Promise<SyscallReadResult>;
syscall(type: 'write', params: SyscallWriteParams): Promise<SyscallWriteResult>;
syscall(type: 'shutdown', params?: SyscallShutdownParams): Promise<SyscallShutdownResult>;
syscall(type: 'restart', params?: SyscallRestartParams): Promise<SyscallRestartResult>;
syscall(type: 'logout', params?: SyscallLogoutParams): Promise<SyscallLogoutResult>;
syscall(type: 'lock', params?: SyscallLockParams): Promise<SyscallLockResult>;
syscall(type: 'sleep', params?: SyscallSleepParams): Promise<SyscallSleepResult>;
syscall(type: 'notify', params: SyscallNotifyParams): Promise<SyscallNotifyResult>;
syscall(type: 'portal_respond', params: SyscallPortalRespondParams): Promise<SyscallPortalRespondResult>;
syscall(type: string, params?: any): Promise<any>;
/**
* Subscribe to focus changes
*/
onFocusChange(callback: FocusChangeCallback): Unsubscribe;
/**
* Subscribe to raw window metadata broadcasts from the compositor
*/
onWindowMetadata(callback: (metadata: WindowMetadataEvent) => void): Unsubscribe;
/**
* Create a damage helper for optimized rendering
*/
createDamageHelper?(options: RendererDamageHelperOptions): RendererDamageHelper;
/**
* Forward a keyboard event from a canvas window to the compositor.
* evdevKey is the Linux evdev keycode; time is milliseconds.
*/
forwardKeyboard?(windowName: string, evdevKey: number, pressed: boolean, time: number): void;
/**
* Quit the compositor
*/
quit(): void;
/**
* Reload the UI
*/
reload(): void;
/**
* Toggle Electron DevTools
*/
toggleDevTools(): void;
/**
* Submit a damaged frame from the renderer
*/
submitDamageFrame?(payload: DamageFramePayload): Promise<{ ok: boolean }>;
/**
* Subscribe to Wayland surface pixel buffer updates from the compositor.
* The compositor captures each Wayland window's content offscreen and sends
* raw ARGB8888 pixel data for the client to render.
*/
onSurfaceBuffer?(callback: (data: SurfaceBufferData) => void): Unsubscribe;
/**
* Subscribe to notification events from the compositor
*/
onNotification?(callback: (data: NotificationData) => void): Unsubscribe;
/**
* Forward relative mouse motion from the web UI to the compositor.
*/
forwardRelativePointer?(windowName: string, dx: number, dy: number): void;
/**
* Subscribe to pointer lock requests from the compositor.
*/
onPointerLockRequest?(callback: (data: { window: string; lock: boolean }) => void): Unsubscribe;
/**
* Subscribe to generic portal requests so clients can provide custom UI.
*/
onPortalRequest?(callback: (data: PortalRequestEvent) => void): Unsubscribe;
}
/** Alias for Compositor to match preload.ts imports */
export type CompositorAPI = Compositor;
/**
* Pixel data for a captured Wayland surface
*/
export interface SurfaceBufferData {
name: string;
width: number;
height: number;
stride: number;
pixels: Uint8Array;
}
/**
* Notification data from the compositor
*/
export interface NotificationData {
id?: string;
title: string;
body: string;
icon?: string;
timeout?: number;
timestamp?: number;
}
declare global {
window.woClient = {} as WoClient;
window.compositor = {} as Compositor;
}
/**
* Input event types from the compositor
*/
export interface MouseMoveEvent {
type: 'pointer_motion';
x: number;
y: number;
time: number;
}
export interface MouseButtonEvent {
type: 'pointer_button';
button: number;
pressed: boolean;
time: number;
}
export interface PointerLeaveEvent {
type: 'pointer_leave';
time: number;
}
export interface ScrollEvent {
type: 'pointer_scroll';
vertical: number;
horizontal: number;
time: number;
}
export interface KeyboardEvent {
type: 'keyboard_key';
key: number;
pressed: boolean;
time: number;
}
export type InputEvent = MouseMoveEvent | MouseButtonEvent | PointerLeaveEvent | ScrollEvent | KeyboardEvent;
export interface WindowMetadataEvent {
type: 'windowMetadata';
metadata: string;
}
export interface FocusChangeEvent {
window: string;
focused: boolean;
}
export interface RendererDamageHelperOptions {
name: string;
width: number;
height: number;
maxRects?: number;
fullFrameThreshold?: number;
}
export interface RendererDamageHelper {
resize(width: number, height: number): void;
markDirty(rect: DamageRect): void;
updateFullFrame(rgba: Uint8Array, stride?: number): void;
updatePatch(rect: DamageRect, rgba: Uint8Array, stride?: number): void;
captureFromCanvas(canvas: any, rects?: DamageRect[]): void;
flush(): Promise<{ ok: boolean; seq?: string; reason?: string; skipped?: boolean }>;
}
export interface DamageRect {
x: number;
y: number;
width: number;
height: number;
}
export interface DamagePatch {
rect: DamageRect;
rgba: Uint8Array;
stride: number;
}
export interface DamageFramePayload {
width: number;
height: number;
fullFrame?: Uint8Array;
fullStride?: number;
patches?: DamagePatch[];
}

View file

@ -0,0 +1,9 @@
[Desktop Entry]
Name=wo
Comment=wo Wayland Compositor Session
Exec=wo
TryExec=wo
Type=Application
DesktopNames=wo
Keywords=wayland;compositor;
X-GDM-SessionRegisters=true

51
src/backend.rs Normal file
View file

@ -0,0 +1,51 @@
//! Unified rendering backend trait for nested and DRM compositors
//!
//! This module provides a trait-based abstraction for rendering that works
//! with both Nested (GLES via winit) and DRM/KMS backends.
use anyhow::Result;
use smithay::utils::{Physical, Rectangle};
use crate::dmabuf::ElectronTexture;
/// Common interface for rendering frames across different backends
pub trait RenderBackend {
/// Begin rendering a new frame with the given size and transform
fn begin_frame(&mut self, width: i32, height: i32) -> Result<()>;
/// Clear the frame with the given color
fn clear_frame(&mut self, r: f32, g: f32, b: f32, a: f32) -> Result<()>;
/// Render a texture at the given position
fn render_texture(
&mut self,
texture: &ElectronTexture,
x: i32,
y: i32,
width: u32,
height: u32,
) -> Result<()>;
/// Finish rendering the current frame
fn finish_frame(&mut self) -> Result<()>;
/// Submit the rendered frame to the display/framebuffer
fn submit_frame(&mut self) -> Result<()>;
/// Get the output rectangle dimensions
fn output_rect(&self) -> Rectangle<i32, Physical>;
}
/// Frame abstraction for rendering operations
pub struct Frame {
pub transform: smithay::utils::Transform,
pub output_rect: Rectangle<i32, Physical>,
}
impl Frame {
pub fn new(output_rect: Rectangle<i32, Physical>) -> Self {
Frame {
transform: smithay::utils::Transform::Normal,
output_rect,
}
}
}

299
src/bin/wo-portal.rs Normal file
View file

@ -0,0 +1,299 @@
/// xdg-desktop-portal-wo - Standalone portal service for Wo compositor
///
/// This is a standalone D-Bus service that implements the xdg-desktop-portal
/// interfaces for screen capture. Applications can use this to request screen
/// or window capture without direct compositor integration.
use anyhow::Result;
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use tracing::{info, warn};
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
use zbus::{Connection, interface, fdo};
use wo::state::portal::{WoPortal, CaptureRequest, SourceType};
fn request_screen_share_consent(
app_id: &str,
session_id: &str,
requested_source: SourceType,
) -> Result<Option<(SourceType, Option<String>)>> {
let mut stream = UnixStream::connect("/tmp/wo-portal-ui.sock")?;
stream.set_read_timeout(Some(Duration::from_secs(95)))?;
let requested = match requested_source {
SourceType::Monitor => "Monitor",
SourceType::Window => "Window",
SourceType::Virtual => "Virtual",
};
let req = serde_json::json!({
"type": "screen_share_request",
"appName": if app_id.is_empty() { "Application" } else { app_id },
"sessionId": session_id,
"requestedSource": requested,
});
let payload = format!("{}\n", serde_json::to_string(&req)?);
stream.write_all(payload.as_bytes())?;
stream.flush()?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
if response.trim().is_empty() {
return Ok(None);
}
let json: serde_json::Value = serde_json::from_str(response.trim())?;
let allowed = json.get("allowed").and_then(|v| v.as_bool()).unwrap_or(false);
if !allowed {
return Ok(None);
}
let source = match json.get("sourceType").and_then(|v| v.as_str()).unwrap_or("Monitor") {
"Window" => SourceType::Window,
"Virtual" => SourceType::Virtual,
_ => SourceType::Monitor,
};
let window_name = json
.get("windowName")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(Some((source, window_name)))
}
/// D-Bus interface implementation for org.freedesktop.impl.portal.ScreenCast
struct ScreenCastImpl {
portal: Arc<WoPortal>,
session_map: Mutex<std::collections::HashMap<String, String>>,
}
#[interface(name = "org.freedesktop.impl.portal.ScreenCast")]
impl ScreenCastImpl {
/// CreateSession method - Creates a new screen cast session
async fn create_session(
&self,
#[zbus(header)] _hdr: zbus::message::Header<'_>,
_handle: zbus::zvariant::ObjectPath<'_>,
session_handle: zbus::zvariant::ObjectPath<'_>,
app_id: &str,
_options: std::collections::HashMap<String, zbus::zvariant::Value<'_>>,
) -> fdo::Result<(u32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>)> {
info!("CreateSession called: app_id={}, session={:?}", app_id, session_handle);
let internal_session_id = self.portal.create_session();
let external_session_id = session_handle
.as_str()
.split('/')
.last()
.unwrap_or("default")
.to_string();
self.session_map
.lock()
.unwrap()
.insert(external_session_id, internal_session_id);
let response: std::collections::HashMap<String, zbus::zvariant::OwnedValue> =
std::collections::HashMap::new();
Ok((0, response)) // 0 = success
}
/// SelectSources method - Selects which sources to capture
async fn select_sources(
&self,
#[zbus(header)] _hdr: zbus::message::Header<'_>,
_handle: zbus::zvariant::ObjectPath<'_>,
session_handle: zbus::zvariant::ObjectPath<'_>,
app_id: &str,
options: std::collections::HashMap<String, zbus::zvariant::Value<'_>>,
) -> fdo::Result<(u32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>)> {
info!("SelectSources called for session: {:?}", session_handle);
// Extract source type from options
let source_type = if let Some(types) = options.get("types") {
if let Ok(type_val) = types.downcast_ref::<u32>() {
match type_val {
1 => SourceType::Monitor,
2 => SourceType::Window,
4 => SourceType::Virtual,
_ => SourceType::Monitor,
}
} else {
SourceType::Monitor
}
} else {
SourceType::Monitor
};
// Get session ID from session_handle path
let external_session_id = session_handle
.as_str()
.split('/')
.last()
.unwrap_or("default");
let session_id = self
.session_map
.lock()
.unwrap()
.get(external_session_id)
.cloned()
.unwrap_or_else(|| external_session_id.to_string());
let consent = match request_screen_share_consent(app_id, &session_id, source_type) {
Ok(v) => v,
Err(e) => {
warn!("Screen share consent UI failed: {}", e);
None
}
};
let Some((selected_source, selected_target)) = consent else {
info!("Screen share request denied/cancelled for session: {}", session_id);
let response: std::collections::HashMap<String, zbus::zvariant::OwnedValue> =
std::collections::HashMap::new();
return Ok((1, response));
};
let request = CaptureRequest {
source_type: selected_source,
target: selected_target,
cursor_mode: options
.get("cursor_mode")
.and_then(|v| v.downcast_ref::<u32>().ok()),
restore_token: None,
};
if let Err(e) = self.portal.select_sources(&session_id, request) {
return Err(fdo::Error::Failed(format!("Failed to select sources: {}", e)));
}
let response: std::collections::HashMap<String, zbus::zvariant::OwnedValue> =
std::collections::HashMap::new();
Ok((0, response))
}
/// Start method - Starts the screen cast and returns active stream node ID
async fn start(
&self,
#[zbus(header)] _hdr: zbus::message::Header<'_>,
_handle: zbus::zvariant::ObjectPath<'_>,
session_handle: zbus::zvariant::ObjectPath<'_>,
_app_id: &str,
_parent_window: &str,
_options: std::collections::HashMap<String, zbus::zvariant::Value<'_>>,
) -> fdo::Result<(u32, std::collections::HashMap<String, zbus::zvariant::OwnedValue>)> {
info!("Start called for session: {:?}", session_handle);
let external_session_id = session_handle
.as_str()
.split('/')
.last()
.unwrap_or("default");
let session_id = self
.session_map
.lock()
.unwrap()
.get(external_session_id)
.cloned()
.unwrap_or_else(|| external_session_id.to_string());
// Get screen dimensions from compositor or use defaults
let width = 1920;
let height = 1080;
match self.portal.start_session(&session_id, width, height) {
Ok(capture_response) => {
use zbus::zvariant::OwnedValue;
let mut response: std::collections::HashMap<String, OwnedValue> =
std::collections::HashMap::new();
// Log stream activation and frame queueing begins
for stream in &capture_response.streams {
let src_type = match stream.source_type {
wo::state::portal::SourceType::Monitor => "Monitor",
wo::state::portal::SourceType::Window => "Window",
wo::state::portal::SourceType::Virtual => "Virtual",
};
info!("Activated stream {}: {} [{}x{}] with frame queueing",
stream.node_id, src_type, stream.width, stream.height);
}
// Return success status - actual streaming happens via frame queueing
let stream_count = capture_response.streams.len() as i32;
response.insert("status".to_string(), OwnedValue::from(0i32));
response.insert("stream_count".to_string(), OwnedValue::from(stream_count));
if let Some(first) = capture_response.streams.first() {
response.insert("primary_node_id".to_string(), OwnedValue::from(first.node_id as i32));
}
for stream in &capture_response.streams {
info!(" Frame queue for stream {}: 0/{} frames queued", stream.node_id, 30);
}
Ok((0, response))
}
Err(e) => {
Err(fdo::Error::Failed(format!("Failed to start session: {}", e)))
}
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.with(tracing_subscriber::fmt::layer())
.init();
info!("xdg-desktop-portal-wo starting");
// Load config to get compositor IPC socket path
let config = wo::config::Config::load().unwrap_or_else(|_| {
info!("Using default config");
wo::config::Config::default()
});
// Create the portal backend with compositor socket
let portal = Arc::new(
WoPortal::new().with_compositor_socket(config.compositor.ipc_socket.clone())
);
// Connect to the session bus
let connection = Connection::session().await?;
// Create the ScreenCast interface implementation
let screencast = ScreenCastImpl {
portal: portal.clone(),
session_map: Mutex::new(std::collections::HashMap::new()),
};
// Register the interface at the portal path
connection
.object_server()
.at("/org/freedesktop/portal/desktop", screencast)
.await?;
// Request the well-known name
connection
.request_name("org.freedesktop.impl.portal.desktop.wo")
.await?;
info!("Portal service registered as org.freedesktop.impl.portal.desktop.wo");
info!("Compositor IPC socket: {}", config.compositor.ipc_socket);
info!("Listening for screen capture requests...");
// Keep the service running
std::future::pending::<()>().await;
Ok(())
}

314
src/config.rs Normal file
View file

@ -0,0 +1,314 @@
use serde::Deserialize;
use std::path::PathBuf;
use anyhow::{Context, Result};
/// Compositor configuration
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
/// Global compositor settings
#[serde(default)]
pub compositor: CompositorConfig,
/// Electron-rendered window definitions
#[serde(default)]
pub windows: Vec<WindowConfig>,
/// Root/fullscreen desktop windows (alternative to [[windows]])
#[serde(default)]
pub root: Vec<WindowConfig>,
/// Local asset directories to serve
#[serde(default)]
pub assets: Vec<AssetConfig>,
/// Commands to autostart
#[serde(default)]
pub autostart: Vec<AutostartConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CompositorConfig {
/// DRM device path (e.g. "/dev/dri/card0"). Auto-detected if absent.
pub drm_device: Option<String>,
/// Force nested mode (run inside another compositor). Auto-detected if absent.
pub nested: Option<bool>,
/// Exit when Escape key is pressed (nested mode only)
#[serde(default = "default_true")]
pub exit_on_escape: bool,
/// Initial window width in nested mode
#[serde(default = "default_nested_width")]
pub nested_width: u32,
/// Initial window height in nested mode
#[serde(default = "default_nested_height")]
pub nested_height: u32,
/// Wayland socket name (defaults to "wo-0")
#[serde(default = "default_socket_name")]
pub socket_name: String,
/// IPC socket path the compositor listens on for Electron connections
#[serde(default = "default_ipc_socket")]
pub ipc_socket: String,
/// Path to the Electron executable
#[serde(default = "default_electron_path")]
pub electron_path: String,
/// Background colour as [r, g, b, a] 0.0–1.0
#[serde(default = "default_background")]
pub background: [f32; 4],
/// Enable xdg-desktop-portal integration for screen/window capture
#[serde(default)]
pub enable_portal: bool,
/// Path to the xdg-desktop-portal socket
#[serde(default = "default_portal_socket")]
pub portal_socket: String,
/// Allow executing shell commands and file I/O via IPC syscalls
#[serde(default)]
pub enable_syscalls: bool,
/// Send focus change notifications to windows
#[serde(default)]
pub focus_notify: bool,
/// Pinned applications for the launcher dock
#[serde(default)]
pub applications: Vec<ApplicationConfig>,
}
impl Default for CompositorConfig {
fn default() -> Self {
Self {
drm_device: None,
nested: None,
exit_on_escape: true,
nested_width: default_nested_width(),
nested_height: default_nested_height(),
socket_name: default_socket_name(),
ipc_socket: default_ipc_socket(),
electron_path: default_electron_path(),
background: default_background(),
enable_portal: false,
portal_socket: default_portal_socket(),
enable_syscalls: false,
focus_notify: false,
applications: vec![],
}
}
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct WindowConfig {
pub name: String,
pub url: Option<String>,
pub html: Option<String>,
pub css: Option<String>,
pub js: Option<String>,
#[serde(default = "default_width")]
pub width: u32,
#[serde(default = "default_height")]
pub height: u32,
#[serde(default)]
pub x: i32,
#[serde(default)]
pub y: i32,
#[serde(default)]
pub z_order: i32,
#[serde(default = "default_fps")]
pub fps: u32,
#[serde(default = "default_format")]
pub format: String,
#[serde(default)]
pub focusable: bool,
#[serde(default = "default_true")]
pub interactive: bool,
#[serde(default)]
pub floating: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AssetConfig {
pub name: String,
pub path: PathBuf,
#[serde(default = "default_asset_prefix")]
pub serve_at: String,
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct AutostartConfig {
pub name: String,
pub command: String,
#[serde(default)]
pub delay: u64,
#[serde(default)]
pub restart: bool,
}
/// Application launcher configuration
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct ApplicationConfig {
pub name: String,
pub command: String,
#[serde(default)]
pub icon: Option<ApplicationIconConfig>,
#[serde(default)]
pub multi_instance: bool,
}
/// Icon configuration for applications
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(untagged)]
pub enum ApplicationIconConfig {
/// Iconify icon name (e.g., "mdi:firefox")
Iconify(String),
/// Full icon configuration with type and data
Full {
#[serde(rename = "type")]
icon_type: IconType,
data: String,
#[serde(default)]
mime_type: Option<String>,
#[serde(default)]
fallback: Option<Box<ApplicationIconConfig>>,
},
}
/// Icon type specification
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum IconType {
Iconify,
Base64,
Url,
Path,
}
impl ApplicationIconConfig {
/// Convert to JSON representation for sending to clients
pub fn to_json(&self) -> serde_json::Value {
match self {
ApplicationIconConfig::Iconify(name) => serde_json::json!({
"type": "iconify",
"data": name,
}),
ApplicationIconConfig::Full {
icon_type,
data,
mime_type,
fallback,
} => {
let mut obj = serde_json::json!({
"type": match icon_type {
IconType::Iconify => "iconify",
IconType::Base64 => "base64",
IconType::Url => "url",
IconType::Path => "path",
},
"data": data,
});
if let Some(mime) = mime_type {
obj["mimeType"] = serde_json::Value::String(mime.clone());
}
if let Some(fb) = fallback {
obj["fallback"] = fb.to_json();
}
obj
}
}
}
}
impl ApplicationConfig {
/// Convert to JSON representation for sending to clients
pub fn to_json(&self) -> serde_json::Value {
let mut obj = serde_json::json!({
"name": &self.name,
"command": &self.command,
});
if let Some(icon) = &self.icon {
obj["icon"] = icon.to_json();
}
if self.multi_instance {
obj["multi_instance"] = serde_json::Value::Bool(true);
}
obj
}
}
// Defaults
fn default_socket_name() -> String { "wo-0".into() }
fn default_ipc_socket() -> String { "/run/user/1000/wo-ipc.sock".into() }
fn default_electron_path() -> String { "electron".into() }
fn default_background() -> [f32; 4] { [0.1, 0.1, 0.1, 1.0] }
fn default_portal_socket() -> String { "/run/user/1000/wo-portal.sock".into() }
fn default_width() -> u32 { 1920 }
fn default_height() -> u32 { 1080 }
fn default_nested_width() -> u32 { 1920 }
fn default_nested_height() -> u32 { 1080 }
fn default_fps() -> u32 { 60 }
fn default_format() -> String { "ARGB8888".into() }
fn default_asset_prefix() -> String { "file".into() }
fn default_true() -> bool { true }
impl Config {
pub fn load() -> Result<Self> {
// Try multiple locations in order of priority:
// 1. WO_CONFIG environment variable
// 2. ./config.toml (current directory)
// 3. ~/.config/wo/config.toml (user config)
let paths = vec![
std::env::var("WO_CONFIG").ok().map(PathBuf::from),
Some(PathBuf::from("./config.toml")),
Some(config_path()),
];
for path_opt in paths.into_iter().flatten() {
if path_opt.exists() {
tracing::info!("Loading config from {path_opt:?}");
let text = std::fs::read_to_string(&path_opt)
.with_context(|| format!("reading config {path_opt:?}"))?;
return toml::from_str(&text)
.with_context(|| format!("parsing config {path_opt:?}"));
}
}
tracing::warn!("No config found, using defaults");
Ok(Self::default())
}
pub fn config_dir() -> PathBuf {
dirs_config()
}
}
fn config_path() -> PathBuf {
dirs_config().join("config.toml")
}
fn dirs_config() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
PathBuf::from(home).join(".config/wo")
}
impl Default for Config {
fn default() -> Self {
Self {
compositor: CompositorConfig::default(),
windows: vec![],
root: vec![],
assets: vec![],
autostart: vec![],
}
}
}

330
src/dmabuf.rs Normal file
View file

@ -0,0 +1,330 @@
/// DMABUF import helpers.
///
/// When a [`crate::electron::ElectronFrame`] arrives the compositor:
/// 1. Looks up (or allocates) a `TextureSlot` for that window name.
/// 2. Imports the DMABUF fds into the EGL / GL renderer.
/// 3. Uses the resulting texture for compositing.
///
/// On the export side (serving Wayland clients) the compositor creates a
/// `zwp_linux_dmabuf_v1` buffer from the same backing GBM BO so that other
/// Wayland clients can access the pixel data zero-copy.
use std::collections::HashMap;
use anyhow::{Context, Result};
use smithay::backend::{
allocator::{dmabuf::{Dmabuf, DmabufFlags}, Fourcc, Modifier},
renderer::{gles::{GlesRenderer, GlesTexture}, ImportDma},
};
use crate::electron::ElectronFrame;
/// In-flight texture imported from an Electron DMABUF frame.
#[allow(dead_code)]
pub struct ElectronTexture {
pub name: String,
pub width: u32,
pub height: u32,
pub texture: GlesTexture,
/// Keep-alive handle so the underlying DMABUF fds stay open.
pub dmabuf: Dmabuf,
}
/// Cached DMABUF for a window with mutable pixel storage.
/// Allows efficient pixel updates without recreating the DMABUF.
#[allow(dead_code)]
pub struct WindowDmabufCache {
pub name: String,
pub width: i32,
pub height: i32,
pub stride: u32,
pub dmabuf: Dmabuf,
/// Mutable memfd fd for updating pixel data in-place
pub memfd_fd: std::os::unix::io::OwnedFd,
}
impl WindowDmabufCache {
/// Update pixels in the cached DMABUF without recreating it.
/// Returns Ok(true) if pixels were successfully updated.
#[allow(dead_code)]
pub fn update_pixels(&mut self, pixels: &[u8]) -> Result<()> {
use std::os::unix::io::AsRawFd;
use nix::unistd;
// Seek to start
unistd::lseek(&self.memfd_fd, 0, unistd::Whence::SeekSet)
.context("seeking memfd to start")?;
// Write new pixel data
unistd::write(&self.memfd_fd, pixels)
.context("writing pixels to memfd")?;
Ok(())
}
}
/// Cached DMABUF data for Electron frames (texture is re-imported each frame to avoid
/// lifetime issues with GlesRenderer contexts).
pub struct CachedDmabufFrame {
pub name: String,
pub width: u32,
pub height: u32,
pub dmabuf: Dmabuf,
}
/// Create a cached DMABUF for window pixel storage with in-place update capability.
#[allow(dead_code)]
pub fn create_window_dmabuf_cache(
window_name: String,
width: i32,
height: i32,
stride: u32,
pixels: &[u8],
) -> Result<WindowDmabufCache> {
use smithay::backend::allocator::Fourcc;
use smithay::backend::allocator::Modifier;
// Create a memfd for the pixel data
let memfd_fd = nix::sys::memfd::memfd_create(
std::ffi::CStr::from_bytes_with_nul(b"wayland_window\0").unwrap(),
nix::sys::memfd::MFdFlags::MFD_CLOEXEC
)
.context("creating memfd for window DMABUF")?;
// Truncate to size
let size = pixels.len() as u64;
nix::unistd::ftruncate(&memfd_fd, size as i64)
.context("truncating memfd")?;
// Write initial pixel data
nix::unistd::write(&memfd_fd, pixels)
.context("writing initial pixels to memfd")?;
// Seek back to start for DMABUF builder
nix::unistd::lseek(&memfd_fd, 0, nix::unistd::Whence::SeekSet)
.context("seeking memfd to start")?;
// Build DMABUF with the memfd
let mut builder = Dmabuf::builder(
(width, height),
Fourcc::Argb8888,
Modifier::Invalid,
DmabufFlags::empty(),
);
// Clone the fd for DMABUF ownership
let dup_fd = nix::unistd::dup(&memfd_fd).context("dup memfd for DMABUF")?;
builder.add_plane(dup_fd, 0, 0, stride);
let dmabuf = builder.build().context("building window DMABUF")?;
Ok(WindowDmabufCache {
name: window_name,
width,
height,
stride,
dmabuf,
memfd_fd,
})
}
/// Convert an [`ElectronFrame`] into a texture and import it into the renderer.
///
/// Now returns just the texture for immediate rendering; use `cache_frame_for_reimport`
/// to cache the DMABUF for later re-importing.
pub fn import_electron_frame(
renderer: &mut GlesRenderer,
frame: ElectronFrame,
) -> Result<(ElectronTexture, CachedDmabufFrame)> {
use tracing::{debug, trace};
trace!("Importing DMABUF frame: {}x{} format=0x{:08X} planes={}",
frame.width, frame.height, frame.format, frame.planes.len());
let fourcc: Fourcc = fourcc_from_u32(frame.format)
.with_context(|| format!("unknown fourcc 0x{:08X}", frame.format))?;
let modifier = frame
.planes
.first()
.map(|p| Modifier::from(p.modifier))
.unwrap_or(Modifier::Invalid);
trace!("Fourcc: {:?}, Modifier: {:?}", fourcc, modifier);
let mut builder = Dmabuf::builder(
(frame.width as i32, frame.height as i32),
fourcc,
modifier,
DmabufFlags::empty(),
);
// Duplicate fds before passing to Dmabuf builder
// This allows both the Dmabuf and the imported texture to safely own independent copies
for (idx, plane) in frame.planes.into_iter().enumerate() {
let dup_fd = nix::unistd::dup(&plane.fd)
.with_context(|| format!("Failed to dup plane {} fd", idx))?;
if !builder.add_plane(dup_fd, idx as u32, plane.offset, plane.stride) {
debug!("Failed to add plane {} to DMABUF", idx);
anyhow::bail!("too many planes in DMABUF frame");
}
trace!("Plane {}: offset={} stride={}", idx, plane.offset, plane.stride);
}
let dmabuf = builder.build().context("building DMABUF")?;
trace!("DMABUF built successfully");
let texture = renderer
.import_dmabuf(&dmabuf, None)
.context("importing DMABUF into renderer")?;
trace!("DMABUF imported into renderer successfully");
// Create a cache-friendly version with fresh DMABUF for later re-import
let cache_dmabuf = dmabuf.clone();
Ok((
ElectronTexture {
name: frame.name.clone(),
width: frame.width,
height: frame.height,
texture,
dmabuf,
},
CachedDmabufFrame {
name: frame.name,
width: frame.width,
height: frame.height,
dmabuf: cache_dmabuf,
},
))
}
/// Export the texture backing DMABUF for Wayland clients.
#[allow(dead_code)]
pub fn export_dmabuf_for_client(texture: &ElectronTexture) -> Dmabuf {
texture.dmabuf.clone()
}
/// Re-import a cached DMABUF frame into a renderer (used during rendering).
pub fn reimport_cached_frame(
renderer: &mut GlesRenderer,
cached: &CachedDmabufFrame,
) -> Result<GlesTexture> {
renderer
.import_dmabuf(&cached.dmabuf, None)
.context("re-importing cached DMABUF")
}
/// Create a temporary DMABUF shm-backed frame from pixel data.
/// This is for transmitting window pixel data as DMABUF fds rather than raw pixels.
/// Each window gets its own DMABUF.
#[allow(dead_code)]
pub fn create_temp_dmabuf_from_pixels(
width: i32,
height: i32,
stride: u32,
pixels: &[u8],
) -> Result<Dmabuf> {
use smithay::backend::allocator::Fourcc;
use smithay::backend::allocator::Modifier;
// Create a memfd for the pixel data
let memfd_fd = nix::sys::memfd::memfd_create(
"wayland_window",
nix::sys::memfd::MFdFlags::MFD_CLOEXEC
)
.context("creating memfd for DMABUF pixels")?;
// Truncate to size
let size = pixels.len() as u64;
nix::unistd::ftruncate(&memfd_fd, size as i64)
.context("truncating memfd")?;
// Write pixel data directly using nix::unistd::write (no File wrapper)
nix::unistd::write(&memfd_fd, pixels)
.context("writing pixels to memfd")?;
// Seek back to start
nix::unistd::lseek(&memfd_fd, 0, nix::unistd::Whence::SeekSet)
.context("seeking memfd to start")?;
// memfd_fd is already an OwnedFd, just use it directly in builder
// Build DMABUF with single plane
let mut builder = Dmabuf::builder(
(width, height),
Fourcc::Argb8888,
Modifier::Invalid,
DmabufFlags::empty(),
);
builder.add_plane(memfd_fd, 0, 0, stride);
builder.build().context("building DMABUF from pixels")
}
/// Export an offscreen texture as a DMABUF for zero-copy transmission.
/// Used to export Wayland window textures to comraw via IPC using individual DMABUFs.
#[allow(dead_code)]
pub fn export_texture_as_dmabuf(
_renderer: &GlesRenderer,
_texture: &GlesTexture,
_width: u32,
_height: u32,
) -> Result<Dmabuf> {
// For now, we'll read pixels and create a DMABUF from them
// A more optimal approach would use EGLImage to back the DMABUF, but
// this is simpler and still zero-copy at the IPC boundary
use smithay::utils::Rectangle;
// This would need a mutable reference, which we don't have
// So instead, for each window we'll create DMABUF on-demand after pixelreads
anyhow::bail!("texture export not directly supported; use create_temp_dmabuf_from_pixels after reading pixels")
}
// ── Texture cache ─────────────────────────────────────────────────────────────
/// Keeps the most-recent DMABUF frame per window (textures are imported on-demand).
#[derive(Default)]
pub struct TextureCache {
inner: HashMap<String, CachedDmabufFrame>,
}
impl TextureCache {
pub fn insert_dmabuf(&mut self, frame: CachedDmabufFrame) {
self.inner.insert(frame.name.clone(), frame);
}
pub fn get_dmabuf(&self, name: &str) -> Option<&CachedDmabufFrame> {
self.inner.get(name)
}
pub fn remove(&mut self, name: &str) -> Option<CachedDmabufFrame> {
self.inner.remove(name)
}
pub fn iter(&self) -> impl Iterator<Item = &CachedDmabufFrame> {
self.inner.values()
}
}
// ── Fourcc helpers ────────────────────────────────────────────────────────────
fn fourcc_from_u32(v: u32) -> Option<Fourcc> {
Fourcc::try_from(v).ok()
}
/// Map a human-readable format string from the config to a DRM fourcc code.
#[allow(dead_code)]
pub fn format_str_to_fourcc(s: &str) -> Option<u32> {
match s.to_uppercase().as_str() {
"ARGB8888" => Some(u32::from_le_bytes(*b"AR24")), // DRM_FORMAT_ARGB8888
"BGRA8888" => Some(u32::from_le_bytes(*b"BA24")), // DRM_FORMAT_BGRA8888
"XRGB8888" => Some(u32::from_le_bytes(*b"XR24")), // DRM_FORMAT_XRGB8888
"ABGR8888" => Some(u32::from_le_bytes(*b"AB24")), // DRM_FORMAT_ABGR8888
"XBGR8888" => Some(u32::from_le_bytes(*b"XB24")), // DRM_FORMAT_XBGR8888
_ => None,
}
}

1211
src/electron.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,99 @@
use smithay::{
backend::renderer::utils::on_commit_buffer_handler,
delegate_compositor, delegate_shm,
reexports::wayland_server::protocol::{wl_buffer, wl_surface::WlSurface},
wayland::{
buffer::BufferHandler,
compositor::{
get_parent, is_sync_subsurface, CompositorClientState, CompositorHandler,
CompositorState,
},
shm::{ShmHandler, ShmState},
},
};
use crate::state::WoState;
// ── Compositor ───────────────────────────────────────────────────────────────
impl CompositorHandler for WoState {
fn compositor_state(&mut self) -> &mut CompositorState {
&mut self.compositor_state
}
fn client_compositor_state<'a>(
&self,
client: &'a smithay::reexports::wayland_server::Client,
) -> &'a CompositorClientState {
if let Some(data) = client.get_data::<crate::state::ClientData>() {
return &data.compositor_state;
}
static LOGGED_ONCE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if !LOGGED_ONCE.swap(true, std::sync::atomic::Ordering::Relaxed) {
tracing::info!("Client missing ClientData (likely XWayland), using fallback compositor state");
} else {
tracing::debug!("Client missing ClientData (likely XWayland), using fallback compositor state");
}
static FALLBACK: std::sync::OnceLock<CompositorClientState> = std::sync::OnceLock::new();
FALLBACK.get_or_init(CompositorClientState::default)
}
fn commit(&mut self, surface: &WlSurface) {
if self.debug_new_toplevel {
tracing::info!("commit handler ENTER (debug_new_toplevel=true)");
}
on_commit_buffer_handler::<Self>(surface);
if !is_sync_subsurface(surface) {
let mut root = surface.clone();
while let Some(parent) = get_parent(&root) {
root = parent;
}
if let Some(window) = self
.space
.elements()
.find(|w| w.toplevel().map(|t| t.wl_surface() == &root).unwrap_or(false))
.cloned()
{
window.on_commit();
self.dirty_surfaces.insert(root.clone());
} else if let Some(window) = self
.space
.elements()
.find(|w| {
w.x11_surface()
.and_then(|x| x.wl_surface())
.map(|s| s == root)
.unwrap_or(false)
})
.cloned()
{
window.on_commit();
self.dirty_surfaces.insert(root.clone());
}
}
self.popup_manager.commit(surface);
crate::handlers::xdg_shell::ensure_initial_configure(surface, &self.space, &mut self.popup_manager);
if self.debug_new_toplevel {
tracing::info!("commit handler EXIT");
}
}
}
delegate_compositor!(WoState);
// ── SHM ──────────────────────────────────────────────────────────────────────
impl BufferHandler for WoState {
fn buffer_destroyed(&mut self, _buffer: &wl_buffer::WlBuffer) {}
}
impl ShmHandler for WoState {
fn shm_state(&self) -> &ShmState {
&self.shm_state
}
}
delegate_shm!(WoState);

View file

@ -0,0 +1,48 @@
use smithay::{
backend::allocator::dmabuf::Dmabuf,
delegate_dmabuf,
wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, DmabufFeedback, ImportNotifier}
};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use crate::state::WoState;
impl DmabufHandler for WoState {
fn dmabuf_state(&mut self) -> &mut DmabufState {
&mut self.dmabuf_state
}
fn dmabuf_imported(
&mut self,
_global: &DmabufGlobal,
_dmabuf: Dmabuf,
notifier: ImportNotifier,
) {
tracing::info!("dmabuf_imported: accepting DMABUF unconditionally");
// Frames from Electron arrive via the custom IPC channel, not via the
// Wayland DMABUF protocol. However, native Wayland clients (and Electron's
// own GPU subprocess when it connects as a Wayland client) import DMABUFs
// through this path. We accept unconditionally here without touching the
// GPU — actual validation happens when the buffer is used for rendering.
//
// IMPORTANT: Dropping the notifier WITHOUT calling .successful() sends a
// failure response to the client, preventing it from creating DMABUF-backed
// wl_buffers entirely and causing the app (and rendering) to freeze.
let _ = notifier.successful::<WoState>();
tracing::info!("dmabuf_imported: done");
}
fn new_surface_feedback(
&mut self,
_surface: &WlSurface,
_global: &DmabufGlobal,
) -> Option<DmabufFeedback> {
// Returning None lets the client use the global/default DMABUF feedback.
// Per-surface feedback was causing the compositor to freeze inside
// dispatch_clients() — Smithay's internal feedback-sending machinery
// (format table FD transfer) hangs when sending per-surface feedback.
None
}
}
delegate_dmabuf!(WoState);

703
src/handlers/input.rs Normal file
View file

@ -0,0 +1,703 @@
use smithay::{
backend::input::{
AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, InputBackend, InputEvent,
KeyState, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent,
},
input::keyboard::FilterResult,
utils::SERIAL_COUNTER,
};
use tracing::info;
use crate::{electron::ElectronInputEvent, handlers::xdg_shell::GrabState, state::WoState};
fn evdev_function_key_to_vt(evdev_key: u32) -> Option<u32> {
match evdev_key {
59..=68 => Some(evdev_key - 58), // F1..F10 -> VT1..VT10
87 => Some(11), // F11 -> VT11
88 => Some(12), // F12 -> VT12
_ => None,
}
}
impl WoState {
/// Dispatch a generic input event to the appropriate handler.
pub fn process_input_event<B: InputBackend>(&mut self, event: InputEvent<B>) {
match event {
InputEvent::Keyboard { event } => self.on_keyboard::<B>(event),
InputEvent::PointerMotion { event } => self.on_pointer_motion::<B>(event),
InputEvent::PointerMotionAbsolute { event } => self.on_pointer_motion_abs::<B>(event),
InputEvent::PointerButton { event } => self.on_pointer_button::<B>(event),
InputEvent::PointerAxis { event } => self.on_pointer_axis::<B>(event),
_ => {}
}
}
/// Determine which Electron window (if any) is under the given position.
fn window_under(&self, pos: (f64, f64)) -> Option<String> {
// Windows are drawn in z-order, so we check them in reverse z-order
// to find the topmost one under the cursor.
let mut windows: Vec<_> = self
.config
.windows
.iter()
.chain(self.config.root.iter())
.collect();
windows.sort_by_key(|w| std::cmp::Reverse(w.z_order));
for win in windows {
let x1 = win.x as f64;
let y1 = win.y as f64;
let x2 = x1 + win.width as f64;
let y2 = y1 + win.height as f64;
// Skip windows that are explicitly unmapped by the compositor
// (Electron-managed windows signal this via `window_mapped[name] = false`).
if let Some(mapped) = self.window_mapped.get(&win.name) {
if !*mapped {
continue;
}
}
if pos.0 >= x1 && pos.0 < x2 && pos.1 >= y1 && pos.1 < y2 {
return Some(win.name.clone());
}
}
None
}
/// Get the coordinates of a window relative to the window's top-left.
fn local_window_coords(&self, window_name: &str, global_pos: (f64, f64)) -> Option<(f64, f64)> {
self.config
.windows
.iter()
.chain(self.config.root.iter())
.find(|w| w.name == window_name)
.map(|w| (global_pos.0 - w.x as f64, global_pos.1 - w.y as f64))
}
fn on_keyboard<B: InputBackend>(&mut self, event: B::KeyboardKeyEvent) {
let serial = SERIAL_COUNTER.next_serial();
let time = event.time_msec();
let press = event.state() == KeyState::Pressed;
let key_code = event.key_code();
// Smithay's libinput backend stores XKB keycodes (evdev + 8).
// The Electron keycode map uses raw evdev scancodes, so subtract
// the XKB offset before forwarding.
let evdev_key = u32::from(key_code).saturating_sub(8);
// Use non-blocking send to avoid input stalls.
let wayland_has_focus = self
.seat
.get_keyboard()
.map(|kb| kb.current_focus().is_some())
.unwrap_or(false);
if !wayland_has_focus {
if let Some(ref focused_win) = self.keyboard_window_focus {
if let Some(ref ipc) = self.electron_ipc {
if let Some(client) = ipc.clients.lock().unwrap().get(focused_win).cloned() {
// Non-blocking send.
let _ = client.try_send_input_event(&ElectronInputEvent::Keyboard {
key: evdev_key,
pressed: press,
time,
});
}
}
}
}
// Always dispatch through Smithay's keyboard handler (forwards to focused Wayland surface).
let keyboard = self.seat.get_keyboard().unwrap();
keyboard.input::<(), _>(
self,
key_code,
event.state(),
serial,
time,
|state, modifiers, _handle| {
if press && state.can_switch_vt {
if let Some(vt) = evdev_function_key_to_vt(evdev_key) {
// Accept Ctrl+Alt+Fn and Meta+Alt+Fn.
if modifiers.alt && (modifiers.ctrl || modifiers.logo) {
state.pending_vt_switch = Some(vt);
info!(vt, "VT switch requested from keyboard shortcut");
return FilterResult::Intercept(());
}
}
}
let is_backspace = evdev_key == 14;
if press && modifiers.ctrl && modifiers.alt && is_backspace {
info!("Ctrl+Alt+Backspace pressed, quitting");
state.running = false;
return FilterResult::Intercept(());
}
FilterResult::Forward
},
);
}
fn on_pointer_motion<B: InputBackend>(&mut self, event: B::PointerMotionEvent) {
let delta = (event.delta_x(), event.delta_y());
let current = self.pointer_location;
self.pointer_location = (
(current.x + delta.0).clamp(0.0, self.output_size.0 as f64),
(current.y + delta.1).clamp(0.0, self.output_size.1 as f64),
)
.into();
// Process active grab (interactive move/resize) before normal dispatch
if let Some(ref grab) = self.grab_state.clone() {
match grab {
GrabState::Move {
window,
initial_window_location,
initial_pointer,
} => {
let dx = self.pointer_location.x - initial_pointer.x;
let dy = self.pointer_location.y - initial_pointer.y;
let new_loc = (
initial_window_location.x + dx as i32,
initial_window_location.y + dy as i32,
);
self.space.map_element(window.clone(), new_loc, false);
}
GrabState::Resize {
surface,
window,
edges,
initial_window_location,
initial_window_size,
initial_pointer,
} => {
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge;
let dx = (self.pointer_location.x - initial_pointer.x) as i32;
let dy = (self.pointer_location.y - initial_pointer.y) as i32;
let (mut new_w, mut new_h) = *initial_window_size;
let (mut new_x, mut new_y) =
(initial_window_location.x, initial_window_location.y);
match *edges {
ResizeEdge::Right | ResizeEdge::TopRight | ResizeEdge::BottomRight => {
new_w = (new_w + dx).max(1);
}
ResizeEdge::Left | ResizeEdge::TopLeft | ResizeEdge::BottomLeft => {
new_w = (new_w - dx).max(1);
new_x += dx;
}
_ => {}
}
match *edges {
ResizeEdge::Bottom | ResizeEdge::BottomLeft | ResizeEdge::BottomRight => {
new_h = (new_h + dy).max(1);
}
ResizeEdge::Top | ResizeEdge::TopLeft | ResizeEdge::TopRight => {
new_h = (new_h - dy).max(1);
new_y += dy;
}
_ => {}
}
// Respect client min/max size constraints.
let (clamped_w, clamped_h) = self.clamp_to_size_hints(window, new_w, new_h);
surface.with_pending_state(|s| {
s.size = Some((clamped_w, clamped_h).into());
});
surface.send_configure();
self.space
.map_element(window.clone(), (new_x, new_y), false);
}
}
return;
}
// In nested mode, pointer events are routed through Electron/comraw.
if self.electron_ipc.is_some() {
let under_window =
self.window_under((self.pointer_location.x, self.pointer_location.y));
if self.pointer_window_focus != under_window {
self.pointer_window_focus = under_window.clone();
}
if let Some(ref win_name) = under_window {
if let Some(ref ipc) = self.electron_ipc {
if let Some(local) = self.local_window_coords(
win_name,
(self.pointer_location.x, self.pointer_location.y),
) {
if let Some(client) = ipc.clients.lock().unwrap().get(win_name).cloned() {
// Non-blocking send.
let _ = client.try_send_input_event(&ElectronInputEvent::MouseMove {
x: local.0,
y: local.1,
});
}
}
}
}
return;
}
}
fn on_pointer_motion_abs<B: InputBackend>(&mut self, event: B::PointerMotionAbsoluteEvent) {
self.pointer_location = event
.position_transformed((self.output_size.0 as i32, self.output_size.1 as i32).into())
.into();
// Process active grab (interactive move/resize) before normal dispatch
if let Some(ref grab) = self.grab_state.clone() {
match grab {
GrabState::Move {
window,
initial_window_location,
initial_pointer,
} => {
let dx = self.pointer_location.x - initial_pointer.x;
let dy = self.pointer_location.y - initial_pointer.y;
let new_loc = (
initial_window_location.x + dx as i32,
initial_window_location.y + dy as i32,
);
self.space.map_element(window.clone(), new_loc, false);
}
GrabState::Resize {
surface,
window,
edges,
initial_window_location,
initial_window_size,
initial_pointer,
} => {
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge;
let dx = (self.pointer_location.x - initial_pointer.x) as i32;
let dy = (self.pointer_location.y - initial_pointer.y) as i32;
let (mut new_w, mut new_h) = *initial_window_size;
let (mut new_x, mut new_y) =
(initial_window_location.x, initial_window_location.y);
match *edges {
ResizeEdge::Right | ResizeEdge::TopRight | ResizeEdge::BottomRight => {
new_w = (new_w + dx).max(1);
}
ResizeEdge::Left | ResizeEdge::TopLeft | ResizeEdge::BottomLeft => {
new_w = (new_w - dx).max(1);
new_x += dx;
}
_ => {}
}
match *edges {
ResizeEdge::Bottom | ResizeEdge::BottomLeft | ResizeEdge::BottomRight => {
new_h = (new_h + dy).max(1);
}
ResizeEdge::Top | ResizeEdge::TopLeft | ResizeEdge::TopRight => {
new_h = (new_h - dy).max(1);
new_y += dy;
}
_ => {}
}
// Respect client min/max size constraints.
let (clamped_w, clamped_h) = self.clamp_to_size_hints(window, new_w, new_h);
surface.with_pending_state(|s| {
s.size = Some((clamped_w, clamped_h).into());
});
surface.send_configure();
self.space
.map_element(window.clone(), (new_x, new_y), false);
}
}
return;
}
// In nested mode, pointer events are routed through Electron/comraw.
if self.electron_ipc.is_some() {
let under_window =
self.window_under((self.pointer_location.x, self.pointer_location.y));
if self.pointer_window_focus != under_window {
self.pointer_window_focus = under_window.clone();
}
if let Some(ref win_name) = under_window {
if let Some(ref ipc) = self.electron_ipc {
if let Some(local) = self.local_window_coords(
win_name,
(self.pointer_location.x, self.pointer_location.y),
) {
let client = ipc.clients.lock().unwrap().get(win_name).cloned();
if let Some(client) = client {
let _ = client.try_send_input_event(&ElectronInputEvent::MouseMove {
x: local.0,
y: local.1,
});
}
}
}
}
return;
}
}
fn on_pointer_button<B: InputBackend>(&mut self, event: B::PointerButtonEvent) {
let serial = SERIAL_COUNTER.next_serial();
let time = event.time_msec();
let button_state = event.state();
let button_code = event.button_code();
// End any active grab on button release
if button_state == ButtonState::Released && self.grab_state.is_some() {
self.grab_state = None;
return;
}
// In nested mode, pointer button events are routed through Electron/comraw.
if self.electron_ipc.is_some() {
let under_window =
self.window_under((self.pointer_location.x, self.pointer_location.y));
if let Some(ref win_name) = under_window {
if button_state == ButtonState::Pressed {
self.keyboard_window_focus = Some(win_name.clone());
// Clear Wayland keyboard focus so Electron receives keys.
let keyboard = self.seat.get_keyboard().unwrap();
keyboard.set_focus(self, None, serial);
}
if let Some(ref ipc) = self.electron_ipc {
if let Some(client) = ipc.clients.lock().unwrap().get(win_name).cloned() {
// Non-blocking send.
let _ = client.try_send_input_event(&ElectronInputEvent::MouseButton {
button: button_code,
pressed: button_state == ButtonState::Pressed,
time,
});
}
}
} else {
// Fall back to native window hit-testing.
if button_state == ButtonState::Pressed {
use smithay::utils::{Logical, Point};
let pos: Point<f64, Logical> = self.pointer_location;
let under_surface = self.space.element_under(pos);
if let Some((window, surface_loc)) = under_surface {
let surface_opt = if let Some(x11) = window.x11_surface() {
x11.wl_surface()
} else {
window.toplevel().map(|t| t.wl_surface().clone())
};
if let Some(surface) = surface_opt {
let keyboard = self.seat.get_keyboard().unwrap();
keyboard.set_focus(self, Some(surface.clone()), serial);
self.keyboard_window_focus = None;
let pointer = self.seat.get_pointer().unwrap();
pointer.button(
self,
&smithay::input::pointer::ButtonEvent {
button: button_code,
state: button_state,
serial,
time,
},
);
}
} else {
// Clicked empty space: clear focus.
self.keyboard_window_focus = None;
let keyboard = self.seat.get_keyboard().unwrap();
keyboard.set_focus(self, None, serial);
}
}
}
return;
}
}
fn on_pointer_axis<B: InputBackend>(&mut self, event: B::PointerAxisEvent) {
let time = event.time_msec();
// In nested mode, scroll events are routed through Electron/comraw.
if self.electron_ipc.is_some() {
let under_window =
self.window_under((self.pointer_location.x, self.pointer_location.y));
if let Some(ref win_name) = under_window {
if let Some(ref ipc) = self.electron_ipc {
let mut vertical = 0i32;
let mut horizontal = 0i32;
if let Some(v) = event.amount_v120(Axis::Vertical) {
vertical = v as i32;
} else if let Some(v) = event.amount(Axis::Vertical) {
vertical = (v * 15.0) as i32;
}
if let Some(h) = event.amount_v120(Axis::Horizontal) {
horizontal = h as i32;
} else if let Some(h) = event.amount(Axis::Horizontal) {
horizontal = (h * 15.0) as i32;
}
if vertical != 0 || horizontal != 0 {
if let Some(client) = ipc.clients.lock().unwrap().get(win_name).cloned() {
// Non-blocking send.
let _ = client.try_send_input_event(&ElectronInputEvent::Scroll {
vertical,
horizontal,
time,
});
}
}
}
}
return;
}
}
/// Forward a pointer motion event received from the Electron web UI to the
/// Wayland surface under the given window-local coordinates.
pub fn handle_forwarded_pointer_event(&mut self, window_name: &str, x: f64, y: f64) {
use smithay::{
desktop::WindowSurfaceType,
input::pointer::MotionEvent,
utils::{Logical, Point},
};
let window = match self.find_wayland_window(window_name) {
Some(w) => w,
None => return,
};
let win_loc = self.space.element_location(&window).unwrap_or_default();
let global_x = win_loc.x as f64 + x;
let global_y = win_loc.y as f64 + y;
self.pointer_location = (global_x, global_y).into();
let pointer = match self.seat.get_pointer() {
Some(p) => p,
None => return,
};
let serial = SERIAL_COUNTER.next_serial();
// Coordinates (x, y) are already relative to the window/surface origin.
let focus = window
.surface_under(Point::<f64, Logical>::from((x, y)), WindowSurfaceType::ALL)
.map(|(s, p)| (s, p.to_f64()));
pointer.motion(
self,
focus,
&MotionEvent {
location: self.pointer_location,
serial,
time: 0,
},
);
pointer.frame(self);
}
/// Forward a keyboard event received from the Electron web UI to the
/// Wayland surface corresponding to the given window name.
pub fn handle_forwarded_keyboard_event(
&mut self,
window_name: &str,
evdev_key: u32,
pressed: bool,
time: u32,
) {
let maybe_window = self.find_wayland_window(window_name);
let is_x11 = maybe_window
.as_ref()
.map(|w| w.x11_surface().is_some())
.unwrap_or(false);
let surface = maybe_window
.as_ref()
.and_then(|window| {
window
.toplevel()
.map(|t| t.wl_surface().clone())
.or_else(|| window.x11_surface().and_then(|x| x.wl_surface()))
})
.or_else(|| {
tracing::warn!(
"handle_forwarded_keyboard_event: using current keyboard focus fallback for {}",
window_name
);
self.seat.get_keyboard().and_then(|kb| kb.current_focus())
});
let surface = match surface {
Some(s) => s,
None => {
tracing::warn!(
"handle_forwarded_keyboard_event: no target surface (window={}, x11={})",
window_name,
is_x11
);
return;
}
};
let keyboard = match self.seat.get_keyboard() {
Some(kb) => kb,
None => return,
};
let serial = SERIAL_COUNTER.next_serial();
let had_focus = keyboard.current_focus().as_ref() == Some(&surface);
if !had_focus {
tracing::info!(
"Setting keyboard focus to {} window: {}",
if is_x11 { "X11" } else { "Wayland" },
window_name
);
keyboard.set_focus(self, Some(surface.clone()), serial);
}
let xkb_code = evdev_key + 8;
tracing::trace!(
"Forwarding keyboard event to {}: key={} (xkb={}), pressed={}",
if is_x11 { "X11" } else { "Wayland" },
evdev_key,
xkb_code,
pressed
);
keyboard.input::<(), _>(
self,
xkb_code.into(),
if pressed {
KeyState::Pressed
} else {
KeyState::Released
},
serial,
time,
|_state, _modifiers, _handle| FilterResult::Forward,
);
}
/// Forward a pointer button event (press/release) from the web UI.
/// Moves the pointer to (x, y) within the window first, then sends the button event.
pub fn handle_forwarded_pointer_button(
&mut self,
window_name: &str,
x: f64,
y: f64,
button: u32,
pressed: bool,
time: u32,
) {
use smithay::input::pointer::ButtonEvent;
// Update pointer location
self.handle_forwarded_pointer_event(window_name, x, y);
let pointer = match self.seat.get_pointer() {
Some(p) => p,
None => return,
};
if pressed {
if let Some(window) = self.find_wayland_window(window_name) {
let is_x11 = window.x11_surface().is_some();
let surface = window
.toplevel()
.map(|t| t.wl_surface().clone())
.or_else(|| window.x11_surface().and_then(|x| x.wl_surface()));
if let Some(surface) = surface {
if let Some(keyboard) = self.seat.get_keyboard() {
let focus_serial = SERIAL_COUNTER.next_serial();
tracing::info!(
"Pointer button press: setting keyboard focus to {} window: {}",
if is_x11 { "X11" } else { "Wayland" },
window_name
);
keyboard.set_focus(self, Some(surface), focus_serial);
}
} else {
tracing::warn!(
"Pointer button: {} window {} has no wl_surface",
if is_x11 { "X11" } else { "Wayland" },
window_name
);
}
}
}
let serial = SERIAL_COUNTER.next_serial();
pointer.button(
self,
&ButtonEvent {
serial,
time,
button,
state: if pressed {
ButtonState::Pressed
} else {
ButtonState::Released
},
},
);
pointer.frame(self);
}
/// Forward a pointer scroll event from the web UI.
pub fn handle_forwarded_pointer_scroll(&mut self, window_name: &str, dx: f64, dy: f64) {
use smithay::input::pointer::AxisFrame;
if self.find_wayland_window(window_name).is_none() {
return;
}
let pointer = match self.seat.get_pointer() {
Some(p) => p,
None => return,
};
let mut frame = AxisFrame::new(0).source(AxisSource::Wheel);
if dx != 0.0 {
frame = frame.value(Axis::Horizontal, dx);
}
if dy != 0.0 {
frame = frame.value(Axis::Vertical, dy);
}
pointer.axis(self, frame);
pointer.frame(self);
}
/// Forward relative motion received from the web UI to the Wayland surface.
pub fn handle_forwarded_relative_pointer_event(&mut self, window_name: &str, dx: f64, dy: f64) {
use smithay::input::pointer::RelativeMotionEvent;
let window = match self.find_wayland_window(window_name) {
Some(w) => w,
None => return,
};
let surface = window
.toplevel()
.map(|t| t.wl_surface().clone())
.or_else(|| window.x11_surface().and_then(|x| x.wl_surface().clone()));
if let Some(surface) = surface {
if let Some(pointer) = self.seat.get_pointer() {
let event = RelativeMotionEvent {
utime: 0,
delta: (dx, dy).into(),
delta_unaccel: (dx, dy).into(),
};
pointer.relative_motion(self, Some((surface, (0.0, 0.0).into())), &event);
pointer.frame(self);
}
}
}
}

7
src/handlers/mod.rs Normal file
View file

@ -0,0 +1,7 @@
pub mod compositor;
pub mod dmabuf_handler;
pub mod input;
pub mod output;
pub mod xdg_decoration;
pub mod xdg_shell;
pub mod xwayland;

47
src/handlers/output.rs Normal file
View file

@ -0,0 +1,47 @@
use smithay::{
delegate_output,
output::{Output, PhysicalProperties, Subpixel},
utils::Transform,
wayland::output::OutputHandler,
};
use crate::state::WoState;
impl OutputHandler for WoState {}
delegate_output!(WoState);
impl WoState {
/// Create and register a single logical output with Smithay.
pub fn create_output(
width: i32,
height: i32,
refresh_mhz: i32,
display: &smithay::reexports::wayland_server::DisplayHandle,
) -> Output {
let output = Output::new(
"wo-output".to_string(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "Wo".into(),
model: "Virtual".into(),
},
);
let mode = smithay::output::Mode {
size: (width, height).into(),
refresh: refresh_mhz,
};
output.change_current_state(
Some(mode),
Some(Transform::Normal),
Some(smithay::output::Scale::Integer(1)),
Some((0, 0).into()),
);
output.set_preferred(mode);
output.create_global::<WoState>(display);
output
}
}

View file

@ -0,0 +1,68 @@
use smithay::{
delegate_xdg_decoration,
reexports::wayland_server::Resource,
wayland::shell::xdg::{
ToplevelSurface,
decoration::XdgDecorationHandler,
},
};
use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode;
use crate::state::WoState;
impl XdgDecorationHandler for WoState {
fn new_decoration(&mut self, toplevel: ToplevelSurface) {
tracing::info!("new_decoration: client attached xdg_decoration");
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
let suggest_w = (ow * 2 / 3).max(640).min(1600);
let suggest_h = (oh * 2 / 3).max(480).min(1000);
toplevel.with_pending_state(|state| {
state.size = Some((suggest_w, suggest_h).into());
});
}
fn request_mode(&mut self, toplevel: ToplevelSurface, mode: Mode) {
tracing::info!("request_mode: client requests {:?}", mode);
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
let suggest_w = (ow * 2 / 3).max(640).min(1600);
let suggest_h = (oh * 2 / 3).max(480).min(1000);
toplevel.with_pending_state(|state| {
state.decoration_mode = Some(mode);
if state.size.is_none() {
state.size = Some((suggest_w, suggest_h).into());
}
});
match mode {
Mode::ServerSide => {
self.ssd_windows.insert(toplevel.wl_surface().id());
}
_ => {
self.ssd_windows.remove(&toplevel.wl_surface().id());
}
}
toplevel.send_configure();
}
fn unset_mode(&mut self, toplevel: ToplevelSurface) {
tracing::info!("unset_mode: reverting to default client-side decorations");
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
let suggest_w = (ow * 2 / 3).max(640).min(1600);
let suggest_h = (oh * 2 / 3).max(480).min(1000);
toplevel.with_pending_state(|state| {
state.decoration_mode = None;
if state.size.is_none() {
state.size = Some((suggest_w, suggest_h).into());
}
});
self.ssd_windows.remove(&toplevel.wl_surface().id());
toplevel.send_configure();
}
}
delegate_xdg_decoration!(WoState);

377
src/handlers/xdg_shell.rs Normal file
View file

@ -0,0 +1,377 @@
use smithay::{
delegate_xdg_shell,
desktop::{PopupManager, Space, Window},
reexports::wayland_server::{protocol::{wl_output, wl_seat}, Resource},
utils::Serial,
wayland::{
compositor::with_states,
shell::xdg::{
PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState,
XdgToplevelSurfaceData,
},
},
};
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge;
use crate::state::WoState;
/// Height of the comraw top panel in pixels.
pub const PANEL_H: i32 = 32;
/// Height of the comraw MacWindow title bar in pixels.
/// Only applied to windows that negotiated SSD via xdg-decoration.
/// CSD windows (GTK4/libadwaita/Firefox) draw their own decorations.
pub const TITLE_H: i32 = 30;
impl XdgShellHandler for WoState {
fn xdg_shell_state(&mut self) -> &mut XdgShellState {
&mut self.xdg_shell_state
}
fn new_toplevel(&mut self, surface: ToplevelSurface) {
tracing::info!("new_toplevel ENTER");
let app_id = with_states(surface.wl_surface(), |states| {
states.data_map
.get::<XdgToplevelSurfaceData>()
.and_then(|d| d.lock().ok().and_then(|data| data.app_id.clone()))
});
tracing::info!(app_id = ?app_id, "new_toplevel: native Wayland window created");
self.debug_new_toplevel = true;
self.metadata_dirty = true;
let oid = surface.wl_surface().id();
let stable_name = format!("wayland-{}", self.next_wayland_id);
self.next_wayland_id += 1;
self.wayland_window_names.insert(oid.clone(), stable_name.clone());
self.wayland_name_to_id.insert(stable_name.clone(), oid.clone());
tracing::info!(name = %stable_name, "assigned stable window name");
// Mark this Wayland window as mapped/visible for metadata and frontend
self.window_mapped.insert(stable_name.clone(), true);
self.ssd_windows.remove(&oid);
let window = Window::new(surface);
self.all_windows.push(window.clone());
let parent_surface = window.toplevel().and_then(|t| {
with_states(t.wl_surface(), |states| {
states
.data_map
.get::<XdgToplevelSurfaceData>()
.and_then(|d| d.lock().ok().and_then(|data| data.parent.clone()))
})
});
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
let location = if let Some(ref parent_wl) = parent_surface {
let parent_center = self
.space
.elements()
.find(|w| {
w.toplevel()
.map(|t| t.wl_surface() == parent_wl)
.unwrap_or(false)
})
.map(|parent_win| {
let loc = self.space.element_location(parent_win).unwrap_or_default();
let geo = parent_win.geometry();
(loc.x + geo.size.w / 2, loc.y + geo.size.h / 2)
})
.unwrap_or_else(|| (ow / 2, oh / 2));
let dialog_y = (parent_center.1 - 75).max(PANEL_H);
(parent_center.0 - 200, dialog_y)
} else {
let suggest_w = (ow * 2 / 3).max(640).min(1600);
let suggest_h = (oh * 2 / 3).max(480).min(1000);
let center_y = ((oh - suggest_h) / 2).max(PANEL_H + 20);
((ow - suggest_w) / 2, center_y)
};
self.space.map_element(window.clone(), location, true);
// (clients that use xdg_decoration will get a sized configure later).
if let Some(toplevel) = window.toplevel() {
toplevel.send_configure();
// Give keyboard focus to newly mapped windows (unless they're dialogs with parents)
// This ensures games and apps receive keyboard input immediately
if parent_surface.is_none() {
let serial = smithay::utils::SERIAL_COUNTER.next_serial();
if let Some(keyboard) = self.seat.get_keyboard() {
keyboard.set_focus(self, Some(toplevel.wl_surface().clone()), serial);
}
}
}
tracing::info!("new_toplevel EXIT (mapped + configured)");
}
fn new_popup(&mut self, surface: PopupSurface, positioner: PositionerState) {
let mut geo = positioner.get_geometry();
// Constrain the popup to stay within the output bounds.
// We look up the parent window's screen position so we can compute the
// absolute popup rectangle and clamp it back into [0, output_size].
let parent_loc: Option<smithay::utils::Point<i32, smithay::utils::Logical>> =
smithay::wayland::compositor::with_states(surface.wl_surface(), |states| {
states.data_map
.get::<smithay::wayland::shell::xdg::XdgPopupSurfaceData>()
.and_then(|d| d.lock().ok().and_then(|locked| locked.parent.clone()))
})
.and_then(|parent_wl| {
self.space
.elements()
.find(|w| {
w.toplevel()
.map(|t| t.wl_surface() == &parent_wl)
.unwrap_or(false)
})
.and_then(|w| self.space.element_location(w))
});
if let Some(parent_loc) = parent_loc {
let abs_x = parent_loc.x + geo.loc.x;
let abs_y = parent_loc.y + geo.loc.y;
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
let clamped_abs_x = abs_x.clamp(0, (ow - geo.size.w).max(0));
let clamped_abs_y = abs_y.clamp(0, (oh - geo.size.h).max(0));
geo.loc.x = clamped_abs_x - parent_loc.x;
geo.loc.y = clamped_abs_y - parent_loc.y;
}
surface.with_pending_state(|s| {
s.geometry = geo;
});
// XDG shell protocol requires the compositor to send an initial configure
// immediately after xdg_popup is created, before any surface commit.
// Without this, clients that wait for configure before committing will hang.
if let Err(e) = surface.send_configure() {
tracing::warn!("failed to send initial popup configure: {e}");
}
if let Err(e) = self.popup_manager.track_popup(smithay::desktop::PopupKind::Xdg(surface)) {
tracing::warn!("failed tracking popup: {e}");
}
}
fn reposition_request(
&mut self,
surface: PopupSurface,
positioner: PositionerState,
token: u32,
) {
surface.with_pending_state(|s| {
s.geometry = positioner.get_geometry();
s.positioner = positioner;
});
surface.send_repositioned(token);
}
fn move_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial) {
// Find the window element for this surface and start tracking a move.
let window = self.space.elements()
.find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false))
.cloned();
if let Some(window) = window {
let initial_loc = self.space.element_location(&window).unwrap_or_default();
self.grab_state = Some(GrabState::Move {
window,
initial_window_location: initial_loc,
initial_pointer: self.pointer_location,
});
}
}
fn resize_request(
&mut self,
surface: ToplevelSurface,
_seat: wl_seat::WlSeat,
_serial: Serial,
edges: ResizeEdge,
) {
let window = self.space.elements()
.find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false))
.cloned();
if let Some(window) = window {
let initial_loc = self.space.element_location(&window).unwrap_or_default();
let geo = window.geometry();
self.grab_state = Some(GrabState::Resize {
surface,
window,
edges,
initial_window_location: initial_loc,
initial_window_size: (geo.size.w, geo.size.h),
initial_pointer: self.pointer_location,
});
}
}
fn grab(&mut self, _surface: PopupSurface, _seat: wl_seat::WlSeat, _serial: Serial) { }
fn fullscreen_request(&mut self, surface: ToplevelSurface, _output: Option<wl_output::WlOutput>) {
let window = self.space.elements()
.find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false))
.cloned();
if let Some(window) = window {
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as XdgState;
let th = self.title_h_for_window(&window);
let y_offset = th.max(PANEL_H);
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
let content_h = (oh - y_offset).max(1);
surface.with_pending_state(|state| {
state.states.set(XdgState::Fullscreen);
state.states.unset(XdgState::Maximized);
state.size = Some((ow, content_h).into());
});
surface.send_configure();
self.space.map_element(window.clone(), (0, y_offset), false);
self.metadata_dirty = true;
// Automatically give keyboard focus to fullscreen windows (important for games)
let serial = smithay::utils::SERIAL_COUNTER.next_serial();
if let Some(keyboard) = self.seat.get_keyboard() {
keyboard.set_focus(self, Some(surface.wl_surface().clone()), serial);
}
}
}
fn unfullscreen_request(&mut self, surface: ToplevelSurface) {
let window = self.space.elements()
.find(|w| w.toplevel().map(|t| t == &surface).unwrap_or(false))
.cloned();
if let Some(window) = window {
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as XdgState;
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
let suggest_w = (ow * 2 / 3).max(640).min(1600);
let suggest_h = (oh * 2 / 3).max(480).min(1000);
surface.with_pending_state(|state| {
state.states.unset(XdgState::Fullscreen);
if state.size.is_none() {
state.size = Some((suggest_w, suggest_h).into());
}
});
surface.send_configure();
self.metadata_dirty = true;
}
}
fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
tracing::info!("toplevel_destroyed called");
self.metadata_dirty = true;
// Clean up stable window name mappings.
let oid = surface.wl_surface().id();
if let Some(name) = self.wayland_window_names.remove(&oid) {
self.wayland_name_to_id.remove(&name);
if self.keyboard_window_focus.as_deref() == Some(&name) {
self.keyboard_window_focus = None;
}
if self.pointer_window_focus.as_deref() == Some(&name) {
self.pointer_window_focus = None;
}
tracing::info!(name = %name, "removed stable window name");
}
self.ssd_windows.remove(&oid);
if let Some(keyboard) = self.seat.get_keyboard() {
if keyboard.current_focus().as_ref() == Some(surface.wl_surface()) {
let serial = smithay::utils::SERIAL_COUNTER.next_serial();
keyboard.set_focus(self, None, serial);
}
}
let to_remove = self
.all_windows
.iter()
.find(|w| {
w.toplevel()
.map(|t| t == &surface)
.unwrap_or(false)
})
.cloned();
if let Some(window) = to_remove {
self.all_windows.retain(|w| w != &window);
self.space.unmap_elem(&window);
self.refocus_topmost_native_window();
}
}
}
delegate_xdg_shell!(WoState);
/// Active grab state for interactive move/resize of Wayland client windows.
#[derive(Debug, Clone)]
pub enum GrabState {
Move {
window: Window,
initial_window_location: smithay::utils::Point<i32, smithay::utils::Logical>,
initial_pointer: smithay::utils::Point<f64, smithay::utils::Logical>,
},
Resize {
surface: ToplevelSurface,
window: Window,
edges: ResizeEdge,
initial_window_location: smithay::utils::Point<i32, smithay::utils::Logical>,
initial_window_size: (i32, i32),
initial_pointer: smithay::utils::Point<f64, smithay::utils::Logical>,
},
}
/// Send the initial configure to a surface if it hasn't received one yet.
pub fn ensure_initial_configure(
surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
space: &Space<Window>,
popups: &mut PopupManager,
) {
if let Some(window) = space
.elements()
.find(|w| {
w.toplevel()
.map(|t| t.wl_surface() == surface)
.unwrap_or(false)
})
.cloned()
{
if let Some(toplevel) = window.toplevel() {
let initial_configure_sent = with_states(surface, |states| {
states
.data_map
.get::<XdgToplevelSurfaceData>()
.map(|d| d.lock().unwrap().initial_configure_sent)
.unwrap_or(false)
});
if !initial_configure_sent {
toplevel.send_configure();
}
}
return;
}
// Handle popups.
if let Some(popup) = popups.find_popup(surface) {
if let smithay::desktop::PopupKind::Xdg(ref xdg) = popup {
let initial_configure_sent = with_states(surface, |states| {
states
.data_map
.get::<smithay::wayland::shell::xdg::XdgPopupSurfaceData>()
.map(|d| d.lock().unwrap().initial_configure_sent)
.unwrap_or(false)
});
if !initial_configure_sent {
xdg.send_configure().ok();
}
}
}
}

331
src/handlers/xwayland.rs Normal file
View file

@ -0,0 +1,331 @@
//! XWayland window manager handler implementation for the Wo compositor.
//!
//! Implements [`XwmHandler`] and [`XWaylandShellHandler`] so that X11 clients
//! running under XWayland can be managed by the compositor.
use smithay::{
desktop::Window,
utils::{Logical, Rectangle},
wayland::xwayland_shell::{XWaylandShellHandler, XWaylandShellState},
xwayland::xwm::{
Reorder, ResizeEdge as X11ResizeEdge, X11Surface, X11Wm, XwmHandler, XwmId,
},
};
use smithay::xwayland::xwm::X11Window;
use smithay::reexports::wayland_server::Resource;
use tracing::{info, warn};
use crate::state::WoState;
use crate::handlers::xdg_shell::PANEL_H;
impl XwmHandler for WoState {
fn xwm_state(&mut self, _xwm: XwmId) -> &mut X11Wm {
self.xwm.as_mut().expect("xwm not initialized")
}
fn new_window(&mut self, _xwm: XwmId, window: X11Surface) {
info!(
window_id = window.window_id(),
"XWayland: new X11 window created"
);
}
fn new_override_redirect_window(&mut self, _xwm: XwmId, window: X11Surface) {
info!(
window_id = window.window_id(),
"XWayland: new override-redirect window"
);
}
fn map_window_request(&mut self, _xwm: XwmId, window: X11Surface) {
info!(
window_id = window.window_id(),
title = %window.title(),
class = %window.class(),
"XWayland: map window request"
);
if let Err(e) = window.set_mapped(true) {
warn!("XWayland: failed to set_mapped(true): {e}");
return;
}
let stable_name = format!("x11-{}", window.window_id());
self.x11_window_names.insert(window.window_id(), stable_name.clone());
let parent_x11_id = window.is_transient_for();
let geo = window.geometry();
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
let location = if let Some(parent_id) = parent_x11_id {
info!(
window_id = window.window_id(),
parent_id = parent_id,
"XWayland: window is transient-for (dialog)"
);
let parent_center = self
.space
.elements()
.find(|w| {
w.x11_surface()
.map(|x| x.window_id() == parent_id)
.unwrap_or(false)
})
.and_then(|parent_win| {
let loc = self.space.element_location(parent_win)?;
let geo = parent_win.geometry();
Some((loc.x + geo.size.w / 2, loc.y + geo.size.h / 2))
})
.unwrap_or_else(|| (ow / 2, oh / 2));
let dialog_y = (parent_center.1 - 75).max(PANEL_H);
(parent_center.0 - 200, dialog_y)
} else {
let adjusted_y = geo.loc.y.max(PANEL_H);
(geo.loc.x, adjusted_y)
};
let win = Window::new_x11_window(window.clone());
self.all_windows.push(win.clone());
self.space.map_element(win, location, true);
self.metadata_dirty = true;
if parent_x11_id.is_none() {
if let Some(wl_surface) = window.wl_surface() {
let serial = smithay::utils::SERIAL_COUNTER.next_serial();
if let Some(keyboard) = self.seat.get_keyboard() {
keyboard.set_focus(self, Some(wl_surface), serial);
}
}
if let Some(name) = self.x11_window_names.get(&window.window_id()).cloned() {
self.keyboard_window_focus = Some(name.clone());
self.pointer_window_focus = Some(name);
}
}
}
fn mapped_override_redirect_window(&mut self, _xwm: XwmId, window: X11Surface) {
info!(
window_id = window.window_id(),
"XWayland: override-redirect window mapped"
);
let geo = window.geometry();
let adjusted_y = geo.loc.y.max(PANEL_H);
let win = Window::new_x11_window(window);
self.all_windows.push(win.clone());
self.space.map_element(win, (geo.loc.x, adjusted_y), false);
self.metadata_dirty = true;
}
fn unmapped_window(&mut self, _xwm: XwmId, window: X11Surface) {
info!(
window_id = window.window_id(),
"XWayland: window unmapped"
);
if let Some(name) = self.x11_window_names.get(&window.window_id()).cloned() {
if self.keyboard_window_focus.as_deref() == Some(&name) {
self.keyboard_window_focus = None;
}
if self.pointer_window_focus.as_deref() == Some(&name) {
self.pointer_window_focus = None;
}
}
if let Some(wl_surface) = window.wl_surface() {
if let Some(keyboard) = self.seat.get_keyboard() {
if keyboard.current_focus().as_ref() == Some(&wl_surface) {
let serial = smithay::utils::SERIAL_COUNTER.next_serial();
keyboard.set_focus(self, None, serial);
}
}
}
let to_remove = self.space.elements().find(|w| {
w.x11_surface()
.map(|x| x.window_id() == window.window_id())
.unwrap_or(false)
}).cloned();
if let Some(w) = to_remove {
self.space.unmap_elem(&w);
self.all_windows.retain(|win| win != &w);
self.refocus_topmost_native_window();
}
self.metadata_dirty = true;
}
fn destroyed_window(&mut self, _xwm: XwmId, window: X11Surface) {
info!(
window_id = window.window_id(),
"XWayland: window destroyed"
);
if let Some(name) = self.x11_window_names.get(&window.window_id()).cloned() {
if self.keyboard_window_focus.as_deref() == Some(&name) {
self.keyboard_window_focus = None;
}
if self.pointer_window_focus.as_deref() == Some(&name) {
self.pointer_window_focus = None;
}
}
if let Some(wl_surface) = window.wl_surface() {
if let Some(keyboard) = self.seat.get_keyboard() {
if keyboard.current_focus().as_ref() == Some(&wl_surface) {
let serial = smithay::utils::SERIAL_COUNTER.next_serial();
keyboard.set_focus(self, None, serial);
}
}
}
self.x11_window_names.remove(&window.window_id());
let to_remove = self.space.elements().find(|w| {
w.x11_surface()
.map(|x| x.window_id() == window.window_id())
.unwrap_or(false)
}).cloned();
if let Some(w) = to_remove {
self.space.unmap_elem(&w);
self.all_windows.retain(|win| win != &w);
self.refocus_topmost_native_window();
}
self.metadata_dirty = true;
}
fn configure_request(
&mut self,
_xwm: XwmId,
window: X11Surface,
_x: Option<i32>,
_y: Option<i32>,
_w: Option<u32>,
_h: Option<u32>,
_reorder: Option<Reorder>,
) {
if let Err(e) = window.configure(None) {
warn!("XWayland: configure failed: {e}");
}
}
fn configure_notify(
&mut self,
_xwm: XwmId,
_window: X11Surface,
_geometry: Rectangle<i32, Logical>,
_above: Option<X11Window>,
) {}
fn resize_request(
&mut self,
_xwm: XwmId,
_window: X11Surface,
_button: u32,
_resize_edge: X11ResizeEdge,
) {}
fn move_request(&mut self, _xwm: XwmId, _window: X11Surface, _button: u32) {}
fn fullscreen_request(&mut self, _xwm: XwmId, window: X11Surface) {
info!(
window_id = window.window_id(),
"XWayland: fullscreen request"
);
if let Err(e) = window.set_fullscreen(true) {
warn!("XWayland: failed to set fullscreen: {e}");
return;
}
let th = if let Some(wl_surface) = window.wl_surface() {
if self.ssd_windows.contains(&wl_surface.id()) {
30
} else {
0
}
} else {
0
};
let y_offset = th.max(PANEL_H);
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
let content_h = (oh - y_offset).max(1);
if let Err(e) = window.configure(Some(Rectangle::from_loc_and_size((0, y_offset), (ow, content_h)))) {
warn!("XWayland: fullscreen configure failed: {e}");
}
let win = self.space.elements().find(|w| {
w.x11_surface()
.map(|x| x.window_id() == window.window_id())
.unwrap_or(false)
}).cloned();
if let Some(w) = win {
self.space.map_element(w, (0, y_offset), false);
}
// Automatically give keyboard focus to fullscreen windows (important for games)
if let Some(wl_surface) = window.wl_surface() {
let serial = smithay::utils::SERIAL_COUNTER.next_serial();
if let Some(keyboard) = self.seat.get_keyboard() {
keyboard.set_focus(self, Some(wl_surface.clone()), serial);
}
}
if let Some(name) = self.x11_window_names.get(&window.window_id()).cloned() {
self.keyboard_window_focus = Some(name.clone());
self.pointer_window_focus = Some(name);
}
self.metadata_dirty = true;
}
fn unfullscreen_request(&mut self, _xwm: XwmId, window: X11Surface) {
info!(
window_id = window.window_id(),
"XWayland: unfullscreen request"
);
if let Err(e) = window.set_fullscreen(false) {
warn!("XWayland: failed to unset fullscreen: {e}");
return;
}
let (ow, oh) = (self.output_size.0 as i32, self.output_size.1 as i32);
let suggest_w = (ow * 2 / 3).max(640).min(1600);
let suggest_h = (oh * 2 / 3).max(480).min(1000);
if let Err(e) = window.configure(Some(Rectangle::from_loc_and_size((0, 0), (suggest_w, suggest_h)))) {
warn!("XWayland: unfullscreen configure failed: {e}");
}
self.metadata_dirty = true;
}
}
impl XWaylandShellHandler for WoState {
fn xwayland_shell_state(&mut self) -> &mut XWaylandShellState {
self.xwayland_shell_state
.as_mut()
.expect("xwayland_shell_state not initialized")
}
fn surface_associated(
&mut self,
_xwm: XwmId,
wl_surface: smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
window: X11Surface,
) {
info!(
window_id = window.window_id(),
wl_surface = ?wl_surface.id(),
"XWayland: X11 window associated with wl_surface"
);
}
}
smithay::delegate_xwayland_shell!(WoState);

8
src/lib.rs Normal file
View file

@ -0,0 +1,8 @@
//! Wo Wayland compositor library
pub mod config;
pub mod dmabuf;
pub mod electron;
pub mod handlers;
pub mod state;
pub mod syscall;

1604
src/main.rs Normal file

File diff suppressed because it is too large Load diff

1738
src/nested.rs Normal file

File diff suppressed because it is too large Load diff

626
src/state.rs Normal file
View file

@ -0,0 +1,626 @@
use smithay::{
backend::{drm::DrmNode, renderer::{ImportDma, gles::GlesRenderer}},
desktop::{PopupManager, Space, Window},
input::{Seat, SeatHandler, SeatState, pointer::PointerHandle},
output::Output,
reexports::wayland_server::{
Display, DisplayHandle, Resource, backend::{ClientData as WlClientData, ClientId, DisconnectReason, ObjectId}, protocol::wl_surface::WlSurface
},
utils::{Logical, Point, SERIAL_COUNTER},
wayland::{
compositor::{CompositorClientState, CompositorState, with_states}, cursor_shape::CursorShapeManagerState, dmabuf::{DmabufFeedback, DmabufFeedbackBuilder, DmabufState}, fractional_scale::{
self as fractional_scale_mod,
FractionalScaleHandler, FractionalScaleManagerState,
}, output::OutputManagerState, selection::{
SelectionHandler, data_device::{
ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler, set_data_device_focus
}
},
relative_pointer::RelativePointerManagerState,
pointer_constraints::{self, PointerConstraintsHandler, PointerConstraintsState},
shell::xdg::{XdgShellState, decoration::XdgDecorationState}, shm::ShmState, single_pixel_buffer::SinglePixelBufferState, tablet_manager::TabletSeatHandler, viewporter::ViewporterState, xdg_activation::{XdgActivationHandler, XdgActivationState, XdgActivationToken, XdgActivationTokenData}, xwayland_shell::XWaylandShellState
},
xwayland::{XWayland, xwm::X11Wm},
};
use smithay::reexports::wayland_protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::TrancheFlags;
pub mod portal;
use crate::{
config::Config,
dmabuf::TextureCache,
electron::{ElectronIpc, ElectronProcess},
syscall::SyscallHandler,
};
use portal::WoPortal;
use smithay::backend::allocator::dmabuf::Dmabuf;
#[derive(Default)]
pub struct ClientData {
pub compositor_state: CompositorClientState,
}
impl WlClientData for ClientData {
fn initialized(&self, _client_id: ClientId) {}
fn disconnected(&self, _client_id: ClientId, _reason: DisconnectReason) {}
}
pub struct BackendData {
pub renderer: Option<std::sync::Arc<std::sync::Mutex<GlesRenderer>>>,
pub output: Output,
pub size: (u32, u32),
pub dmabuf_formats: Option<Vec<drm_fourcc::DrmFormat>>,
}
impl BackendData {
pub fn renderer(&self) -> Option<std::sync::Arc<std::sync::Mutex<GlesRenderer>>> {
self.renderer.clone()
}
}
pub struct WoState {
pub display_handle: DisplayHandle,
pub compositor_state: CompositorState,
pub xdg_shell_state: XdgShellState,
pub xdg_decoration_state: XdgDecorationState,
pub shm_state: ShmState,
pub dmabuf_state: DmabufState,
// pub dmabuf_global: DmabufGlobal,
pub dmabuf_default_feedback: DmabufFeedback,
pub dmabuf_scanout_feedback: DmabufFeedback,
pub seat_state: SeatState<Self>,
pub data_device_state: DataDeviceState,
pub popup_manager: PopupManager,
pub viewporter_state: ViewporterState,
pub xdg_activation_state: XdgActivationState,
pub single_pixel_buffer_state: SinglePixelBufferState,
pub fractional_scale_manager_state: FractionalScaleManagerState,
pub cursor_shape_manager_state: CursorShapeManagerState,
pub relative_pointer_manager_state: RelativePointerManagerState,
pub pointer_constraints_state: PointerConstraintsState,
pub output_manager: OutputManagerState,
pub seat: Seat<Self>,
pub pointer_location: Point<f64, Logical>,
pub cursor_status: smithay::input::pointer::CursorImageStatus,
pub output_size: (u32, u32),
pub pointer_window_focus: Option<String>,
pub keyboard_window_focus: Option<String>,
pub space: Space<Window>,
pub output: Output,
pub electron_processes: Vec<ElectronProcess>,
pub texture_cache: TextureCache,
pub window_positions: std::collections::HashMap<String, (i32, i32, u32, u32)>,
pub electron_ipc: Option<ElectronIpc>,
pub syscall_handler: Option<SyscallHandler>,
pub portal: Option<std::sync::Arc<WoPortal>>,
/// Mapping state for named Electron windows. If false, the window should
/// not be rendered (unmapped/minimized).
pub window_mapped: std::collections::HashMap<String, bool>,
/// Damage tracking: which window textures have been updated and need rendering
pub damaged_windows: std::collections::HashSet<String>,
/// Dirty flag for window metadata; set when windows appear/close/move/resize
pub metadata_dirty: bool,
/// Wayland surfaces that committed a new buffer and need offscreen capture.
pub dirty_surfaces: std::collections::HashSet<WlSurface>,
/// Persistent SHM file descriptors (memfds) for each window.
pub window_shm_buffers: std::collections::HashMap<String, std::os::unix::io::OwnedFd>,
/// Buffered pixel exports from offscreen rendering, written to SHM after render block.
pub pending_shm_exports: Vec<(String, u32, u32, u32, Vec<u8>)>,
pub grab_state: Option<crate::handlers::xdg_shell::GrabState>,
/// Set to true inside new_toplevel to enable checkpoint logging on the
/// same main-loop tick where the window appears.
pub debug_new_toplevel: bool,
/// Stable name mapping for Wayland windows (surface ObjectId → "wayland-N").
pub wayland_window_names: std::collections::HashMap<ObjectId, String>,
/// Reverse lookup: stable name → surface ObjectId for action dispatch.
pub wayland_name_to_id: std::collections::HashMap<String, ObjectId>,
/// Monotonic counter for assigning stable Wayland window IDs.
pub next_wayland_id: u32,
/// Surfaces that negotiated SSD via the xdg-decoration protocol.
/// For these windows comraw renders its own title bar; for others (CSD)
/// the client draws its own decorations and no extra title offset is added.
pub ssd_windows: std::collections::HashSet<ObjectId>,
/// Stable name mapping for X11 (XWayland) windows (X11 window_id → "x11-N").
pub x11_window_names: std::collections::HashMap<u32, String>,
/// XWayland instance handle (if running).
pub xwayland: Option<XWayland>,
/// X11 window manager attached to the XWayland instance.
pub xwm: Option<X11Wm>,
/// XWayland shell protocol state for associating X11 windows with wl_surfaces.
pub xwayland_shell_state: Option<XWaylandShellState>,
pub all_windows: Vec<Window>,
pub backend: &'static BackendData,
pub config: Config,
pub running: bool,
pub can_switch_vt: bool,
pub pending_vt_switch: Option<u32>,
}
impl WoState {
pub fn new(
display: &mut Display<Self>,
config: Config,
render_node: DrmNode,
backend: BackendData,
) -> Self {
let dh = display.handle();
let compositor_state = CompositorState::new::<Self>(&dh);
let xdg_shell_state = XdgShellState::new::<Self>(&dh);
let xdg_decoration_state = XdgDecorationState::new::<Self>(&dh);
let relative_pointer_manager_state = RelativePointerManagerState::new::<Self>(&dh);
let pointer_constraints_state = PointerConstraintsState::new::<Self>(&dh);
let shm_state = ShmState::new::<Self>(&dh, vec![]);
let viewporter_state = ViewporterState::new::<Self>(&dh);
let xdg_activation_state = XdgActivationState::new::<Self>(&dh);
let single_pixel_buffer_state = SinglePixelBufferState::new::<Self>(&dh);
let fractional_scale_manager_state = FractionalScaleManagerState::new::<Self>(&dh);
let cursor_shape_manager_state = CursorShapeManagerState::new::<Self>(&dh);
// Register zxdg_output_manager_v1 so clients like GTK4 can get logical
// output dimensions via the XDG output protocol.
let output_manager = OutputManagerState::new_with_xdg_output::<Self>(&dh);
let output_size = backend.size;
let output = backend.output.clone();
let mut seat_state = SeatState::new();
let mut seat: Seat<Self> = seat_state.new_wl_seat(&dh, "wo");
seat.add_keyboard(Default::default(), 200, 25)
.expect("keyboard");
seat.add_pointer();
// Initialize syscall handler if enabled
let syscall_handler = if config.compositor.enable_syscalls {
let apps: Vec<serde_json::Value> = config
.compositor
.applications
.iter()
.map(|app| app.to_json())
.collect();
Some(SyscallHandler::new(true, false).with_applications(apps))
} else {
None
};
// Initialize portal if enabled
let portal = if config.compositor.enable_portal {
Some(std::sync::Arc::new(WoPortal::new()))
} else {
None
};
let data_device_state = DataDeviceState::new::<Self>(&dh);
let dmabuf_state = DmabufState::new();
// Get DMABUF formats from renderer if available
let (default_feedback, scanout_feedback) = if let Some(renderer_arc) = &backend.renderer {
let renderer_guard = renderer_arc.lock().expect("renderer mutex poisoned");
let dmabuf_formats = renderer_guard.dmabuf_formats();
let default_fb =
DmabufFeedbackBuilder::new(render_node.dev_id(), dmabuf_formats.clone())
.build()
.expect("building default dmabuf feedback");
let scanout_fb =
DmabufFeedbackBuilder::new(render_node.dev_id(), dmabuf_formats.clone())
.add_preference_tranche(
render_node.dev_id(),
Some(TrancheFlags::Scanout),
dmabuf_formats,
)
.build()
.expect("building scanout dmabuf feedback");
(default_fb, scanout_fb)
} else if let Some(ref formats) = backend.dmabuf_formats {
// For nested mode with pre-collected formats
let default_fb = DmabufFeedbackBuilder::new(render_node.dev_id(), formats.clone())
.build()
.expect("building default dmabuf feedback");
let scanout_fb = DmabufFeedbackBuilder::new(render_node.dev_id(), formats.clone())
.add_preference_tranche(
render_node.dev_id(),
Some(TrancheFlags::Scanout),
formats.clone(),
)
.build()
.expect("building scanout dmabuf feedback");
(default_fb, scanout_fb)
} else {
// Fallback: create empty DMABUF feedbacks
let default_fb = DmabufFeedbackBuilder::new(render_node.dev_id(), vec![])
.build()
.expect("building empty default dmabuf feedback");
let scanout_fb = DmabufFeedbackBuilder::new(render_node.dev_id(), vec![])
.build()
.expect("building empty scanout dmabuf feedback");
(default_fb, scanout_fb)
};
// Disable DMABUF advertising to force clients to use SHM for the pixel export pipeline.
/*
let dmabuf_global = dmabuf_state.create_global_with_default_feedback::<Self>(
&dh,
&default_feedback,
);
*/
let popup_manager = PopupManager::default();
let space = Space::default();
let mut state = Self {
display_handle: dh,
compositor_state,
xdg_shell_state,
xdg_decoration_state,
shm_state,
dmabuf_state,
// dmabuf_global,
dmabuf_default_feedback: default_feedback,
dmabuf_scanout_feedback: scanout_feedback,
seat_state,
data_device_state,
popup_manager,
viewporter_state,
xdg_activation_state,
single_pixel_buffer_state,
fractional_scale_manager_state,
cursor_shape_manager_state,
relative_pointer_manager_state,
pointer_constraints_state,
output_manager,
seat,
pointer_location: (0.0, 0.0).into(),
cursor_status: smithay::input::pointer::CursorImageStatus::default_named(),
pointer_window_focus: None,
keyboard_window_focus: None,
output_size,
space,
output,
electron_processes: vec![],
texture_cache: TextureCache::default(),
window_positions: std::collections::HashMap::new(),
electron_ipc: None,
syscall_handler,
portal,
xwayland: None,
xwm: None,
xwayland_shell_state: None,
all_windows: Vec::new(),
backend: Box::leak(Box::new(backend)),
config,
running: true,
window_mapped: std::collections::HashMap::new(),
damaged_windows: std::collections::HashSet::new(),
metadata_dirty: true,
dirty_surfaces: std::collections::HashSet::new(),
window_shm_buffers: std::collections::HashMap::new(),
pending_shm_exports: Vec::new(),
grab_state: None,
debug_new_toplevel: false,
wayland_window_names: std::collections::HashMap::new(),
wayland_name_to_id: std::collections::HashMap::new(),
next_wayland_id: 0,
ssd_windows: std::collections::HashSet::new(),
x11_window_names: std::collections::HashMap::new(),
can_switch_vt: false,
pending_vt_switch: None,
};
// Map the output into the space at origin.
state.space.map_output(&state.output, (0, 0));
// Initialize mapping state for configured windows as mapped (visible).
for win in &state.config.windows {
state.window_mapped.insert(win.name.clone(), true);
}
state
}
/// Return the effective title-bar height for a Wayland window.
/// SSD windows get TITLE_H (comraw draws the title bar); CSD windows get 0
/// (the client draws its own decorations).
pub fn title_h_for_surface(&self, surface: &WlSurface) -> i32 {
if self.ssd_windows.contains(&surface.id()) {
crate::handlers::xdg_shell::TITLE_H
} else {
0
}
}
/// Return the effective title-bar height for a Window element.
/// X11 windows always use SSD (comraw draws the chrome).
pub fn title_h_for_window(&self, window: &Window) -> i32 {
if window.x11_surface().is_some() {
return crate::handlers::xdg_shell::TITLE_H;
}
window
.toplevel()
.map(|t| self.title_h_for_surface(t.wl_surface()))
.unwrap_or(0)
}
/// Clamp a requested size to the client's min/max size hints.
/// Returns `(width, height)` clamped to the XDG toplevel's declared
/// min_size / max_size constraints. A constraint component of 0 means
/// "no limit" per the xdg-shell protocol.
pub fn clamp_to_size_hints(&self, window: &Window, mut w: i32, mut h: i32) -> (i32, i32) {
if let Some(toplevel) = window.toplevel() {
use smithay::wayland::shell::xdg::SurfaceCachedState;
with_states(toplevel.wl_surface(), |states| {
let mut cached = states.cached_state.get::<SurfaceCachedState>();
let current = cached.current();
let min = current.min_size;
let max = current.max_size;
// min_size: (0,0) means no minimum
if min.w > 0 {
w = w.max(min.w);
}
if min.h > 0 {
h = h.max(min.h);
}
// max_size: (0,0) means no maximum
if max.w > 0 {
w = w.min(max.w);
}
if max.h > 0 {
h = h.min(max.h);
}
});
}
(w.max(1), h.max(1))
}
/// Return true if the given Wayland surface currently has keyboard focus.
pub fn surface_has_keyboard_focus(&self, surface: &WlSurface) -> bool {
self.seat
.get_keyboard()
.and_then(|kb| kb.current_focus())
.map(|focused| &focused == surface)
.unwrap_or(false)
}
/// Get the stable name for a window (Wayland or X11), if assigned.
pub fn wayland_window_name(&self, window: &Window) -> Option<&String> {
let surface_id = window.toplevel().map(|t| t.wl_surface().id());
if let Some(id) = surface_id {
if let Some(name) = self.wayland_window_names.get(&id) {
return Some(name);
}
}
if let Some(x11) = window.x11_surface() {
return self.x11_window_names.get(&x11.window_id());
}
None
}
/// Find a Wayland or X11 Window element by its stable name (even if minimized).
pub fn find_wayland_window(&self, name: &str) -> Option<Window> {
self.all_windows.iter().find(|w| {
if let Some(toplevel) = w.toplevel() {
if let Some(win_name) = self.wayland_window_names.get(&toplevel.wl_surface().id()) {
if win_name == name { return true; }
}
}
if let Some(x11) = w.x11_surface() {
if let Some(win_name) = self.x11_window_names.get(&x11.window_id()) {
if win_name == name { return true; }
}
}
false
}).cloned()
}
/// Focus the topmost mapped native window in `space`.
pub fn refocus_topmost_native_window(&mut self) {
let top = self.space.elements().next().cloned();
let Some(window) = top else {
if let Some(keyboard) = self.seat.get_keyboard() {
let serial = SERIAL_COUNTER.next_serial();
keyboard.set_focus(self, None, serial);
}
self.keyboard_window_focus = None;
self.pointer_window_focus = None;
return;
};
let surface = window
.toplevel()
.map(|t| t.wl_surface().clone())
.or_else(|| window.x11_surface().and_then(|x| x.wl_surface()));
if let Some(surface) = surface {
if let Some(keyboard) = self.seat.get_keyboard() {
let serial = SERIAL_COUNTER.next_serial();
keyboard.set_focus(self, Some(surface), serial);
}
if let Some(name) = self.wayland_window_name(&window).cloned() {
self.keyboard_window_focus = Some(name.clone());
self.pointer_window_focus = Some(name);
}
}
}
/// Forward input events from the compositor to Electron windows.
/// This is called by the nested compositor's event loop.
/// The actual forwarding is handled by process_input_event.
pub fn forward_input_to_electron<B: smithay::backend::input::InputBackend>(
&mut self,
event: &smithay::backend::input::InputEvent<B>,
) {
// The actual forwarding is done by process_input_event which is called separately
// This is a no-op wrapper that exists for API compatibility with nested.rs
// All input forwarding is handled through the individual input event handlers
// (on_keyboard, on_pointer_motion, on_pointer_button, etc.)
}
/// Return an exported DMABUF for the most-recent texture of a named
/// Electron window, if available. Callers can use this to create a
/// Wayland `wl_buffer` via `DmabufState` APIs for zero-copy sharing.
pub fn exported_dmabuf_for_window(&self, name: &str) -> Option<Dmabuf> {
self.texture_cache
.get_dmabuf(name)
.map(|t| t.dmabuf.clone())
}
/// Return the Wayland surface (and local pointer position) currently under the pointer.
pub fn surface_under(
&self,
) -> Option<(
smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
Point<f64, Logical>,
)> {
let pos = self.pointer_location;
self.space
.element_under(pos)
.and_then(|(window, window_pos)| {
let local = pos - window_pos.to_f64();
// Translate global space coordinate to surface-local coordinate
// window.geometry().loc is the offset from surface origin to content area.
let geo = window.geometry();
let surface_local: Point<f64, Logical> = (local.x + geo.loc.x as f64, local.y + geo.loc.y as f64).into();
window
.surface_under(surface_local, smithay::desktop::WindowSurfaceType::ALL)
.map(|(s, p)| (s, p.to_f64() + window_pos.to_f64()))
})
}
}
impl SeatHandler for WoState {
type KeyboardFocus = smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
type PointerFocus = smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
type TouchFocus = smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
fn seat_state(&mut self) -> &mut SeatState<Self> {
&mut self.seat_state
}
fn focus_changed(&mut self, seat: &Seat<Self>, target: Option<&Self::KeyboardFocus>) {
let dh = &self.display_handle;
let _ = target;
let focus = None;
set_data_device_focus(dh, seat, focus);
}
fn cursor_image(
&mut self,
_seat: &Seat<Self>,
image: smithay::input::pointer::CursorImageStatus,
) {
self.cursor_status = image;
}
}
smithay::delegate_seat!(WoState);
impl SelectionHandler for WoState {
type SelectionUserData = ();
}
impl DataDeviceHandler for WoState {
fn data_device_state(&self) -> &DataDeviceState {
&self.data_device_state
}
}
impl ClientDndGrabHandler for WoState {}
impl ServerDndGrabHandler for WoState {}
smithay::delegate_data_device!(WoState);
impl XdgActivationHandler for WoState {
fn activation_state(&mut self) -> &mut XdgActivationState {
&mut self.xdg_activation_state
}
fn request_activation(
&mut self,
_token: XdgActivationToken,
_token_data: XdgActivationTokenData,
surface: WlSurface,
) {
// Grant all activation requests by giving keyboard focus to the surface.
let serial = SERIAL_COUNTER.next_serial();
if let Some(keyboard) = self.seat.get_keyboard() {
keyboard.set_focus(self, Some(surface), serial);
}
}
}
smithay::delegate_xdg_activation!(WoState);
impl FractionalScaleHandler for WoState {
fn new_fractional_scale(&mut self, surface: WlSurface) {
tracing::info!("new_fractional_scale: setting preferred_scale(1.0)");
// Immediately advertise scale 1.0. Without this, GTK4 apps (and
// Firefox's GTK layer) wait indefinitely for the preferred_scale
// event before committing their first buffer, causing a visible freeze.
with_states(&surface, |states| {
fractional_scale_mod::with_fractional_scale(states, |fs| {
fs.set_preferred_scale(1.0);
});
});
tracing::info!("new_fractional_scale: done");
}
}
smithay::delegate_fractional_scale!(WoState);
smithay::delegate_viewporter!(WoState);
smithay::delegate_single_pixel_buffer!(WoState);
impl TabletSeatHandler for WoState {}
smithay::delegate_cursor_shape!(WoState);
impl PointerConstraintsHandler for WoState {
fn cursor_position_hint(
&mut self,
_surface: &WlSurface,
_pointer: &PointerHandle<Self>,
_location: Point<f64, Logical>,
) {
}
fn new_constraint(&mut self, surface: &WlSurface, pointer: &PointerHandle<Self>) {
// When a new constraint is created, check if it's a lock and notify Electron.
pointer_constraints::with_pointer_constraint(surface, pointer, |constraint| {
if let Some(constraint) = constraint {
if let smithay::wayland::pointer_constraints::PointerConstraint::Locked(_) =
&*constraint
{
// Call activate to actually lock it in Smithay
constraint.activate();
if let Some(target_window) = self.space.elements().find(|w| {
w.toplevel()
.map(|t| t.wl_surface() == surface)
.unwrap_or(false)
|| w.x11_surface()
.and_then(|x| x.wl_surface())
.map(|s| s == *surface)
.unwrap_or(false)
}) {
if let Some(name) = self.wayland_window_name(target_window) {
if let Some(ref ipc) = self.electron_ipc {
let _ = ipc.send_to_window(
&name,
&crate::electron::ElectronInputEvent::PointerLockRequest {
window_name: name.clone(),
lock: true,
},
);
}
}
}
}
}
});
}
}
smithay::delegate_pointer_constraints!(WoState);
smithay::delegate_relative_pointer!(WoState);

700
src/state/portal.rs Normal file
View file

@ -0,0 +1,700 @@
/// xdg-desktop-portal implementation for Wo compositor
use anyhow::{Context, Result};
use pipewire as pw;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{debug, info, warn};
/// Portal session state
#[derive(Debug, Clone)]
pub struct PortalSession {
pub session_id: String,
pub source_type: SourceType,
pub target: Option<String>, // "screen" or window name
pub active: bool,
}
/// Portal source types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SourceType {
Monitor,
Window,
Virtual,
}
/// Portal capture request
#[derive(Debug, Clone, Deserialize)]
pub struct CaptureRequest {
pub source_type: SourceType,
pub target: Option<String>,
pub cursor_mode: Option<u32>,
pub restore_token: Option<String>,
}
/// Portal capture response
#[derive(Debug, Clone, Serialize)]
pub struct CaptureResponse {
pub session_id: String,
pub streams: Vec<StreamInfo>,
}
/// Stream information for PipeWire
#[derive(Debug, Clone, Serialize)]
pub struct StreamInfo {
pub node_id: u32,
pub width: u32,
pub height: u32,
pub source_type: SourceType,
}
/// xdg-desktop-portal handler
pub struct WoPortal {
sessions: Arc<Mutex<HashMap<String, PortalSession>>>,
next_session_id: Arc<Mutex<u64>>,
streams: Arc<Mutex<HashMap<u32, Arc<ScreenCaptureStream>>>>,
compositor_socket: Option<String>,
}
impl WoPortal {
pub fn new() -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
next_session_id: Arc::new(Mutex::new(1)),
streams: Arc::new(Mutex::new(HashMap::new())),
compositor_socket: None,
}
}
pub fn with_compositor_socket(mut self, socket: String) -> Self {
self.compositor_socket = Some(socket);
self
}
/// Request capture from compositor via IPC
fn request_compositor_capture(&self, window: &Option<String>, source_type: SourceType) -> Result<()> {
if let Some(socket_path) = &self.compositor_socket {
use std::os::unix::net::UnixStream;
use std::io::Write;
// Connect to compositor IPC
if let Ok(mut stream) = UnixStream::connect(socket_path) {
let request = serde_json::json!({
"type": "portal_capture_request",
"source_type": source_type,
"window": window,
});
let msg = serde_json::to_string(&request)?;
let _ = stream.write_all(msg.as_bytes());
info!("Requested capture from compositor: {:?}", source_type);
} else {
warn!("Could not connect to compositor socket: {}", socket_path);
}
}
Ok(())
}
/// Create a new portal session
pub fn create_session(&self) -> String {
let mut next_id = self.next_session_id.lock().unwrap();
let session_id = format!("wo_portal_session_{}", *next_id);
*next_id += 1;
let session = PortalSession {
session_id: session_id.clone(),
source_type: SourceType::Monitor,
target: None,
active: false,
};
self.sessions.lock().unwrap().insert(session_id.clone(), session);
info!("Created portal session: {}", session_id);
session_id
}
/// Select sources for a session
pub fn select_sources(
&self,
session_id: &str,
request: CaptureRequest,
) -> Result<()> {
let mut sessions = self.sessions.lock().unwrap();
let session = sessions
.get_mut(session_id)
.context("session not found")?;
session.source_type = request.source_type;
session.target = request.target.clone();
debug!(
"Selected sources for session {}: source_type={:?}, target={:?}",
session_id, request.source_type, request.target
);
Ok(())
}
/// Start a capture session
pub fn start_session(
&self,
session_id: &str,
width: u32,
height: u32,
) -> Result<CaptureResponse> {
let mut sessions = self.sessions.lock().unwrap();
let session = sessions
.get_mut(session_id)
.context("session not found")?;
session.active = true;
// Generate a PipeWire node ID based on session
let node_id = session_id
.chars()
.filter(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u32>()
.unwrap_or(1) + 100; // Offset to avoid conflicts
// Create the actual PipeWire stream
let stream = Arc::new(ScreenCaptureStream::new(
node_id,
width,
height,
session.target.clone(),
)?);
// Connect the stream — returns the real PipeWire-assigned node ID
let real_node_id = stream.connect()?;
// Store the stream keyed by its real PipeWire node ID
let mut streams = self.streams.lock().unwrap();
streams.insert(real_node_id, stream);
// Request capture from compositor
self.request_compositor_capture(&session.target, session.source_type)?;
let stream_info = StreamInfo {
node_id: real_node_id,
width,
height,
source_type: session.source_type,
};
info!(
"Started portal session {}: {}x{} (node {})",
session_id, width, height, real_node_id
);
Ok(CaptureResponse {
session_id: session_id.to_string(),
streams: vec![stream_info],
})
}
/// Stop a capture session
pub fn stop_session(&self, session_id: &str) -> Result<()> {
let mut sessions = self.sessions.lock().unwrap();
if let Some(session) = sessions.get_mut(session_id) {
session.active = false;
info!("Stopped portal session: {}", session_id);
}
Ok(())
}
/// Close a portal session
pub fn close_session(&self, session_id: &str) {
let mut sessions = self.sessions.lock().unwrap();
sessions.remove(session_id);
info!("Closed portal session: {}", session_id);
}
/// Get active sessions
pub fn get_active_sessions(&self) -> Vec<PortalSession> {
self.sessions
.lock()
.unwrap()
.values()
.filter(|s| s.active)
.cloned()
.collect()
}
/// Get session by ID
pub fn get_session(&self, session_id: &str) -> Option<PortalSession> {
self.sessions.lock().unwrap().get(session_id).cloned()
}
/// Push a DMABUF frame to appropriate streams
pub fn push_frame_to_streams(
&self,
window_name: Option<&str>,
dmabuf_fd: i32,
_width: u32,
_height: u32,
stride: u32,
offset: u32,
modifier: u64,
) -> Result<()> {
let streams = self.streams.lock().unwrap();
for stream in streams.values() {
// Check if this stream is for this window
let matches = match (&stream.window_name, window_name) {
(Some(stream_win), Some(win)) => stream_win == win,
(None, None) => true, // Monitor/screen capture
_ => false,
};
if matches && stream.is_active() {
stream.push_frame(dmabuf_fd, stride, offset, modifier)?;
}
}
Ok(())
}
/// Get the stream for a given node ID
pub fn get_stream(&self, node_id: u32) -> Option<Arc<ScreenCaptureStream>> {
self.streams.lock().unwrap().get(&node_id).cloned()
}
/// Push raw pixel data (ARGB8888/BGRx) to matching portal streams.
/// Called from the compositor after offscreen capture or full-screen compositing.
pub fn push_pixels_to_streams(
&self,
window_name: Option<&str>,
pixels: &[u8],
) {
let streams = self.streams.lock().unwrap();
for stream in streams.values() {
let matches = match (&stream.window_name, window_name) {
(Some(stream_win), Some(win)) => stream_win == win,
(None, None) => true, // Monitor capture gets full screen
(None, Some(_)) => true, // Monitor capture sees all windows
_ => false,
};
if matches && stream.is_active() {
stream.push_pixels(pixels);
}
}
}
}
impl Default for WoPortal {
fn default() -> Self {
Self::new()
}
}
/// D-Bus handler for xdg-desktop-portal
pub struct PortalDBusHandler {
portal: Arc<WoPortal>,
}
impl PortalDBusHandler {
pub fn new(portal: Arc<WoPortal>) -> Self {
Self { portal }
}
/// Handle D-Bus method calls
pub fn handle_method_call(
&self,
interface: &str,
method: &str,
args: serde_json::Value,
) -> Result<serde_json::Value> {
debug!("Portal method call: {}.{}", interface, method);
match (interface, method) {
("org.freedesktop.portal.ScreenCast", "CreateSession") => {
let session_id = self.portal.create_session();
Ok(serde_json::json!({
"session_handle": format!("/org/freedesktop/portal/desktop/session/{}", session_id),
}))
}
("org.freedesktop.portal.ScreenCast", "SelectSources") => {
let request: CaptureRequest = serde_json::from_value(args)?;
let session_id = request.restore_token.clone().unwrap_or_else(|| "default".to_string());
self.portal.select_sources(&session_id, request)?;
Ok(serde_json::json!({ "success": true }))
}
("org.freedesktop.portal.ScreenCast", "Start") => {
let session_id = args
.get("session_handle")
.and_then(|v| v.as_str())
.unwrap_or("default");
let width = args.get("width").and_then(|v| v.as_u64()).unwrap_or(1920) as u32;
let height = args.get("height").and_then(|v| v.as_u64()).unwrap_or(1080) as u32;
let response = self.portal.start_session(session_id, width, height)?;
Ok(serde_json::to_value(response)?)
}
_ => {
warn!("Unhandled portal method: {}.{}", interface, method);
Ok(serde_json::json!({ "error": "not implemented" }))
}
}
}
}
/// PipeWire stream implementation for screen capture with real frame streaming.
/// Spawns a dedicated PipeWire thread that produces video frames sourced from
/// the compositor's offscreen capture (raw ARGB8888/BGRx pixels).
pub struct ScreenCaptureStream {
pub node_id: u32,
pub width: u32,
pub height: u32,
pub window_name: Option<String>,
active: Arc<Mutex<bool>>,
frame_counter: Arc<Mutex<u64>>,
frame_queue: Arc<Mutex<Vec<PipeWireFrame>>>,
/// Latest raw pixel buffer (ARGB8888/BGRx) for PipeWire consumption.
/// The PipeWire process callback copies from this into PipeWire-allocated buffers.
latest_pixels: Arc<Mutex<Option<Vec<u8>>>>,
}
/// Frame data for PipeWire streaming
#[derive(Clone, Debug)]
pub struct PipeWireFrame {
pub pts: u64, // Presentation timestamp
pub dmabuf_fd: i32,
pub stride: u32,
pub offset: u32,
pub modifier: u64,
pub width: u32,
pub height: u32,
}
impl ScreenCaptureStream {
pub fn new(node_id: u32, width: u32, height: u32, window_name: Option<String>) -> Result<Self> {
info!(
"Created PipeWire stream {} for {}x{} capture (window: {:?})",
node_id, width, height, window_name
);
Ok(Self {
node_id,
width,
height,
window_name,
active: Arc::new(Mutex::new(false)),
frame_counter: Arc::new(Mutex::new(0)),
frame_queue: Arc::new(Mutex::new(Vec::new())),
latest_pixels: Arc::new(Mutex::new(None)),
})
}
/// Push a frame to the PipeWire stream with DMABUF
pub fn push_frame(&self, dmabuf_fd: i32, stride: u32, offset: u32, modifier: u64) -> Result<()> {
if !*self.active.lock().unwrap() {
return Ok(()); // Stream not active
}
let mut counter = self.frame_counter.lock().unwrap();
let pts = *counter;
*counter += 1;
// Queue frame for consumption by PipeWire
let frame = PipeWireFrame {
pts,
dmabuf_fd,
stride,
offset,
modifier,
width: self.width,
height: self.height,
};
let mut queue = self.frame_queue.lock().unwrap();
queue.push(frame.clone());
// Keep queue bounded to prevent memory issues
if queue.len() > 30 { // 30 frame buffer
queue.remove(0);
}
debug!(
"Stream {} frame {}: DMABUF fd={}, stride={}, offset={}, modifier={:#x}",
self.node_id, pts, dmabuf_fd, stride, offset, modifier
);
Ok(())
}
/// Get the next frame from the queue (for PipeWire processing)
pub fn dequeue_frame(&self) -> Option<PipeWireFrame> {
let mut queue = self.frame_queue.lock().unwrap();
if queue.is_empty() {
None
} else {
Some(queue.remove(0))
}
}
/// Push raw pixel data (ARGB8888/BGRx) for PipeWire consumption.
/// The PipeWire process callback will copy from this buffer into PipeWire-allocated buffers.
pub fn push_pixels(&self, pixels: &[u8]) {
if !self.is_active() {
return;
}
let mut counter = self.frame_counter.lock().unwrap();
*counter += 1;
let mut latest = self.latest_pixels.lock().unwrap();
*latest = Some(pixels.to_vec());
}
/// Connect and activate the stream.
/// Spawns a PipeWire thread that creates a Video/Source node.
/// Returns the real PipeWire-assigned node ID (which consumers use to subscribe).
pub fn connect(&self) -> Result<u32> {
*self.active.lock().unwrap() = true;
let width = self.width;
let height = self.height;
let fallback_node_id = self.node_id;
let active = self.active.clone();
let latest_pixels = self.latest_pixels.clone();
let (node_tx, node_rx) = std::sync::mpsc::sync_channel::<u32>(1);
std::thread::Builder::new()
.name(format!("pw-capture-{}", self.node_id))
.spawn(move || {
if let Err(e) = run_pw_output_stream(
width,
height,
active.clone(),
latest_pixels,
node_tx,
) {
warn!("PipeWire stream thread failed: {e:#}");
// Mark inactive on failure so the stream reports itself as dead.
*active.lock().unwrap() = false;
}
})
.context("Failed to spawn PipeWire thread")?;
// Wait for PipeWire to assign the real node ID.
match node_rx.recv_timeout(Duration::from_secs(5)) {
Ok(real_node_id) => {
info!(
"Stream {}: PipeWire ready with node_id {} ({}x{}, 60fps)",
fallback_node_id, real_node_id, width, height
);
Ok(real_node_id)
}
Err(_) => {
warn!(
"Stream {}: PipeWire did not become ready within 5s, using fallback node_id",
fallback_node_id
);
Ok(fallback_node_id)
}
}
}
pub fn is_active(&self) -> bool {
*self.active.lock().unwrap()
}
pub fn disconnect(&self) {
*self.active.lock().unwrap() = false;
debug!("Stream {}: disconnected", self.node_id);
}
pub fn frame_count(&self) -> u64 {
*self.frame_counter.lock().unwrap()
}
pub fn queue_size(&self) -> usize {
self.frame_queue.lock().unwrap().len()
}
}
impl Drop for ScreenCaptureStream {
fn drop(&mut self) {
self.disconnect();
debug!("Stream {}: dropped (pushed {} frames)", self.node_id, self.frame_count());
}
}
// ── PipeWire output stream implementation ───────────────────────────────────
/// User data passed into PipeWire stream callbacks.
struct PwStreamUserData {
active: Arc<Mutex<bool>>,
latest_pixels: Arc<Mutex<Option<Vec<u8>>>>,
width: u32,
_height: u32,
/// One-shot channel to report the assigned PipeWire node ID back to the
/// caller of `connect()`. Consumed (taken) on the first state transition
/// to `Paused`.
node_tx: Option<std::sync::mpsc::SyncSender<u32>>,
}
/// Run a PipeWire output (Video/Source) stream on a dedicated thread.
///
/// The stream advertises BGRx video at the requested resolution and frame rate.
/// When a consumer (OBS, xdg-desktop-portal client, etc.) connects, the
/// `process` callback copies the latest pixel buffer from `latest_pixels` into
/// PipeWire-allocated mapped buffers.
fn run_pw_output_stream(
width: u32,
height: u32,
active: Arc<Mutex<bool>>,
latest_pixels: Arc<Mutex<Option<Vec<u8>>>>,
node_tx: std::sync::mpsc::SyncSender<u32>,
) -> Result<()> {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None)
.map_err(|e| anyhow::anyhow!("PipeWire MainLoop creation failed: {e}"))?;
let context = pw::context::ContextRc::new(&mainloop, None)
.map_err(|e| anyhow::anyhow!("PipeWire Context creation failed: {e}"))?;
let core = context
.connect_rc(None)
.map_err(|e| anyhow::anyhow!("PipeWire daemon connection failed: {e}"))?;
let stream = pw::stream::StreamBox::new(
&core,
"wo-screen-capture",
pw::properties::properties! {
*pw::keys::MEDIA_TYPE => "Video",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_CLASS => "Video/Source",
*pw::keys::MEDIA_ROLE => "Screen",
},
)
.map_err(|e| anyhow::anyhow!("PipeWire stream creation failed: {e}"))?;
let user_data = PwStreamUserData {
active,
latest_pixels,
width,
_height: height,
node_tx: Some(node_tx),
};
let _listener = stream
.add_local_listener_with_user_data(user_data)
.state_changed(|stream_ref, ud, old, new| {
info!("PipeWire stream state: {:?} -> {:?}", old, new);
// Report the assigned node ID once the stream reaches Paused
// (the first stable state after connection where node_id is valid).
if matches!(new, pw::stream::StreamState::Paused) {
if let Some(tx) = ud.node_tx.take() {
let node_id = stream_ref.node_id();
info!("PipeWire assigned node_id: {}", node_id);
let _ = tx.send(node_id);
}
}
})
.param_changed(|_, _, _id, _param| {
// Format negotiation is handled automatically by PipeWire.
})
.process(|stream_ref, ud| {
if !*ud.active.lock().unwrap() {
return;
}
let pixels_guard = ud.latest_pixels.lock().unwrap();
if let Some(ref pixels) = *pixels_guard {
if let Some(mut buffer) = stream_ref.dequeue_buffer() {
let datas = buffer.datas_mut();
if !datas.is_empty() {
let data = &mut datas[0];
if let Some(slice) = data.data() {
let copy_len = slice.len().min(pixels.len());
slice[..copy_len].copy_from_slice(&pixels[..copy_len]);
}
let chunk = data.chunk_mut();
*chunk.size_mut() = pixels.len() as u32;
*chunk.stride_mut() = (ud.width * 4) as i32;
*chunk.offset_mut() = 0;
}
}
}
})
.register()
.map_err(|e| anyhow::anyhow!("PipeWire listener registration failed: {e}"))?;
// Build SPA pod describing the video format we produce: BGRx at (width x height), 60 fps.
let obj = pw::spa::pod::object!(
pw::spa::utils::SpaTypes::ObjectParamFormat,
pw::spa::param::ParamType::EnumFormat,
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::MediaType,
Id,
pw::spa::param::format::MediaType::Video
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::MediaSubtype,
Id,
pw::spa::param::format::MediaSubtype::Raw
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoFormat,
Choice,
Enum,
Id,
pw::spa::param::video::VideoFormat::BGRx,
pw::spa::param::video::VideoFormat::BGRx,
pw::spa::param::video::VideoFormat::RGBA
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoSize,
Choice,
Range,
Rectangle,
pw::spa::utils::Rectangle { width, height },
pw::spa::utils::Rectangle {
width: 1,
height: 1
},
pw::spa::utils::Rectangle {
width: 4096,
height: 4096
}
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoFramerate,
Choice,
Range,
Fraction,
pw::spa::utils::Fraction { num: 60, denom: 1 },
pw::spa::utils::Fraction { num: 1, denom: 1 },
pw::spa::utils::Fraction {
num: 120,
denom: 1
}
),
);
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.map_err(|e| anyhow::anyhow!("SPA pod serialization failed: {e:?}"))?
.0
.into_inner();
let mut params = [pw::spa::pod::Pod::from_bytes(&values)
.ok_or_else(|| anyhow::anyhow!("Failed to parse SPA pod from serialized bytes"))?];
stream
.connect(
pw::spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::MAP_BUFFERS,
&mut params,
)
.map_err(|e| anyhow::anyhow!("PipeWire stream connect failed: {e}"))?;
info!("PipeWire output stream connected, entering main loop");
mainloop.run();
info!("PipeWire output stream main loop exited");
Ok(())
}

578
src/syscall.rs Normal file
View file

@ -0,0 +1,578 @@
/// Dynamic syscall handling for IPC
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use tracing::{debug, warn};
/// Syscall request types
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum SyscallRequest {
/// List pinned/configured applications
#[serde(rename = "list_applications")]
ListApplications,
/// Browse all installed applications
#[serde(rename = "browse_applications")]
BrowseApplications,
/// Launch an application (fire-and-forget, non-blocking)
#[serde(rename = "launch")]
Launch {
command: String,
args: Option<Vec<String>>,
},
/// Execute a shell command
#[serde(rename = "exec")]
Exec {
command: String,
args: Option<Vec<String>>,
#[serde(default)]
capture_output: bool,
},
/// Read file contents
#[serde(rename = "read")]
Read {
path: String,
#[serde(default)]
max_bytes: Option<usize>,
},
/// Write file contents
#[serde(rename = "write")]
Write {
path: String,
content: String,
#[serde(default)]
append: bool,
},
/// List directory contents
#[serde(rename = "listdir")]
ListDir {
path: String,
},
/// Get file/directory metadata
#[serde(rename = "stat")]
Stat {
path: String,
},
/// Delete file
#[serde(rename = "delete")]
Delete {
path: String,
},
/// Custom extension syscall
#[serde(rename = "custom")]
Custom {
name: String,
#[serde(default)]
payload: serde_json::Value,
},
#[serde(rename = "quit")]
Quit,
/// Power off the system
#[serde(rename = "shutdown")]
Shutdown,
/// Reboot the system
#[serde(rename = "restart")]
Restart,
/// Log out the current session
#[serde(rename = "logout")]
Logout,
/// Lock the screen
#[serde(rename = "lock")]
Lock,
/// Suspend the system
#[serde(rename = "sleep")]
Sleep,
}
/// Trait for external syscall handlers
pub trait ExtensionHandler: Send + Sync {
/// Handle a custom syscall request
/// Returns JSON response or error
fn handle(&self, name: &str, payload: serde_json::Value) -> Result<serde_json::Value>;
}
/// Syscall response
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status")]
pub enum SyscallResponse {
Success {
data: serde_json::Value,
},
Error {
message: String,
},
}
/// Syscall handler with security policies
pub struct SyscallHandler {
/// Allow executing arbitrary commands
pub allow_exec: bool,
/// Allow reading files outside home directory
pub allow_read_system: bool,
/// Allow writing files
pub allow_write: bool,
/// Custom extension handlers
extensions: std::collections::HashMap<String, Arc<dyn ExtensionHandler>>,
/// Application configurations for launcher
pub applications: Vec<serde_json::Value>,
}
impl Default for SyscallHandler {
fn default() -> Self {
Self {
allow_exec: true,
allow_read_system: false,
allow_write: false,
extensions: std::collections::HashMap::new(),
applications: vec![],
}
}
}
impl SyscallHandler {
pub fn new(allow_exec: bool, allow_write: bool) -> Self {
Self {
allow_exec,
allow_write,
..Default::default()
}
}
/// Set applications list from config
pub fn with_applications(mut self, apps: Vec<serde_json::Value>) -> Self {
self.applications = apps;
self
}
/// Register a custom extension handler
pub fn register_extension(&mut self, name: String, handler: Arc<dyn ExtensionHandler>) {
self.extensions.insert(name, handler);
}
/// Unregister an extension handler
pub fn unregister_extension(&mut self, name: &str) -> Option<Arc<dyn ExtensionHandler>> {
self.extensions.remove(name)
}
/// Process a syscall request
pub fn handle(&self, request: SyscallRequest) -> SyscallResponse {
match self.handle_inner(request) {
Ok(data) => SyscallResponse::Success { data },
Err(e) => {
warn!("Syscall error: {:#}", e);
SyscallResponse::Error {
message: format!("{:#}", e),
}
}
}
}
fn handle_inner(&self, request: SyscallRequest) -> Result<serde_json::Value> {
match request {
SyscallRequest::ListApplications => {
Ok(serde_json::Value::Array(self.applications.clone()))
}
SyscallRequest::BrowseApplications => {
browse_applications()
}
SyscallRequest::Launch { command, args } => self.handle_launch(&command, args.as_deref()),
SyscallRequest::Exec {
command,
args,
capture_output,
} => self.handle_exec(&command, args.as_deref(), capture_output),
SyscallRequest::Read { path, max_bytes } => self.handle_read(&path, max_bytes),
SyscallRequest::Write {
path,
content,
append,
} => self.handle_write(&path, &content, append),
SyscallRequest::ListDir { path } => self.handle_list_dir(&path),
SyscallRequest::Stat { path } => self.handle_stat(&path),
SyscallRequest::Delete { path } => self.handle_delete(&path),
SyscallRequest::Custom { name, payload } => self.handle_custom(&name, payload),
SyscallRequest::Quit => {
std::process::exit(0);
}
SyscallRequest::Shutdown => {
handle_power_command("systemctl", &["poweroff"])
}
SyscallRequest::Restart => {
handle_power_command("systemctl", &["reboot"])
}
SyscallRequest::Logout => {
if let Ok(session_id) = std::env::var("XDG_SESSION_ID") {
handle_power_command("loginctl", &["terminate-session", &session_id])
} else {
// Fallback: exit the compositor
std::process::exit(0);
}
}
SyscallRequest::Lock => {
handle_power_command("loginctl", &["lock-session"])
}
SyscallRequest::Sleep => {
handle_power_command("systemctl", &["suspend"])
}
}
}
fn handle_launch(
&self,
command: &str,
args: Option<&[String]>,
) -> Result<serde_json::Value> {
if !self.allow_exec {
bail!("exec syscalls are disabled");
}
tracing::info!("Launching command (fire-and-forget): {} {:?}", command, args);
let mut cmd = Command::new("sh");
let shell_cmd = if let Some(args) = args {
format!("{} {}", command, args.join(" "))
} else {
command.to_string()
};
cmd.arg("-c").arg(&shell_cmd);
// Detach from the compositor process so the child survives independently
use std::process::Stdio;
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
// Use pre_exec to setsid so the child runs in its own session
unsafe {
cmd.pre_exec(|| {
let _ = nix::unistd::setsid();
Ok(())
});
}
match cmd.spawn() {
Ok(child) => {
let pid = child.id();
tracing::info!("Launched process '{}' with PID {}", command, pid);
Ok(serde_json::json!({
"pid": pid,
"command": command,
}))
}
Err(e) => {
bail!("failed to launch '{}': {}", command, e);
}
}
}
fn handle_exec(
&self,
command: &str,
args: Option<&[String]>,
capture_output: bool,
) -> Result<serde_json::Value> {
if !self.allow_exec {
bail!("exec syscalls are disabled");
}
debug!("Executing command: {} {:?}", command, args);
let mut cmd = Command::new(command);
if let Some(args) = args {
cmd.args(args);
}
if capture_output {
let output = cmd.output().context("executing command")?;
Ok(serde_json::json!({
"stdout": String::from_utf8_lossy(&output.stdout),
"stderr": String::from_utf8_lossy(&output.stderr),
"exit_code": output.status.code(),
"success": output.status.success(),
}))
} else {
// Non-blocking: spawn detached so we don't block the event loop
use std::process::Stdio;
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
match cmd.spawn() {
Ok(child) => {
Ok(serde_json::json!({
"pid": child.id(),
"launched": true,
}))
}
Err(e) => {
bail!("failed to execute '{}': {}", command, e);
}
}
}
}
fn handle_read(&self, path: &str, max_bytes: Option<usize>) -> Result<serde_json::Value> {
let path = self.validate_read_path(path)?;
debug!("Reading file: {:?}", path);
let content = std::fs::read_to_string(&path).context("reading file")?;
let content = if let Some(max) = max_bytes {
content.chars().take(max).collect()
} else {
content
};
Ok(serde_json::json!({
"content": content,
"path": path.display().to_string(),
}))
}
fn handle_write(&self, path: &str, content: &str, append: bool) -> Result<serde_json::Value> {
if !self.allow_write {
bail!("write syscalls are disabled");
}
let path = self.validate_write_path(path)?;
debug!("Writing to file: {:?}", path);
if append {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.context("opening file for append")?;
file.write_all(content.as_bytes())
.context("writing to file")?;
} else {
std::fs::write(&path, content).context("writing file")?;
}
Ok(serde_json::json!({
"path": path.display().to_string(),
"bytes_written": content.len(),
}))
}
fn handle_list_dir(&self, path: &str) -> Result<serde_json::Value> {
let path = self.validate_read_path(path)?;
debug!("Listing directory: {:?}", path);
let entries: Vec<_> = std::fs::read_dir(&path)
.context("reading directory")?
.filter_map(|e| {
let entry = e.ok()?;
let metadata = entry.metadata().ok()?;
Some(serde_json::json!({
"name": entry.file_name().to_string_lossy().to_string(),
"is_dir": metadata.is_dir(),
"is_file": metadata.is_file(),
"size": metadata.len(),
}))
})
.collect();
Ok(serde_json::json!({
"path": path.display().to_string(),
"entries": entries,
}))
}
fn handle_stat(&self, path: &str) -> Result<serde_json::Value> {
let path = self.validate_read_path(path)?;
debug!("Getting metadata for: {:?}", path);
let metadata = std::fs::metadata(&path).context("getting metadata")?;
Ok(serde_json::json!({
"path": path.display().to_string(),
"is_dir": metadata.is_dir(),
"is_file": metadata.is_file(),
"is_symlink": metadata.file_type().is_symlink(),
"size": metadata.len(),
"readonly": metadata.permissions().readonly(),
}))
}
fn handle_delete(&self, path: &str) -> Result<serde_json::Value> {
if !self.allow_write {
bail!("delete syscalls are disabled");
}
let path = self.validate_write_path(path)?;
debug!("Deleting: {:?}", path);
let metadata = std::fs::metadata(&path).context("checking path")?;
if metadata.is_dir() {
std::fs::remove_dir_all(&path).context("removing directory")?;
} else {
std::fs::remove_file(&path).context("removing file")?;
}
Ok(serde_json::json!({
"path": path.display().to_string(),
"deleted": true,
}))
}
fn validate_read_path(&self, path: &str) -> Result<PathBuf> {
let path = shellexpand::tilde(path).to_string();
let path = PathBuf::from(path);
if !path.exists() {
bail!("path does not exist: {}", path.display());
}
// If not allowing system reads, restrict to home directory
if !self.allow_read_system {
let home = std::env::var("HOME")
.ok()
.map(PathBuf::from)
.context("HOME not set")?;
let canonical = path.canonicalize().context("canonicalizing path")?;
if !canonical.starts_with(&home) && !canonical.starts_with("/proc") {
bail!(
"path outside home directory: {}",
canonical.display()
);
}
}
Ok(path)
}
fn validate_write_path(&self, path: &str) -> Result<PathBuf> {
let path = shellexpand::tilde(path).to_string();
let path = PathBuf::from(path);
// Always restrict writes to home directory
let home = std::env::var("HOME")
.ok()
.map(PathBuf::from)
.context("HOME not set")?;
let parent = path.parent().context("invalid path")?;
if parent.exists() {
let canonical = parent.canonicalize().context("canonicalizing parent")?;
if !canonical.starts_with(&home) {
bail!(
"write path outside home directory: {}",
canonical.display()
);
}
}
Ok(path)
}
fn handle_custom(&self, name: &str, payload: serde_json::Value) -> Result<serde_json::Value> {
let handler = self
.extensions
.get(name)
.ok_or_else(|| anyhow::anyhow!("unknown extension: {}", name))?;
handler.handle(name, payload)
}
}
/// Execute a power management command (systemctl, loginctl)
fn handle_power_command(program: &str, args: &[&str]) -> Result<serde_json::Value> {
use std::process::Stdio;
let mut cmd = Command::new(program);
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
match cmd.spawn() {
Ok(_child) => Ok(serde_json::json!({ "ok": true, "command": format!("{} {}", program, args.join(" ")) })),
Err(e) => bail!("failed to execute '{}': {}", program, e),
}
}
/// Synchronously browse and return all installed applications from system
pub fn browse_applications() -> Result<serde_json::Value> {
use std::fs;
use std::path::Path;
let mut apps = Vec::new();
let dirs = vec!["/usr/share/applications"];
let mut seen = std::collections::HashSet::new();
const MAX_APPS: usize = 500; // Limit to prevent excessive scanning
'outer: for dir in dirs {
if !Path::new(dir).exists() {
continue;
}
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
if apps.len() >= MAX_APPS {
break 'outer;
}
let path = entry.path();
if path.extension().map(|e| e == "desktop").unwrap_or(false) {
// Use timeout to prevent hanging on problematic files
if let Ok(content) = std::fs::read_to_string(&path) {
if content.len() > 100_000 || content.contains("NoDisplay=true") {
continue;
}
let name = content
.lines()
.find_map(|l| l.strip_prefix("Name="))
.and_then(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
let exec = content
.lines()
.find_map(|l| l.strip_prefix("Exec="))
.and_then(|s| s.split_whitespace().next())
.and_then(|s| s.split('/').last())
.and_then(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
let icon = content
.lines()
.find_map(|l| l.strip_prefix("Icon="))
.map(|s| s.trim().to_string());
if let (Some(n), Some(e)) = (name, exec) {
if seen.insert(n.clone()) {
let mut app = serde_json::json!({
"name": n,
"command": e,
});
if let Some(i) = icon {
app["icon"] = serde_json::json!({
"type": "iconify",
"data": i,
});
}
apps.push(app);
}
}
}
}
}
}
}
debug!("Browsed {} applications from system", apps.len());
Ok(serde_json::Value::Array(apps))
}