From 33359b814e9a01f6ed60cdafc8026258352ba4c7 Mon Sep 17 00:00:00 2001 From: Stephen Hellings Date: Sun, 8 Mar 2026 23:04:02 -0400 Subject: [PATCH] Initial commit --- .gitignore | 4 + Cargo.lock | 3301 ++++++++++++++++++++++++ Cargo.toml | 73 + PKGBUILD | 57 + README.md | 3 + config.example.toml | 21 + electron/.gitignore | 7 + electron/bun.lock | 368 +++ electron/native/binding.gyp | 30 + electron/native/wo_dmabuf.cc | 524 ++++ electron/package.json | 30 + electron/src/damage-buffer.ts | 292 +++ electron/src/main.ts | 1766 +++++++++++++ electron/src/preload.ts | 127 + electron/src/protocol.ts | 237 ++ electron/src/renderer-damage-helper.ts | 219 ++ electron/src/wo-input-receiver.ts | 199 ++ electron/tsconfig.json | 31 + electron/types/.gitignore | 34 + electron/types/CLAUDE.md | 106 + electron/types/README.md | 186 ++ electron/types/bun.lock | 26 + electron/types/dispatcher.ts | 91 + electron/types/index.ts | 24 + electron/types/package.json | 12 + electron/types/tsconfig.json | 29 + electron/types/types.d.ts | 402 +++ packaging/wayland-sessions/wo.desktop | 9 + src/backend.rs | 51 + src/bin/wo-portal.rs | 299 +++ src/config.rs | 314 +++ src/dmabuf.rs | 330 +++ src/electron.rs | 1211 +++++++++ src/handlers/compositor.rs | 99 + src/handlers/dmabuf_handler.rs | 48 + src/handlers/input.rs | 703 +++++ src/handlers/mod.rs | 7 + src/handlers/output.rs | 47 + src/handlers/xdg_decoration.rs | 68 + src/handlers/xdg_shell.rs | 377 +++ src/handlers/xwayland.rs | 331 +++ src/lib.rs | 8 + src/main.rs | 1604 ++++++++++++ src/nested.rs | 1738 +++++++++++++ src/state.rs | 626 +++++ src/state/portal.rs | 700 +++++ src/syscall.rs | 578 +++++ 47 files changed, 17347 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 PKGBUILD create mode 100644 README.md create mode 100644 config.example.toml create mode 100644 electron/.gitignore create mode 100644 electron/bun.lock create mode 100644 electron/native/binding.gyp create mode 100644 electron/native/wo_dmabuf.cc create mode 100644 electron/package.json create mode 100644 electron/src/damage-buffer.ts create mode 100644 electron/src/main.ts create mode 100644 electron/src/preload.ts create mode 100644 electron/src/protocol.ts create mode 100644 electron/src/renderer-damage-helper.ts create mode 100644 electron/src/wo-input-receiver.ts create mode 100644 electron/tsconfig.json create mode 100644 electron/types/.gitignore create mode 100644 electron/types/CLAUDE.md create mode 100644 electron/types/README.md create mode 100644 electron/types/bun.lock create mode 100644 electron/types/dispatcher.ts create mode 100644 electron/types/index.ts create mode 100644 electron/types/package.json create mode 100644 electron/types/tsconfig.json create mode 100644 electron/types/types.d.ts create mode 100644 packaging/wayland-sessions/wo.desktop create mode 100644 src/backend.rs create mode 100644 src/bin/wo-portal.rs create mode 100644 src/config.rs create mode 100644 src/dmabuf.rs create mode 100644 src/electron.rs create mode 100644 src/handlers/compositor.rs create mode 100644 src/handlers/dmabuf_handler.rs create mode 100644 src/handlers/input.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/handlers/output.rs create mode 100644 src/handlers/xdg_decoration.rs create mode 100644 src/handlers/xdg_shell.rs create mode 100644 src/handlers/xwayland.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/nested.rs create mode 100644 src/state.rs create mode 100644 src/state/portal.rs create mode 100644 src/syscall.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed06dc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +storage/ +snapshots/ +.qdrant* \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1c5bffc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3301 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.11.0", + "cc", + "cesu8", + "jni", + "jni-sys", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "appendlist" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e149dc73cd30538307e7ffa2acd3d2221148eaeed4871f246657b1c3eaa1cbd2" + +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic_float" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "annotate-snippets", + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "async-task", + "bitflags 2.11.0", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-expr" +version = "0.20.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cef5b5a1a6827c7322ae2a636368a573006b27cfa76c7ebd53e834daeaab6a" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx", + "num-traits", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "drm" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b71449a23fe79542d6527ca572844b2016abf9573c49e43144d546b1735aec" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "bytemuck_derive", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.4", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gbm" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" +dependencies = [ + "bitflags 2.11.0", + "drm", + "drm-fourcc", + "gbm-sys", + "libc", + "wayland-backend", + "wayland-server", +] + +[[package]] +name = "gbm-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" +dependencies = [ + "libc", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "input" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbdc09524a91f9cacd26f16734ff63d7dc650daffadd2b6f84d17a285bd875a9" +dependencies = [ + "bitflags 2.11.0", + "input-sys", + "libc", + "log", + "udev", +] + +[[package]] +name = "input-sys" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd4f5b4d1c00331c5245163aacfe5f20be75b564c7112d45893d4ae038119eb0" + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libseat" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6656c69b6e0366ffb83faa232f3e4fda5fc91161722d140f2c95ce2e90b1ef31" +dependencies = [ + "errno", + "libseat-sys", + "log", +] + +[[package]] +name = "libseat-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a484fa48be5f62a8d3ffed767779d0ab808cfead91de98ef4362d469097dfb" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libspa" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4" +dependencies = [ + "bitflags 2.11.0", + "cc", + "convert_case", + "cookie-factory", + "libc", + "libspa-sys", + "nix 0.30.1", + "nom 8.0.0", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" +dependencies = [ + "bindgen", + "cc", + "system-deps", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ad2c6bae700b7aa5d1cc30c59bdd3a1c180b09dbaea51e2ae2b8e1cf211fdd" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pipewire" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "libc", + "libspa", + "libspa-sys", + "nix 0.30.1", + "once_cell", + "pipewire-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "pipewire-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb028afee0d6ca17020b090e3b8fa2d7de23305aef975c7e5192a5050246ea36" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + +[[package]] +name = "pixman" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea217d496c19ac0a8e502b37078e1f683d16344adee9eb247a5d57c165e1edf" +dependencies = [ + "paste", + "pixman-sys", + "thiserror 1.0.69", +] + +[[package]] +name = "pixman-sys" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0483e89e81d7915defe83c51f23f6800594d64f6f4a21253ce87fd8444ada" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740cea6927892bc182d5bf70c8f79806c8bc9f68f2fb96e55a30be171b63af98" +dependencies = [ + "appendlist", + "atomic_float", + "bitflags 2.11.0", + "calloop 0.14.4", + "cc", + "cgmath", + "cursor-icon", + "downcast-rs", + "drm", + "drm-ffi", + "drm-fourcc", + "encoding_rs", + "errno", + "gbm", + "gl_generator", + "indexmap", + "input", + "libc", + "libloading", + "libseat", + "pkg-config", + "profiling", + "rand", + "rustix 1.1.4", + "scopeguard", + "sha2", + "smallvec", + "tempfile", + "thiserror 2.0.18", + "tracing", + "udev", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-egl", + "wayland-protocols", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-server", + "wayland-sys", + "winit", + "x11rb", + "xkbcommon 0.8.0", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.0", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 0.9.12+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml" +version = "1.0.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.0.0+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0a07913e63758bc95142d9863a5a45173b71515e68b690cad70cf99c3255ce1" +dependencies = [ + "indexmap", + "toml_datetime 1.0.0+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "uds_windows" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +dependencies = [ + "bitflags 2.11.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-egl" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a2c9ef35f3b2e11d5709d8e75dc1e7e064fd8eec5d705d56073fafca85de854" +dependencies = [ + "wayland-backend", + "wayland-sys", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", + "wayland-server", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791c58fdeec5406aa37169dd815327d1e47f334219b523444bc26d70ceb4c34e" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-protocols", + "wayland-scanner", + "wayland-server", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa98634619300a535a9a97f338aed9a5ff1e01a461943e8346ff4ae26007306b" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", + "wayland-server", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-server" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9297ab90f8d1f597711d36455c5b1b2290eca59b8134485e377a296b80b118c9" +dependencies = [ + "bitflags 2.11.0", + "downcast-rs", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-sys" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +dependencies = [ + "dlib", + "libc", + "log", + "memoffset", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.0", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "wo" +version = "0.1.0" +dependencies = [ + "anyhow", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "ctrlc", + "drm", + "drm-fourcc", + "gbm", + "gl", + "input", + "libc", + "nix 0.31.2", + "pipewire", + "pixman", + "serde", + "serde_json", + "shellexpand", + "smithay", + "tokio", + "toml 1.0.3+spec-1.1.0", + "tracing", + "tracing-subscriber", + "udev", + "wayland-backend", + "wayland-protocols", + "wayland-server", + "xkbcommon 0.9.0", + "zbus", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkbcommon" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a974f48060a14e95705c01f24ad9c3345022f4d97441b8a36beb7ed5c4a02d" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c7f7e43 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..9034d48 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,57 @@ +# Maintainer: Stephen E. Hellings + +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 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..34443b2 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# wo compositor + +don't bother (woe) \ No newline at end of file diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..3fd5612 --- /dev/null +++ b/config.example.toml @@ -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 \ No newline at end of file diff --git a/electron/.gitignore b/electron/.gitignore new file mode 100644 index 0000000..2e4d293 --- /dev/null +++ b/electron/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +build/ +out/ +*.log +*.tsbuildinfo +*.map \ No newline at end of file diff --git a/electron/bun.lock b/electron/bun.lock new file mode 100644 index 0000000..e6b7c35 --- /dev/null +++ b/electron/bun.lock @@ -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=="], + } +} diff --git a/electron/native/binding.gyp b/electron/native/binding.gyp new file mode 100644 index 0000000..9f752ed --- /dev/null +++ b/electron/native/binding.gyp @@ -0,0 +1,30 @@ +{ + "targets": [ + { + "target_name": "wo_dmabuf", + "sources": [ + "wo_dmabuf.cc" + ], + "include_dirs": [ + " + +#include +#include +#include +#include +#include +#include + +// Linux / GBM / DRM +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +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 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 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 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 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 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 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().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>(); + uint32_t w = info[1].As().Uint32Value(); + uint32_t h = info[2].As().Uint32Value(); + uint32_t src_stride = info[3].As().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(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(mod >> 32); + uint32_t mod_lo = static_cast(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().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().Int32Value(); + int dmabuf_fd = info[1].As().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().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().Utf8Value(); + int fd = info[1].As().Int32Value(); + int width = info[2].As().Int32Value(); + int height = info[3].As().Int32Value(); + uint32_t format = info[4].As().Uint32Value(); + + if (!ensure_egl()) { + Napi::Error::New(env, "Failed to initialize EGL").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + std::lock_guard 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().Utf8Value(); + + std::lock_guard 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().Utf8Value(); + + std::lock_guard 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().Int32Value(); + size_t size = static_cast(info[1].As().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::New( + env, static_cast(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) diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000..ef7dec9 --- /dev/null +++ b/electron/package.json @@ -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" + } +} diff --git a/electron/src/damage-buffer.ts b/electron/src/damage-buffer.ts new file mode 100644 index 0000000..3734393 --- /dev/null +++ b/electron/src/damage-buffer.ts @@ -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; +} diff --git a/electron/src/main.ts b/electron/src/main.ts new file mode 100644 index 0000000..01a3e36 --- /dev/null +++ b/electron/src/main.ts @@ -0,0 +1,1766 @@ +import { app, BrowserWindow, ipcMain } from 'electron'; +import { createConnection, createServer, Socket, Server } from 'net'; +import { fileURLToPath } from 'url'; +import { dirname, resolve as resolvePath, join as joinPath } from 'path'; +import fs from 'fs'; +import { createRequire } from 'module'; +import { MAGIC, stringToBuffer } from './protocol.js'; +import { + applyDamagePayload, + DamageBuffer, + type DamageFramePayload, + loadNativeDmabufModule, + WoDmabufSender, +} from './damage-buffer.js'; + +// ESM __dirname equivalent +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const require = createRequire(import.meta.url); + +// Debug logging to file +const DEBUG_LOG = '/tmp/wo-electron-debug.log'; +function debugLog(...args: any[]) { + const msg = `[${new Date().toISOString()}] ` + args.join(' ') + '\n'; + try { + fs.appendFileSync(DEBUG_LOG, msg); + } catch (e) { + // ignore + } + console.log(...args); +} + +debugLog('[Wo] Electron starting, NODE_VERSION=', process.version); + +type ClientWindowInfo = { + id: string; + title: string; + width: number; + height: number; +}; + +const IPC_SOCKET = process.env.WO_IPC_SOCKET || '/run/user/1000/wo-ipc.sock'; +debugLog('[Wo] WO_WINDOW_CONFIG env =', process.env.WO_WINDOW_CONFIG); +const WINDOW_CONFIG = JSON.parse(process.env.WO_WINDOW_CONFIG || '{}'); +debugLog('[Wo] WINDOW_CONFIG parsed =', JSON.stringify(WINDOW_CONFIG)); +const CLIENT_MODE = process.env.WO_CLIENT_MODE === '1'; + +const { + name = 'default', + url = null, + html = null, + width = 1920, + height = 1080, +} = WINDOW_CONFIG; + +let mainWindow: BrowserWindow | null = null; +let ipcSocket: Socket | null = null; +let portalUiServer: Server | null = null; +type PendingPortalRequest = { + socket: Socket; + kind: 'screen_share'; + sessionId: string; +}; +const portalPendingRequests = new Map(); +const PORTAL_UI_SOCKET = '/tmp/wo-portal-ui.sock'; +let ipcConnected = false; +let ipcReconnectTimer: NodeJS.Timeout | null = null; +let windowPositionUpdateTimer: NodeJS.Timeout | null = null; +let dmabufSender: WoDmabufSender | null = null; +let damageBuffer: DamageBuffer | null = null; +let nativeDmabuf: any = null; +let compositorRxBuffer = Buffer.alloc(0); +// Per-window surface buffer throttle (~30fps max delivery to renderer) +const surfaceBufferPending = new Map(); +let surfaceBufferTimer: NodeJS.Timeout | null = null; +const shmReadBufferCache = new Map(); +const shmFdCache = new Map(); + +function closeShmFdCache() { + for (const entry of shmFdCache.values()) { + try { + fs.closeSync(entry.extFd); + } catch { + // ignore close errors + } + } + shmFdCache.clear(); +} + +function getOrOpenShmFd(windowName: string, pid: number, fd: number): number { + const cached = shmFdCache.get(windowName); + if (cached && cached.pid === pid && cached.fd === fd) { + return cached.extFd; + } + + if (cached) { + try { + fs.closeSync(cached.extFd); + } catch { + // ignore + } + } + + const fdPath = `/proc/${pid}/fd/${fd}`; + const extFd = fs.openSync(fdPath, 'r'); + shmFdCache.set(windowName, { pid, fd, extFd }); + return extFd; +} + +function getReusableShmBuffer(windowName: string, size: number): Buffer { + const existing = shmReadBufferCache.get(windowName); + if (existing && existing.length >= size) { + return existing; + } + const next = Buffer.allocUnsafe(size); + shmReadBufferCache.set(windowName, next); + return next; +} + +function flushSurfaceBuffers() { + surfaceBufferTimer = null; + if (!mainWindow || mainWindow.isDestroyed()) { surfaceBufferPending.clear(); return; } + for (const entry of surfaceBufferPending.values()) { + mainWindow.webContents.send('wo:surface-buffer', entry); + } + surfaceBufferPending.clear(); +} +const inFlightFrameSeqs = new Set(); +const MAX_IN_FLIGHT_FRAMES = 2; +// When the app uses compositor.submitDamageFrame() directly, skip OSR paint path to avoid double submission +let appUsedDamageHelper = false; +// Track pointer position for mouse button/scroll events (window-local coords) +let pointerX = 0; +let pointerY = 0; + +function respondToPortalRequest(requestId: string, params: { + allowed: boolean; + type?: string; + windowName?: unknown; +}): { ok: boolean; error?: string } { + const pending = portalPendingRequests.get(requestId); + if (!pending) { + return { ok: false, error: `No pending portal request: ${requestId}` }; + } + + let response: Record; + if (pending.kind === 'screen_share') { + response = { + allowed: Boolean(params.allowed), + sourceType: params.type === 'window' ? 'Window' : 'Monitor', + windowName: typeof params.windowName === 'string' ? params.windowName : null, + }; + } else { + response = { allowed: false }; + } + + try { + pending.socket.write(`${JSON.stringify(response)}\n`); + pending.socket.end(); + portalPendingRequests.delete(requestId); + return { ok: true }; + } catch (error) { + portalPendingRequests.delete(requestId); + return { ok: false, error: `Failed to send portal response: ${String(error)}` }; + } +} + +function startPortalUiBridge() { + try { + if (fs.existsSync(PORTAL_UI_SOCKET)) { + fs.unlinkSync(PORTAL_UI_SOCKET); + } + } catch { + // ignore stale socket cleanup errors + } + + portalUiServer = createServer((socket) => { + let received = ''; + + socket.on('data', (chunk: Buffer) => { + received += chunk.toString('utf8'); + const nl = received.indexOf('\n'); + if (nl < 0) return; + + const line = received.slice(0, nl).trim(); + received = received.slice(nl + 1); + if (!line) return; + + try { + const req = JSON.parse(line) as { + type?: string; + appName?: string; + sessionId?: string; + }; + + if (req.type !== 'screen_share_request' || !req.sessionId) { + socket.end('{"allowed":false,"reason":"invalid_request"}\n'); + return; + } + + const requestId = req.sessionId; + + portalPendingRequests.set(requestId, { + socket, + kind: 'screen_share', + sessionId: req.sessionId, + }); + + // Auto-deny if UI does not respond within 90s + socket.setTimeout(90_000, () => { + const pending = portalPendingRequests.get(requestId); + if (pending) { + portalPendingRequests.delete(requestId); + pending.socket.end('{"allowed":false,"reason":"timeout"}\n'); + } + }); + + if (mainWindow && !mainWindow.isDestroyed()) { + const payload = { + requestId, + kind: 'screen_share', + appName: req.appName || 'Application', + sessionId: req.sessionId, + }; + + mainWindow.webContents.send('wo:portal-request', payload); + } else { + portalPendingRequests.delete(requestId); + socket.end('{"allowed":false,"reason":"no_ui"}\n'); + } + } catch { + socket.end('{"allowed":false,"reason":"invalid_json"}\n'); + } + }); + + socket.on('close', () => { + for (const [sessionId, pending] of portalPendingRequests.entries()) { + if (pending.socket === socket) { + portalPendingRequests.delete(sessionId); + break; + } + } + }); + + socket.on('error', () => { + // socket errors are expected if requester exits early + }); + }); + + portalUiServer.listen(PORTAL_UI_SOCKET, () => { + debugLog('[Wo] Portal UI bridge listening at', PORTAL_UI_SOCKET); + }); + + portalUiServer.on('error', (err) => { + console.error('[Wo] Portal UI bridge error:', err); + }); +} + +function stopPortalUiBridge() { + for (const pending of portalPendingRequests.values()) { + try { + pending.socket.end('{"allowed":false,"reason":"shutdown"}\n'); + } catch { + // ignore + } + } + portalPendingRequests.clear(); + + if (portalUiServer) { + portalUiServer.close(); + portalUiServer = null; + } + + try { + if (fs.existsSync(PORTAL_UI_SOCKET)) { + fs.unlinkSync(PORTAL_UI_SOCKET); + } + } catch { + // ignore socket cleanup errors + } +} + +function parseClientWindows(): ClientWindowInfo[] { + try { + const raw = process.env.WO_CLIENT_WINDOWS; + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed.map((w: any, idx: number) => ({ + id: String(w.id ?? w.name ?? `win-${idx + 1}`), + title: String(w.title ?? w.name ?? `Window ${idx + 1}`), + width: Number(w.width ?? 960), + height: Number(w.height ?? 540), + })); + } + } + } catch (error) { + console.warn('[Wo client] invalid WO_CLIENT_WINDOWS:', error); + } + + return [{ id: String(name), title: String(name), width: Number(width), height: Number(height) }]; +} + +let clientWindows: ClientWindowInfo[] = parseClientWindows(); +// Live window list received from the compositor via WOWM metadata messages. +let compositorWindows: Record[] = []; + +type StatusNotifierTrayItem = { + id: string; + title: string; + status: 'active' | 'passive' | 'attention'; + icon: string; + iconDataUrl?: string; + service: string; + objectPath: string; + menuPath?: string; + hasMenu: boolean; +}; + +function parseQuotedStrings(output: string): string[] { + const matches = output.matchAll(/"((?:[^"\\]|\\.)*)"/g); + const values: string[] = []; + for (const match of matches) { + const raw = match[1]; + values.push(raw.replace(/\\"/g, '"').replace(/\\\\/g, '\\')); + } + return values; +} + +function runBusctlUser(args: string[]): string { + try { + const { execFileSync } = require('child_process') as typeof import('child_process'); + return execFileSync('busctl', ['--user', ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +function parseServiceAndPath(itemRef: string): { service: string; objectPath: string } | null { + const slashIdx = itemRef.indexOf('/'); + if (slashIdx > 0) { + return { + service: itemRef.slice(0, slashIdx), + objectPath: itemRef.slice(slashIdx), + }; + } + + if (!itemRef) { + return null; + } + + return { + service: itemRef, + objectPath: '/StatusNotifierItem', + }; +} + +function parseBusctlStringValue(output: string): string { + const quoted = parseQuotedStrings(output); + if (quoted.length > 0) { + return quoted[0]; + } + const parts = output.split(/\s+/); + return parts.length >= 2 ? parts.slice(1).join(' ').trim() : ''; +} + +function getBusProperty(service: string, objectPath: string, iface: string, prop: string): string { + const out = runBusctlUser(['get-property', service, objectPath, iface, prop]); + return out ? parseBusctlStringValue(out) : ''; +} + +function mapTrayIcon(iconName: string, title: string): string { + const n = iconName.toLowerCase(); + const t = title.toLowerCase(); + if (n.includes('network') || t.includes('network') || t.includes('wifi')) return 'mdi:wifi'; + if (n.includes('audio') || n.includes('volume') || t.includes('volume')) return 'mdi:volume-high'; + if (n.includes('battery') || t.includes('battery')) return 'mdi:battery'; + if (n.includes('bluetooth') || t.includes('bluetooth')) return 'mdi:bluetooth'; + if (n.includes('telegram') || t.includes('telegram')) return 'mdi:telegram'; + if (n.includes('discord') || t.includes('discord')) return 'mdi:discord'; + if (n.includes('steam') || t.includes('steam')) return 'mdi:steam'; + if (n.includes('dropbox') || t.includes('dropbox')) return 'mdi:dropbox'; + if (n.includes('mail') || t.includes('mail')) return 'mdi:email-outline'; + if (n.includes('kde') || t.includes('kde')) return 'mdi:kde'; + return 'mdi:circle-medium'; +} + +function iconPathCandidates(iconName: string): string[] { + const direct = iconName.startsWith('/') ? [iconName] : []; + if (iconName.startsWith('/')) { + return direct; + } + + const exts = ['png', 'svg']; + const bases = [ + '/usr/share/pixmaps', + '/usr/share/icons/hicolor/16x16/apps', + '/usr/share/icons/hicolor/22x22/apps', + '/usr/share/icons/hicolor/24x24/apps', + '/usr/share/icons/hicolor/32x32/apps', + '/usr/share/icons/hicolor/48x48/apps', + '/usr/share/icons/hicolor/scalable/apps', + '/usr/share/icons/breeze/apps/22', + '/usr/share/icons/breeze/apps/24', + '/usr/share/icons/breeze/apps/32', + ]; + + const results: string[] = []; + for (const base of bases) { + for (const ext of exts) { + results.push(`${base}/${iconName}.${ext}`); + } + } + return results; +} + +function toDataUrlForIconFile(iconPath: string): string | undefined { + try { + if (!fs.existsSync(iconPath)) { + return undefined; + } + const lower = iconPath.toLowerCase(); + const mime = lower.endsWith('.svg') ? 'image/svg+xml' : lower.endsWith('.png') ? 'image/png' : ''; + if (!mime) { + return undefined; + } + const base64 = fs.readFileSync(iconPath).toString('base64'); + return `data:${mime};base64,${base64}`; + } catch { + return undefined; + } +} + +function resolveTrayIconDataUrl(iconName: string): string | undefined { + for (const candidate of iconPathCandidates(iconName)) { + const dataUrl = toDataUrlForIconFile(candidate); + if (dataUrl) { + return dataUrl; + } + } + return undefined; +} + +function getStatusNotifierTrayItems(): StatusNotifierTrayItem[] { + const watcherCall = runBusctlUser([ + 'call', + 'org.kde.StatusNotifierWatcher', + '/StatusNotifierWatcher', + 'org.kde.StatusNotifierWatcher', + 'RegisteredStatusNotifierItems', + ]); + + if (!watcherCall) { + return []; + } + + const refs = parseQuotedStrings(watcherCall); + const results: StatusNotifierTrayItem[] = []; + + for (const itemRef of refs) { + const parsed = parseServiceAndPath(itemRef); + if (!parsed) { + continue; + } + + const statusRaw = getBusProperty(parsed.service, parsed.objectPath, 'org.kde.StatusNotifierItem', 'Status'); + const title = + getBusProperty(parsed.service, parsed.objectPath, 'org.kde.StatusNotifierItem', 'Title') + || getBusProperty(parsed.service, parsed.objectPath, 'org.kde.StatusNotifierItem', 'Id') + || parsed.service; + const iconName = + getBusProperty(parsed.service, parsed.objectPath, 'org.kde.StatusNotifierItem', 'AttentionIconName') + || getBusProperty(parsed.service, parsed.objectPath, 'org.kde.StatusNotifierItem', 'IconName'); + const menuPath = getBusProperty(parsed.service, parsed.objectPath, 'org.kde.StatusNotifierItem', 'Menu'); + const iconDataUrl = iconName ? resolveTrayIconDataUrl(iconName) : undefined; + + let status: StatusNotifierTrayItem['status'] = 'active'; + const s = statusRaw.toLowerCase(); + if (s.includes('passive')) { + status = 'passive'; + } else if (s.includes('attention') || s.includes('needsattention')) { + status = 'attention'; + } + + results.push({ + id: `${parsed.service}${parsed.objectPath}`, + title, + status, + icon: mapTrayIcon(iconName || title, title), + iconDataUrl, + service: parsed.service, + objectPath: parsed.objectPath, + menuPath: menuPath || undefined, + hasMenu: Boolean(menuPath && menuPath !== '/NO_DBUSMENU'), + }); + } + + return results; +} + +function activateStatusNotifierItem(service: string, objectPath: string): boolean { + const out = runBusctlUser([ + 'call', + service, + objectPath, + 'org.kde.StatusNotifierItem', + 'Activate', + 'ii', + '0', + '0', + ]); + return out.length > 0; +} + +function secondaryActivateStatusNotifierItem(service: string, objectPath: string): boolean { + const out = runBusctlUser([ + 'call', + service, + objectPath, + 'org.kde.StatusNotifierItem', + 'SecondaryActivate', + 'ii', + '0', + '0', + ]); + return out.length > 0; +} + +function openStatusNotifierContextMenu(service: string, objectPath: string, x: number, y: number): boolean { + const out = runBusctlUser([ + 'call', + service, + objectPath, + 'org.kde.StatusNotifierItem', + 'ContextMenu', + 'ii', + String(Math.trunc(x)), + String(Math.trunc(y)), + ]); + return out.length > 0; +} + +type DBusMenuItem = { + id: string; + label: string; + enabled: boolean; + children?: DBusMenuItem[]; +}; + +function getDBusMenuLayout(service: string, menuPath: string): DBusMenuItem[] { + try { + const out = runBusctlUser([ + 'call', + service, + menuPath.startsWith('/') ? menuPath : '/com/canonical/dbusmenu', + 'com.canonical.dbusmenu', + 'GetLayout', + 'u', + '0', + ]); + + if (!out) { + return []; + } + + const lines = out.split('\n').filter(l => l.trim().length > 0); + const items: DBusMenuItem[] = []; + let depth = 0; + + for (const line of lines) { + const trimmed = line.trim(); + const newDepth = (line.match(/^\s*/)?.[0]?.length ?? 0) / 2; + + if (trimmed.startsWith('(') && trimmed.includes('"')) { + const quoted = parseQuotedStrings(trimmed); + if (quoted.length > 0) { + const label = quoted[0]; + const item: DBusMenuItem = { + id: label, + label, + enabled: !line.toLowerCase().includes('disabled'), + }; + + if (newDepth === 0) { + items.push(item); + } else if (items.length > 0 && newDepth > depth) { + const parent = items[items.length - 1]; + if (!parent.children) { + parent.children = []; + } + parent.children.push(item); + } + } + depth = newDepth; + } + } + + return items; + } catch { + return []; + } +} + +function triggerDBusMenuEvent(service: string, menuPath: string, menuId: string): boolean { + try { + const out = runBusctlUser([ + 'call', + service, + menuPath.startsWith('/') ? menuPath : '/com/canonical/dbusmenu', + 'com.canonical.dbusmenu', + 'Event', + 'sis', + menuId, + 'clicked', + '', + ]); + return out.length > 0; + } catch { + return false; + } +} + +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 scheduleIpcReconnect(delayMs = 1000) { + if (CLIENT_MODE || process.env.WO_STANDALONE === '1') { + return; + } + if (ipcReconnectTimer) { + return; + } + + ipcReconnectTimer = setTimeout(() => { + ipcReconnectTimer = null; + connectToCompositor() + .then(() => { + debugLog('[Wo] IPC reconnect succeeded'); + }) + .catch((error) => { + debugLog('[Wo] IPC reconnect failed:', String(error)); + scheduleIpcReconnect(); + }); + }, delayMs); +} + +function sendActionToCompositor(action: string, payload?: Record): boolean { + if (!ipcSocket || !ipcConnected) { + return false; + } + + try { + const actionBuf = stringToBuffer(action); + const payloadStr = payload ? JSON.stringify(payload) : ''; + const payloadBuf = stringToBuffer(payloadStr); + + const messageBuf = Buffer.alloc(12 + actionBuf.length + payloadBuf.length); + let offset = 0; + messageBuf.writeUInt32LE(MAGIC.ACTION, offset); offset += 4; + messageBuf.writeUInt32LE(actionBuf.length, offset); offset += 4; + actionBuf.copy(messageBuf, offset); offset += actionBuf.length; + messageBuf.writeUInt32LE(payloadBuf.length, offset); offset += 4; + payloadBuf.copy(messageBuf, offset); + + ipcSocket.write(messageBuf); + return true; + } catch { + return false; + } +} + +function sendWindowPositionUpdate(x: number, y: number, width: number, height: number): boolean { + if (!ipcSocket || !ipcConnected) { + return false; + } + + try { + const nameBuf = stringToBuffer(name); + const messageBuf = Buffer.alloc(8 + nameBuf.length + 16); + + let offset = 0; + messageBuf.writeUInt32LE(MAGIC.WINDOW_POS, offset); offset += 4; + messageBuf.writeUInt32LE(nameBuf.length, offset); offset += 4; + nameBuf.copy(messageBuf, offset); offset += nameBuf.length; + messageBuf.writeInt32LE(x, offset); offset += 4; + messageBuf.writeInt32LE(y, offset); offset += 4; + messageBuf.writeUInt32LE(width, offset); offset += 4; + messageBuf.writeUInt32LE(height, offset); + + ipcSocket.write(messageBuf); + return true; + } catch { + return false; + } +} + +function sendForwardedKeyboard(windowName: string, evdevKey: number, pressed: boolean, time: number): boolean { + if (!ipcSocket || !ipcConnected) { + return false; + } + + try { + const nameBuf = stringToBuffer(windowName); + // wire: magic(4) + name_len(4) + name(N) + key(4) + pressed(4) + time(4) + const messageBuf = Buffer.alloc(8 + nameBuf.length + 12); + let offset = 0; + messageBuf.writeUInt32LE(MAGIC.FORWARD_KEYBOARD, offset); offset += 4; + messageBuf.writeUInt32LE(nameBuf.length, offset); offset += 4; + nameBuf.copy(messageBuf, offset); offset += nameBuf.length; + messageBuf.writeUInt32LE(evdevKey, offset); offset += 4; + messageBuf.writeUInt32LE(pressed ? 1 : 0, offset); offset += 4; + messageBuf.writeUInt32LE(time, offset); + + ipcSocket.write(messageBuf); + return true; + } catch { + return false; + } +} + +// Map Linux evdev button code to Electron mouse button name +function linuxButtonToElectron(btn: number): 'left' | 'middle' | 'right' { + if (btn === 273) return 'right'; + if (btn === 274) return 'middle'; + return 'left'; // 272 = BTN_LEFT and default +} + +// Modifier state tracked from key events +const modifierState = { shift: false, control: false, alt: false, meta: false }; + +// Evdev keycodes that correspond to modifier keys +const MODIFIER_KEYCODES: Record = { + 29: 'control', // KEY_LEFTCTRL + 42: 'shift', // KEY_LEFTSHIFT + 54: 'shift', // KEY_RIGHTSHIFT + 56: 'alt', // KEY_LEFTALT + 97: 'control', // KEY_RIGHTCTRL + 100: 'alt', // KEY_RIGHTALT + 125: 'meta', // KEY_LEFTMETA + 126: 'meta', // KEY_RIGHTMETA +}; + +type InputModifier = 'shift' | 'control' | 'alt' | 'meta'; + +function getModifierArray(): InputModifier[] { + const mods: InputModifier[] = []; + if (modifierState.shift) mods.push('shift'); + if (modifierState.control) mods.push('control'); + if (modifierState.alt) mods.push('alt'); + if (modifierState.meta) mods.push('meta'); + return mods; +} + +// Shifted character mapping for generating correct char events +const SHIFTED_CHARS: Record = { + '1': '!', '2': '@', '3': '#', '4': '$', '5': '%', '6': '^', '7': '&', '8': '*', '9': '(', '0': ')', + '-': '_', '=': '+', '[': '{', ']': '}', '\\': '|', ';': ':', "'": '"', '`': '~', + ',': '<', '.': '>', '/': '?', +}; + +// Map Linux evdev keycode to Electron sendInputEvent keyCode string +const LINUX_KEYCODE_MAP: Record = { + // Row 1: Escape and function keys + 1: 'Escape', + 59: 'F1', 60: 'F2', 61: 'F3', 62: 'F4', 63: 'F5', 64: 'F6', + 65: 'F7', 66: 'F8', 67: 'F9', 68: 'F10', 87: 'F11', 88: 'F12', + + // Row 2: Number row + 41: '`', 2: '1', 3: '2', 4: '3', 5: '4', 6: '5', 7: '6', 8: '7', 9: '8', 10: '9', + 11: '0', 12: '-', 13: '=', 14: 'Backspace', + + // Row 3: QWERTY + 15: 'Tab', + 16: 'q', 17: 'w', 18: 'e', 19: 'r', 20: 't', 21: 'y', 22: 'u', 23: 'i', 24: 'o', 25: 'p', + 26: '[', 27: ']', 43: '\\', + + // Row 4: Home row + 58: 'CapsLock', + 30: 'a', 31: 's', 32: 'd', 33: 'f', 34: 'g', 35: 'h', 36: 'j', 37: 'k', 38: 'l', + 39: ';', 40: "'", 28: 'Return', + + // Row 5: Bottom row + 42: 'Shift', + 44: 'z', 45: 'x', 46: 'c', 47: 'v', 48: 'b', 49: 'n', 50: 'm', + 51: ',', 52: '.', 53: '/', 54: 'Shift', + + // Row 6: Modifiers and space + 29: 'Control', 125: 'Meta', 56: 'Alt', 57: 'Space', 100: 'Alt', 126: 'Meta', 97: 'Control', + + // Navigation cluster + 110: 'Insert', 102: 'Home', 104: 'PageUp', + 111: 'Delete', 107: 'End', 109: 'PageDown', + + // Arrow keys + 103: 'Up', 105: 'Left', 108: 'Down', 106: 'Right', + + // Numpad + 69: 'NumLock', 98: 'numdiv', 55: 'nummult', 74: 'numsub', + 71: 'num7', 72: 'num8', 73: 'num9', 78: 'numadd', + 75: 'num4', 76: 'num5', 77: 'num6', + 79: 'num1', 80: 'num2', 81: 'num3', 96: 'Enter', + 82: 'num0', 83: 'numdec', + + // Media / misc + 99: 'PrintScreen', 70: 'ScrollLock', 119: 'Pause', + 113: 'VolumeMute', 114: 'VolumeDown', 115: 'VolumeUp', + 163: 'MediaNextTrack', 165: 'MediaPreviousTrack', 164: 'MediaPlayPause', 166: 'MediaStop', +}; + +function connectToCompositor(): Promise { + return new Promise((resolve, reject) => { + if (ipcReconnectTimer) { + clearTimeout(ipcReconnectTimer); + ipcReconnectTimer = null; + } + if (ipcSocket && !ipcSocket.destroyed) { + ipcSocket.destroy(); + } + + debugLog('[Wo] Connecting to compositor IPC:', IPC_SOCKET); + ipcSocket = createConnection(IPC_SOCKET, () => { + const windowName = name; + const nameBuf = stringToBuffer(windowName); + const messageBuf = Buffer.alloc(8 + nameBuf.length + 8); + + let offset = 0; + messageBuf.writeUInt32LE(MAGIC.HELLO, offset); offset += 4; + messageBuf.writeUInt32LE(nameBuf.length, offset); offset += 4; + nameBuf.copy(messageBuf, offset); offset += nameBuf.length; + messageBuf.writeUInt32LE(width, offset); offset += 4; + messageBuf.writeUInt32LE(height, offset); + + ipcSocket!.write(messageBuf); + ipcConnected = true; + debugLog('[Wo] IPC connected, sent HELLO message'); + + try { + const nativePath = resolvePath(__dirname, '../native/build/Release/wo_dmabuf.node'); + nativeDmabuf = loadNativeDmabufModule(require, nativePath); + nativeDmabuf.init(process.env.WO_DRM_RENDER_NODE || '/dev/dri/renderD128'); + dmabufSender = new WoDmabufSender(ipcSocket!, name, nativeDmabuf); + debugLog('[Wo] DMABUF sender initialized'); + } catch (error) { + debugLog('[Wo] DMABUF sender unavailable:', error); + dmabufSender = null; + } + + resolve(); + }); + + let ipcDataCalls = 0; + let ipcDataLastLog = Date.now(); + // Input event batching for improved responsiveness + let pendingMouseMove: { x: number; y: number } | null = null; + let inputFlushTimer: NodeJS.Timeout | null = null; + const flushInputEvents = () => { + inputFlushTimer = null; + if (pendingMouseMove && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ + type: 'mouseMove', + x: pendingMouseMove.x, + y: pendingMouseMove.y, + }); + pendingMouseMove = null; + } + }; + + ipcSocket.on('data', (chunk: Buffer) => { + ipcDataCalls++; + const now = Date.now(); + if (now - ipcDataLastLog > 2000) { + ipcDataCalls = 0; + ipcDataLastLog = now; + } + const dataT0 = Date.now(); + compositorRxBuffer = Buffer.concat([compositorRxBuffer, chunk]); + while (compositorRxBuffer.length >= 4) { + const magic = compositorRxBuffer.readUInt32LE(0); + + if (magic === MAGIC.FRAME_ACK) { + if (compositorRxBuffer.length < 12) break; + const seq = compositorRxBuffer.readBigUInt64LE(4).toString(); + inFlightFrameSeqs.delete(seq); + compositorRxBuffer = compositorRxBuffer.slice(12); + continue; + } + + if (magic === MAGIC.MOUSE_MOVE) { + // magic(4) + x(8, f64) + y(8, f64) = 20 bytes + if (compositorRxBuffer.length < 20) break; + const x = compositorRxBuffer.readDoubleLE(4); + const y = compositorRxBuffer.readDoubleLE(12); + compositorRxBuffer = compositorRxBuffer.slice(20); + pointerX = Math.round(x); + pointerY = Math.round(y); + + // OPTIMIZATION: Batch mouse moves for responsiveness + // Flush immediately if no timer pending, otherwise coalesce + if (!inputFlushTimer) { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ type: 'mouseMove', x: pointerX, y: pointerY }); + } + } else { + // Coalesce subsequent moves + pendingMouseMove = { x: pointerX, y: pointerY }; + inputFlushTimer = setTimeout(flushInputEvents, 0); + } + continue; + } + + if (magic === MAGIC.MOUSE_BUTTON) { + // magic(4) + button(4) + pressed(4) + time(4) = 16 bytes + if (compositorRxBuffer.length < 16) break; + const button = compositorRxBuffer.readUInt32LE(4); + const pressed = compositorRxBuffer.readUInt32LE(8) !== 0; + compositorRxBuffer = compositorRxBuffer.slice(16); + + // OPTIMIZATION: Flush pending mouse moves before button events + if (pendingMouseMove && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ + type: 'mouseMove', + x: pendingMouseMove.x, + y: pendingMouseMove.y, + }); + pendingMouseMove = null; + } + if (inputFlushTimer) { + clearTimeout(inputFlushTimer); + inputFlushTimer = null; + } + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ + type: pressed ? 'mouseDown' : 'mouseUp', + x: pointerX, + y: pointerY, + button: linuxButtonToElectron(button), + clickCount: 1, + modifiers: getModifierArray(), + }); + } + continue; + } + + if (magic === MAGIC.KEYBOARD) { + // magic(4) + key(4) + pressed(4) + time(4) = 16 bytes + if (compositorRxBuffer.length < 16) break; + const key = compositorRxBuffer.readUInt32LE(4); + const pressed = compositorRxBuffer.readUInt32LE(8) !== 0; + compositorRxBuffer = compositorRxBuffer.slice(16); + + // Update modifier state before generating events + const modKey = MODIFIER_KEYCODES[key]; + if (modKey) { + modifierState[modKey] = pressed; + } + + const keyCode = LINUX_KEYCODE_MAP[key]; + if (keyCode && mainWindow && !mainWindow.isDestroyed()) { + const modifiers = getModifierArray(); + mainWindow.webContents.sendInputEvent({ + type: pressed ? 'keyDown' : 'keyUp', + keyCode, + modifiers, + }); + // Send char event for printable keys on press + if (pressed && keyCode.length === 1) { + let charKey = keyCode; + if (modifierState.shift) { + if (charKey >= 'a' && charKey <= 'z') { + charKey = charKey.toUpperCase(); + } else if (SHIFTED_CHARS[charKey]) { + charKey = SHIFTED_CHARS[charKey]; + } + } + mainWindow.webContents.sendInputEvent({ type: 'char', keyCode: charKey, modifiers }); + } + } + continue; + } + + if (magic === MAGIC.SCROLL) { + // magic(4) + vertical(4, i32) + horizontal(4, i32) + time(4) = 16 bytes + if (compositorRxBuffer.length < 16) break; + const vertical = compositorRxBuffer.readInt32LE(4); + const horizontal = compositorRxBuffer.readInt32LE(8); + compositorRxBuffer = compositorRxBuffer.slice(16); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.sendInputEvent({ + type: 'mouseWheel', + x: pointerX, + y: pointerY, + deltaX: horizontal, + deltaY: vertical, + canScroll: true, + }); + } + continue; + } + + if (magic === MAGIC.FOCUS_CHANGE) { + // magic(4) + name_len(4) + focused(4) + name(N) = 12 + N bytes + if (compositorRxBuffer.length < 12) break; + const nameLen = compositorRxBuffer.readUInt32LE(4); + if (compositorRxBuffer.length < 12 + nameLen) break; + const focused = compositorRxBuffer.readUInt32LE(8) !== 0; + const windowName = compositorRxBuffer.slice(12, 12 + nameLen).toString('utf8'); + compositorRxBuffer = compositorRxBuffer.slice(12 + nameLen); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('wo:focus-change', { window: windowName, focused }); + } + continue; + } + + if (magic === MAGIC.WINDOW_META) { + // magic(4) + payload_len(4) + payload(N) = 8 + N bytes + if (compositorRxBuffer.length < 8) break; + const payloadLen = compositorRxBuffer.readUInt32LE(4); + if (compositorRxBuffer.length < 8 + payloadLen) break; + const metadata = compositorRxBuffer.slice(8, 8 + payloadLen).toString('utf8'); + compositorRxBuffer = compositorRxBuffer.slice(8 + payloadLen); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('wo:window-metadata', { type: 'windowMetadata', metadata }); + // Parse and forward as structured window list for woClient.onWindows + try { + const parsed = JSON.parse(metadata); + if (parsed && Array.isArray(parsed.windows)) { + compositorWindows = parsed.windows; + mainWindow.webContents.send('wo:windows', compositorWindows); + } + } catch { /* ignore parse errors */ } + } + continue; + } + + if (magic === MAGIC.SHM_BUFFER) { + if (compositorRxBuffer.length < 8) break; + const nameLen = compositorRxBuffer.readUInt32LE(4); + const headerSize = 8 + nameLen + 20; + if (compositorRxBuffer.length < headerSize) break; + let off = 8; + const windowName = compositorRxBuffer.slice(off, off + nameLen).toString('utf8'); + off += nameLen; + const sbWidth = compositorRxBuffer.readUInt32LE(off); off += 4; + const sbHeight = compositorRxBuffer.readUInt32LE(off); off += 4; + const sbStride = compositorRxBuffer.readUInt32LE(off); off += 4; + const pid = compositorRxBuffer.readUInt32LE(off); off += 4; + const fd = compositorRxBuffer.readUInt32LE(off); + compositorRxBuffer = compositorRxBuffer.slice(headerSize); + + try { + const extFd = getOrOpenShmFd(windowName, pid, fd); + const size = sbStride * sbHeight; + const safeBuffer = getReusableShmBuffer(windowName, size); + let totalRead = 0; + while (totalRead < size) { + const bytesRead = fs.readSync(extFd, safeBuffer, totalRead, size - totalRead, totalRead); + if (bytesRead <= 0) break; + totalRead += bytesRead; + } + + if (totalRead === size) { + surfaceBufferPending.set(windowName, { + name: windowName, + width: sbWidth, + height: sbHeight, + stride: sbStride, + pixels: safeBuffer, + }); + if (!surfaceBufferTimer) { + surfaceBufferTimer = setTimeout(flushSurfaceBuffers, 10); + } + } + } catch (err) { + const cached = shmFdCache.get(windowName); + if (cached) { + try { + fs.closeSync(cached.extFd); + } catch { + // ignore + } + shmFdCache.delete(windowName); + } + debugLog(`[Wo] SHM_BUFFER fs.readSync failed for ${windowName}:`, err); + } + continue; + } + + if (magic === MAGIC.DMABUF_FRAME) { + if (compositorRxBuffer.length < 8) break; + const nameLen = compositorRxBuffer.readUInt32LE(4); + const numPlanesOffset = 8 + nameLen + 12; + if (compositorRxBuffer.length < numPlanesOffset + 4) break; + + let off = 8; + const dmabufName = compositorRxBuffer.slice(off, off + nameLen).toString('utf8'); + off += nameLen; + const dmabufW = compositorRxBuffer.readUInt32LE(off); off += 4; + const dmabufH = compositorRxBuffer.readUInt32LE(off); off += 4; + const dmabufFormat = compositorRxBuffer.readUInt32LE(off); off += 4; + const numPlanes = compositorRxBuffer.readUInt32LE(off); off += 4; + + const totalSize = off + (numPlanes * 24); + if (compositorRxBuffer.length < totalSize) break; + + try { + if (nativeDmabuf && ipcSocket) { + const socketFd = (ipcSocket as any)?._handle?.fd; + if (typeof socketFd === 'number' && socketFd >= 0) { + const dmabufFd = nativeDmabuf.recvFd(socketFd); + if (dmabufFd >= 0) { + try { + const textureInfo = nativeDmabuf.importDmabufTexture(dmabufName, dmabufFd, dmabufW, dmabufH, dmabufFormat); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('wo:dmabuf-frame', { + name: dmabufName, + texture: textureInfo.texture, + width: textureInfo.width, + height: textureInfo.height, + }); + } + } finally { + fs.closeSync(dmabufFd); + } + } + } + } + } catch (err) { + debugLog(`[Wo] DMABUF import failed for ${dmabufName}:`, err); + } + + compositorRxBuffer = compositorRxBuffer.slice(totalSize); + continue; + } + + if (magic === MAGIC.POINTER_LOCK_REQUEST) { + // magic(4) + name_len(4) + lock(4) + name(N) = 12 + N bytes + if (compositorRxBuffer.length < 12) break; + const nameLen = compositorRxBuffer.readUInt32LE(4); + if (compositorRxBuffer.length < 12 + nameLen) break; + const lock = compositorRxBuffer.readUInt32LE(8) !== 0; + const windowName = compositorRxBuffer.slice(12, 12 + nameLen).toString('utf8'); + compositorRxBuffer = compositorRxBuffer.slice(12 + nameLen); + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('wo:pointer-lock-request', { window: windowName, lock }); + } + continue; + } + + // Unknown message magic; resync by advancing one byte. + debugLog('[Wo] UNKNOWN magic 0x' + magic.toString(16).padStart(8, '0') + ' bufLen=' + compositorRxBuffer.length + ' — stream may be corrupted'); + compositorRxBuffer = compositorRxBuffer.slice(1); + } + const dataElapsed = Date.now() - dataT0; + if (dataElapsed > 50) { + console.warn(`[IPC SLOW] data handler took ${dataElapsed}ms chunkLen=${chunk.length}`); + } + }); + + ipcSocket.on('error', (error: Error) => { + debugLog('[Wo] IPC connection error:', error); + const hadConnected = ipcConnected; + ipcConnected = false; + dmabufSender = null; + if (hadConnected) { + scheduleIpcReconnect(); + return; + } + reject(error); + }); + ipcSocket.on('close', () => { + debugLog('[Wo] IPC connection closed'); + ipcConnected = false; + ipcSocket = null; + dmabufSender = null; + inFlightFrameSeqs.clear(); + compositorRxBuffer = Buffer.alloc(0); + scheduleIpcReconnect(); + }); + + setTimeout(() => { + if (!ipcConnected) { + debugLog('[Wo] IPC connection timeout'); + reject(new Error('Failed to connect to compositor IPC socket')); + } + }, 5000); + }); +} + +function setupIpcHandlers() { + ipcMain.handle('action', (_event, action: string, payload?: Record) => { + return sendActionToCompositor(action, payload); + }); + + ipcMain.on('action-sync', (_event, action: string, payload?: Record) => { + sendActionToCompositor(action, payload); + }); + + ipcMain.handle('wo:get-windows', async () => compositorWindows.length > 0 ? compositorWindows : clientWindows); + + ipcMain.on('wo:keybind-action', (_event, action: string) => { + if (action === 'shuffle') { + clientWindows = [...clientWindows].sort(() => Math.random() - 0.5); + } else if (action === 'reverse') { + clientWindows = [...clientWindows].reverse(); + } + + if (mainWindow) { + mainWindow.webContents.send('wo:windows', clientWindows); + } + }); + + ipcMain.handle('wo:submit-damage-frame', (_event, payload: DamageFramePayload) => { + if (!dmabufSender) { + return { ok: false, reason: 'dmabuf-sender-not-ready' }; + } + + if (inFlightFrameSeqs.size >= MAX_IN_FLIGHT_FRAMES) { + return { ok: true, skipped: true, reason: 'backpressure' }; + } + + if (!damageBuffer) { + damageBuffer = new DamageBuffer(payload.width, payload.height); + } + + // Mark that the app is using the damage helper path; OSR paint will be suppressed + appUsedDamageHelper = true; + applyDamagePayload(damageBuffer, payload); + const seq = dmabufSender.send(damageBuffer); + inFlightFrameSeqs.add(seq.toString()); + return { ok: true, seq: seq.toString() }; + }); + + // Keyboard forwarding: renderer sends evdev key events for focused Wayland canvas windows + ipcMain.on('wo:forward-keyboard', (_event, windowName: string, key: number, pressed: boolean, time: number) => { + if (ipcSocket && ipcConnected) { + const nameBuf = Buffer.from(windowName, 'utf8'); + const msg = Buffer.alloc(4 + 4 + nameBuf.length + 12); + let off = 0; + msg.writeUInt32LE(MAGIC.FORWARD_KEYBOARD, off); off += 4; + msg.writeUInt32LE(nameBuf.length, off); off += 4; + nameBuf.copy(msg, off); off += nameBuf.length; + msg.writeUInt32LE(key, off); off += 4; + msg.writeUInt32LE(pressed ? 1 : 0, off); off += 4; + msg.writeUInt32LE(time || 0, off); + ipcSocket.write(msg); + } + }); + + ipcMain.on('wo:forward-relative-pointer', (_event, windowName: string, dx: number, dy: number) => { + if (ipcSocket && ipcConnected) { + const nameBuf = Buffer.from(windowName, 'utf8'); + const msg = Buffer.alloc(4 + 4 + nameBuf.length + 16); + let off = 0; + msg.writeUInt32LE(MAGIC.FORWARD_RELATIVE_POINTER, off); off += 4; + msg.writeUInt32LE(nameBuf.length, off); off += 4; + nameBuf.copy(msg, off); off += nameBuf.length; + msg.writeDoubleLE(dx, off); off += 8; + msg.writeDoubleLE(dy, off); + ipcSocket.write(msg); + } + }); + + // Syscall handler for system operations + const iconCache = new Map(); + + function resolveDesktopIconFast(iconName: string, fs: any, path: any): { type: string; data: string; mimeType?: string } | null { + if (iconCache.has(iconName)) return iconCache.get(iconName) ?? null; + + let result: { type: string; data: string; mimeType?: string } | null = null; + + // Absolute path: read directly + if (iconName.startsWith('/')) { + try { + if (fs.existsSync(iconName)) { + const ext = path.extname(iconName).toLowerCase(); + const mime = ext === '.svg' ? 'image/svg+xml' : 'image/png'; + result = { type: 'base64', data: fs.readFileSync(iconName).toString('base64'), mimeType: mime }; + } + } catch { /* ignore */ } + iconCache.set(iconName, result); + return result; + } + + // Only check 4 fast paths to avoid blocking the event loop: + // 1. /usr/share/pixmaps/{name}.png + // 2. /usr/share/pixmaps/{name}.svg + // 3. hicolor 48x48/apps/{name}.png + // 4. hicolor scalable/apps/{name}.svg + const quickPaths = [ + [`/usr/share/pixmaps/${iconName}.png`, 'image/png'], + [`/usr/share/pixmaps/${iconName}.svg`, 'image/svg+xml'], + [`/usr/share/icons/hicolor/48x48/apps/${iconName}.png`, 'image/png'], + [`/usr/share/icons/hicolor/scalable/apps/${iconName}.svg`, 'image/svg+xml'], + ]; + + for (const [iconPath, mime] of quickPaths) { + try { + if (fs.existsSync(iconPath)) { + result = { type: 'base64', data: fs.readFileSync(iconPath).toString('base64'), mimeType: mime }; + break; + } + } catch { /* ignore */ } + } + + iconCache.set(iconName, result); + return result; + } + + ipcMain.handle('wo:syscall', async (_event, type: string, params: Record) => { + try { + switch (type) { + case 'list_applications': { + // Scan desktop files from standard locations + const path = await import('path'); + const fs = await import('fs'); + + const apps: Array<{ name: string; icon?: { type: string; data: string }; command: string }> = []; + const dirs = ['/usr/share/applications', `${process.env.HOME}/.local/share/applications`]; + const seen = new Set(); + + for (const dir of dirs) { + try { + if (!fs.default.existsSync(dir)) continue; + const files = fs.default.readdirSync(dir).filter((f: string) => f.endsWith('.desktop')); + + for (const file of files) { + const filePath = path.default.join(dir, file); + try { + const content = fs.default.readFileSync(filePath, 'utf-8'); + if (content.includes('NoDisplay=true')) continue; + + const nameMatch = content.match(/^Name=(.+)$/m); + const execMatch = content.match(/^Exec=(.+)$/m); + const iconMatch = content.match(/^Icon=(.+)$/m); + + if (nameMatch && execMatch) { + const name = nameMatch[1].trim(); + if (!seen.has(name)) { + seen.add(name); + const exec = execMatch[1].trim().split(' ')[0].split('/').pop() || execMatch[1].trim(); + const app: any = { + name, + command: exec, + }; + // Resolve icon: if absolute path use it directly, otherwise search icon theme dirs + if (iconMatch) { + const iconName = iconMatch[1].trim(); + const resolved = resolveDesktopIconFast(iconName, fs.default, path.default); + if (resolved) { + app.icon = resolved; + } else { + app.icon = { type: 'iconify', data: iconName }; + } + } + apps.push(app); + } + } + } catch { + // Skip files that can't be read + } + } + } catch { + // Skip directories that don't exist or can't be read + } + } + + return apps.length > 0 ? apps : [ + { name: 'Terminal', icon: { type: 'iconify', data: 'mdi:console' }, command: 'xterm' }, + { name: 'Firefox', icon: { type: 'iconify', data: 'mdi:firefox' }, command: 'firefox' }, + { name: 'Files', icon: { type: 'iconify', data: 'mdi:folder' }, command: 'nautilus' }, + ]; + } + + case 'launch': + case 'exec': { + const { spawn } = await import('child_process'); + const command = (params.command as string) || ''; + + if (!command) { + return { ok: false, error: 'No command specified' }; + } + + try { + const child = spawn(command, { + detached: true, + stdio: 'ignore', + shell: true, + }); + child.unref(); + return { ok: true, pid: child.pid }; + } catch (error) { + return { ok: false, error: String(error) }; + } + } + + case 'listdir': { + const fs = await import('fs'); + const path = await import('path'); + const dirPath = (params.path as string) || process.env.HOME || '/home'; + + try { + const entries = fs.default.readdirSync(dirPath, { withFileTypes: true }); + return { + ok: true, + path: dirPath, + entries: entries.map((e) => ({ + name: e.name, + type: e.isDirectory() ? 'dir' : 'file', + })), + }; + } catch (error) { + return { ok: false, error: String(error) }; + } + } + + case 'read': { + const fs = await import('fs'); + const filePath = params.path as string; + + if (!filePath) { + return { ok: false, error: 'No file path specified' }; + } + + try { + const content = fs.default.readFileSync(filePath, 'utf-8'); + return { ok: true, content, size: content.length }; + } catch (error) { + return { ok: false, error: String(error) }; + } + } + + case 'write': { + const fs = await import('fs'); + const filePath = params.path as string; + const content = params.content as string; + + if (!filePath || content === undefined) { + return { ok: false, error: 'Missing file path or content' }; + } + + try { + fs.default.writeFileSync(filePath, content); + return { ok: true, bytesWritten: content.length }; + } catch (error) { + return { ok: false, error: String(error) }; + } + } + + case 'shutdown': { + const { spawn } = await import('child_process'); + spawn('systemctl', ['poweroff'], { detached: true, stdio: 'ignore' }).unref(); + return { ok: true }; + } + + case 'restart': { + const { spawn } = await import('child_process'); + spawn('systemctl', ['reboot'], { detached: true, stdio: 'ignore' }).unref(); + return { ok: true }; + } + + case 'logout': { + const { spawn } = await import('child_process'); + const sessionId = process.env.XDG_SESSION_ID || ''; + if (sessionId) { + spawn('loginctl', ['terminate-session', sessionId], { detached: true, stdio: 'ignore' }).unref(); + } else { + // Fallback: quit the compositor + sendActionToCompositor('quit', { code: 0 }); + } + return { ok: true }; + } + + case 'lock': { + const { spawn } = await import('child_process'); + spawn('loginctl', ['lock-session'], { detached: true, stdio: 'ignore' }).unref(); + return { ok: true }; + } + + case 'sleep': { + const { spawn } = await import('child_process'); + spawn('systemctl', ['suspend'], { detached: true, stdio: 'ignore' }).unref(); + return { ok: true }; + } + + case 'notify': { + // Forward notification to renderer + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('wo:notification', { + id: params.id || `notify-${Date.now()}`, + title: params.title || 'Notification', + body: params.body || '', + icon: params.icon, + timeout: params.timeout ?? 5000, + timestamp: Date.now(), + }); + } + return { ok: true }; + } + + case 'tray_list': { + return getStatusNotifierTrayItems(); + } + + case 'tray_activate': { + const service = String(params.service || ''); + const objectPath = String(params.objectPath || ''); + if (!service || !objectPath) { + return { ok: false, error: 'Missing service or objectPath' }; + } + const ok = activateStatusNotifierItem(service, objectPath); + return ok ? { ok: true } : { ok: false, error: 'Activation failed' }; + } + + case 'tray_secondary_activate': { + const service = String(params.service || ''); + const objectPath = String(params.objectPath || ''); + if (!service || !objectPath) { + return { ok: false, error: 'Missing service or objectPath' }; + } + const ok = secondaryActivateStatusNotifierItem(service, objectPath); + return ok ? { ok: true } : { ok: false, error: 'Secondary activation failed' }; + } + + case 'tray_context_menu': { + const service = String(params.service || ''); + const objectPath = String(params.objectPath || ''); + const x = Number(params.x ?? 0); + const y = Number(params.y ?? 0); + if (!service || !objectPath) { + return { ok: false, error: 'Missing service or objectPath' }; + } + const ok = openStatusNotifierContextMenu(service, objectPath, x, y); + return ok ? { ok: true } : { ok: false, error: 'Context menu failed' }; + } + + case 'tray_menu_items': { + const service = String(params.service || ''); + const menuPath = String(params.menuPath || ''); + if (!service || !menuPath) { + return []; + } + return getDBusMenuLayout(service, menuPath); + } + + case 'tray_menu_event': { + const service = String(params.service || ''); + const menuPath = String(params.menuPath || ''); + const menuId = String(params.menuId || ''); + if (!service || !menuPath || !menuId) { + return { ok: false, error: 'Missing parameters' }; + } + const ok = triggerDBusMenuEvent(service, menuPath, menuId); + return ok ? { ok: true } : { ok: false, error: 'Menu event failed' }; + } + + case 'portal_respond': { + const requestId = String(params.requestId || ''); + if (!requestId) { + return { ok: false, error: 'Missing portal requestId' }; + } + return respondToPortalRequest(requestId, { + allowed: Boolean(params.allowed), + type: typeof params.type === 'string' ? params.type : undefined, + windowName: params.windowName, + }); + } + + default: + return { ok: false, error: `Unknown syscall: ${type}` }; + } + } catch (error) { + return { ok: false, error: `Syscall error: ${String(error)}` }; + } + }); +} + +async function createWindow() { + debugLog('[Wo] createWindow starting'); + const preloadPath = joinPath(__dirname, 'preload.js'); + debugLog('[Wo] preloadPath=' + preloadPath); + + try { + mainWindow = new BrowserWindow({ + width: CLIENT_MODE ? 1400 : width, + height: CLIENT_MODE ? 900 : height, + transparent: true, + backgroundColor: '#00000000', + frame: CLIENT_MODE, + webPreferences: { + sandbox: false, + contextIsolation: true, + preload: preloadPath, + nodeIntegration: false, + offscreen: !CLIENT_MODE + }, + show: CLIENT_MODE, + }); + + debugLog('[Wo] BrowserWindow created, CLIENT_MODE=' + CLIENT_MODE + ' offscreen=' + !CLIENT_MODE); + + setupIpcHandlers(); + debugLog('[Wo] setupIpcHandlers completed'); + + // Send initial window list regardless of mode + mainWindow.webContents.send('wo:windows', clientWindows); + + if (CLIENT_MODE) { + return; + } + + // In offscreen mode, we need actual content to trigger paint events + // Load the configured URL or fallback to minimal page + debugLog('[Wo] Loading content URL: ' + (url || 'minimal fallback')); + try { + const pageUrl = url || 'data:text/html,'; + const loadPromise = mainWindow!.loadURL(pageUrl); + // Give it time to load but don't wait forever + await Promise.race([ + loadPromise, + new Promise(r => setTimeout(() => { debugLog('[Wo] Load timeout, continuing'); r(undefined); }, 3000)) + ]); + debugLog('[Wo] Content loaded from: ' + pageUrl); + } catch (e) { + debugLog('[Wo] Load failed:', String(e)); + } + + debugLog('[Wo] Page initialized, setting up rendering'); + + // Ensure the webcontents has focus so sendInputEvent works for keyboard events + mainWindow.webContents.focus(); + + if (!CLIENT_MODE) { + debugLog('[Wo] Setting up OSR rendering, FPS=' + (WINDOW_CONFIG.fps ?? 60)); + mainWindow.webContents.setFrameRate(WINDOW_CONFIG.fps ?? 60); + mainWindow.webContents.on('did-finish-load', () => { + mainWindow!.webContents.setFrameRate(WINDOW_CONFIG.fps ?? 60); + }); + const frameIntervalMs = Math.round(1000 / (WINDOW_CONFIG.fps ?? 60)); + setInterval(() => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.invalidate(); + } + }, frameIntervalMs); + let skippedCount = 0; + mainWindow.webContents.on('paint', (_event, _dirty, image) => { + if (!dmabufSender || !ipcConnected) { + skippedCount++; + debugLog('[Wo] SKIPPED paint (dmabufSender=' + !!dmabufSender + ' ipcConnected=' + ipcConnected + ')'); + return; + } + if (appUsedDamageHelper) { + return; + } + if (inFlightFrameSeqs.size >= MAX_IN_FLIGHT_FRAMES) { + skippedCount++; + if (skippedCount === 1 || skippedCount % 120 === 0) { + debugLog('[Wo] SKIPPED paint due to frame backpressure inFlight=' + inFlightFrameSeqs.size); + } + return; + } + const size = image.getSize(); + if (!damageBuffer) { + damageBuffer = new DamageBuffer(size.width, size.height); + } + const pixels = image.getBitmap(); + applyDamagePayload(damageBuffer, { + width: size.width, + height: size.height, + fullFrame: new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength), + }); + try { + const seq = dmabufSender.send(damageBuffer); + inFlightFrameSeqs.add(seq.toString()); + } catch (e) { + debugLog('[Wo] ERROR sending frame:', String(e)); + } + }); + } + + if (process.env.WO_DEBUG === '1') { + mainWindow.webContents.openDevTools(); + } + + debugLog('[Wo] createWindow completed successfully'); + } catch (error) { + debugLog('[Wo] createWindow error:', error); + throw error; + } + + mainWindow.on('closed', () => { + mainWindow = null; + if (windowPositionUpdateTimer) { + clearInterval(windowPositionUpdateTimer); + windowPositionUpdateTimer = null; + } + }); + + // Send window position updates to compositor for window capture and positioning + if (!CLIENT_MODE && ipcConnected) { + const sendPosition = () => { + if (mainWindow && !mainWindow.isDestroyed()) { + const bounds = mainWindow.getBounds(); + sendWindowPositionUpdate(bounds.x, bounds.y, bounds.width, bounds.height); + } + }; + + // Send initial position + sendPosition(); + + // Update position periodically (every 100ms) + windowPositionUpdateTimer = setInterval(sendPosition, 100); + + // Also send on move/resize events + mainWindow.on('move', sendPosition); + mainWindow.on('resize', sendPosition); + } +} + +app.on('ready', async () => { + debugLog('[Wo] App ready, CLIENT_MODE=', CLIENT_MODE, 'WO_STANDALONE=', process.env.WO_STANDALONE); + try { + startPortalUiBridge(); + + if (!CLIENT_MODE && process.env.WO_STANDALONE !== '1') { + debugLog('[Wo] Will connect to compositor'); + await connectToCompositor(); + } + + debugLog('[Wo] Creating window'); + await createWindow(); + debugLog('[Wo] Window created successfully'); + } catch (error) { + debugLog('[Wo] Failed to start app:', error); + app.quit(); + } +}); + +app.on('window-all-closed', () => { + stopPortalUiBridge(); + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('activate', () => { + if (mainWindow === null) { + createWindow().catch(console.error); + } +}); + +app.on('before-quit', () => { + stopPortalUiBridge(); + closeShmFdCache(); +}); + +export { sendActionToCompositor, connectToCompositor }; diff --git a/electron/src/preload.ts b/electron/src/preload.ts new file mode 100644 index 0000000..737404c --- /dev/null +++ b/electron/src/preload.ts @@ -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) => { + return await ipcRenderer.invoke('action', name, payload ?? {}); + }, + actionSync: (name: string, payload?: Record) => { + 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) => { + 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, + 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 }; diff --git a/electron/src/protocol.ts b/electron/src/protocol.ts new file mode 100644 index 0000000..53555e6 --- /dev/null +++ b/electron/src/protocol.ts @@ -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; +} + +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); +} diff --git a/electron/src/renderer-damage-helper.ts b/electron/src/renderer-damage-helper.ts new file mode 100644 index 0000000..5053212 --- /dev/null +++ b/electron/src/renderer-damage-helper.ts @@ -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 }; +} diff --git a/electron/src/wo-input-receiver.ts b/electron/src/wo-input-receiver.ts new file mode 100644 index 0000000..35bc8ed --- /dev/null +++ b/electron/src/wo-input-receiver.ts @@ -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 }; + diff --git a/electron/tsconfig.json b/electron/tsconfig.json new file mode 100644 index 0000000..cf78736 --- /dev/null +++ b/electron/tsconfig.json @@ -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" + ] +} diff --git a/electron/types/.gitignore b/electron/types/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/electron/types/.gitignore @@ -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 diff --git a/electron/types/CLAUDE.md b/electron/types/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/electron/types/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +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

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/electron/types/README.md b/electron/types/README.md new file mode 100644 index 0000000..b005a8c --- /dev/null +++ b/electron/types/README.md @@ -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([]); + const [metadata, setMetadata] = useState(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 ( +
+

Active windows: {metadata?.count ?? 0}

+
+ ); +} +``` + +## API Reference + +### `CompositorAPI` + +Exposed as `window.compositor` in Electron windows. + +#### Methods + +- `action(name: string, payload?: Record): Promise` + - Send an action to the compositor + +- `actionSync(name: string, payload?: Record): void` + - Send an action synchronously (fire and forget) + +- `quit(code?: number): Promise` + - Request the compositor to quit + +- `reload(): Promise` + - Reload the current window + +- `toggleDevTools(): Promise` + - 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): Promise` + - Execute a syscall in the compositor + +### `WoClientAPI` + +Exposed as `window.woClient` in Electron windows. + +#### Methods + +- `getWindows(): Promise` + - 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 diff --git a/electron/types/bun.lock b/electron/types/bun.lock new file mode 100644 index 0000000..72a899a --- /dev/null +++ b/electron/types/bun.lock @@ -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=="], + } +} diff --git a/electron/types/dispatcher.ts b/electron/types/dispatcher.ts new file mode 100644 index 0000000..22a20f3 --- /dev/null +++ b/electron/types/dispatcher.ts @@ -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 }); + } +} diff --git a/electron/types/index.ts b/electron/types/index.ts new file mode 100644 index 0000000..921133e --- /dev/null +++ b/electron/types/index.ts @@ -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'; diff --git a/electron/types/package.json b/electron/types/package.json new file mode 100644 index 0000000..7424458 --- /dev/null +++ b/electron/types/package.json @@ -0,0 +1,12 @@ +{ + "name": "wo-types", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/electron/types/tsconfig.json b/electron/types/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/electron/types/tsconfig.json @@ -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 + } +} diff --git a/electron/types/types.d.ts b/electron/types/types.d.ts new file mode 100644 index 0000000..2042840 --- /dev/null +++ b/electron/types/types.d.ts @@ -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; + + /** + * 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): void; + + /** + * Execute a syscall (filesystem, process, etc.) with strong typings + */ + syscall(type: 'list_applications', params?: SyscallListApplicationsParams): Promise; + syscall(type: 'launch', params: SyscallLaunchParams): Promise; + syscall(type: 'exec', params: SyscallExecParams): Promise; + syscall(type: 'listdir', params?: SyscallListDirParams): Promise; + syscall(type: 'read', params: SyscallReadParams): Promise; + syscall(type: 'write', params: SyscallWriteParams): Promise; + syscall(type: 'shutdown', params?: SyscallShutdownParams): Promise; + syscall(type: 'restart', params?: SyscallRestartParams): Promise; + syscall(type: 'logout', params?: SyscallLogoutParams): Promise; + syscall(type: 'lock', params?: SyscallLockParams): Promise; + syscall(type: 'sleep', params?: SyscallSleepParams): Promise; + syscall(type: 'notify', params: SyscallNotifyParams): Promise; + syscall(type: 'portal_respond', params: SyscallPortalRespondParams): Promise; + syscall(type: string, params?: any): Promise; + + /** + * 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[]; +} \ No newline at end of file diff --git a/packaging/wayland-sessions/wo.desktop b/packaging/wayland-sessions/wo.desktop new file mode 100644 index 0000000..72bb4cb --- /dev/null +++ b/packaging/wayland-sessions/wo.desktop @@ -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 diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..250de1b --- /dev/null +++ b/src/backend.rs @@ -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; +} + +/// Frame abstraction for rendering operations +pub struct Frame { + pub transform: smithay::utils::Transform, + pub output_rect: Rectangle, +} + +impl Frame { + pub fn new(output_rect: Rectangle) -> Self { + Frame { + transform: smithay::utils::Transform::Normal, + output_rect, + } + } +} diff --git a/src/bin/wo-portal.rs b/src/bin/wo-portal.rs new file mode 100644 index 0000000..9e97816 --- /dev/null +++ b/src/bin/wo-portal.rs @@ -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)>> { + 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, + session_map: Mutex>, +} + +#[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>, + ) -> fdo::Result<(u32, std::collections::HashMap)> { + 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 = + 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>, + ) -> fdo::Result<(u32, std::collections::HashMap)> { + 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::() { + 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 = + 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::().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 = + 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>, + ) -> fdo::Result<(u32, std::collections::HashMap)> { + 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 = + 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(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d9b86e3 --- /dev/null +++ b/src/config.rs @@ -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, + + /// Root/fullscreen desktop windows (alternative to [[windows]]) + #[serde(default)] + pub root: Vec, + + /// Local asset directories to serve + #[serde(default)] + pub assets: Vec, + + /// Commands to autostart + #[serde(default)] + pub autostart: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CompositorConfig { + /// DRM device path (e.g. "/dev/dri/card0"). Auto-detected if absent. + pub drm_device: Option, + + /// Force nested mode (run inside another compositor). Auto-detected if absent. + pub nested: Option, + + /// 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, +} + +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, + pub html: Option, + pub css: Option, + pub js: Option, + + #[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, + #[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, + #[serde(default)] + fallback: Option>, + }, +} + +/// 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 { + // 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![], + } + } +} diff --git a/src/dmabuf.rs b/src/dmabuf.rs new file mode 100644 index 0000000..fab5623 --- /dev/null +++ b/src/dmabuf.rs @@ -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 { + 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 { + 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 { + 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 { + // 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, +} + +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 { + self.inner.remove(name) + } + + pub fn iter(&self) -> impl Iterator { + self.inner.values() + } +} + +// ── Fourcc helpers ──────────────────────────────────────────────────────────── + +fn fourcc_from_u32(v: u32) -> Option { + 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 { + 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, + } +} diff --git a/src/electron.rs b/src/electron.rs new file mode 100644 index 0000000..39f895e --- /dev/null +++ b/src/electron.rs @@ -0,0 +1,1211 @@ +/// IPC protocol between the Wo compositor and the Electron helper process. + +use std::{ + os::fd::{FromRawFd, OwnedFd}, + os::unix::{ + io::{AsRawFd, RawFd}, + net::UnixListener, + }, + path::Path, + io::{IoSliceMut}, + sync::{Arc, Mutex}, + collections::HashMap, +}; + + +use anyhow::{bail, Context, Result}; +use nix::{ + cmsg_space, + sys::{ + socket::{recvmsg, send, setsockopt, sockopt::ReceiveTimeout, ControlMessage, ControlMessageOwned, MsgFlags, UnixAddr}, + time::TimeVal, + }, + unistd, +}; +use tokio::sync::mpsc::{self, error::TrySendError}; +use tracing::{debug, error, info, warn, trace}; + +use crate::config::WindowConfig; + +pub const MAGIC_HELLO: u32 = 0x574F484C; +pub const MAGIC_FRAME: u32 = 0x574F4652; +pub const MAGIC_MOUSE_MOVE: u32 = 0x574F4D4D; +pub const MAGIC_MOUSE_BUTTON: u32 = 0x574F4D42; +pub const MAGIC_KEYBOARD: u32 = 0x574F4B42; +pub const MAGIC_SCROLL: u32 = 0x574F5343; +pub const MAGIC_ACTION: u32 = 0x574F4341; +pub const MAGIC_FOCUS_CHANGE: u32 = 0x574F4643; // "WOFC" +pub const MAGIC_WINDOW_META: u32 = 0x574F574D; // "WOWM" +pub const MAGIC_WINDOW_POS: u32 = 0x574F5750; // "WOWP" +pub const MAGIC_SYSCALL: u32 = 0x574F5359; // "WOSY" - fixed conflict! +pub const MAGIC_FRAME_ACK: u32 = 0x574F4641; // "WOFA" +pub const MAGIC_SURFACE_BUFFER: u32 = 0x574F5342; // "WOSB" — Wayland surface pixels sent to Electron +pub const MAGIC_SHM_BUFFER: u32 = 0x574F534D; // "WOSM" — Wayland SHM surface sent to Electron via process FD +pub const MAGIC_DMABUF_FRAME: u32 = 0x574F4446; // "WODF" — Wayland surface DMABUF fds sent to Electron +pub const MAGIC_FORWARD_POINTER: u32 = 0x574F5045; // "WOPE" +pub const MAGIC_FORWARD_KEYBOARD: u32 = 0x574F4B45; // "WOKE" +pub const MAGIC_FORWARD_RELATIVE_POINTER: u32 = 0x574F5245; // "WORE" +pub const MAGIC_FORWARD_POINTER_BUTTON: u32 = 0x574F5042; // "WOPB" +pub const MAGIC_FORWARD_POINTER_SCROLL: u32 = 0x574F5053; // "WOPS" +pub const MAGIC_POINTER_LOCK_REQUEST: u32 = 0x574F504C; // "WOPL" — server-to-client pointer lock request + + +#[repr(C, packed)] +#[derive(Clone, Copy, Debug)] +pub struct PlaneInfoWire { + pub offset: u32, + pub stride: u32, + pub mod_hi: u32, + pub mod_lo: u32, +} + +/// Parsed plane description with the received file descriptor. +#[derive(Debug)] +pub struct PlaneInfo { + pub fd: OwnedFd, + pub offset: u32, + pub stride: u32, + pub modifier: u64, +} + +/// A fully parsed frame from an Electron window. +#[derive(Debug)] +pub struct ElectronFrame { + /// Which window this frame belongs to (matches `WindowConfig::name`). + pub name: String, + pub seq: u64, + pub width: u32, + pub height: u32, + pub format: u32, // DRM fourcc + pub planes: Vec, +} + +/// Window position update from client +#[derive(Debug, Clone)] +pub struct WindowPositionUpdate { + pub window_name: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// Syscall request from client +#[derive(Debug, Clone)] +pub struct SyscallRequest { + pub window_name: String, + pub syscall_type: String, // "exec", "read", "write", etc. + pub payload: String, // JSON payload with request parameters +} + +/// Messages that can be received from Electron clients +#[derive(Debug)] +pub enum ElectronMessage { + Frame(ElectronFrame), + WindowPosition(WindowPositionUpdate), + Syscall(SyscallRequest), + Action(CompositorActionMessage), + /// Forwarded pointer motion from web UI canvas (window-local coordinates) + ForwardedPointer { window_name: String, x: f64, y: f64 }, + /// Forwarded keyboard event from web UI canvas (evdev keycode) + ForwardedKeyboard { window_name: String, key: u32, pressed: bool, time: u32 }, + /// Forwarded relative motion from web UI canvas + ForwardedRelativePointer { window_name: String, dx: f64, dy: f64 }, + /// Forwarded pointer button from web UI + ForwardedPointerButton { window_name: String, x: f64, y: f64, button: u32, pressed: bool, time: u32 }, + /// Forwarded pointer scroll from web UI + ForwardedPointerScroll { window_name: String, dx: f64, dy: f64 }, +} + +/// Actions that Electron clients can request from the compositor. +#[derive(Debug, Clone)] +pub enum CompositorAction { + /// Request the compositor to quit + Quit { code: i32 }, + /// Custom extension action with JSON payload + Custom { action: String, payload: Option }, +} + +/// A message sent from an Electron client to the compositor. +#[derive(Debug, Clone)] +pub struct CompositorActionMessage { + pub window_name: String, + pub action: CompositorAction, +} + +/// Input event types that can be sent to Electron clients. +#[derive(Debug, Clone)] +pub enum ElectronInputEvent { + MouseMove { x: f64, y: f64 }, + MouseButton { button: u32, pressed: bool, time: u32 }, + Keyboard { key: u32, pressed: bool, time: u32 }, + Scroll { vertical: i32, horizontal: i32, time: u32 }, + FocusChange { window_name: String, focused: bool }, + WindowMetadata { metadata: String }, // JSON payload + PointerLockRequest { window_name: String, lock: bool }, +} + +/// A bidirectional connection to an Electron client. +pub struct ElectronClientConnection { + pub name: String, + fd: OwnedFd, + /// Serializes all writes to this socket. The IPC reader thread and the + /// compositor main thread both write ACKs / input events to the same + /// socket; without this lock interleaved `write(2)` calls corrupt the + /// byte stream Electron receives, breaking `FRAME_ACK` parsing and + /// permanently sticking `inFlightFrameSeqs` at the backpressure limit. + write_lock: Mutex<()>, +} + +impl ElectronClientConnection { + /// Serialize a raw byte buffer to the socket, holding `write_lock`. + fn write_bytes(&self, buf: &[u8]) -> Result<()> { + let _guard = self.write_lock.lock().unwrap(); + write_all(self.fd.as_raw_fd(), buf) + } + + /// Non-blocking variant: drops the message silently on EAGAIN. + /// Uses try_lock so the caller never blocks waiting for write_lock. + /// If a concurrent write is in progress the message is dropped, + /// which is acceptable for input events and metadata broadcasts. + fn write_bytes_nonblocking(&self, buf: &[u8]) -> Result<()> { + let _guard = match self.write_lock.try_lock() { + Ok(g) => g, + Err(_) => return Ok(()), + }; + write_nonblocking(self.fd.as_raw_fd(), buf) + } + + /// Send a FRAME_ACK for `seq` to unblock Electron's inFlight counter. + /// Uses a blocking write: the message is 12 bytes on a local Unix socket so + /// it completes in nanoseconds, and dropping ACKs would permanently stall + /// Electron's backpressure mechanism (inFlightFrameSeqs never decrements). + pub fn write_frame_ack(&self, seq: u64) -> Result<()> { + let mut buf = [0u8; 12]; + buf[0..4].copy_from_slice(&MAGIC_FRAME_ACK.to_le_bytes()); + buf[4..12].copy_from_slice(&seq.to_le_bytes()); + self.write_bytes(&buf) + } + + /// Send a Wayland surface's pixel content to the Electron client. + /// Wire format: magic(4) + name_len(4) + name(N) + width(4) + height(4) + stride(4) + data_len(4) + pixels(data_len) + /// Uses non-blocking write — if the socket buffer is full the frame is dropped. + pub fn send_surface_buffer( + &self, + window_name: &str, + width: u32, + height: u32, + stride: u32, + pixels: &[u8], + ) -> Result<()> { + let name_bytes = window_name.as_bytes(); + let header_len = 4 + 4 + name_bytes.len() + 4 + 4 + 4 + 4; + let mut buf = vec![0u8; header_len + pixels.len()]; + let mut off = 0; + buf[off..off + 4].copy_from_slice(&MAGIC_SURFACE_BUFFER.to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&(name_bytes.len() as u32).to_le_bytes()); + off += 4; + buf[off..off + name_bytes.len()].copy_from_slice(name_bytes); + off += name_bytes.len(); + buf[off..off + 4].copy_from_slice(&width.to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&height.to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&stride.to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&(pixels.len() as u32).to_le_bytes()); + off += 4; + buf[off..].copy_from_slice(pixels); + self.write_bytes_nonblocking(&buf) + } + + /// Send a Wayland surface's SHM buffer metadata to the Electron client. + /// Wire format: magic(4) + name_len(4) + name(N) + width(4) + height(4) + stride(4) + pid(4) + fd(4) + /// The compositor PID and fd number let Electron open /proc//fd/ + /// to mmap pixels without copying. + pub fn send_shm_buffer( + &self, + window_name: &str, + width: u32, + height: u32, + stride: u32, + pid: u32, + memfd_fd: i32, + ) -> Result<()> { + let name_bytes = window_name.as_bytes(); + let header_len = 4 + 4 + name_bytes.len() + 4 + 4 + 4 + 4 + 4; + let mut buf = vec![0u8; header_len]; + let mut off = 0; + buf[off..off + 4].copy_from_slice(&MAGIC_SHM_BUFFER.to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&(name_bytes.len() as u32).to_le_bytes()); + off += 4; + buf[off..off + name_bytes.len()].copy_from_slice(name_bytes); + off += name_bytes.len(); + buf[off..off + 4].copy_from_slice(&width.to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&height.to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&stride.to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&pid.to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&(memfd_fd as u32).to_le_bytes()); + + self.write_bytes_nonblocking(&buf) + } + + /// Send a DMABUF-backed Wayland surface frame to the Electron client. + /// Sends the window name, dimensions, format, and plane information as metadata, + /// with the actual file descriptors sent via Unix socket ancillary data (zero-copy). + /// Wire format: magic(4) + name_len(4) + name(N) + width(4) + height(4) + format(4) + num_planes(4) + planes*24 + pub fn send_dmabuf_frame( + &self, + window_name: &str, + dmabuf: &smithay::backend::allocator::dmabuf::Dmabuf, + ) -> Result<()> { + use smithay::backend::allocator::Buffer as _; + + let name_bytes = window_name.as_bytes(); + + // Get buffer properties + let num_planes = dmabuf.num_planes(); + let (w, h) = { + let size = dmabuf.size(); + (size.w as u32, size.h as u32) + }; + let format = dmabuf.format(); + + // Build metadata message: magic + name_len + name + width + height + format + num_planes + plane_info + let header_len = 4 + 4 + name_bytes.len() + 4 + 4 + 4 + 4 + (num_planes * 24); + let mut buf = vec![0u8; header_len]; + let mut off = 0; + + buf[off..off + 4].copy_from_slice(&MAGIC_DMABUF_FRAME.to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&(name_bytes.len() as u32).to_le_bytes()); + off += 4; + buf[off..off + name_bytes.len()].copy_from_slice(name_bytes); + off += name_bytes.len(); + + // Width and height + buf[off..off + 4].copy_from_slice(&w.to_le_bytes()); + off += 4; + buf[off..off + 4].copy_from_slice(&h.to_le_bytes()); + off += 4; + + let fourcc = format.code as u32; + buf[off..off + 4].copy_from_slice(&fourcc.to_le_bytes()); + off += 4; + + // Number of planes + buf[off..off + 4].copy_from_slice(&(num_planes as u32).to_le_bytes()); + off += 4; + + // For each plane: fd(4) + offset(8) + stride(4) + modifier(8) + // Note: We'll collect actual plane data from dmabuf handles iterator + let mut plane_idx = 0; + for _handle in dmabuf.handles() { + // fd: will be sent via ancillary data, so we put 0 as placeholder + buf[off..off + 4].copy_from_slice(&0u32.to_le_bytes()); + off += 4; + // offset: 0 for now (can be extended later) + buf[off..off + 8].copy_from_slice(&0u64.to_le_bytes()); + off += 8; + // stride: will depend on format (approximate) + let stride = (w * 4) as u32; // ARGB8888 = 4 bytes/pixel, adjust as needed + buf[off..off + 4].copy_from_slice(&stride.to_le_bytes()); + off += 4; + // modifier (8 bytes) - DrmModifier converts to u64 + let modifier_val: u64 = format.modifier.into(); + buf[off..off + 8].copy_from_slice(&modifier_val.to_le_bytes()); + off += 8; + plane_idx += 1; + } + + // Send metadata with file descriptors as ancillary data + self.sendmsg_with_fds(&buf, dmabuf) + } + + /// Send a message with file descriptors via Unix socket ancillary data. + fn sendmsg_with_fds( + &self, + metadata: &[u8], + dmabuf: &smithay::backend::allocator::dmabuf::Dmabuf, + ) -> Result<()> { + use std::os::unix::io::AsRawFd; + + let _guard = match self.write_lock.try_lock() { + Ok(g) => g, + Err(_) => return Ok(()), // Drop if lock contested + }; + + // Build ancillary data for file descriptors + let fds: Vec = dmabuf + .handles() + .map(|h| h.as_raw_fd()) + .collect(); + + if fds.is_empty() { + // No FDs to send, just send metadata + return write_nonblocking(self.fd.as_raw_fd(), metadata); + } + + // Use nix sendmsg with file descriptor passing via control message + // Convert metadata slice to IoSlice for sendmsg + let iov = [std::io::IoSlice::new(metadata)]; + + let mut cmsgs = vec![]; + cmsgs.push(ControlMessage::ScmRights(&fds[..])); + + match nix::sys::socket::sendmsg::( + self.fd.as_raw_fd(), + &iov, + &cmsgs, + MsgFlags::MSG_NOSIGNAL, + None, + ) { + Ok(_) => Ok(()), + Err(e) => { + debug!("sendmsg failed: {}", e); + Ok(()) // Non-blocking: drop on error + } + } + } + + /// Send an input event to the Electron client. + pub fn send_input_event(&self, event: &ElectronInputEvent) -> Result<()> { + + match event { + ElectronInputEvent::MouseMove { x, y } => { + let mut buf = [0u8; 20]; + buf[0..4].copy_from_slice(&MAGIC_MOUSE_MOVE.to_le_bytes()); + buf[4..12].copy_from_slice(&x.to_le_bytes()); + buf[12..20].copy_from_slice(&y.to_le_bytes()); + self.write_bytes(&buf)?; + }, + ElectronInputEvent::MouseButton { button, pressed, time } => { + let mut buf = [0u8; 16]; + buf[0..4].copy_from_slice(&MAGIC_MOUSE_BUTTON.to_le_bytes()); + buf[4..8].copy_from_slice(&button.to_le_bytes()); + buf[8..12].copy_from_slice(&(*pressed as u32).to_le_bytes()); + buf[12..16].copy_from_slice(&time.to_le_bytes()); + self.write_bytes(&buf)?; + }, + ElectronInputEvent::Keyboard { key, pressed, time } => { + let mut buf = [0u8; 16]; + buf[0..4].copy_from_slice(&MAGIC_KEYBOARD.to_le_bytes()); + buf[4..8].copy_from_slice(&key.to_le_bytes()); + buf[8..12].copy_from_slice(&(*pressed as u32).to_le_bytes()); + buf[12..16].copy_from_slice(&time.to_le_bytes()); + self.write_bytes(&buf)?; + }, + ElectronInputEvent::Scroll { vertical, horizontal, time } => { + let mut buf = [0u8; 16]; + buf[0..4].copy_from_slice(&MAGIC_SCROLL.to_le_bytes()); + buf[4..8].copy_from_slice(&vertical.to_le_bytes()); + buf[8..12].copy_from_slice(&horizontal.to_le_bytes()); + buf[12..16].copy_from_slice(&time.to_le_bytes()); + self.write_bytes(&buf)?; + }, + ElectronInputEvent::FocusChange { window_name, focused } => { + let name_bytes = window_name.as_bytes(); + let mut buf = vec![0u8; 12 + name_bytes.len()]; + buf[0..4].copy_from_slice(&MAGIC_FOCUS_CHANGE.to_le_bytes()); + buf[4..8].copy_from_slice(&(*focused as u32).to_le_bytes()); + buf[8..12].copy_from_slice(&(name_bytes.len() as u32).to_le_bytes()); + buf[12..].copy_from_slice(name_bytes); + self.write_bytes(&buf)?; + }, + ElectronInputEvent::WindowMetadata { metadata } => { + let meta_bytes = metadata.as_bytes(); + let mut buf = vec![0u8; 8 + meta_bytes.len()]; + buf[0..4].copy_from_slice(&MAGIC_WINDOW_META.to_le_bytes()); + buf[4..8].copy_from_slice(&(meta_bytes.len() as u32).to_le_bytes()); + buf[8..].copy_from_slice(meta_bytes); + self.write_bytes(&buf)?; + }, + ElectronInputEvent::PointerLockRequest { window_name, lock } => { + let name_bytes = window_name.as_bytes(); + let mut buf = vec![0u8; 12 + name_bytes.len()]; + buf[0..4].copy_from_slice(&MAGIC_POINTER_LOCK_REQUEST.to_le_bytes()); + buf[4..8].copy_from_slice(&(*lock as u32).to_le_bytes()); + buf[8..12].copy_from_slice(&(name_bytes.len() as u32).to_le_bytes()); + buf[12..].copy_from_slice(name_bytes); + self.write_bytes(&buf)?; + }, + } + Ok(()) + } + + /// Send an input event to the Electron client without blocking. + /// If the socket send buffer is full (EAGAIN), the event is silently + /// dropped — this prevents the compositor main thread from stalling + /// when a client is slow to drain its receive buffer. + pub fn try_send_input_event(&self, event: &ElectronInputEvent) -> Result<()> { + match event { + ElectronInputEvent::MouseMove { x, y } => { + let mut buf = [0u8; 20]; + buf[0..4].copy_from_slice(&MAGIC_MOUSE_MOVE.to_le_bytes()); + buf[4..12].copy_from_slice(&x.to_le_bytes()); + buf[12..20].copy_from_slice(&y.to_le_bytes()); + self.write_bytes_nonblocking(&buf) + }, + ElectronInputEvent::MouseButton { button, pressed, time } => { + let mut buf = [0u8; 16]; + buf[0..4].copy_from_slice(&MAGIC_MOUSE_BUTTON.to_le_bytes()); + buf[4..8].copy_from_slice(&button.to_le_bytes()); + buf[8..12].copy_from_slice(&(*pressed as u32).to_le_bytes()); + buf[12..16].copy_from_slice(&time.to_le_bytes()); + self.write_bytes_nonblocking(&buf) + }, + ElectronInputEvent::Keyboard { key, pressed, time } => { + let mut buf = [0u8; 16]; + buf[0..4].copy_from_slice(&MAGIC_KEYBOARD.to_le_bytes()); + buf[4..8].copy_from_slice(&key.to_le_bytes()); + buf[8..12].copy_from_slice(&(*pressed as u32).to_le_bytes()); + buf[12..16].copy_from_slice(&time.to_le_bytes()); + self.write_bytes_nonblocking(&buf) + }, + ElectronInputEvent::Scroll { vertical, horizontal, time } => { + let mut buf = [0u8; 16]; + buf[0..4].copy_from_slice(&MAGIC_SCROLL.to_le_bytes()); + buf[4..8].copy_from_slice(&vertical.to_le_bytes()); + buf[8..12].copy_from_slice(&horizontal.to_le_bytes()); + buf[12..16].copy_from_slice(&time.to_le_bytes()); + self.write_bytes_nonblocking(&buf) + }, + ElectronInputEvent::FocusChange { window_name, focused } => { + let name_bytes = window_name.as_bytes(); + let mut buf = vec![0u8; 12 + name_bytes.len()]; + buf[0..4].copy_from_slice(&MAGIC_FOCUS_CHANGE.to_le_bytes()); + buf[4..8].copy_from_slice(&(name_bytes.len() as u32).to_le_bytes()); + buf[8..12].copy_from_slice(&(*focused as u32).to_le_bytes()); + buf[12..].copy_from_slice(name_bytes); + self.write_bytes_nonblocking(&buf) + }, + ElectronInputEvent::WindowMetadata { metadata } => { + let meta_bytes = metadata.as_bytes(); + let mut buf = vec![0u8; 8 + meta_bytes.len()]; + buf[0..4].copy_from_slice(&MAGIC_WINDOW_META.to_le_bytes()); + buf[4..8].copy_from_slice(&(meta_bytes.len() as u32).to_le_bytes()); + buf[8..].copy_from_slice(meta_bytes); + self.write_bytes_nonblocking(&buf) + }, + ElectronInputEvent::PointerLockRequest { window_name, lock } => { + let name_bytes = window_name.as_bytes(); + let mut buf = vec![0u8; 12 + name_bytes.len()]; + buf[0..4].copy_from_slice(&MAGIC_POINTER_LOCK_REQUEST.to_le_bytes()); + buf[4..8].copy_from_slice(&(name_bytes.len() as u32).to_le_bytes()); + buf[8..12].copy_from_slice(&(*lock as u32).to_le_bytes()); + buf[12..].copy_from_slice(name_bytes); + self.write_bytes_nonblocking(&buf) + } + } + } +} + + + + +pub struct ElectronProcess { + pub name: String, + pub config: WindowConfig, + pub child: std::process::Child, +} + +impl ElectronProcess { + /// Spawn `electron ` for the given window config. + /// The app dir is resolved to `/wo-electron`. + pub fn spawn( + config: &WindowConfig, + electron_bin: &str, + app_dir: &Path, + ipc_socket: &str, + ) -> Result { + use std::process::{Command, Stdio}; + + tracing::info!("Spawning Electron for window '{}' with config: {:?}", config.name, config); + + let serialized = serde_json::to_string(config) + .context("serializing window config")?; + + tracing::info!("Serialized config: {}", serialized); + + //Explicitly specify the main.js entry point + let main_js = app_dir.join("dist/main.js"); + + let child = Command::new(electron_bin) + .arg(&main_js) + .env("WO_IPC_SOCKET", ipc_socket) + .env("WO_WINDOW_CONFIG", &serialized) + // Force software rendering to avoid Wayland buffer issues + .env("DISABLE_GPU_SANDBOX", "1") + .env("DISABLE_SOFTWARE_RASTERIZER", "0") + // Use XWayland if possible, or X11 if available, fallback to headless + .env("DISPLAY", ":0") + // Disable Wayland protocol issues + .env("QT_QPA_PLATFORM", "offscreen") + // Electron needs these flags for proper rendering + .arg("--disable-gpu") + .arg("--disable-gpu-compositing") + .arg("--disable-software-rasterizer=false") + .arg("--no-sandbox") + .arg("--disable-gpu-sandbox") + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .with_context(|| format!("spawning electron for window '{}'", config.name))?; + + info!(window = %config.name, pid = child.id(), "spawned Electron process"); + + Ok(Self { + name: config.name.clone(), + config: config.clone(), + child, + }) + } + + pub fn kill(&mut self) { + let _ = self.child.kill(); + } +} + +impl Drop for ElectronProcess { + fn drop(&mut self) { + self.kill(); + } +} + + +pub struct ElectronIpc { + pub tx: mpsc::Sender, + /// Map of window name -> client connection for sending input events. + pub clients: Arc>>>, +} + +impl ElectronIpc { + /// Bind the socket and start accepting connections. + pub fn listen(socket_path: &str) -> Result<(Self, mpsc::Receiver)> { + // Remove stale socket file. + let _ = std::fs::remove_file(socket_path); + + let listener = UnixListener::bind(socket_path) + .with_context(|| format!("binding IPC socket {socket_path}"))?; + info!(socket = socket_path, "IPC socket listening"); + + let (tx, rx) = mpsc::channel(8); + let tx2 = tx.clone(); + let clients = Arc::new(Mutex::new(HashMap::new())); + let clients2 = clients.clone(); + + std::thread::spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let tx = tx2.clone(); + let clients = clients2.clone(); + std::thread::spawn(move || { + if let Err(e) = handle_connection(stream.as_raw_fd(), tx, clients) { + error!("electron IPC error: {e:#}"); + } + }); + } + Err(e) => error!("IPC accept error: {e}"), + } + } + }); + + Ok((Self { tx, clients }, rx)) + } + + /// Send input event to a specific window (non-blocking). + /// If the socket buffer is full or the write lock is contended, the event + /// is silently dropped. This prevents the compositor main thread from + /// stalling when an Electron client is slow to drain its receive buffer. + pub fn send_to_window(&self, window_name: &str, event: &ElectronInputEvent) -> Result<()> { + let client = { + let clients = self.clients.lock().unwrap(); + clients.get(window_name).cloned() + }; + // clients Mutex released — safe to do I/O without blocking other IPC ops. + if let Some(client) = client { + client.try_send_input_event(event)?; + } + Ok(()) + } + + /// Broadcast window metadata to all windows + pub fn broadcast_metadata(&self, metadata: &str) -> Result<()> { + let snapshot: Vec<_> = { + let clients = self.clients.lock().unwrap(); + clients.values().cloned().collect() + }; + // clients Mutex released — safe to do I/O. + let event = ElectronInputEvent::WindowMetadata { + metadata: metadata.to_string(), + }; + for client in &snapshot { + if let Err(e) = client.try_send_input_event(&event) { + warn!("metadata broadcast to '{}' failed (stream may be corrupted): {e:#}", client.name); + } + } + Ok(()) + } + + /// Send syscall response to a window (non-blocking). + /// If the socket buffer is full, the response is dropped and a warning is + /// logged. This is preferable to blocking the compositor event loop — the + /// Electron client will time out its IPC promise rather than deadlocking + /// the entire compositor. + pub fn send_syscall_response(&self, window_name: &str, response: &str) -> Result<()> { + let client = { + let clients = self.clients.lock().unwrap(); + clients.get(window_name).cloned() + }; + // clients Mutex released — safe to do I/O. + if let Some(client) = client { + let response_bytes = response.as_bytes(); + let mut buf = vec![0u8; 8 + response_bytes.len()]; + buf[0..4].copy_from_slice(&MAGIC_SYSCALL.to_le_bytes()); + buf[4..8].copy_from_slice(&(response_bytes.len() as u32).to_le_bytes()); + buf[8..].copy_from_slice(response_bytes); + if let Err(e) = client.write_bytes_nonblocking(&buf) { + warn!("syscall response to '{}' dropped (socket full): {e:#}", client.name); + } + } + Ok(()) + } + + /// Acknowledge that a frame was consumed/imported so the sender can apply backpressure. + pub fn send_frame_ack(&self, window_name: &str, seq: u64) -> Result<()> { + let client = { + let clients = self.clients.lock().unwrap(); + clients.get(window_name).cloned() + }; + // clients Mutex released — safe to do I/O. + // ACKs are only 12 bytes; a blocking write is acceptable here since + // it completes in nanoseconds on a local socket and dropping ACKs + // would permanently stall Electron's backpressure. + if let Some(client) = client { + client.write_frame_ack(seq)?; + } + Ok(()) + } + + /// Broadcast a Wayland surface's pixel content to all Electron clients. + /// Non-blocking: drops the buffer for clients that can't keep up. + pub fn broadcast_surface_buffer( + &self, + window_name: &str, + width: u32, + height: u32, + stride: u32, + pixels: &[u8], + ) -> Result<()> { + let snapshot: Vec<_> = { + let clients = self.clients.lock().unwrap(); + clients.values().cloned().collect() + }; + // clients Mutex released — safe to do I/O. + for client in &snapshot { + if let Err(e) = client.send_surface_buffer(window_name, width, height, stride, pixels) { + debug!("surface buffer to '{}' dropped: {e:#}", client.name); + } + } + Ok(()) + } + + /// Broadcast a Wayland surface's SHM buffer metadata to all Electron clients. + /// Non-blocking: drops the buffer for clients that can't keep up. + pub fn broadcast_shm_buffer( + &self, + window_name: &str, + width: u32, + height: u32, + stride: u32, + pid: u32, + memfd_fd: i32, + ) -> Result<()> { + let snapshot: Vec<_> = { + let clients = self.clients.lock().unwrap(); + clients.values().cloned().collect() + }; + // clients Mutex released — safe to do I/O. + for client in &snapshot { + if let Err(e) = client.send_shm_buffer(window_name, width, height, stride, pid, memfd_fd) { + debug!("shm buffer to '{}' dropped: {e:#}", client.name); + } + } + Ok(()) + } + + /// Broadcast a Wayland surface's DMABUF frame to all Electron clients. + /// Sends file descriptors via Unix socket ancillary data (zero-copy). + /// Non-blocking: drops the frame for clients that can't keep up. + pub fn broadcast_dmabuf_frame( + &self, + window_name: &str, + dmabuf: &smithay::backend::allocator::dmabuf::Dmabuf, + ) -> Result<()> { + let snapshot: Vec<_> = { + let clients = self.clients.lock().unwrap(); + clients.values().cloned().collect() + }; + // clients Mutex released — safe to do I/O. + for client in &snapshot { + if let Err(e) = client.send_dmabuf_frame(window_name, dmabuf) { + debug!("dmabuf frame to '{}' dropped: {e:#}", client.name); + } + } + Ok(()) + } +} + + +fn handle_connection( + fd: RawFd, + tx: mpsc::Sender, + clients: Arc>>>, +) -> Result<()> { + // Set a 5-second receive timeout before any blocking read so the thread + // cannot hang indefinitely if Electron connects but stalls mid-handshake. + { + let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) }; + let timeout = TimeVal::new(5, 0); + if let Err(e) = setsockopt(&borrowed, ReceiveTimeout, &timeout) { + warn!("Failed to set SO_RCVTIMEO on IPC socket: {e}"); + } + } + + // 1. Read HelloMessage + let mut buf = [0u8; 4096]; + let name = recv_hello(fd, &mut buf)?; + info!(window = %name, "Electron window connected"); + + let fd_for_write = { + let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) }; + unistd::dup(borrowed).context("duplicating IPC socket fd")? + }; + + // Register this client connection for input event dispatch + let client = Arc::new(ElectronClientConnection { + name: name.clone(), + fd: fd_for_write, + write_lock: Mutex::new(()), + }); + clients.lock().unwrap().insert(name.clone(), client.clone()); + + // 2. Stream Messages (frames, syscalls, positions, etc.) + let mut msg_count = 0u32; + loop { + match recv_message(fd, &name, &mut buf) { + Ok(msg) => { + msg_count += 1; + trace!(window = %name, msg_num = msg_count, "received IPC message"); + match msg { + ElectronMessage::Frame(frame) => { + let seq = frame.seq; + debug!(window = %name, seq, "sending FRAME_ACK"); + if let Err(e) = client.write_frame_ack(seq) { + error!(window = %name, seq, "failed to send FRAME_ACK: {e:#}"); + } + match tx.try_send(ElectronMessage::Frame(frame)) { + Ok(()) => { + trace!(window = %name, "frame queued for compositor"); + } + Err(TrySendError::Full(_)) => { + debug!(window = %name, seq, "dropping frame (channel full, already ACKed)"); + } + Err(TrySendError::Closed(_)) => { + warn!(window = %name, "compositor channel closed, exiting IPC reader"); + break; + } + } + } + _ => { + // Non-frame messages (WindowPosition, Syscall, Action): use try_send + // to avoid blocking the IPC reader thread. If the channel is full, + // the message is dropped — non-critical messages will arrive again + // on the next event (positions, actions), or are processed + // asynchronously anyway. + match tx.try_send(msg) { + Ok(()) => { + trace!(window = %name, "non-frame message queued"); + } + Err(TrySendError::Full(_)) => { + debug!(window = %name, "dropping non-frame IPC message (channel full)"); + } + Err(TrySendError::Closed(_)) => { + warn!(window = %name, "compositor channel closed, exiting IPC reader"); + break; + } + } + } + } + } + Err(e) => { + // Connection closed = normal Electron exit, but warn so it's visible. + warn!(window = %name, msg_count, "IPC connection closed: {e}"); + break; + } + } + } + + // Clean up client registration on disconnect + clients.lock().unwrap().remove(&name); + info!(window = %name, msg_count, "IPC reader thread exiting"); + + Ok(()) +} + +fn recv_message(fd: RawFd, name: &str, buf: &mut [u8]) -> Result { + // Read magic to determine message type + read_exact(fd, &mut buf[..4]).context("reading message magic")?; + let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap()); + trace!("recv_message: got magic 0x{:08X}", magic); + + match magic { + MAGIC_FRAME => { + trace!("recv_message: parsing FRAME message"); + // Read header: magic(4) + name_len(4) + read_exact(fd, &mut buf[4..8]).context("reading FRAME name_len")?; + let name_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + if name_len > 256 { bail!("window name too long in FRAME: {name_len}"); } + + // Read the window name + read_exact(fd, &mut buf[8..8+name_len]).context("reading FRAME window name")?; + let frame_name = String::from_utf8_lossy(&buf[8..8+name_len]).into_owned(); + + // Rest of frame header: seq(8) + width(4) + height(4) + format(4) + num_planes(4) + const REST_HEADER: usize = 8 + 4 + 4 + 4 + 4; + read_exact(fd, &mut buf[..REST_HEADER]).context("reading FRAME header fields")?; + + let frame = parse_frame_message(fd, &frame_name, buf) + .context("parsing FRAME message data")?; + trace!("recv_message: FRAME parsed successfully: {}x{}", frame.width, frame.height); + Ok(ElectronMessage::Frame(frame)) + } + MAGIC_WINDOW_POS => { + trace!("recv_message: parsing WINDOW_POS message"); + // Window position: magic(4) + name_len(4) + name(N) + x(4) + y(4) + width(4) + height(4) + read_exact(fd, &mut buf[4..8]).context("reading WINDOW_POS name_len")?; + let name_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + if name_len > 256 { bail!("window name too long in WINDOW_POS: {name_len}"); } + let mut pos_buf = vec![0u8; name_len + 16]; + read_exact(fd, &mut pos_buf).context("reading WINDOW_POS data")?; + let x = i32::from_le_bytes(pos_buf[name_len..name_len+4].try_into().unwrap()); + let y = i32::from_le_bytes(pos_buf[name_len+4..name_len+8].try_into().unwrap()); + let width = u32::from_le_bytes(pos_buf[name_len+8..name_len+12].try_into().unwrap()); + let height = u32::from_le_bytes(pos_buf[name_len+12..name_len+16].try_into().unwrap()); + + Ok(ElectronMessage::WindowPosition(WindowPositionUpdate { + window_name: name.to_string(), + x, y, width, height, + })) + } + MAGIC_SYSCALL => { + trace!("recv_message: parsing SYSCALL message"); + // Syscall: magic(4) + type_len(4) + payload_len(4) + type_str + payload_str + read_exact(fd, &mut buf[4..12]).context("reading SYSCALL header")?; + let type_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + let payload_len = u32::from_le_bytes(buf[8..12].try_into().unwrap()) as usize; + + if type_len > 256 || payload_len > 1024 * 1024 { + bail!("syscall message too large: type_len={} payload_len={}", type_len, payload_len); + } + + read_exact(fd, &mut buf[..type_len]).context("reading SYSCALL type")?; + let syscall_type = String::from_utf8_lossy(&buf[..type_len]).into_owned(); + + let mut payload_buf = vec![0u8; payload_len]; + read_exact(fd, &mut payload_buf).context("reading SYSCALL payload")?; + let payload = String::from_utf8_lossy(&payload_buf).into_owned(); + + Ok(ElectronMessage::Syscall(SyscallRequest { + window_name: name.to_string(), + syscall_type, + payload, + })) + } + MAGIC_ACTION => { + trace!("recv_message: parsing ACTION message"); + // Action: magic(4) + action_len(4) + action_str(action_len) + payload_len(4) + payload_str + read_exact(fd, &mut buf[4..8]).context("reading ACTION action_len")?; + let action_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + + if action_len > 256 { + bail!("action string too large: action_len={}", action_len); + } + + read_exact(fd, &mut buf[..action_len]).context("reading ACTION string")?; + let action = String::from_utf8_lossy(&buf[..action_len]).into_owned(); + + // Now read payload_len + read_exact(fd, &mut buf[..4]).context("reading ACTION payload_len")?; + let payload_len = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize; + + if payload_len > 1024 * 1024 { + bail!("action payload too large: payload_len={}", payload_len); + } + + let payload = if payload_len > 0 { + let mut payload_buf = vec![0u8; payload_len]; + read_exact(fd, &mut payload_buf).context("reading ACTION payload")?; + Some(String::from_utf8_lossy(&payload_buf).into_owned()) + } else { + None + }; + + let compositor_action = if action == "quit" { + let code = payload.as_ref() + .and_then(|p| { + // Payload may be JSON like {"code":0} or a plain integer + if let Ok(v) = serde_json::from_str::(p) { + v.get("code").and_then(|c| c.as_i64()).map(|c| c as i32) + } else { + p.parse::().ok() + } + }) + .unwrap_or(0); + CompositorAction::Quit { code } + } else { + CompositorAction::Custom { action, payload } + }; + + Ok(ElectronMessage::Action(CompositorActionMessage { + window_name: name.to_string(), + action: compositor_action, + })) + } + MAGIC_FORWARD_POINTER => { + trace!("recv_message: parsing FORWARD_POINTER message"); + read_exact(fd, &mut buf[4..8]).context("reading FORWARD_POINTER window_len")?; + let window_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + if window_len > 256 { bail!("window name too long in FORWARD_POINTER: {window_len}"); } + let mut data_buf = vec![0u8; window_len + 16]; + read_exact(fd, &mut data_buf).context("reading FORWARD_POINTER data")?; + let window_name = String::from_utf8_lossy(&data_buf[..window_len]).into_owned(); + let x = f64::from_le_bytes(data_buf[window_len..window_len+8].try_into().unwrap()); + let y = f64::from_le_bytes(data_buf[window_len+8..window_len+16].try_into().unwrap()); + Ok(ElectronMessage::ForwardedPointer { window_name, x, y }) + } + MAGIC_FORWARD_KEYBOARD => { + trace!("recv_message: parsing FORWARD_KEYBOARD message"); + read_exact(fd, &mut buf[4..8]).context("reading FORWARD_KEYBOARD window_len")?; + let window_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + if window_len > 256 { bail!("window name too long in FORWARD_KEYBOARD: {window_len}"); } + let mut data_buf = vec![0u8; window_len + 12]; + read_exact(fd, &mut data_buf).context("reading FORWARD_KEYBOARD data")?; + let window_name = String::from_utf8_lossy(&data_buf[..window_len]).into_owned(); + let key = u32::from_le_bytes(data_buf[window_len..window_len+4].try_into().unwrap()); + let pressed = u32::from_le_bytes(data_buf[window_len+4..window_len+8].try_into().unwrap()) != 0; + let time = u32::from_le_bytes(data_buf[window_len+8..window_len+12].try_into().unwrap()); + Ok(ElectronMessage::ForwardedKeyboard { window_name, key, pressed, time }) + } + MAGIC_FORWARD_RELATIVE_POINTER => { + trace!("recv_message: parsing FORWARD_RELATIVE_POINTER message"); + read_exact(fd, &mut buf[4..8]).context("reading FORWARD_RELATIVE_POINTER window_len")?; + let window_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + if window_len > 256 { bail!("window name too long in FORWARD_RELATIVE_POINTER: {window_len}"); } + let mut data_buf = vec![0u8; window_len + 16]; + read_exact(fd, &mut data_buf).context("reading FORWARD_RELATIVE_POINTER data")?; + let window_name = String::from_utf8_lossy(&data_buf[..window_len]).into_owned(); + let dx = f64::from_le_bytes(data_buf[window_len..window_len+8].try_into().unwrap()); + let dy = f64::from_le_bytes(data_buf[window_len+8..window_len+16].try_into().unwrap()); + Ok(ElectronMessage::ForwardedRelativePointer { window_name, dx, dy }) + } + MAGIC_FORWARD_POINTER_BUTTON => { + trace!("recv_message: parsing FORWARD_POINTER_BUTTON message"); + read_exact(fd, &mut buf[4..8]).context("reading FORWARD_POINTER_BUTTON window_len")?; + let window_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + if window_len > 256 { bail!("window name too long in FORWARD_POINTER_BUTTON: {window_len}"); } + let mut data_buf = vec![0u8; window_len + 28]; + read_exact(fd, &mut data_buf).context("reading FORWARD_POINTER_BUTTON data")?; + let window_name = String::from_utf8_lossy(&data_buf[..window_len]).into_owned(); + let x = f64::from_le_bytes(data_buf[window_len..window_len+8].try_into().unwrap()); + let y = f64::from_le_bytes(data_buf[window_len+8..window_len+16].try_into().unwrap()); + let button = u32::from_le_bytes(data_buf[window_len+16..window_len+20].try_into().unwrap()); + let pressed = u32::from_le_bytes(data_buf[window_len+20..window_len+24].try_into().unwrap()) != 0; + let time = u32::from_le_bytes(data_buf[window_len+24..window_len+28].try_into().unwrap()); + Ok(ElectronMessage::ForwardedPointerButton { window_name, x, y, button, pressed, time }) + } + MAGIC_FORWARD_POINTER_SCROLL => { + trace!("recv_message: parsing FORWARD_POINTER_SCROLL message"); + read_exact(fd, &mut buf[4..8]).context("reading FORWARD_POINTER_SCROLL window_len")?; + let window_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + if window_len > 256 { bail!("window name too long in FORWARD_POINTER_SCROLL: {window_len}"); } + let mut data_buf = vec![0u8; window_len + 16]; + read_exact(fd, &mut data_buf).context("reading FORWARD_POINTER_SCROLL data")?; + let window_name = String::from_utf8_lossy(&data_buf[..window_len]).into_owned(); + let dx = f64::from_le_bytes(data_buf[window_len..window_len+8].try_into().unwrap()); + let dy = f64::from_le_bytes(data_buf[window_len+8..window_len+16].try_into().unwrap()); + Ok(ElectronMessage::ForwardedPointerScroll { window_name, dx, dy }) + } + _ => { + error!("recv_message: unknown magic 0x{:08X}", magic); + bail!("unknown message magic: 0x{magic:08X}"); + } + } +} + +fn parse_frame_message(fd: RawFd, name: &str, buf: &mut [u8]) -> Result { + // Buffer contains: seq(8) + width(4) + height(4) + format(4) + num_planes(4) + + let seq = u64::from_le_bytes(buf[0..8].try_into().unwrap()); + let width = u32::from_le_bytes(buf[8..12].try_into().unwrap()); + let height = u32::from_le_bytes(buf[12..16].try_into().unwrap()); + let format = u32::from_le_bytes(buf[16..20].try_into().unwrap()); + let num_planes = u32::from_le_bytes(buf[20..24].try_into().unwrap()) as usize; + + if num_planes == 0 || num_planes > 4 { + bail!("invalid num_planes: {num_planes}"); + } + + // Read PlaneInfoWire structs (16 bytes each) + SCM_RIGHTS fds. + const PLANE_SIZE: usize = 16; + let plane_bytes = num_planes * PLANE_SIZE; + read_exact(fd, &mut buf[..plane_bytes])?; + + // Receive file descriptors via recvmsg SCM_RIGHTS. + let fds = recv_fds(fd, num_planes)?; + if fds.len() != num_planes { + bail!("expected {num_planes} fds, got {}", fds.len()); + } + + let mut planes = Vec::with_capacity(num_planes); + for (i, fd) in fds.into_iter().enumerate() { + let base = i * PLANE_SIZE; + let offset = u32::from_le_bytes(buf[base..base+4].try_into().unwrap()); + let stride = u32::from_le_bytes(buf[base+4..base+8].try_into().unwrap()); + let mod_hi = u32::from_le_bytes(buf[base+8..base+12].try_into().unwrap()); + let mod_lo = u32::from_le_bytes(buf[base+12..base+16].try_into().unwrap()); + let modifier = ((mod_hi as u64) << 32) | mod_lo as u64; + planes.push(PlaneInfo { fd, offset, stride, modifier }); + } + + Ok(ElectronFrame { + name: name.to_string(), + seq, width, height, format, planes, + }) +} + +fn read_exact(fd: RawFd, buf: &mut [u8]) -> Result<()> { + use std::os::unix::io::FromRawFd; + use std::io::Read; + // SAFETY: fd is valid for the lifetime of this call. + let mut f = unsafe { std::fs::File::from_raw_fd(fd) }; + let r = f.read_exact(buf); + std::mem::forget(f); // don't close fd + r.context("IPC read") +} + +fn write_all(fd: RawFd, buf: &[u8]) -> Result<()> { + use std::os::unix::io::FromRawFd; + use std::io::Write; + // SAFETY: fd is valid for the lifetime of this call. + let mut f = unsafe { std::fs::File::from_raw_fd(fd) }; + let r = f.write_all(buf); + std::mem::forget(f); // don't close fd + r.context("IPC write") +} + +/// Non-blocking socket write: sends `buf` only if the entire message fits in the +/// kernel send buffer without blocking. If there is not enough space, the message +/// is silently dropped. This prevents both: +/// 1. Blocking the compositor main thread on a slow IPC client. +/// 2. Partial writes that would corrupt the framed byte stream — on SOCK_STREAM a +/// partial `send(MSG_DONTWAIT)` writes some bytes and returns n < buf.len(), +/// leaving the receiver with a truncated message. All subsequent messages +/// (including FRAME_ACKs) then misalign, causing Electron's inFlightFrameSeqs +/// counter to stall at its limit and stopping all frame delivery. +/// +/// Strategy: query the number of bytes already queued in the kernel send buffer +/// via TIOCOUTQ. Compare against SO_SNDBUF. Only call send() when the full +/// message provably fits, ensuring atomic all-or-nothing delivery. +fn write_nonblocking(fd: RawFd, buf: &[u8]) -> Result<()> { + // Query bytes currently waiting in the kernel send buffer (unread by receiver). + let mut queued: libc::c_int = 0; + let ioctl_ret = unsafe { libc::ioctl(fd, libc::TIOCOUTQ, &mut queued as *mut libc::c_int) }; + if ioctl_ret == -1 { + // If TIOCOUTQ is unavailable (shouldn't happen on Linux), fall back to a + // plain MSG_DONTWAIT send and treat partial writes as drops. + return match send(fd, buf, MsgFlags::MSG_DONTWAIT) { + Ok(n) if n == buf.len() => Ok(()), + Ok(_) => Ok(()), // partial write — drop silently, stream may be corrupt + Err(nix::errno::Errno::EAGAIN) | Err(nix::errno::Errno::EWOULDBLOCK) => Ok(()), + Err(e) => Err(anyhow::anyhow!("IPC non-blocking write: {e}")), + }; + } + + // SO_SNDBUF: Linux reports 2× the requested value; use it directly as a + // conservative upper bound for available send-buffer capacity. + let sndbuf = nix::sys::socket::getsockopt( + &unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) }, + nix::sys::socket::sockopt::SndBuf, + ) + .unwrap_or(212992) as libc::c_int; // 212992 is the Linux default SO_SNDBUF + + let available = sndbuf.saturating_sub(queued) as usize; + if available < buf.len() { + // Not enough room — drop this non-critical message to avoid a partial write. + return Ok(()); + } + + match send(fd, buf, MsgFlags::MSG_DONTWAIT) { + Ok(n) if n == buf.len() => Ok(()), + // With the space check above a partial write should not happen, but handle + // it defensively: drop and signal the caller so it can log a warning. + Ok(n) => Err(anyhow::anyhow!( + "IPC partial non-blocking write: {n}/{} bytes sent despite space check", + buf.len() + )), + Err(nix::errno::Errno::EAGAIN) | Err(nix::errno::Errno::EWOULDBLOCK) => Ok(()), + Err(e) => Err(anyhow::anyhow!("IPC non-blocking write: {e}")), + } +} + +fn recv_hello(fd: RawFd, buf: &mut [u8]) -> Result { + // magic(4) + name_len(4) + read_exact(fd, &mut buf[..8])?; + let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap()); + if magic != MAGIC_HELLO { + bail!("bad hello magic: 0x{magic:08X}"); + } + let name_len = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + if name_len > 256 { + bail!("name too long: {name_len}"); + } + read_exact(fd, &mut buf[..name_len])?; + // Save name before overwriting the buffer with the width/height fields below. + let name = String::from_utf8_lossy(&buf[..name_len]).into_owned(); + // width(4) + height(4) – consume but ignore (comes from config) + read_exact(fd, &mut buf[..8])?; + Ok(name) +} + +fn recv_fds(sock_fd: RawFd, count: usize) -> Result> { + let mut cmsg_buf = cmsg_space!([RawFd; 4]); + let mut iov_buf = [0u8; 1]; + let mut iov = [IoSliceMut::new(&mut iov_buf[..])]; + + let msg = recvmsg::(sock_fd, &mut iov, Some(&mut cmsg_buf), MsgFlags::empty()) + .context("recvmsg for fds")?; + + let mut fds: Vec = Vec::new(); + for cmsg in msg.cmsgs().context("iterating control messages")? { + if let ControlMessageOwned::ScmRights(received) = cmsg { + for fd in received { + let owned = unsafe { OwnedFd::from_raw_fd(fd) }; + fds.push(owned); + } + } + } + if fds.len() < count { + bail!("received {} fds, need {}", fds.len(), count); + } + fds.truncate(count); + Ok(fds) +} diff --git a/src/handlers/compositor.rs b/src/handlers/compositor.rs new file mode 100644 index 0000000..573dd57 --- /dev/null +++ b/src/handlers/compositor.rs @@ -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::() { + 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 = 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::(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); diff --git a/src/handlers/dmabuf_handler.rs b/src/handlers/dmabuf_handler.rs new file mode 100644 index 0000000..66bee87 --- /dev/null +++ b/src/handlers/dmabuf_handler.rs @@ -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::(); + tracing::info!("dmabuf_imported: done"); + } + + fn new_surface_feedback( + &mut self, + _surface: &WlSurface, + _global: &DmabufGlobal, + ) -> Option { + // 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); diff --git a/src/handlers/input.rs b/src/handlers/input.rs new file mode 100644 index 0000000..834d906 --- /dev/null +++ b/src/handlers/input.rs @@ -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 { + 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(&mut self, event: InputEvent) { + match event { + InputEvent::Keyboard { event } => self.on_keyboard::(event), + InputEvent::PointerMotion { event } => self.on_pointer_motion::(event), + InputEvent::PointerMotionAbsolute { event } => self.on_pointer_motion_abs::(event), + InputEvent::PointerButton { event } => self.on_pointer_button::(event), + InputEvent::PointerAxis { event } => self.on_pointer_axis::(event), + _ => {} + } + } + + /// Determine which Electron window (if any) is under the given position. + fn window_under(&self, pos: (f64, f64)) -> Option { + // 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(&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(&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(&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(&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 = 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(&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::::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); + } + } + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..89f423d --- /dev/null +++ b/src/handlers/mod.rs @@ -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; diff --git a/src/handlers/output.rs b/src/handlers/output.rs new file mode 100644 index 0000000..a931703 --- /dev/null +++ b/src/handlers/output.rs @@ -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::(display); + + output + } +} diff --git a/src/handlers/xdg_decoration.rs b/src/handlers/xdg_decoration.rs new file mode 100644 index 0000000..2ee099d --- /dev/null +++ b/src/handlers/xdg_decoration.rs @@ -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); diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs new file mode 100644 index 0000000..01700ef --- /dev/null +++ b/src/handlers/xdg_shell.rs @@ -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::() + .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::() + .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::wayland::compositor::with_states(surface.wl_surface(), |states| { + states.data_map + .get::() + .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) { + 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, + initial_pointer: smithay::utils::Point, + }, + Resize { + surface: ToplevelSurface, + window: Window, + edges: ResizeEdge, + initial_window_location: smithay::utils::Point, + initial_window_size: (i32, i32), + initial_pointer: smithay::utils::Point, + }, +} + +/// 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, + 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::() + .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::() + .map(|d| d.lock().unwrap().initial_configure_sent) + .unwrap_or(false) + }); + if !initial_configure_sent { + xdg.send_configure().ok(); + } + } + } +} diff --git a/src/handlers/xwayland.rs b/src/handlers/xwayland.rs new file mode 100644 index 0000000..6cd6dfa --- /dev/null +++ b/src/handlers/xwayland.rs @@ -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, + _y: Option, + _w: Option, + _h: Option, + _reorder: Option, + ) { + if let Err(e) = window.configure(None) { + warn!("XWayland: configure failed: {e}"); + } + } + + fn configure_notify( + &mut self, + _xwm: XwmId, + _window: X11Surface, + _geometry: Rectangle, + _above: Option, + ) {} + + 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); diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..49c2f3c --- /dev/null +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2dff966 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1604 @@ +//! Wo Wayland compositor + +mod backend; +mod config; +mod dmabuf; +mod electron; +mod handlers; +mod nested; + +use ::input::Libinput; +use anyhow::Context; +use calloop::EventLoop; +use gbm::Modifier; +use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus}; +use smithay::backend::allocator::Fourcc; +use smithay::wayland::xdg_activation::{XdgActivationToken, XdgActivationTokenData}; +use smithay::{ + backend::{ + allocator::{ + dmabuf::AsDmabuf, + gbm::{GbmAllocator, GbmBufferFlags, GbmDevice}, + Swapchain, + }, + drm::{ + gbm::framebuffer_from_bo, DrmDevice, DrmDeviceFd, DrmNode, PlaneConfig, + PlaneState, + }, + egl::{EGLContext, EGLDisplay}, + libinput::{LibinputInputBackend, LibinputSessionInterface}, + renderer::{gles::GlesRenderer, Bind, Color32F, Frame, ImportDma, Renderer}, + session::{Session, libseat::LibSeatSession}, + udev::primary_gpu, + }, + output::{Mode as OutputMode, Output, PhysicalProperties, Scale, Subpixel}, + reexports::{ + drm::control::{connector, Device as _, ModeTypeFlags}, + rustix::fs::{Mode, OFlags}, + wayland_server::{Display, ListeningSocket}, + }, + utils::{Buffer, DeviceFd, Physical, Rectangle, Transform}, + wayland::{ + seat::WaylandFocus, xdg_activation::XdgActivationHandler, + }, +}; +use std::path::Path; +use std::{ + time::{Duration, Instant}, +}; +use tracing::{debug, error, info, trace, warn}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +pub mod state; +pub mod syscall; + +use crate::{ + config::Config, + dmabuf::import_electron_frame, + electron::{ElectronIpc, ElectronProcess}, + state::{BackendData, WoState}, +}; + +struct EventLoopData { + display: Display, + state: WoState, + msg_rx: tokio::sync::mpsc::Receiver, + swapchain: Swapchain>, + drm_device_fd: DrmDeviceFd, + drm_surface: smithay::backend::drm::DrmSurface, + output_rect: Rectangle, + src_rect: Rectangle, + bg: Color32F, + width: u32, + height: u32, + frame_time: Duration, + last_render: std::time::Instant, + autostart_tasks: Vec<(crate::config::AutostartConfig, std::time::Instant)>, + first_render: bool, + wayland_socket: ListeningSocket, + frame_timestamps: std::collections::HashMap, + input_events_pending: bool, +} + +fn is_egl_context_or_surface_lost(err: &str) -> bool { + let lowered = err.to_ascii_lowercase(); + lowered.contains("context has been lost") + || lowered.contains("egl_bad_surface") + || lowered.contains("bad_surface") + || lowered.contains("not current draw surface") + || lowered.contains("egl_bad_alloc") + || lowered.contains("failed to allocate resources") +} + +fn main() -> Result<(), anyhow::Error> { + let log_file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("/tmp/wo-compositor-debug.log") + .ok(); + + let registry = tracing_subscriber::registry() + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "wo=info,smithay=warn".into())) + .with(tracing_subscriber::fmt::layer()); + + if let Some(file) = log_file { + registry + .with( + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(std::sync::Mutex::new(file)), + ) + .init(); + } else { + registry.init(); + } + + info!("wo compositor starting"); + + let config = Config::load().context("loading config")?; + + // Determine backend priority: nested (gles) is prioritized with DRM fallback + let force_drm = matches!(config.compositor.nested, Some(false)); + + if !force_drm { + // Try nested mode first (prioritizes gles renderer) + info!("Attempting nested mode (gles renderer)"); + match nested::run_nested(config.clone()) { + Ok(_) => return Ok(()), + Err(e) => { + info!("Nested mode failed ({}), falling back to DRM/KMS", e); + } + } + } + + // Fall back to DRM/KMS mode + info!("Running in DRM/KMS mode"); + run_drm(config) +} + +fn run_drm(config: Config) -> Result<(), anyhow::Error> { + let mut session = match LibSeatSession::new() { + Ok((session, _notifier)) => Some(session), + Err(err) => { + warn!( + "Failed to create libseat session: {err}. Falling back to direct DRM open; libinput and VT switching will be disabled" + ); + None + } + }; + + let seat_name = session + .as_ref() + .map(|s| s.seat()) + .unwrap_or_else(|| "seat0".to_string()); + + let mut display: Display = Display::new().context("creating Wayland display")?; + let dh = display.handle(); + + let gpu_path = if let Some(p) = &config.compositor.drm_device { + std::path::PathBuf::from(p) + } else { + primary_gpu(&seat_name) + .ok() + .flatten() + .context("no primary GPU found")? + }; + info!(gpu = %gpu_path.display(), "using GPU"); + + let drm_fd = if let Some(session) = session.as_mut() { + session + .open( + &gpu_path, + OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOCTTY | OFlags::NONBLOCK, + ) + .context("opening DRM device")? + } else { + smithay::reexports::rustix::fs::open( + &gpu_path, + OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOCTTY | OFlags::NONBLOCK, + Mode::empty(), + ) + .context("opening DRM device directly (no session)")? + }; + + let drm_device_fd = DrmDeviceFd::new(DeviceFd::from(drm_fd)); + let (mut drm, _drm_notifier) = + DrmDevice::new(drm_device_fd.clone(), true).context("creating DRM device")?; + + let gbm_device = GbmDevice::new(drm_device_fd.clone()).context("creating GBM device")?; + + let egl_display = + unsafe { EGLDisplay::new(gbm_device.clone()) }.context("creating EGL display")?; + let egl_context = EGLContext::new(&egl_display).context("creating EGL context")?; + let renderer: GlesRenderer = + unsafe { GlesRenderer::new(egl_context).context("creating GLES renderer")? }; + + let resources = drm.resource_handles().context("DRM resource handles")?; + let connector_h = resources + .connectors() + .iter() + .find_map(|&h| { + let info = drm.get_connector(h, false).ok()?; + if info.state() == connector::State::Connected { + Some(h) + } else { + None + } + }) + .context("no connected DRM connector")?; + + let connector_info = drm.get_connector(connector_h, true)?; + let &mode = connector_info + .modes() + .iter() + .max_by_key(|m| (m.mode_type().contains(ModeTypeFlags::PREFERRED), m.size())) + .context("no DRM mode")?; + + let crtc_h = resources + .crtcs() + .iter() + .next() + .copied() + .context("no suitable CRTC")?; + + let drm_surface = drm + .create_surface(crtc_h, mode, &[connector_h]) + .context("creating DRM surface")?; + + let (w, h) = mode.size(); + let (width, height) = (w as u32, h as u32); + + let output = Output::new( + "wo-output".to_string(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "Wo".into(), + model: "Virtual".into(), + }, + ); + + let output_mode = OutputMode { + size: (width as i32, height as i32).into(), + refresh: mode.vrefresh() as i32 * 1000, + }; + + output.change_current_state( + Some(output_mode), + Some(Transform::Normal), + Some(Scale::Integer(1)), + Some((0, 0).into()), + ); + output.set_preferred(output_mode); + output.create_global::(&dh); + + let backend = BackendData { + output: output.clone(), + size: (width, height), + renderer: Some(std::sync::Arc::new(std::sync::Mutex::new(renderer))), + dmabuf_formats: None, + }; + let render_node = + DrmNode::from_path(&gpu_path).context("resolving DRM node for dmabuf feedback")?; + + let mut state = WoState::new(&mut display, config.clone(), render_node, backend); + state.can_switch_vt = session.is_some(); + + let (ipc, msg_rx) = ElectronIpc::listen(&config.compositor.ipc_socket).context("IPC socket")?; + + state.electron_ipc = Some(ipc); + + std::env::set_var("WAYLAND_DISPLAY", &config.compositor.socket_name); + + let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("electron"); + // Spawn any root (fullscreen) entries first. Root entries are an + // alternative to [[windows]] and are mapped to the full output size. + eprintln!("Root configs: {:#?}", config.root); + for root_cfg in &config.root { + let mut full = root_cfg.clone(); + full.x = 0; + full.y = 0; + full.width = width; + full.height = height; + // put roots behind normal windows by using a very low z_order + full.z_order = std::i32::MIN / 2; + match ElectronProcess::spawn( + &full, + &config.compositor.electron_path, + &app_dir, + &config.compositor.ipc_socket, + ) { + Ok(proc) => state.electron_processes.push(proc), + Err(e) => error!(window = %full.name, "failed to spawn Electron root: {e:#}"), + } + } + + for win_cfg in &config.windows { + match ElectronProcess::spawn( + win_cfg, + &config.compositor.electron_path, + &app_dir, + &config.compositor.ipc_socket, + ) { + Ok(proc) => state.electron_processes.push(proc), + Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"), + } + } + + // Schedule autostart tasks to be executed from the event loop thread + let mut autostart_tasks: Vec<(crate::config::AutostartConfig, std::time::Instant)> = Vec::new(); + let now = std::time::Instant::now(); + for task in &config.autostart { + let when = now + std::time::Duration::from_millis(task.delay); + autostart_tasks.push((task.clone(), when)); + } + + let swapchain: Swapchain> = Swapchain::new( + GbmAllocator::new( + gbm_device, + GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT, + ), + width, + height, + Fourcc::Xrgb8888, + vec![Modifier::Invalid], + ); + + let output_rect: Rectangle = + Rectangle::new((0, 0).into(), (width as i32, height as i32).into()); + let src_rect: Rectangle = + Rectangle::from_size((width as i32, height as i32).into()).to_f64(); + let bg = Color32F::from(config.compositor.background); + + // Initialize libinput for input events (only when session backend is available) + let libinput_backend = if let Some(session_ref) = session.as_ref() { + let mut libinput_context = + Libinput::new_with_udev(LibinputSessionInterface::from(session_ref.clone())); + libinput_context + .udev_assign_seat(&session_ref.seat()) + .map_err(|()| anyhow::anyhow!("Failed to assign seat to libinput"))?; + Some(LibinputInputBackend::new(libinput_context)) + } else { + warn!("Running without libinput backend because no session backend is available"); + None + }; + + // Prepare frame pacing using the active DRM mode refresh when available. + let refresh_hz = (output_mode.refresh as f64) / 1000.0; + let frame_time = if refresh_hz > 1.0 { + Duration::from_secs_f64((1.0 / refresh_hz).clamp(1.0 / 240.0, 1.0 / 30.0)) + } else { + Duration::from_millis(16) + }; + let last_render = std::time::Instant::now(); + + let wayland_socket = + ListeningSocket::bind(&config.compositor.socket_name).context("binding Wayland socket")?; + info!(socket = %config.compositor.socket_name, "Wayland socket bound"); + + // Move all state into EventLoopData for access in event loop callbacks + let mut loop_data = EventLoopData { + display, + state, + msg_rx, + swapchain, + drm_device_fd: drm_device_fd.clone(), + drm_surface, + output_rect, + src_rect, + bg, + width, + height, + frame_time, + last_render, + autostart_tasks, + first_render: true, + wayland_socket, + frame_timestamps: std::collections::HashMap::new(), + input_events_pending: false, + }; + + // Create calloop event loop + let mut event_loop: EventLoop = + EventLoop::try_new().context("creating event loop")?; + let loop_handle = event_loop.handle(); + let loop_signal = event_loop.get_signal(); + + // Set up signal handler for clean shutdown + let signal_for_handler = loop_signal.clone(); + ctrlc::set_handler(move || { + info!("Received Ctrl+C, initiating shutdown"); + signal_for_handler.stop(); + }) + .expect("Error setting Ctrl-C handler"); + + // Insert libinput backend into event loop (operate on `loop_data.state`) + if let Some(libinput_backend) = libinput_backend { + loop_handle + .insert_source( + libinput_backend, + move |event, _, data: &mut EventLoopData| { + data.state.process_input_event(event); + data.input_events_pending = true; + }, + ) + .map_err(|e| anyhow::anyhow!("Error inserting libinput source: {e:?}"))?; + } + + // Main event loop with rendering + // Run the calloop event loop with our loop data + event_loop + .run(Some(Duration::from_millis(1)), &mut loop_data, |data| { + // Check if we should stop + if !data.state.running { + loop_signal.stop(); + return; + } + + if let Some(vt) = data.state.pending_vt_switch.take() { + if let Some(session) = session.as_mut() { + if let Err(e) = session.change_vt(vt as i32) { + warn!(vt, "Failed to switch VT: {e}"); + } else { + info!(vt, "Switched to VT"); + } + } else { + warn!(vt, "Ignoring VT switch request: no active session backend"); + } + } + + while let Ok(Some(stream)) = data.wayland_socket.accept() { + if let Err(e) = data.display.handle().insert_client( + stream, + std::sync::Arc::new(crate::state::ClientData::default()), + ) { + error!("Failed to insert Wayland client: {e}"); + } + } + if let Err(e) = data.display.dispatch_clients(&mut data.state) { + error!("Failed to dispatch Wayland clients: {e}"); + } + if let Err(e) = data.display.flush_clients() { + error!("Failed to flush Wayland clients: {e}"); + } + data.state.space.refresh(); + if let Err(e) = data.display.flush_clients() { + error!("Failed to flush after space refresh: {e}"); + } + + // Process IPC messages with input prioritization and frame backpressure + let mut input_batch = Vec::new(); + let mut frames_batch = Vec::new(); + let mut other_batch = Vec::new(); + + while let Ok(msg) = data.msg_rx.try_recv() { + use crate::electron::ElectronMessage; + match msg { + ElectronMessage::ForwardedPointer { .. } + | ElectronMessage::ForwardedKeyboard { .. } + | ElectronMessage::ForwardedRelativePointer { .. } + | ElectronMessage::ForwardedPointerButton { .. } + | ElectronMessage::ForwardedPointerScroll { .. } => { + input_batch.push(msg); + } + ElectronMessage::Frame(_) => { + frames_batch.push(msg); + } + _ => { + other_batch.push(msg); + } + } + } + + // Process input events first for low latency + for msg in input_batch { + use crate::electron::ElectronMessage; + match msg { + ElectronMessage::ForwardedPointer { window_name, x, y } => { + data.state.handle_forwarded_pointer_event(&window_name, x, y); + } + ElectronMessage::ForwardedKeyboard { window_name, key, pressed, time } => { + data.state.handle_forwarded_keyboard_event(&window_name, key, pressed, time); + } + ElectronMessage::ForwardedRelativePointer { window_name, dx, dy } => { + data.state.handle_forwarded_relative_pointer_event(&window_name, dx, dy); + } + ElectronMessage::ForwardedPointerButton { window_name, x, y, button, pressed, time } => { + data.state.handle_forwarded_pointer_button(&window_name, x, y, button, pressed, time); + } + ElectronMessage::ForwardedPointerScroll { window_name, dx, dy } => { + data.state.handle_forwarded_pointer_scroll(&window_name, dx, dy); + } + _ => {} + } + } + + // Process frames with backpressure (8ms throttle per window) + let now = Instant::now(); + let mut latest_frames = std::collections::HashMap::new(); + for msg in frames_batch { + if let crate::electron::ElectronMessage::Frame(frame) = msg { + let name = frame.name.clone(); + + if let Some(last_ts) = data.frame_timestamps.get(&name) { + if now.duration_since(*last_ts) < Duration::from_millis(8) { + trace!("Dropping frame {} due to backpressure", name); + continue; + } + } + + data.frame_timestamps.insert(name.clone(), now); + latest_frames.insert(name, frame); + } + } + + // Import latest frames + for (_name, frame) in latest_frames { + let frame_name = frame.name.clone(); + if let Some(renderer_arc) = data.state.backend.renderer() { + match renderer_arc.lock() { + Ok(ref mut renderer) => { + match import_electron_frame(renderer, frame) { + Ok((_tex, cached)) => { + data.state.damaged_windows.insert(frame_name.clone()); + data.state.texture_cache.insert_dmabuf(cached); + } + Err(e) => warn!("DMABUF import failed for '{}': {e:#}", frame_name), + } + } + Err(e) => warn!("Failed to lock renderer: {e}"), + } + } + } + + // Process other messages + for msg in other_batch { + use crate::electron::ElectronMessage; + match msg { + ElectronMessage::WindowPosition(pos) => { + trace!( + "Window position: {} at ({}, {})", + pos.window_name, pos.x, pos.y + ); + data.state.window_positions.insert( + pos.window_name.clone(), + (pos.x, pos.y, pos.width, pos.height), + ); + data.state.metadata_dirty = true; + } + ElectronMessage::Syscall(req) => { + if let Some(ref handler) = data.state.syscall_handler { + trace!("Syscall from {}: {}", req.window_name, req.syscall_type); + + if let Ok(syscall_req) = + serde_json::from_str::(&req.payload) + { + let response = handler.handle(syscall_req); + let response_json = serde_json::to_string(&response) + .unwrap_or_else(|_| "{}".to_string()); + + if let Some(ref ipc) = data.state.electron_ipc { + let _ = + ipc.send_syscall_response(&req.window_name, &response_json); + } + } else { + warn!("Failed to parse syscall payload: {}", req.payload); + } + } else { + warn!("Syscall requested but syscalls are disabled"); + } + } + ElectronMessage::Action(action_msg) => { + use crate::electron::CompositorAction; + info!( + "Action from {}: {:?}", + action_msg.window_name, action_msg.action + ); + + match action_msg.action { + CompositorAction::Quit { code } => { + info!("Quit requested with code {}", code); + data.state.running = false; + } + CompositorAction::Custom { + action: act_str, + payload, + } => { + info!("Custom action '{}': ", act_str); + + let payload_json: Option = payload + .as_ref() + .and_then(|p| serde_json::from_str(p).ok()); + + let target_name: Option = payload_json + .as_ref() + .and_then(|p| p.get("window")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + if action_msg.window_name.starts_with("wayland-") { + Some(action_msg.window_name.clone()) + } else { + None + } + }); + + let wayland_window = target_name.as_ref() + .and_then(|name| data.state.find_wayland_window(name)); + + if let Some(window) = wayland_window { + let th = data.state.title_h_for_window(&window); + match act_str.as_str() { + "move" => { + let x = payload_json.as_ref() + .and_then(|p| p.get("x")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + let y = payload_json.as_ref() + .and_then(|p| p.get("y")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + data.state.space.map_element(window, (x, y + th), false); + } + "resize" => { + let new_w = payload_json.as_ref() + .and_then(|p| p.get("width")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + let new_h = payload_json.as_ref() + .and_then(|p| p.get("height")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + if let Some(toplevel) = window.toplevel() { + let content_h = (new_h - th).max(1); + let (cw, ch) = data.state.clamp_to_size_hints(&window, new_w, content_h); + toplevel.with_pending_state(|s| { + s.size = Some((cw, ch).into()); + }); + toplevel.send_configure(); + } + } + "close" => { + if let Some(toplevel) = window.toplevel() { + toplevel.send_close(); + } + data.state.space.unmap_elem(&window); + data.state.all_windows.retain(|w| w != &window); + if let Some(name) = target_name.as_ref() { + data.state.window_mapped.insert(name.clone(), false); + } + data.state.refocus_topmost_native_window(); + data.state.metadata_dirty = true; + } + "focus" => { + 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 data.state.space.element_location(&window).is_none() { + use smithay::wayland::compositor::with_states; + let parent_surface = window.toplevel().and_then(|t| { + with_states(t.wl_surface(), |states| { + states.data_map + .get::() + .and_then(|d| d.lock().ok().and_then(|data| data.parent.clone())) + }) + }); + let (ow, oh) = ( + data.state.output_size.0 as i32, + data.state.output_size.1 as i32, + ); + let location = if let Some(ref parent_wl) = parent_surface { + let parent_center = data + .state + .space + .elements() + .find(|w| { + w.toplevel() + .map(|t| t.wl_surface() == parent_wl) + .unwrap_or(false) + }) + .map(|parent_win| { + let loc = data + .state + .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(handlers::xdg_shell::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(handlers::xdg_shell::PANEL_H + 20); + ((ow - suggest_w) / 2, center_y) + }; + let tname = target_name.clone().unwrap(); + data.state.window_mapped.insert(tname, true); + data.state.space.map_element(window.clone(), location, true); + data.state.metadata_dirty = true; + } + + // Use xdg-activation for xdg toplevels; directly set + // keyboard focus for X11 windows. + if window.toplevel().is_some() { + use smithay::wayland::compositor::with_states; + let token = XdgActivationToken::from(format!( + "electron-focus-{}", + Instant::now().elapsed().as_millis() + )); + let token_data = XdgActivationTokenData { + surface: Some(surface.clone()), + serial: None, + timestamp: Instant::now(), + app_id: window.toplevel().and_then(|t| { + with_states(t.wl_surface(), |states| { + states + .data_map + .get::() + .and_then(|d| { + d.lock().ok().and_then( + |data| data.app_id.clone(), + ) + }) + }) + }), + client_id: None, + user_data: std::sync::Arc::new( + smithay::utils::user_data::UserDataMap::new(), + ), + }; + data.state.request_activation( + token, + token_data, + surface.clone(), + ); + + if let Some(keyboard) = data.state.seat.get_keyboard() { + let serial = smithay::utils::SERIAL_COUNTER.next_serial(); + keyboard.set_focus( + &mut data.state, + Some(surface.clone()), + serial, + ); + } + + if let Some(name) = target_name.as_ref() { + data.state.keyboard_window_focus = Some(name.clone()); + data.state.pointer_window_focus = Some(name.clone()); + } + } else if let Some(keyboard) = data.state.seat.get_keyboard() { + let serial = smithay::utils::SERIAL_COUNTER.next_serial(); + info!("Focus action: setting keyboard focus to X11 window: {:?}", target_name); + keyboard.set_focus( + &mut data.state, + Some(surface.clone()), + serial, + ); + + if let Some(name) = target_name.as_ref() { + data.state.keyboard_window_focus = Some(name.clone()); + data.state.pointer_window_focus = Some(name.clone()); + } + } + } + } + "minimize" => { + data.state.space.unmap_elem(&window); + data.state.window_mapped.insert(target_name.clone().unwrap(), false); + data.state.refocus_topmost_native_window(); + data.state.metadata_dirty = true; + } + "maximize" => { + if let Some(toplevel) = window.toplevel() { + use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as XdgState; + use wo::handlers::xdg_shell::PANEL_H; + let (ow, oh) = (data.state.output_size.0 as i32, data.state.output_size.1 as i32); + let y_offset = th.max(PANEL_H); + let content_h = (oh - y_offset).max(1); + let (cw, ch) = data.state.clamp_to_size_hints(&window, ow, content_h); + let x_offset = ((ow - cw) / 2).max(0); + toplevel.with_pending_state(|s| { + s.states.set(XdgState::Maximized); + s.size = Some((cw, ch).into()); + }); + toplevel.send_configure(); + data.state.space.map_element(window.clone(), (x_offset, y_offset), false); + } + } + "pointer_motion" => { + let x = payload_json.as_ref().and_then(|p| p.get("x")).and_then(|v| v.as_f64()).unwrap_or(0.0); + let y = payload_json.as_ref().and_then(|p| p.get("y")).and_then(|v| v.as_f64()).unwrap_or(0.0); + let tname = target_name.as_deref().unwrap_or(""); + data.state.handle_forwarded_pointer_event(tname, x, y); + } + "pointer_button" => { + let x = payload_json.as_ref().and_then(|p| p.get("x")).and_then(|v| v.as_f64()).unwrap_or(0.0); + let y = payload_json.as_ref().and_then(|p| p.get("y")).and_then(|v| v.as_f64()).unwrap_or(0.0); + let button = payload_json.as_ref().and_then(|p| p.get("button")).and_then(|v| v.as_u64()).unwrap_or(272) as u32; + let pressed = payload_json.as_ref().and_then(|p| p.get("pressed")).and_then(|v| v.as_bool()).unwrap_or(false); + let tname = target_name.as_deref().unwrap_or(""); + data.state.handle_forwarded_pointer_button(tname, x, y, button, pressed, 0); + } + "pointer_scroll" => { + let dx = payload_json.as_ref().and_then(|p| p.get("dx")).and_then(|v| v.as_f64()).unwrap_or(0.0); + let dy = payload_json.as_ref().and_then(|p| p.get("dy")).and_then(|v| v.as_f64()).unwrap_or(0.0); + let tname = target_name.as_deref().unwrap_or(""); + data.state.handle_forwarded_pointer_scroll(tname, dx, dy); + } + "pointer_leave" => { + use smithay::input::pointer::MotionEvent; + if let Some(pointer) = data.state.seat.get_pointer() { + let serial = smithay::utils::SERIAL_COUNTER.next_serial(); + let loc = data.state.pointer_location; + pointer.motion(&mut data.state, None, &MotionEvent { + location: loc, + serial, + time: 0, + }); + pointer.frame(&mut data.state); + } + } + _ => {} + } + } else { + // Electron window actions (existing logic) + match act_str.as_str() { + "focus" => { + data.state + .window_mapped + .insert(action_msg.window_name.clone(), true); + // Give keyboard and pointer logical focus to this window + data.state.keyboard_window_focus = Some(action_msg.window_name.clone()); + data.state.pointer_window_focus = Some(action_msg.window_name.clone()); + data.state.metadata_dirty = true; + } + "map" => { + data.state + .window_mapped + .insert(action_msg.window_name.clone(), true); + data.state.metadata_dirty = true; + } + "unmap" | "minimize" => { + data.state + .window_mapped + .insert(action_msg.window_name.clone(), false); + // Clear any frontend keyboard/pointer focus targeting this window + if data.state.keyboard_window_focus.as_deref() == Some(&action_msg.window_name) { + data.state.keyboard_window_focus = None; + } + if data.state.pointer_window_focus.as_deref() == Some(&action_msg.window_name) { + data.state.pointer_window_focus = None; + } + data.state.metadata_dirty = true; + } + "close" => { + if let Some(idx) = data + .state + .electron_processes + .iter() + .position(|p| p.name == action_msg.window_name) + { + let mut proc = + data.state.electron_processes.remove(idx); + proc.kill(); + } + data.state.texture_cache.remove(&action_msg.window_name); + data.state.window_positions.remove(&action_msg.window_name); + data.state.window_mapped.remove(&action_msg.window_name); + // Clear any stored focus referring to this window + if data.state.keyboard_window_focus.as_deref() == Some(&action_msg.window_name) { + data.state.keyboard_window_focus = None; + } + if data.state.pointer_window_focus.as_deref() == Some(&action_msg.window_name) { + data.state.pointer_window_focus = None; + } + if let Some(ref ipc) = data.state.electron_ipc { + let _ = ipc + .clients + .lock() + .unwrap() + .remove(&action_msg.window_name); + } + data.state.metadata_dirty = true; + } + _ => {} + } + } + } + } + } + _ => {} + } + } + + if !data.state.running { + loop_signal.stop(); + return; + } + + let adaptive_frame_time = if data.input_events_pending { + Duration::from_millis(8) + } else { + data.frame_time + }; + + if data.last_render.elapsed() < adaptive_frame_time { + return; + } + data.last_render = std::time::Instant::now(); + data.input_events_pending = false; + + if data.first_render { + for win_cfg in &data.state.config.windows { + data.state.damaged_windows.insert(win_cfg.name.clone()); + } + data.first_render = false; + } + + // Execute any due autostart tasks (scheduled at startup) + if !data.autostart_tasks.is_empty() { + let now = std::time::Instant::now(); + let mut pending: Vec<(crate::config::AutostartConfig, std::time::Instant)> = + Vec::new(); + for (task, when) in data.autostart_tasks.drain(..) { + if when <= now { + let cmd = task.command.clone(); + // Handle simple wo://window/ built-in action + if let Some(name) = cmd.strip_prefix("wo://window/") { + if let Some(win_cfg) = + data.state.config.windows.iter().find(|w| w.name == name) + { + match ElectronProcess::spawn( + win_cfg, + &data.state.config.compositor.electron_path, + &Path::new(env!("CARGO_MANIFEST_DIR")).join("electron"), + &data.state.config.compositor.ipc_socket, + ) { + Ok(proc) => data.state.electron_processes.push(proc), + Err(e) => { + error!(window = %name, "autostart spawn failed: {e:#}") + } + } + } else { + warn!("autostart: window config not found: {}", name); + } + } else if cmd == "wo://portal/start" { + let portal_bin = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("wo-portal"))) + .unwrap_or_else(|| std::path::PathBuf::from("wo-portal")); + match std::process::Command::new(&portal_bin).spawn() { + Ok(_) => info!("Spawned portal service: {:?}", portal_bin), + Err(e) => error!("Failed to spawn portal: {e:#}"), + } + } else if let Some(path) = cmd.strip_prefix("file://") { + // Execute script file (path expected absolute or relative) + let p = path.to_string(); + if task.restart { + std::thread::spawn(move || loop { + match std::process::Command::new(&p).spawn() { + Ok(mut child) => { + let _ = child.wait(); + } + Err(e) => { + eprintln!("autostart exec failed {}: {e}", p); + break; + } + } + }); + } else { + let _ = std::process::Command::new(&p).spawn(); + } + } else { + // Generic command: if contains whitespace, run via shell + if cmd.contains(' ') { + if task.restart { + let c = cmd.clone(); + std::thread::spawn(move || loop { + match std::process::Command::new("sh") + .arg("-c") + .arg(&c) + .spawn() + { + Ok(mut child) => { + let _ = child.wait(); + } + Err(e) => { + eprintln!("autostart shell failed {}: {e}", c); + break; + } + } + }); + } else { + let _ = std::process::Command::new("sh") + .arg("-c") + .arg(&cmd) + .spawn(); + } + } else { + // Simple binary invocation + if task.restart { + let c = cmd.clone(); + std::thread::spawn(move || loop { + match std::process::Command::new(&c).spawn() { + Ok(mut child) => { + let _ = child.wait(); + } + Err(e) => { + eprintln!("autostart exec failed {}: {e}", c); + break; + } + } + }); + } else { + let _ = std::process::Command::new(&cmd).spawn(); + } + } + } + } else { + pending.push((task, when)); + } + } + data.autostart_tasks = pending; + } + + // Poll for exited Electron helper processes (non-blocking) and mark metadata dirty + if !data.state.electron_processes.is_empty() { + for idx in (0..data.state.electron_processes.len()).rev() { + if let Some(pid_u32) = data.state.electron_processes.get(idx).map(|p| p.child.id()) { + let pid = pid_u32 as u32; + let npid = nix::unistd::Pid::from_raw(pid as i32); + match waitpid(Some(npid), Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::StillAlive) => {} + Ok(status) => { + let name = data.state.electron_processes[idx].name.clone(); + info!(window = %name, pid = pid, "Electron process exited: {:?}", status); + let mut proc = data.state.electron_processes.remove(idx); + let _ = proc.child.try_wait(); + data.state.metadata_dirty = true; + } + Err(e) => { + warn!("waitpid failed for electron pid {}: {e}", pid); + } + } + } + } + } + + // Send window metadata update to all clients if dirty + if data.state.metadata_dirty { + let mut metadata_list = Vec::new(); + + // Electron-managed windows + for win_cfg in &data.state.config.windows { + let (x, y, w, h) = data + .state + .window_positions + .get(&win_cfg.name) + .copied() + .unwrap_or((win_cfg.x, win_cfg.y, win_cfg.width, win_cfg.height)); + + let mapped = data.state + .window_mapped + .get(&win_cfg.name) + .copied() + .unwrap_or(true); + + let is_focused = data.state.pointer_window_focus.as_ref() == Some(&win_cfg.name); + + metadata_list.push(serde_json::json!({ + "name": win_cfg.name, + "x": x, + "y": y, + "width": w, + "height": h, + "z_order": win_cfg.z_order, + "focused": is_focused, + "mapped": mapped, + "source": "electron", + "ssd": true, + })); + } + + // Native Wayland windows from the all_windows tracking list. + // Snapshot to avoid borrow issues during parent lookups. + let wayland_windows: Vec<_> = data.state.all_windows.clone(); + for window in wayland_windows.iter() { + let window_name = match data.state.wayland_window_name(window) { + Some(n) => n.clone(), + None => continue, + }; + + let title = if let Some(x11) = window.x11_surface() { + x11.title().to_string() + } else { + window.toplevel() + .and_then(|t| { + smithay::wayland::compositor::with_states(t.wl_surface(), |states| { + states.data_map + .get::() + .and_then(|d| d.lock().ok().and_then(|data| data.title.clone())) + }) + }) + .unwrap_or_else(|| window_name.clone()) + }; + + let app_id = if let Some(x11) = window.x11_surface() { + Some(x11.class().to_string()) + } else { + window.toplevel() + .and_then(|t| { + smithay::wayland::compositor::with_states(t.wl_surface(), |states| { + states.data_map + .get::() + .and_then(|d| d.lock().ok().and_then(|data| data.app_id.clone())) + }) + }) + }; + + let loc = data.state.space.element_location(window).unwrap_or_default(); + let geo = window.geometry(); + + let bbox_w = geo.size.w + geo.loc.x.max(0) * 2; + let bbox_h = geo.size.h + geo.loc.y.max(0) * 2; + + let th = data.state.title_h_for_window(window); + let is_ssd = th > 0; + + let (parent_surface, parent_x11_id) = if let Some(x11) = window.x11_surface() { + (None, x11.is_transient_for()) + } else { + (window.toplevel().and_then(|t| { + smithay::wayland::compositor::with_states(t.wl_surface(), |states| { + states.data_map + .get::() + .and_then(|d| d.lock().ok().and_then(|data| data.parent.clone())) + }) + }), None) + }; + + let parent_name = if let Some(parent_wl) = parent_surface.as_ref() { + use smithay::reexports::wayland_server::Resource; + data.state.wayland_window_names.get(&parent_wl.id()).cloned() + } else if let Some(parent_id) = parent_x11_id { + data.state.x11_window_names.get(&parent_id).cloned() + } else { + None + }; + + let is_dialog = parent_name.is_some(); + + let is_focused = if let Some(x11) = window.x11_surface() { + x11.wl_surface() + .map(|s| data.state.surface_has_keyboard_focus(&s)) + .unwrap_or(false) + } else { + window.toplevel() + .map(|t| data.state.surface_has_keyboard_focus(t.wl_surface())) + .unwrap_or(false) + }; + + let mapped = data.state.space.element_location(window).is_some(); + + let space_idx = data.state.space.elements() + .position(|w| w == window) + .map(|pos| 1000 - pos as i32) + .unwrap_or(0); + + let source = if window.x11_surface().is_some() { + "x11" + } else { + "wayland" + }; + + metadata_list.push(serde_json::json!({ + "name": window_name, + "title": title, + "app_id": app_id, + "x": loc.x - geo.loc.x.max(0), + "y": loc.y - th - geo.loc.y.max(0), + "width": bbox_w, + "height": bbox_h + th, + "z_order": space_idx, + "focused": is_focused, + "source": source, + "mapped": mapped, + "ssd": is_ssd, + "dialog": is_dialog, + "parent_name": parent_name, + })); + } + + if !metadata_list.is_empty() { + let metadata_msg = serde_json::json!({ + "type": "metadata", + "windows": metadata_list, + }); + + if let Some(ref ipc) = data.state.electron_ipc { + let _ = ipc.broadcast_metadata(&metadata_msg.to_string()); + } + } + data.state.metadata_dirty = false; + } + + // Rendering pipeline: acquire swapchain slot, render textures, flip display + let slot = match data.swapchain.acquire() { + Ok(Some(slot)) => slot, + Ok(None) => return, + Err(e) => { + warn!("Error acquiring swapchain slot: {e}"); + return; + } + }; + + let mut target_dmabuf = match slot.export() { + Ok(dmabuf) => dmabuf, + Err(e) => { + warn!("Error exporting swapchain dmabuf: {e}"); + data.swapchain.submitted(&slot); + return; + } + }; + + let render_ok = (|| { + let renderer_arc = match data.state.backend.renderer() { + Some(arc) => arc, + None => { + warn!("No renderer available"); + return false; + } + }; + let mut renderer_guard = match renderer_arc.lock() { + Ok(guard) => guard, + Err(e) => { + warn!("Failed to lock renderer: {e}"); + return false; + } + }; + let renderer = &mut *renderer_guard; + let mut target = match renderer.bind(&mut target_dmabuf) { + Ok(target) => target, + Err(err) => { + warn!("bind render target failed: {err}"); + if is_egl_context_or_surface_lost(&err.to_string()) { + error!( + "EGL context/surface lost while binding DRM render target; shutting down so context can be recreated" + ); + data.state.running = false; + } + return false; + } + }; + + // Pre-import all textures before creating the frame. + // Chain root windows (comraw) so they are also rendered. + let mut imported_textures = std::collections::HashMap::new(); + { + let mut windows: Vec<_> = data.state.config.windows.iter() + .chain(data.state.config.root.iter()) + .collect(); + windows.sort_by_key(|w| w.z_order); + for win in &windows { + if let Some(cached) = data.state.texture_cache.get_dmabuf(&win.name) { + match renderer.import_dmabuf(&cached.dmabuf, None) { + Ok(tex) => { + imported_textures.insert(win.name.clone(), tex); + trace!(window = %win.name, w = win.width, h = win.height, "imported texture"); + } + Err(err) => { + warn!(window = %win.name, "failed to import texture: {err}"); + } + } + } + } + } + + // Prepare cursor elements before starting the frame + use smithay::backend::renderer::element::{ + surface::render_elements_from_surface_tree, + Element, Kind, RenderElement, + }; + use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement; + + let cursor_elements: Vec> = + if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) = data.state.cursor_status { + use smithay::utils::Scale as ScaleF; + let scale = ScaleF::from(1.0); + render_elements_from_surface_tree( + renderer, + cursor_surface, + (data.state.pointer_location.x as i32, data.state.pointer_location.y as i32), + scale, + 1.0, + Kind::Cursor, + ) + } else { + Vec::new() + }; + + let mut frame = match renderer.render( + &mut target, + (data.width as i32, data.height as i32).into(), + Transform::Flipped180, + ) { + Ok(frame) => frame, + Err(err) => { + warn!("begin frame failed: {err}"); + if is_egl_context_or_surface_lost(&err.to_string()) { + error!( + "EGL context/surface lost while starting DRM frame; shutting down so context can be recreated" + ); + data.state.running = false; + } + return false; + } + }; + + if let Err(err) = frame.clear(data.bg.into(), &[data.output_rect]) { + warn!("frame clear failed: {err}"); + false + } else { + let mut ok = true; + let mut windows: Vec<_> = data.state.config.windows.iter() + .chain(data.state.config.root.iter()) + .collect(); + windows.sort_by_key(|w| w.z_order); + for win in windows { + // Skip windows that are explicitly unmapped by Electron clients + let mapped = data + .state + .window_mapped + .get(&win.name) + .copied() + .unwrap_or(true); + if !mapped { + continue; + } + + if let Some(tex) = imported_textures.get(&win.name) { + if let Err(err) = frame.render_texture_at( + tex, + (win.x, win.y).into(), + 1, + 1.0, + smithay::utils::Transform::Normal, + &[data.output_rect], + &[], + 1.0, + ) { + warn!(window = %win.name, "render texture failed: {err}"); + ok = false; + break; + } + } + } + + for element in &cursor_elements { + use smithay::utils::Scale as ScaleF; + let scale = ScaleF::from(1.0); + let src = element.src(); + let dst = element.geometry(scale); + if let Err(e) = element.draw(&mut frame, src, dst, &[data.output_rect], &[]) { + trace!("Failed to render cursor element: {}", e); + } + } + + if ok { + if let Err(err) = frame.finish() { + warn!("frame finish failed: {err}"); + if is_egl_context_or_surface_lost(&err.to_string()) { + error!("EGL context/surface lost while finishing DRM frame"); + data.state.running = false; + } + false + } else { + true + } + } else { + warn!("texture rendering failed, skipping frame"); + false + } + } + })(); + + // Always send frame callbacks to Wayland clients so they can + // schedule their next buffer commit. Skipping these on render + // failures causes clients to stall indefinitely waiting for vblank. + // Use the UNIX epoch timestamp like nested mode. + let presented_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let windows_snapshot: Vec<_> = data.state.space.elements().cloned().collect(); + for window in &windows_snapshot { + window.send_frame( + &data.state.output, + presented_at, + None, + |_, _| Some(data.state.output.clone()), + ); + } + + // Export dirty Wayland surfaces to Electron clients via SURFACE_BUFFER IPC. + // This feeds Wayland client content into comraw's web canvases for rendering. + { + use smithay::backend::renderer::utils::RendererSurfaceStateUserData; + use smithay::reexports::wayland_server::Resource; + use smithay::wayland::compositor::with_states; + use smithay::wayland::shm::with_buffer_contents; + + let dirty: Vec<_> = + data.state.dirty_surfaces.drain().collect(); + + for surface in dirty { + let surface_id = surface.id(); + let (window_name, window_geo) = if let Some(n) = + data.state.wayland_window_names.get(&surface_id).cloned() + { + let sid = surface_id.clone(); + let geo = data.state.space.elements().find_map(|w| { + if w.wl_surface().map(|s| s.id()) == Some(sid.clone()) { + data.state.space.element_geometry(w) + } else { + None + } + }); + (n, geo) + } else { + let sid = surface_id.clone(); + let x11_result = data.state.space.elements().find_map(|w| { + let x11 = w.x11_surface()?; + let wl = x11.wl_surface()?; + if wl.id() == sid.clone() { + let name = data.state.x11_window_names.get(&x11.window_id()).cloned()?; + let geo = data.state.space.element_geometry(w); + Some((name, geo)) + } else { + None + } + }); + match x11_result { + Some((n, geo)) => (n, geo), + None => continue, + } + }; + + let Some(geo) = window_geo else { continue }; + + // Calculate bbox (bounding box) for CSD windows with shadows/decorations + // geo.loc is the offset from surface origin to content area + // For libadwaita: geo.loc is typically (10, 10) for 10px shadows + // bbox needs to include the decorations, so expand by geo.loc on both sides + let bbox_w = (geo.size.w + geo.loc.x.max(0) * 2) as u32; + let bbox_h = (geo.size.h + geo.loc.y.max(0) * 2) as u32; + + let (w, h) = (bbox_w, bbox_h); + if w == 0 || h == 0 { + continue; + } + + // Read pixel data from the committed SHM buffer + let pixels_opt: Option<(u32, u32, u32, Vec)> = + with_states(&surface, |states| { + let rss_data = states + .data_map + .get::()?; + let rss = rss_data.lock().ok()?; + let buffer = rss.buffer()?; + + let mut result: Option<(u32, u32, u32, Vec)> = None; + let _ = with_buffer_contents(buffer, |ptr, len, spec| { + let pixel_bytes = + (spec.stride.max(0) * spec.height.max(0)) as usize; + if pixel_bytes > 0 && pixel_bytes <= len { + // SAFETY: ptr valid for `pixel_bytes` bytes per SHM contract. + let slice = unsafe { + std::slice::from_raw_parts(ptr, pixel_bytes) + }; + result = Some(( + spec.width as u32, + spec.height as u32, + spec.stride as u32, + slice.to_vec(), + )); + } + }); + result + }); + + if let Some((_bw, _bh, stride, pixels)) = pixels_opt { + let memfd_fd = data.state.window_shm_buffers.entry(window_name.clone()).or_insert_with(|| { + nix::sys::memfd::memfd_create( + std::ffi::CStr::from_bytes_with_nul(b"wayland_window\0").unwrap(), + nix::sys::memfd::MFdFlags::MFD_CLOEXEC, + ).expect("creating memfd") + }); + + use std::os::unix::io::AsRawFd; + let size = pixels.len() as i64; + let stat = nix::sys::stat::fstat(&*memfd_fd).unwrap(); + if stat.st_size != size { + nix::unistd::ftruncate(&*memfd_fd, size).expect("truncating memfd"); + } + + nix::unistd::lseek(&*memfd_fd, 0, nix::unistd::Whence::SeekSet).expect("seeking memfd"); + nix::unistd::write(&*memfd_fd, &pixels).expect("writing to memfd"); + + // Surface is from dirty_surfaces, so it's definitely dirty + if let Some(ref ipc) = data.state.electron_ipc { + let pid = std::process::id(); + if let Err(e) = ipc.broadcast_shm_buffer( + &window_name, + w, + h, + stride, + pid, + memfd_fd.as_raw_fd() as i32, + ) { + debug!( + "shm buffer broadcast failed for {}: {}", + window_name, e + ); + } + } + } else if let Some(ref ipc) = data.state.electron_ipc { + // If no SHM pixels, check if it's a DMABUF (optimal for games) + let dmabuf_opt = with_states(&surface, |states| { + let rss_data = states.data_map.get::()?; + let rss = rss_data.lock().ok()?; + let buffer = rss.buffer()?; + smithay::wayland::dmabuf::get_dmabuf(buffer).ok().cloned() + }); + + if let Some(dmabuf) = dmabuf_opt { + // Surface is from dirty_surfaces, so it has new damage + if let Err(e) = ipc.broadcast_dmabuf_frame(&window_name, &dmabuf) { + debug!("dmabuf broadcast failed for {}: {}", window_name, e); + } + } + } + } + } + + // Check rendering result and skip page flip if rendering failed + if !render_ok { + trace!("Skipping page flip for failed frame"); + data.swapchain.submitted(&slot); + data.state.damaged_windows.clear(); + return; + } + + // Clear the damaged windows set for the next frame + data.state.damaged_windows.clear(); + data.state.dirty_surfaces.clear(); + + let fb = match framebuffer_from_bo(&data.drm_device_fd, &slot, false) { + Ok(fb) => fb, + Err(e) => { + warn!("Error creating framebuffer: {e}"); + data.swapchain.submitted(&slot); + return; + } + }; + + let plane_state = PlaneState { + handle: data.drm_surface.plane(), + config: Some(PlaneConfig { + src: data.src_rect, + dst: data.output_rect, + transform: Transform::Normal, + alpha: 1.0, + damage_clips: None, + fb: *fb.as_ref(), + fence: None, + }), + }; + + if let Err(err) = data.drm_surface.page_flip([plane_state.clone()], true) { + if let Err(commit_err) = data.drm_surface.commit([plane_state], true) { + warn!("drm present failed: page_flip={err}, commit={commit_err}"); + } + } + + data.swapchain.submitted(&slot); + }) + .context("running event loop")?; + + info!("DRM compositor exiting cleanly"); + Ok(()) +} diff --git a/src/nested.rs b/src/nested.rs new file mode 100644 index 0000000..05a4937 --- /dev/null +++ b/src/nested.rs @@ -0,0 +1,1738 @@ +//! Nested compositor backend using Smithay's winit backend +//! +//! This module provides nested compositor support for running wo inside +//! another Wayland compositor or X11 session. + +use anyhow::{Context, Result}; +use drm_fourcc::DrmFourcc; +use nix::sys::memfd::MFdFlags; +use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus}; +use smithay::{ + backend::{ + drm::DrmNode, + input::{Axis, KeyState, KeyboardKeyEvent}, + renderer::{ + element::{ + surface::{render_elements_from_surface_tree, WaylandSurfaceRenderElement}, + Element, Kind, RenderElement, + }, + gles::{GlesRenderer, GlesTexture}, + Bind, Color32F, Frame as _, ImportDma, Offscreen, Renderer as _, + }, + winit::{self, WinitEvent}, + }, + output::{Mode as OutputMode, Output, PhysicalProperties, Scale, Subpixel}, + reexports::wayland_server::{Display, ListeningSocket}, + utils::{ + Logical, Physical, Point, Rectangle, + Scale as ScaleF, Size, Transform, + }, + wayland::{seat::WaylandFocus, xdg_activation::XdgActivationHandler}, +}; +use std::{ + collections::HashMap, + path::Path, + time::{Duration, Instant}, +}; +use tracing::{debug, error, info, trace, warn}; + +use crate::{ + config::Config, + dmabuf::import_electron_frame, + electron::{CompositorAction, ElectronIpc, ElectronMessage, ElectronProcess}, + handlers::xdg_shell::PANEL_H, + state::{BackendData, WoState}, +}; +use smithay::wayland::xwayland_shell::XWaylandShellState; +use smithay::xwayland::{xwm::X11Wm, XWayland, XWaylandEvent}; + +fn find_render_node() -> DrmNode { + for i in 128..136 { + let path = format!("/dev/dri/renderD{}", i); + if let Ok(node) = DrmNode::from_path(&path) { + info!("Using render node: {}", path); + return node; + } + } + + for i in 0..4 { + let path = format!("/dev/dri/card{}", i); + if let Ok(node) = DrmNode::from_path(&path) { + warn!("Using card node as fallback: {}", path); + return node; + } + } + + warn!("No render nodes found in /dev/dri/, this may cause issues"); + DrmNode::from_dev_id(226 << 8 | 128) + .unwrap_or_else(|_| panic!("Could not find or create any render node")) +} + +fn is_egl_context_or_surface_lost(err: &str) -> bool { + let lowered = err.to_ascii_lowercase(); + lowered.contains("context has been lost") + || lowered.contains("egl_bad_surface") + || lowered.contains("bad_surface") + || lowered.contains("not current draw surface") + || lowered.contains("egl_bad_alloc") + || lowered.contains("failed to allocate resources") +} + +pub fn run_nested(config: Config) -> Result<()> { + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_IGN); + } + + info!("Starting nested compositor mode"); + + if config.compositor.exit_on_escape { + info!("Press ESC to exit"); + } else { + info!("Press Ctrl+C or close window to exit"); + } + + let mut display: Display = Display::new().context("creating Wayland display")?; + let mut dh = display.handle(); + + let socket = ListeningSocket::bind(&config.compositor.socket_name) + .with_context(|| format!("binding Wayland socket {}", config.compositor.socket_name))?; + info!(socket = %config.compositor.socket_name, "Wayland socket created"); + + let (mut backend, mut winit_evt_loop) = match winit::init::() { + Ok(b) => b, + Err(e) => { + error!("Failed to create winit backend: {:?}", e); + return Err(anyhow::anyhow!("winit backend creation failed")); + } + }; + + let size = backend.window_size(); + let (width, height) = (size.w, size.h); + + info!(width, height, "Nested window size"); + + let output = Output::new( + "wo-nested".to_string(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "Wo".into(), + model: "Nested".into(), + }, + ); + + let output_mode = OutputMode { + size: (width as i32, height as i32).into(), + refresh: 60_000, + }; + + output.change_current_state( + Some(output_mode), + Some(Transform::Normal), + Some(Scale::Integer(1)), + Some((0, 0).into()), + ); + output.set_preferred(output_mode); + output.create_global::(&dh); + + let render_node = find_render_node(); + + let dmabuf_formats = { + match backend.bind() { + Ok((renderer, _target)) => { + let formats = renderer.dmabuf_formats().into_iter().collect::>(); + info!("Collected {} dmabuf formats from renderer", formats.len()); + formats + } + Err(e) => { + warn!( + "Could not bind backend to get formats: {}, using empty list", + e + ); + vec![] + } + } + }; + + let backend_data = BackendData { + renderer: None, + output: output.clone(), + size: (width as u32, height as u32), + dmabuf_formats: Some(dmabuf_formats), + }; + + let mut state = WoState::new(&mut display, config.clone(), render_node, backend_data); + + let (ipc, mut msg_rx) = + ElectronIpc::listen(&config.compositor.ipc_socket).context("IPC socket")?; + state.electron_ipc = Some(ipc); + + let mut calloop_loop = + calloop::EventLoop::::try_new().context("calloop EventLoop creation")?; + let calloop_handle = calloop_loop.handle(); + + state.xwayland_shell_state = Some(XWaylandShellState::new::(&dh)); + + match XWayland::spawn( + &dh, + None, // auto-detect display number + std::iter::empty::<(String, String)>(), + true, // open abstract socket for X11 compatibility + std::process::Stdio::null(), + std::process::Stdio::null(), + |_| {}, // no extra user data + ) { + Ok((xwayland, xw_client)) => { + let wm_handle = calloop_handle.clone(); + let xw_client = std::rc::Rc::new(std::cell::RefCell::new(Some(xw_client))); + let xw_client_cb = xw_client.clone(); + + if let Err(e) = + calloop_handle.insert_source(xwayland, move |event, _, state: &mut WoState| { + match event { + XWaylandEvent::Ready { + x11_socket, + display_number, + } => { + info!("XWayland ready on DISPLAY :{display_number}"); + std::env::set_var("DISPLAY", format!(":{display_number}")); + if let Some(client) = xw_client_cb.borrow_mut().take() { + match X11Wm::start_wm(wm_handle.clone(), x11_socket, client) { + Ok(wm) => { + state.xwm = Some(wm); + info!("X11 window manager attached"); + } + Err(e) => error!("X11Wm::start_wm failed: {e:#}"), + } + } + } + XWaylandEvent::Error => { + warn!("XWayland server encountered an error during startup"); + } + } + }) + { + error!("Failed to register XWayland event source: {e:#}"); + } else { + info!("XWayland server starting"); + } + } + Err(e) => { + warn!("XWayland spawn failed (X11 support disabled): {e:#}"); + } + } + + std::env::set_var("WAYLAND_DISPLAY", &config.compositor.socket_name); + info!("Set WAYLAND_DISPLAY={}", config.compositor.socket_name); + + let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("electron"); + + for root_cfg in &config.root { + let mut full = root_cfg.clone(); + full.x = 0; + full.y = 0; + full.width = width as u32; + full.height = height as u32; + full.z_order = std::i32::MIN / 2; + match ElectronProcess::spawn( + &full, + &config.compositor.electron_path, + &app_dir, + &config.compositor.ipc_socket, + ) { + Ok(proc) => state.electron_processes.push(proc), + Err(e) => error!(window = %full.name, "failed to spawn Electron root: {e:#}"), + } + } + + for win_cfg in &config.windows { + match ElectronProcess::spawn( + win_cfg, + &config.compositor.electron_path, + &app_dir, + &config.compositor.ipc_socket, + ) { + Ok(proc) => state.electron_processes.push(proc), + Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"), + } + } + + let mut autostart_tasks: Vec<(crate::config::AutostartConfig, std::time::Instant)> = Vec::new(); + let now = std::time::Instant::now(); + for task in &config.autostart { + let when = now + std::time::Duration::from_millis(task.delay); + autostart_tasks.push((task.clone(), when)); + } + + let bg = Color32F::from(config.compositor.background); + let frame_time = Duration::from_millis(16); + let mut last_render = std::time::Instant::now(); + let event_start_time = std::time::Instant::now(); + + let mut gles_tex_cache: HashMap = HashMap::new(); + let mut offscreen_tex_cache: HashMap = HashMap::new(); + use crate::electron::ElectronFrame; + let mut latest_frames: HashMap = HashMap::new(); + let mut last_wayland_elem_count: usize = 0; + // Frame tracking for backpressure management + let mut frame_timestamps: HashMap = HashMap::new(); + let _max_frame_age = Duration::from_millis(33); // Drop frames older than 2 frames @60Hz + + info!("Nested compositor running"); + + let mut loop_iter: u64 = 0; + // Main event loop + loop { + loop_iter += 1; + let loop_t0 = std::time::Instant::now(); + + if !state.running { + info!("Compositor shutting down"); + break; + } + + let winit_t0 = std::time::Instant::now(); + let _dispatch_result = winit_evt_loop.dispatch_new_events(|event| { + match event { + WinitEvent::Resized { size, .. } => { + info!("Window resized to {}x{}", size.w, size.h); + let new_mode = OutputMode { + size: (size.w as i32, size.h as i32).into(), + refresh: 60_000, + }; + output.change_current_state(Some(new_mode), None, None, None); + } + WinitEvent::Input(input_event) => { + if config.compositor.exit_on_escape { + use smithay::backend::input::InputEvent as IE; + match &input_event { + IE::Keyboard { event } => { + if event.state() == KeyState::Pressed { + use smithay::input::keyboard::xkb::Keycode; + if event.key_code() == Keycode::new(1) { + info!("ESC pressed, exiting"); + state.running = false; + return; + } + } + } + _ => {} + } + } + + state.forward_input_to_electron(&input_event); + state.process_input_event(input_event); + } + WinitEvent::CloseRequested => { + info!("Close requested"); + state.running = false; + } + WinitEvent::Redraw => { + // Redraw requested + } + WinitEvent::Focus(_) => { + // Focus changed + } + } + }); + let winit_elapsed = winit_t0.elapsed(); + if winit_elapsed.as_millis() > 50 { + warn!("dispatch_new_events took {}ms!", winit_elapsed.as_millis()); + } + + // Check again after events + if !state.running { + break; + } + + while let Ok(Some(stream)) = socket.accept() { + if let Err(e) = dh.insert_client( + stream, + std::sync::Arc::new(crate::state::ClientData::default()), + ) { + error!("Failed to insert Wayland client: {e}"); + } + } + let dispatch_t0 = std::time::Instant::now(); + let log_ckpt = state.debug_new_toplevel; + if log_ckpt { + info!("checkpoint A: before dispatch_clients"); + } + if let Err(e) = display.dispatch_clients(&mut state) { + error!("Failed to dispatch Wayland clients: {e}"); + } + if log_ckpt { + info!( + "checkpoint B: after dispatch_clients (debug_flag={})", + state.debug_new_toplevel + ); + } + if let Err(e) = display.flush_clients() { + error!("Failed to flush Wayland clients: {e}"); + } + if log_ckpt { + info!("checkpoint C: after flush_clients"); + } + state.space.refresh(); + if log_ckpt { + info!("checkpoint D: after space.refresh"); + } + if let Err(e) = display.flush_clients() { + error!("Failed to flush after space refresh: {e}"); + } + if log_ckpt { + info!("checkpoint E: after second flush"); + } + state.debug_new_toplevel = false; + + let calloop_t0 = std::time::Instant::now(); + if let Err(e) = calloop_loop.dispatch(Duration::ZERO, &mut state) { + warn!("calloop dispatch error: {e}"); + } + let calloop_ms = calloop_t0.elapsed().as_millis(); + if calloop_ms > 20 { + warn!("calloop dispatch took {}ms!", calloop_ms); + } + + let cur_wayland_count = state.space.elements().count(); + if cur_wayland_count != last_wayland_elem_count { + info!( + "Wayland space count: {} window(s) in space", + cur_wayland_count + ); + last_wayland_elem_count = cur_wayland_count; + } + let dispatch_elapsed = dispatch_t0.elapsed(); + if dispatch_elapsed.as_millis() > 50 { + warn!( + "dispatch_clients+flush took {}ms!", + dispatch_elapsed.as_millis() + ); + } + + let emsg_t0 = std::time::Instant::now(); + let mut emsg_count = 0u32; + let mut input_events_pending = false; + + // Process messages with priority: input events first, then frames + let mut frames_batch: Vec = Vec::new(); + let mut input_batch: Vec = Vec::new(); + + while let Ok(msg) = msg_rx.try_recv() { + emsg_count += 1; + match &msg { + ElectronMessage::Frame(_) => frames_batch.push(msg), + ElectronMessage::ForwardedPointer { .. } + | ElectronMessage::ForwardedKeyboard { .. } + | ElectronMessage::ForwardedPointerButton { .. } + | ElectronMessage::ForwardedRelativePointer { .. } + | ElectronMessage::ForwardedPointerScroll { .. } => { + input_batch.push(msg); + input_events_pending = true; + } + _ => process_electron_message(msg, &mut state, event_start_time), + } + } + + // Process input events immediately for low latency + for msg in input_batch { + process_electron_message(msg, &mut state, event_start_time); + } + + // Process frames with backpressure handling + let now = Instant::now(); + for msg in frames_batch { + if let ElectronMessage::Frame(frame) = msg { + let nm = frame.name.clone(); + trace!("main-loop: dequeued frame seq={} window={}", frame.seq, nm); + + // Drop frames that are too old (backpressure) + if let Some(last_ts) = frame_timestamps.get(&nm) { + if now.duration_since(*last_ts) < Duration::from_millis(8) { + // Skip frame if less than 8ms since last update (>120fps throttle) + trace!("Dropping frame {} due to backpressure", nm); + continue; + } + } + + frame_timestamps.insert(nm.clone(), now); + // Only keep the latest frame per window + latest_frames.insert(nm, frame); + } + } + + let emsg_ms = emsg_t0.elapsed().as_millis(); + if emsg_ms > 20 { + warn!( + "electron message processing took {}ms (count={})", + emsg_ms, emsg_count + ); + } + + let adaptive_frame_time = if input_events_pending { + Duration::from_millis(8) + } else { + frame_time + }; + + if last_render.elapsed() < adaptive_frame_time { + continue; + } + last_render = std::time::Instant::now(); + + if !autostart_tasks.is_empty() { + let now = std::time::Instant::now(); + let mut pending = Vec::new(); + for (task, when) in autostart_tasks.drain(..) { + if when <= now { + execute_autostart_task(&task, &mut state, &app_dir); + } else { + pending.push((task, when)); + } + } + autostart_tasks = pending; + } + + if !state.electron_processes.is_empty() { + for idx in (0..state.electron_processes.len()).rev() { + if let Some(pid_u32) = state.electron_processes.get(idx).map(|p| p.child.id()) { + let pid = pid_u32 as u32; + let npid = nix::unistd::Pid::from_raw(pid as i32); + match waitpid(Some(npid), Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::StillAlive) => {} + Ok(status) => { + let name = state.electron_processes[idx].name.clone(); + info!(window = %name, pid = pid, "Electron process exited: {:?}", status); + let mut proc = state.electron_processes.remove(idx); + let _ = proc.child.try_wait(); + state.metadata_dirty = true; + } + Err(e) => { + warn!("waitpid failed for electron pid {}: {e}", pid); + } + } + } + } + } + + let meta_t0 = std::time::Instant::now(); + if state.metadata_dirty { + // Simplified metadata is small enough to stay under IPC limits + send_window_metadata(&state); + state.metadata_dirty = false; + } + let meta_ms = meta_t0.elapsed().as_millis(); + if meta_ms > 20 { + warn!("send_window_metadata took {}ms!", meta_ms); + } + + // Render frame + let render_t_start = std::time::Instant::now(); + let size = backend.window_size(); + let output_rect: Rectangle = + Rectangle::new((0, 0).into(), (size.w as i32, size.h as i32).into()); + + // Render in a scope to release borrows before submission. + // Use a labeled block so early exits (bind/render failures) still reach + // the frame-callback section below — Wayland clients MUST receive frame + // callbacks or they stall indefinitely waiting for the next vblank signal. + let mut render_ok = false; + let render_t0 = std::time::Instant::now(); + 'render: { + // Bind backend for rendering (this returns (renderer, target)) + let bind_t0 = std::time::Instant::now(); + let (renderer, mut target) = match backend.bind() { + Ok(t) => { + let bind_ms = bind_t0.elapsed().as_millis(); + if bind_ms > 50 { + warn!("backend.bind() took {}ms!", bind_ms); + } + t + } + Err(e) => { + warn!("Failed to bind backend: {}", e); + if is_egl_context_or_surface_lost(&e.to_string()) { + error!( + "EGL context/surface lost while binding nested backend; shutting down so context can be recreated" + ); + state.running = false; + } + break 'render; + } + }; + + for (_, frame) in latest_frames.drain() { + let win_name = frame.name.clone(); + let (width, height) = (frame.width, frame.height); + + // Import each new frame - each has unique pixel data even if size unchanged + match import_electron_frame(renderer, frame) { + Ok((tex, cached)) => { + trace!("Frame imported for {} ({}x{})", win_name, width, height); + state.texture_cache.insert_dmabuf(cached); + gles_tex_cache.insert(win_name.clone(), tex.texture); + } + Err(e) => { + warn!("DMABUF import failed for {}: {e:#}", win_name); + } + } + } + + // Evict textures for windows no longer in the DMABUF cache. + gles_tex_cache.retain(|name, _| state.texture_cache.get_dmabuf(name).is_some()); + + let imported_textures = &gles_tex_cache; + + // Prepare cursor elements before starting the frame + let cursor_elements: Vec> = + if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) = state.cursor_status { + use smithay::backend::renderer::element::{surface::render_elements_from_surface_tree, Kind}; + use smithay::utils::Scale as ScaleF; + + let scale = ScaleF::from(1.0); + render_elements_from_surface_tree( + renderer, + cursor_surface, + (state.pointer_location.x as i32, state.pointer_location.y as i32), + scale, + 1.0, + Kind::Cursor, + ) + } else { + Vec::new() + }; + + // Wayland windows are exclusively rendered by comraw via off-screen buffers. + // The native space.elements() rendering pass has been removed here. + + let mut frame = match renderer.render( + &mut target, + Size::from((size.w as i32, size.h as i32)), + Transform::Flipped180, + ) { + Ok(frame) => frame, + Err(e) => { + warn!("Failed to begin frame: {}", e); + if is_egl_context_or_surface_lost(&e.to_string()) { + error!( + "EGL context/surface lost while starting frame; shutting down so context can be recreated" + ); + state.running = false; + } + break 'render; + } + }; + + // Clear background + if let Err(e) = frame.clear(bg.into(), &[output_rect]) { + warn!("Failed to clear frame: {}", e); + break 'render; + } + + let mut windows: Vec<_> = state + .config + .windows + .iter() + .chain(state.config.root.iter()) + .collect(); + windows.sort_by_key(|w| w.z_order); + + for win in windows { + let mapped = state.window_mapped.get(&win.name).copied().unwrap_or(true); + if !mapped { + continue; + } + + if let Some(tex) = imported_textures.get(&win.name) { + if let Err(e) = frame.render_texture_at( + tex, + (win.x, win.y).into(), + 1, + 1.0, + Transform::Normal, + &[output_rect], + &[], + 1.0, + ) { + warn!("Failed to render texture for {}: {}", win.name, e); + } + } + } + + // (Native rendering loop was removed) + + // NOTE: Wayland surfaces are rendered via offscreen GL to SHM for comraw, + // allowing the client to fully control window compositing and layout. + + // Render cursor on top of everything + for element in &cursor_elements { + use smithay::utils::Scale as ScaleF; + let scale = ScaleF::from(1.0); + let src = element.src(); + let dst = element.geometry(scale); + if let Err(e) = element.draw(&mut frame, src, dst, &[output_rect], &[]) { + trace!("Failed to render cursor element: {}", e); + } + } + + match frame.finish() { + Ok(_) => { + render_ok = true; + } + Err(e) => { + warn!("Failed to finish frame: {}", e); + if is_egl_context_or_surface_lost(&e.to_string()) { + error!( + "EGL context/surface lost while finishing frame; shutting down so context can be recreated" + ); + state.running = false; + } + } + } + let render_ms = render_t0.elapsed().as_millis(); + if render_ms > 50 { + warn!("render phase took {}ms!", render_ms); + } + + drop(target); + + // Export dirty Wayland surfaces via offscreen rendering to SHM + let mut exported_pixels: Vec<(String, u32, u32, u32, Vec)> = Vec::new(); + { + use smithay::reexports::wayland_server::Resource; + + let dirty: Vec<_> = state.dirty_surfaces.drain().collect(); + + for surface in dirty { + let surface_id = surface.id(); + let (window_name, window_geo) = if let Some(n) = + state.wayland_window_names.get(&surface_id).cloned() + { + let sid = surface_id.clone(); + let geo = state.space.elements().find_map(|w| { + if w.wl_surface().map(|s| s.id()) == Some(sid.clone()) { + state.space.element_geometry(w) + } else { + None + } + }); + (n, geo) + } else { + let sid = surface_id.clone(); + let x11_result = state.space.elements().find_map(|w| { + let x11 = w.x11_surface()?; + let wl = x11.wl_surface()?; + if wl.id() == sid.clone() { + let name = state.x11_window_names.get(&x11.window_id()).cloned()?; + let geo = state.space.element_geometry(w); + Some((name, geo)) + } else { + None + } + }); + match x11_result { + Some((n, geo)) => (n, geo), + None => continue, + } + }; + + let Some(geo) = window_geo else { continue }; + + // Calculate bbox (bounding box) for CSD windows with shadows/decorations + // geo.loc is the offset from surface origin to content area + // For libadwaita: geo.loc is typically (10, 10) for 10px shadows + // bbox needs to include the decorations, so expand by geo.loc on both sides + let bbox_w = (geo.size.w + geo.loc.x.max(0) * 2) as u32; + let bbox_h = (geo.size.h + geo.loc.y.max(0) * 2) as u32; + + let (w, h) = (bbox_w, bbox_h); + if w == 0 || h == 0 { + continue; + } + + // Read SHM buffer directly (for software-rendered clients) + // TODO: Implement GL offscreen rendering for DMABUF/hardware clients + use smithay::backend::renderer::utils::RendererSurfaceStateUserData; + use smithay::wayland::compositor::with_states; + use smithay::wayland::shm::with_buffer_contents; + + let pixels_opt: Option<(u32, u32, u32, Vec)> = + with_states(&surface, |states| { + let rss_data = states.data_map.get::()?; + let rss = rss_data.lock().ok()?; + let buffer = rss.buffer()?; + + let mut result: Option<(u32, u32, u32, Vec)> = None; + let _ = with_buffer_contents(buffer, |ptr, len, spec| { + let pixel_bytes = + (spec.stride.max(0) * spec.height.max(0)) as usize; + if pixel_bytes > 0 && pixel_bytes <= len { + let slice = + unsafe { std::slice::from_raw_parts(ptr, pixel_bytes) }; + result = Some(( + spec.width as u32, + spec.height as u32, + spec.stride as u32, + slice.to_vec(), + )); + } + }); + result + }); + + if let Some((bw, bh, stride, pixels)) = pixels_opt { + exported_pixels.push((window_name, bw, bh, stride, pixels)); + } else if let Some(ref ipc) = state.electron_ipc { + // If no SHM pixels, check if it's a DMABUF (optimal for games) + let dmabuf_opt = with_states(&surface, |states| { + let rss_data = states.data_map.get::()?; + let rss = rss_data.lock().ok()?; + let buffer = rss.buffer()?; + smithay::wayland::dmabuf::get_dmabuf(buffer).ok().cloned() + }); + + if let Some(dmabuf) = dmabuf_opt { + if let Err(e) = ipc.broadcast_dmabuf_frame(&window_name, &dmabuf) { + debug!("dmabuf broadcast failed for {}: {}", window_name, e); + } + } + } else { + // Surface has no SHM buffer - use GL offscreen rendering for DMABUF/hardware clients + let scale = ScaleF::from(1.0); + let elements: Vec> = + render_elements_from_surface_tree( + renderer, + &surface, + (0, 0), + scale, + 1.0, + Kind::Unspecified, + ); + + // Create or retrieve offscreen texture for this window + let cache_key = window_name.clone(); + let needs_new_tex = offscreen_tex_cache + .get(&cache_key) + .map(|(_, tw, th)| *tw != w as i32 || *th != h as i32) + .unwrap_or(true); + + if needs_new_tex { + match renderer.create_buffer( + DrmFourcc::Argb8888, + Size::from((w as i32, h as i32)), + ) { + Ok(tex) => { + offscreen_tex_cache + .insert(cache_key.clone(), (tex, w as i32, h as i32)); + } + Err(e) => { + warn!( + "Failed to create offscreen texture for {}: {}", + window_name, e + ); + continue; + } + } + } + + if let Some((offscreen_tex, _, _)) = offscreen_tex_cache.get_mut(&cache_key) + { + // Render surface tree to offscreen texture + let render_result = + (|| -> Result<(), smithay::backend::renderer::gles::GlesError> { + let mut target = renderer.bind(offscreen_tex)?; + let output_rect = Rectangle::new( + Point::new(0, 0), + Size::new(w as i32, h as i32), + ); + let mut frame = renderer.render( + &mut target, + Size::from((w as i32, h as i32)), + Transform::Normal, + )?; + frame.clear(Color32F::TRANSPARENT, &[output_rect])?; + + // Draw each element in the surface tree + // Elements are positioned relative to surface origin (0, 0) + // For CSD windows, we DON'T offset - surface origin should already + // include the decoration area, and geometry() positions are correct + for element in &elements { + let src = element.src(); + let dst = element.geometry(scale); + element.draw(&mut frame, src, dst, &[output_rect], &[])?; + } + let _ = frame.finish(); + Ok(()) + })(); + + if let Err(e) = render_result { + warn!("Failed to render surface tree for {}: {}", window_name, e); + continue; + } + + // Read back pixels from offscreen texture + // Skip GL rendering for now - only export SHM buffers + warn!( + "Skipping GL offscreen render for {} - only SHM buffers supported", + window_name + ); + continue; + } + } + } + + // Store exported pixels for memfd write after render block + state.pending_shm_exports = exported_pixels; + state.dirty_surfaces.clear(); + } // 'render block ends here, backend borrow is released + + + let did_bind = render_ok; + + if did_bind { + let submit_t0 = std::time::Instant::now(); + if !render_ok { + debug!("render_ok=false, skipping backend.submit() for this frame"); + } else if let Err(e) = backend.submit(None) { + warn!("Failed to submit frame: {}", e); + if is_egl_context_or_surface_lost(&e.to_string()) { + error!( + "EGL context/surface lost while submitting frame; shutting down so context can be recreated" + ); + state.running = false; + } + } else { + trace!("Frame submitted successfully"); + } + let submit_ms = submit_t0.elapsed().as_millis(); + if submit_ms > 50 { + warn!("backend.submit() (eglSwapBuffers) took {}ms! This may indicate a blocked vsync/EGL fence.", submit_ms); + } + } + + let presented_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let windows_snapshot: Vec<_> = state.space.elements().cloned().collect(); + for window in windows_snapshot { + window.send_frame(&state.output, presented_at, None, |_, _| { + Some(state.output.clone()) + }); + } + + // Write exported pixel buffers to SHM and broadcast to Electron + if did_bind { + use std::os::unix::io::AsRawFd; + + let exports = std::mem::take(&mut state.pending_shm_exports); + for (window_name, w, h, stride, pixels) in exports { + let memfd_fd = state + .window_shm_buffers + .entry(window_name.clone()) + .or_insert_with(|| { + nix::sys::memfd::memfd_create( + std::ffi::CStr::from_bytes_with_nul(b"wayland_window\0").unwrap(), + MFdFlags::MFD_CLOEXEC, + ) + .expect("creating memfd") + }); + + let size = pixels.len() as i64; + let stat = nix::sys::stat::fstat(&*memfd_fd).unwrap(); + if stat.st_size != size { + nix::unistd::ftruncate(&*memfd_fd, size).expect("truncating memfd"); + } + + nix::unistd::lseek(&*memfd_fd, 0, nix::unistd::Whence::SeekSet) + .expect("seeking memfd"); + nix::unistd::write(&*memfd_fd, &pixels).expect("writing to memfd"); + + if let Some(ref ipc) = state.electron_ipc { + let pid = std::process::id(); + if let Err(e) = ipc.broadcast_shm_buffer( + &window_name, + w, + h, + stride, + pid, + memfd_fd.as_raw_fd() as i32, + ) { + debug!( + "nested shm buffer broadcast failed for {}: {}", + window_name, e + ); + } + } + } + } + + let render_total_ms = render_t_start.elapsed().as_millis(); + if render_total_ms > 50 { + warn!("render+submit+callbacks total took {}ms!", render_total_ms); + } + + std::thread::sleep(Duration::from_micros(100)); + + // Log if the entire loop iteration was slow. + let total_loop_ms = loop_t0.elapsed().as_millis(); + if total_loop_ms > 50 { + warn!("SLOW LOOP iter={} total={}ms", loop_iter, total_loop_ms); + } + } + } + + // Clean up GL textures while context is still active + offscreen_tex_cache.clear(); + + info!("Nested compositor exiting cleanly"); + Ok(()) +} + +fn execute_autostart_task( + task: &crate::config::AutostartConfig, + state: &mut WoState, + app_dir: &Path, +) { + let cmd = &task.command; + + if let Some(name) = cmd.strip_prefix("wo://window/") { + if let Some(win_cfg) = state.config.windows.iter().find(|w| w.name == name) { + match ElectronProcess::spawn( + win_cfg, + &state.config.compositor.electron_path, + app_dir, + &state.config.compositor.ipc_socket, + ) { + Ok(proc) => state.electron_processes.push(proc), + Err(e) => error!(window = %name, "autostart spawn failed: {e:#}"), + } + } + } else if cmd == "wo://portal/start" { + let portal_bin = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("wo-portal"))) + .unwrap_or_else(|| std::path::PathBuf::from("wo-portal")); + match std::process::Command::new(&portal_bin).spawn() { + Ok(_) => info!("Spawned portal service: {:?}", portal_bin), + Err(e) => error!("Failed to spawn portal: {e:#}"), + } + } else if let Some(path) = cmd.strip_prefix("file://") { + let _ = std::process::Command::new(path).spawn(); + } else if cmd.contains(' ') { + let _ = std::process::Command::new("sh").arg("-c").arg(cmd).spawn(); + } else { + let _ = std::process::Command::new(cmd).spawn(); + } +} + +fn process_electron_message( + msg: ElectronMessage, + state: &mut WoState, + event_start_time: std::time::Instant, +) { + match msg { + ElectronMessage::Frame(_) => { + // Frames are handled via the latest_frames buffer; should not reach here. + } + ElectronMessage::WindowPosition(pos) => { + debug!( + "Window position update: {} at ({}, {})", + pos.window_name, pos.x, pos.y + ); + state.window_positions.insert( + pos.window_name.clone(), + (pos.x, pos.y, pos.width, pos.height), + ); + state.metadata_dirty = true; + } + ElectronMessage::Syscall(req) => { + info!(window = %req.window_name, payload = %req.payload, "syscall received"); + if let Some(ref handler) = state.syscall_handler { + if let Ok(syscall_req) = + serde_json::from_str::(&req.payload) + { + let response = handler.handle(syscall_req); + let response_json = + serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string()); + if let Some(ref ipc) = state.electron_ipc { + let _ = ipc.send_syscall_response(&req.window_name, &response_json); + } + } + } + } + ElectronMessage::Action(action_msg) => { + info!( + "Action from {}: {:?}", + action_msg.window_name, action_msg.action + ); + + match action_msg.action { + CompositorAction::Quit { code } => { + info!("Quit requested with code {}", code); + state.running = false; + } + CompositorAction::Custom { + action: act_str, + payload, + } => { + // Parse optional payload JSON to find a target window. + let payload_json: Option = + payload.as_ref().and_then(|p| serde_json::from_str(p).ok()); + + // Determine target window name (either from payload or action source). + let target_name: Option = payload_json + .as_ref() + .and_then(|p| p.get("window")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + if action_msg.window_name.starts_with("wayland-") { + Some(action_msg.window_name.clone()) + } else { + None + } + }); + + // Check if target is a Wayland window by looking up in stable name map. + let wayland_window = target_name + .as_ref() + .and_then(|name| state.find_wayland_window(name)); + + if let Some(window) = wayland_window { + let th = state.title_h_for_window(&window); + match act_str.as_str() { + "move" => { + let x = payload_json + .as_ref() + .and_then(|p| p.get("x")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + let y = payload_json + .as_ref() + .and_then(|p| p.get("y")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + // comraw reports the MacWindow origin (title bar top); + // the Wayland surface sits th pixels below that. + state.space.map_element(window, (x, y + th), false); + } + "resize" => { + let new_w = payload_json + .as_ref() + .and_then(|p| p.get("width")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) + as i32; + let new_h = payload_json + .as_ref() + .and_then(|p| p.get("height")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) + as i32; + if let Some(toplevel) = window.toplevel() { + // comraw sends the total MacWindow height (title bar + content); + // the Wayland surface only needs the content height. + let content_h = (new_h - th).max(1); + let (cw, ch) = + state.clamp_to_size_hints(&window, new_w, content_h); + toplevel.with_pending_state(|s| { + s.size = Some((cw, ch).into()); + }); + toplevel.send_configure(); + } + } + "close" => { + if let Some(toplevel) = window.toplevel() { + toplevel.send_close(); + } + state.space.unmap_elem(&window); + state.all_windows.retain(|w| w != &window); + if let Some(name) = target_name.as_ref() { + state.window_mapped.insert(name.clone(), false); + } + state.refocus_topmost_native_window(); + state.metadata_dirty = true; + } + "focus" => { + let surface = window + .toplevel() + .map(|t| t.wl_surface().clone()) + .or_else(|| { + window.x11_surface().and_then(|x| x.wl_surface().clone()) + }); + if state.space.element_location(&window).is_none() { + use smithay::wayland::compositor::with_states; + let parent_surface = window.toplevel().and_then(|t| { + with_states(t.wl_surface(), |states| { + states + .data_map + .get::() + .and_then(|d| d.lock().ok().and_then(|data| data.parent.clone())) + }) + }); + let (ow, oh) = + (state.output_size.0 as i32, state.output_size.1 as i32); + let location = if let Some(ref parent_wl) = parent_surface { + let parent_center = state + .space + .elements() + .find(|w| { + w.toplevel() + .map(|t| t.wl_surface() == parent_wl) + .unwrap_or(false) + }) + .map(|parent_win| { + let loc = state + .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) + }; + let tname = target_name.clone().unwrap(); + state.window_mapped.insert(tname, true); + state.space.map_element(window.clone(), location, true); + state.metadata_dirty = true; + } + + if let Some(surface) = surface { + if window.toplevel().is_some() { + use smithay::wayland::compositor::with_states; + use smithay::wayland::xdg_activation::{ + XdgActivationToken, XdgActivationTokenData, + }; + let token = XdgActivationToken::from(format!( + "electron-focus-{}", + event_start_time.elapsed().as_millis() + )); + let token_data = XdgActivationTokenData { + surface: Some(surface.clone()), + serial: None, + timestamp: Instant::now(), + app_id: window.toplevel().and_then(|t| { + with_states(t.wl_surface(), |states| { + states + .data_map + .get::() + .and_then(|d| { + d.lock().ok().and_then(|data| data.app_id.clone()) + }) + }) + }), + client_id: None, + user_data: std::sync::Arc::new( + smithay::utils::user_data::UserDataMap::new(), + ), + }; + state.request_activation(token, token_data, surface.clone()); + + if let Some(keyboard) = state.seat.get_keyboard() { + let serial = smithay::utils::SERIAL_COUNTER.next_serial(); + keyboard.set_focus(state, Some(surface.clone()), serial); + } + + if let Some(name) = target_name.as_ref() { + state.keyboard_window_focus = Some(name.clone()); + state.pointer_window_focus = Some(name.clone()); + } + } else if let Some(keyboard) = state.seat.get_keyboard() { + let serial = smithay::utils::SERIAL_COUNTER.next_serial(); + keyboard.set_focus(state, Some(surface.clone()), serial); + + if let Some(name) = target_name.as_ref() { + state.keyboard_window_focus = Some(name.clone()); + state.pointer_window_focus = Some(name.clone()); + } + } + } + } + "minimize" => { + // Unmap the window from the space to hide it. + // The client receives no buffer commits while unmapped. + state.space.unmap_elem(&window); + state + .window_mapped + .insert(target_name.clone().unwrap(), false); + state.refocus_topmost_native_window(); + state.metadata_dirty = true; + } + "maximize" => { + if let Some(toplevel) = window.toplevel() { + use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as XdgState; + let (ow, oh) = + (state.output_size.0 as i32, state.output_size.1 as i32); + let y_offset = th.max(PANEL_H); + let content_h = (oh - y_offset).max(1); + toplevel.with_pending_state(|s| { + s.states.set(XdgState::Maximized); + s.size = Some((ow, content_h).into()); + }); + toplevel.send_configure(); + state + .space + .map_element(window.clone(), (0, y_offset), false); + } + } + "pointer_motion" => { + if window.wl_surface().is_none() { + // Surface not ready yet. + } else if let Some(loc) = state.space.element_location(&window) { + let px = payload_json + .as_ref() + .and_then(|p| p.get("x")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let py = payload_json + .as_ref() + .and_then(|p| p.get("y")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + + let geo = window.geometry(); + let surface_x = px; + let surface_y = py; + + let global: Point = ( + loc.x as f64 - geo.loc.x.max(0) as f64 + surface_x, + loc.y as f64 - geo.loc.y.max(0) as f64 + surface_y, + ) + .into(); + state.pointer_location = global; + let under = window.surface_under( + (surface_x, surface_y), + smithay::desktop::WindowSurfaceType::ALL, + ); + let focus = under.map(|(s, off)| { + let surface_global: Point = ( + loc.x as f64 - geo.loc.x.max(0) as f64 + off.x as f64, + loc.y as f64 - geo.loc.y.max(0) as f64 + off.y as f64, + ) + .into(); + (s, surface_global) + }); + let time = event_start_time.elapsed().as_millis() as u32; + if let Some(pointer) = state.seat.get_pointer() { + let serial = smithay::utils::SERIAL_COUNTER.next_serial(); + pointer.motion( + state, + focus, + &smithay::input::pointer::MotionEvent { + location: global, + serial, + time, + }, + ); + pointer.frame(state); + } + } + } + "pointer_button" => { + if window.wl_surface().is_none() { + // Surface not ready yet. + } else if let Some(loc) = state.space.element_location(&window) { + let button = payload_json + .as_ref() + .and_then(|p| p.get("button")) + .and_then(|v| v.as_u64()) + .unwrap_or(272) + as u32; + let pressed = payload_json + .as_ref() + .and_then(|p| p.get("pressed")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let px = payload_json + .as_ref() + .and_then(|p| p.get("x")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let py = payload_json + .as_ref() + .and_then(|p| p.get("y")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + + let geo = window.geometry(); + let surface_x = px; + let surface_y = py; + + let global: Point = ( + loc.x as f64 - geo.loc.x.max(0) as f64 + surface_x, + loc.y as f64 - geo.loc.y.max(0) as f64 + surface_y, + ) + .into(); + state.pointer_location = global; + if pressed { + 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 { + let serial = + smithay::utils::SERIAL_COUNTER.next_serial(); + if let Some(keyboard) = state.seat.get_keyboard() { + keyboard.set_focus(state, Some(surface), serial); + } + } + } + let btn_state = if pressed { + smithay::backend::input::ButtonState::Pressed + } else { + smithay::backend::input::ButtonState::Released + }; + let time = event_start_time.elapsed().as_millis() as u32; + if let Some(pointer) = state.seat.get_pointer() { + let serial = smithay::utils::SERIAL_COUNTER.next_serial(); + pointer.button( + state, + &smithay::input::pointer::ButtonEvent { + button, + state: btn_state, + serial, + time, + }, + ); + pointer.frame(state); + } + } + } + "pointer_leave" => { + let time = event_start_time.elapsed().as_millis() as u32; + if let Some(pointer) = state.seat.get_pointer() { + let serial = smithay::utils::SERIAL_COUNTER.next_serial(); + pointer.motion( + state, + None, + &smithay::input::pointer::MotionEvent { + location: state.pointer_location, + serial, + time, + }, + ); + pointer.frame(state); + } + } + "pointer_scroll" => { + let dx = payload_json + .as_ref() + .and_then(|p| p.get("dx")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let dy = payload_json + .as_ref() + .and_then(|p| p.get("dy")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let time = event_start_time.elapsed().as_millis() as u32; + if let Some(pointer) = state.seat.get_pointer() { + let mut frame = smithay::input::pointer::AxisFrame::new(time) + .value(Axis::Vertical, dy) + .value(Axis::Horizontal, dx); + if dy.abs() > 0.0 { + frame = + frame.v120(Axis::Vertical, (dy / 120.0 * 120.0) as i32); + } + if dx.abs() > 0.0 { + frame = frame + .v120(Axis::Horizontal, (dx / 120.0 * 120.0) as i32); + } + pointer.axis(state, frame); + pointer.frame(state); + } + } + "keyboard_key" => { + let keycode = payload_json + .as_ref() + .and_then(|p| p.get("keycode")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) + as u32; + let pressed = payload_json + .as_ref() + .and_then(|p| p.get("pressed")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let key_state = if pressed { + KeyState::Pressed + } else { + KeyState::Released + }; + // Convert evdev scancode to XKB keycode (evdev + 8) + let xkb_keycode: u32 = keycode + 8; + let time = event_start_time.elapsed().as_millis() as u32; + if let Some(keyboard) = state.seat.get_keyboard() { + let serial = smithay::utils::SERIAL_COUNTER.next_serial(); + keyboard.input::<(), _>( + state, + xkb_keycode.into(), + key_state, + serial, + time, + |_, _, _| smithay::input::keyboard::FilterResult::Forward, + ); + } + } + _ => {} + } + } else { + // Electron window actions (existing logic) + match act_str.as_str() { + "map" => { + state + .window_mapped + .insert(action_msg.window_name.clone(), true); + state.metadata_dirty = true; + } + "focus" => { + state + .window_mapped + .insert(action_msg.window_name.clone(), true); + // Give keyboard and pointer logical focus to this window + state.keyboard_window_focus = Some(action_msg.window_name.clone()); + state.pointer_window_focus = Some(action_msg.window_name.clone()); + state.metadata_dirty = true; + } + "unmap" | "minimize" => { + state + .window_mapped + .insert(action_msg.window_name.clone(), false); + // Clear focus targeting this window so input isn't captured while minimized + if state.keyboard_window_focus.as_deref() + == Some(&action_msg.window_name) + { + state.keyboard_window_focus = None; + } + if state.pointer_window_focus.as_deref() + == Some(&action_msg.window_name) + { + state.pointer_window_focus = None; + } + state.metadata_dirty = true; + } + "close" => { + if let Some(idx) = state + .electron_processes + .iter() + .position(|p| p.name == action_msg.window_name) + { + let mut proc = state.electron_processes.remove(idx); + proc.kill(); + } + state.texture_cache.remove(&action_msg.window_name); + state.window_positions.remove(&action_msg.window_name); + state.window_mapped.remove(&action_msg.window_name); + if state.keyboard_window_focus.as_deref() + == Some(&action_msg.window_name) + { + state.keyboard_window_focus = None; + } + if state.pointer_window_focus.as_deref() + == Some(&action_msg.window_name) + { + state.pointer_window_focus = None; + } + state.metadata_dirty = true; + } + _ => {} + } + } + } + } + } + ElectronMessage::ForwardedPointer { window_name, x, y } => { + state.handle_forwarded_pointer_event(&window_name, x, y); + } + ElectronMessage::ForwardedKeyboard { + window_name, + key, + pressed, + time, + } => { + state.handle_forwarded_keyboard_event(&window_name, key, pressed, time); + } + ElectronMessage::ForwardedRelativePointer { + window_name, + dx, + dy, + } => { + state.handle_forwarded_relative_pointer_event(&window_name, dx, dy); + } + ElectronMessage::ForwardedPointerButton { + window_name, + x, + y, + button, + pressed, + time, + } => { + state.handle_forwarded_pointer_button(&window_name, x, y, button, pressed, time); + } + ElectronMessage::ForwardedPointerScroll { + window_name, + dx, + dy, + } => { + state.handle_forwarded_pointer_scroll(&window_name, dx, dy); + } + } +} + +fn send_window_metadata(state: &WoState) { + let mut metadata_list = Vec::new(); + + // Include configured Electron-managed windows + for win_cfg in &state.config.windows { + let (x, y, w, h) = state + .window_positions + .get(&win_cfg.name) + .copied() + .unwrap_or((win_cfg.x, win_cfg.y, win_cfg.width, win_cfg.height)); + let mapped = state + .window_mapped + .get(&win_cfg.name) + .copied() + .unwrap_or(true); + + let is_focused = state.pointer_window_focus.as_ref() == Some(&win_cfg.name); + + metadata_list.push(serde_json::json!({ + "name": win_cfg.name, + "x": x, + "y": y, + "width": w, + "height": h, + "z_order": win_cfg.z_order, + "focused": is_focused, + "mapped": mapped, + "source": "electron", + "ssd": true, + })); + } + + // Include native Wayland client windows from all_windows tracking (includes minimized). + let wayland_windows: Vec<_> = state.all_windows.clone(); + for window in wayland_windows.iter() { + let window_name = match state.wayland_window_name(window) { + Some(n) => n.clone(), + None => continue, + }; + + let title = if let Some(x11) = window.x11_surface() { + x11.title().to_string() + } else { + window + .toplevel() + .and_then(|t| { + smithay::wayland::compositor::with_states(t.wl_surface(), |states| { + states + .data_map + .get::() + .and_then(|d| d.lock().ok().and_then(|data| data.title.clone())) + }) + }) + .unwrap_or_else(|| window_name.clone()) + }; + + let app_id = if let Some(x11) = window.x11_surface() { + Some(x11.class().to_string()) + } else { + window.toplevel().and_then(|t| { + smithay::wayland::compositor::with_states(t.wl_surface(), |states| { + states + .data_map + .get::() + .and_then(|d| d.lock().ok().and_then(|data| data.app_id.clone())) + }) + }) + }; + + let loc = state.space.element_location(window).unwrap_or_default(); + let geo = window.geometry(); + + let bbox_w = geo.size.w + geo.loc.x.max(0) * 2; + let bbox_h = geo.size.h + geo.loc.y.max(0) * 2; + + let th = state.title_h_for_window(window); + let is_ssd = th > 0; + + let (parent_surface, parent_x11_id) = if let Some(x11) = window.x11_surface() { + (None, x11.is_transient_for()) + } else { + (window.toplevel().and_then(|t| { + smithay::wayland::compositor::with_states(t.wl_surface(), |states| { + states + .data_map + .get::() + .and_then(|d| d.lock().ok().and_then(|data| data.parent.clone())) + }) + }), None) + }; + + let parent_name = if let Some(parent_wl) = parent_surface.as_ref() { + use smithay::reexports::wayland_server::Resource; + state.wayland_window_names.get(&parent_wl.id()).cloned() + } else if let Some(parent_id) = parent_x11_id { + state.x11_window_names.get(&parent_id).cloned() + } else { + None + }; + + let is_dialog = parent_name.is_some(); + + let is_focused = if let Some(x11) = window.x11_surface() { + x11.wl_surface() + .map(|s| state.surface_has_keyboard_focus(&s)) + .unwrap_or(false) + } else { + window + .toplevel() + .map(|t| state.surface_has_keyboard_focus(t.wl_surface())) + .unwrap_or(false) + }; + + let space_idx = state + .space + .elements() + .position(|w| w == window) + .map(|pos| 1000 - pos as i32) + .unwrap_or(0); + + let mapped = state.space.element_location(window).is_some(); + metadata_list.push(serde_json::json!({ + "name": window_name, + "title": title, + "app_id": app_id, + "x": loc.x - geo.loc.x.max(0), + "y": loc.y - th - geo.loc.y.max(0), + "width": bbox_w, + "height": bbox_h + th, + "z_order": space_idx, + "focused": is_focused, + "mapped": mapped, + "source": if window.x11_surface().is_some() { "x11" } else { "wayland" }, + "ssd": is_ssd, + "dialog": is_dialog, + "parent_name": parent_name, + })); + } + + let metadata_msg = serde_json::json!({ + "type": "metadata", + "windows": metadata_list, + }); + + if let Some(ref ipc) = state.electron_ipc { + let _ = ipc.broadcast_metadata(&metadata_msg.to_string()); + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..c56dd8b --- /dev/null +++ b/src/state.rs @@ -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>>, + pub output: Output, + pub size: (u32, u32), + pub dmabuf_formats: Option>, +} + +impl BackendData { + pub fn renderer(&self) -> Option>> { + 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, + 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, + pub pointer_location: Point, + pub cursor_status: smithay::input::pointer::CursorImageStatus, + pub output_size: (u32, u32), + pub pointer_window_focus: Option, + pub keyboard_window_focus: Option, + + pub space: Space, + pub output: Output, + + pub electron_processes: Vec, + pub texture_cache: TextureCache, + pub window_positions: std::collections::HashMap, + pub electron_ipc: Option, + pub syscall_handler: Option, + pub portal: Option>, + /// Mapping state for named Electron windows. If false, the window should + /// not be rendered (unmapped/minimized). + pub window_mapped: std::collections::HashMap, + /// Damage tracking: which window textures have been updated and need rendering + pub damaged_windows: std::collections::HashSet, + /// 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, + /// Persistent SHM file descriptors (memfds) for each window. + pub window_shm_buffers: std::collections::HashMap, + /// Buffered pixel exports from offscreen rendering, written to SHM after render block. + pub pending_shm_exports: Vec<(String, u32, u32, u32, Vec)>, + pub grab_state: Option, + + /// 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, + /// Reverse lookup: stable name → surface ObjectId for action dispatch. + pub wayland_name_to_id: std::collections::HashMap, + /// 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, + /// Stable name mapping for X11 (XWayland) windows (X11 window_id → "x11-N"). + pub x11_window_names: std::collections::HashMap, + + /// XWayland instance handle (if running). + pub xwayland: Option, + /// X11 window manager attached to the XWayland instance. + pub xwm: Option, + /// XWayland shell protocol state for associating X11 windows with wl_surfaces. + pub xwayland_shell_state: Option, + pub all_windows: Vec, + + pub backend: &'static BackendData, + pub config: Config, + pub running: bool, + pub can_switch_vt: bool, + pub pending_vt_switch: Option, +} + +impl WoState { + pub fn new( + display: &mut Display, + config: Config, + render_node: DrmNode, + backend: BackendData, + ) -> Self { + let dh = display.handle(); + + let compositor_state = CompositorState::new::(&dh); + let xdg_shell_state = XdgShellState::new::(&dh); + let xdg_decoration_state = XdgDecorationState::new::(&dh); + let relative_pointer_manager_state = RelativePointerManagerState::new::(&dh); + let pointer_constraints_state = PointerConstraintsState::new::(&dh); + let shm_state = ShmState::new::(&dh, vec![]); + let viewporter_state = ViewporterState::new::(&dh); + let xdg_activation_state = XdgActivationState::new::(&dh); + let single_pixel_buffer_state = SinglePixelBufferState::new::(&dh); + let fractional_scale_manager_state = FractionalScaleManagerState::new::(&dh); + let cursor_shape_manager_state = CursorShapeManagerState::new::(&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::(&dh); + let output_size = backend.size; + let output = backend.output.clone(); + + let mut seat_state = SeatState::new(); + let mut seat: Seat = 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 = 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::(&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::( + &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::(); + 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 { + 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( + &mut self, + event: &smithay::backend::input::InputEvent, + ) { + // 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 { + 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, + )> { + 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 = (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 { + &mut self.seat_state + } + + fn focus_changed(&mut self, seat: &Seat, 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, + 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, + _location: Point, + ) { + } + + fn new_constraint(&mut self, surface: &WlSurface, pointer: &PointerHandle) { + // 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); diff --git a/src/state/portal.rs b/src/state/portal.rs new file mode 100644 index 0000000..eabd1a7 --- /dev/null +++ b/src/state/portal.rs @@ -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, // "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, + pub cursor_mode: Option, + pub restore_token: Option, +} + +/// Portal capture response +#[derive(Debug, Clone, Serialize)] +pub struct CaptureResponse { + pub session_id: String, + pub streams: Vec, +} + +/// 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>>, + next_session_id: Arc>, + streams: Arc>>>, + compositor_socket: Option, +} + +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, 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 { + 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::() + .parse::() + .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 { + self.sessions + .lock() + .unwrap() + .values() + .filter(|s| s.active) + .cloned() + .collect() + } + + /// Get session by ID + pub fn get_session(&self, session_id: &str) -> Option { + 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> { + 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, +} + +impl PortalDBusHandler { + pub fn new(portal: Arc) -> Self { + Self { portal } + } + + /// Handle D-Bus method calls + pub fn handle_method_call( + &self, + interface: &str, + method: &str, + args: serde_json::Value, + ) -> Result { + 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, + active: Arc>, + frame_counter: Arc>, + frame_queue: Arc>>, + /// Latest raw pixel buffer (ARGB8888/BGRx) for PipeWire consumption. + /// The PipeWire process callback copies from this into PipeWire-allocated buffers. + latest_pixels: Arc>>>, +} + +/// 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) -> Result { + 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 { + 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 { + *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::(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>, + latest_pixels: Arc>>>, + 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>, +} + +/// 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>, + latest_pixels: Arc>>>, + node_tx: std::sync::mpsc::SyncSender, +) -> 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 = 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(()) +} diff --git a/src/syscall.rs b/src/syscall.rs new file mode 100644 index 0000000..454cfb3 --- /dev/null +++ b/src/syscall.rs @@ -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>, + }, + /// Execute a shell command + #[serde(rename = "exec")] + Exec { + command: String, + args: Option>, + #[serde(default)] + capture_output: bool, + }, + /// Read file contents + #[serde(rename = "read")] + Read { + path: String, + #[serde(default)] + max_bytes: Option, + }, + /// 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; +} + +/// 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>, + /// Application configurations for launcher + pub applications: Vec, +} + +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) -> Self { + self.applications = apps; + self + } + + /// Register a custom extension handler + pub fn register_extension(&mut self, name: String, handler: Arc) { + self.extensions.insert(name, handler); + } + + /// Unregister an extension handler + pub fn unregister_extension(&mut self, name: &str) -> Option> { + 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 { + 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 { + 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 { + 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) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)) +}