Initial commit
This commit is contained in:
commit
33359b814e
47 changed files with 17347 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
target/
|
||||
storage/
|
||||
snapshots/
|
||||
.qdrant*
|
||||
3301
Cargo.lock
generated
Normal file
3301
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
73
Cargo.toml
Normal file
73
Cargo.toml
Normal 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
57
PKGBUILD
Normal 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
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# wo compositor
|
||||
|
||||
don't bother (woe)
|
||||
21
config.example.toml
Normal file
21
config.example.toml
Normal 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
7
electron/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
*.map
|
||||
368
electron/bun.lock
Normal file
368
electron/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
30
electron/native/binding.gyp
Normal file
30
electron/native/binding.gyp
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
524
electron/native/wo_dmabuf.cc
Normal file
524
electron/native/wo_dmabuf.cc
Normal 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
30
electron/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
292
electron/src/damage-buffer.ts
Normal file
292
electron/src/damage-buffer.ts
Normal 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
1766
electron/src/main.ts
Normal file
File diff suppressed because it is too large
Load diff
127
electron/src/preload.ts
Normal file
127
electron/src/preload.ts
Normal 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
237
electron/src/protocol.ts
Normal 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);
|
||||
}
|
||||
219
electron/src/renderer-damage-helper.ts
Normal file
219
electron/src/renderer-damage-helper.ts
Normal 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 };
|
||||
}
|
||||
199
electron/src/wo-input-receiver.ts
Normal file
199
electron/src/wo-input-receiver.ts
Normal 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
31
electron/tsconfig.json
Normal 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
34
electron/types/.gitignore
vendored
Normal 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
106
electron/types/CLAUDE.md
Normal 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
186
electron/types/README.md
Normal 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
26
electron/types/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
91
electron/types/dispatcher.ts
Normal file
91
electron/types/dispatcher.ts
Normal 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
24
electron/types/index.ts
Normal 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';
|
||||
12
electron/types/package.json
Normal file
12
electron/types/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "wo-types",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
29
electron/types/tsconfig.json
Normal file
29
electron/types/tsconfig.json
Normal 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
402
electron/types/types.d.ts
vendored
Normal 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[];
|
||||
}
|
||||
9
packaging/wayland-sessions/wo.desktop
Normal file
9
packaging/wayland-sessions/wo.desktop
Normal 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
51
src/backend.rs
Normal 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
299
src/bin/wo-portal.rs
Normal 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
314
src/config.rs
Normal 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
330
src/dmabuf.rs
Normal 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
1211
src/electron.rs
Normal file
File diff suppressed because it is too large
Load diff
99
src/handlers/compositor.rs
Normal file
99
src/handlers/compositor.rs
Normal 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);
|
||||
48
src/handlers/dmabuf_handler.rs
Normal file
48
src/handlers/dmabuf_handler.rs
Normal 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
703
src/handlers/input.rs
Normal 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
7
src/handlers/mod.rs
Normal 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
47
src/handlers/output.rs
Normal 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
|
||||
}
|
||||
}
|
||||
68
src/handlers/xdg_decoration.rs
Normal file
68
src/handlers/xdg_decoration.rs
Normal 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
377
src/handlers/xdg_shell.rs
Normal 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
331
src/handlers/xwayland.rs
Normal 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
8
src/lib.rs
Normal 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
1604
src/main.rs
Normal file
File diff suppressed because it is too large
Load diff
1738
src/nested.rs
Normal file
1738
src/nested.rs
Normal file
File diff suppressed because it is too large
Load diff
626
src/state.rs
Normal file
626
src/state.rs
Normal 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
700
src/state/portal.rs
Normal 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
578
src/syscall.rs
Normal 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))
|
||||
}
|
||||
Loading…
Reference in a new issue