commit 548adb6bbb52c7619fb935e7c3f630f348b6a7b6 Author: vikingowl Date: Mon Mar 2 15:00:26 2026 +0100 feat: initial implementation of xembed-sni-proxy Lightweight Rust binary that bridges XEmbed tray icons (Wine/Proton) to StatusNotifierItem D-Bus objects for Waybar on Wayland compositors. - Claim _NET_SYSTEM_TRAY_S0 selection and handle dock requests - Per-icon container with MANUAL composite redirect (invisible on XWayland) - Pixel capture via get_image with BGRA→ARGB conversion - SNI D-Bus interface with IconPixmap, Activate, ContextMenu - Minimal com.canonical.dbusmenu stub for Waybar right-click support - XTest fake_input click injection (works on XWayland unlike send_event) - Dynamic icon size detection from client geometry - Graceful shutdown with selection release and proxy cleanup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54466f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target + diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..44ef968 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1191 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[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-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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 = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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 = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[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 = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[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 = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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 = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[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 = "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 = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[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 = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +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 = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +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 = "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 = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "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 = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[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 = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[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 = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[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.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "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 = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "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 = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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 = "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 = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +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-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 = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[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.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[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.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[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" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xembed-sni-proxy" +version = "0.1.0" +dependencies = [ + "tokio", + "tracing", + "tracing-subscriber", + "x11rb", + "zbus", +] + +[[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", + "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 = "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..13973fe --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "xembed-sni-proxy" +version = "0.1.0" +edition = "2021" +description = "Lightweight XEmbed-to-SNI proxy for Wayland compositors" + +[dependencies] +x11rb = { version = "0.13", features = ["composite", "damage", "xtest"] } +zbus = { version = "5", default-features = false, features = ["tokio"] } +tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[profile.release] +lto = true +strip = true diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6da81f9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,96 @@ +mod sni_dbus; +mod sni_proxy; +mod tray_manager; + +use std::os::fd::AsRawFd; +use tokio::io::unix::AsyncFd; +use tokio::io::Interest; +use tokio::signal::unix::{signal, SignalKind}; +use tokio::sync::mpsc; +use tracing::{error, info}; +use x11rb::connection::Connection; +use x11rb::rust_connection::RustConnection; + +use tray_manager::TrayManager; + +/// Commands sent from D-Bus method handlers back to the X11 event loop. +#[derive(Debug)] +pub enum DbusCommand { + /// Inject a click on a tray icon's client window. + Click { + client_window: u32, + x: i32, + y: i32, + button: u8, + }, +} + +/// Newtype wrapper to give the X11 stream fd a 'static-compatible lifetime for AsyncFd. +struct XFd(std::os::fd::OwnedFd); + +impl AsRawFd for XFd { + fn as_raw_fd(&self) -> std::os::fd::RawFd { + self.0.as_raw_fd() + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "xembed_sni_proxy=info".into()), + ) + .init(); + + let (conn, screen_num) = RustConnection::connect(None)?; + info!("connected to X11 display, screen {screen_num}"); + + let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); + + let mut manager = TrayManager::new(&conn, screen_num, cmd_tx)?; + manager.claim_selection()?; + + // Wrap the X11 fd for async polling. + // We dup() the fd so AsyncFd owns it independently of the connection. + use std::os::fd::AsFd; + let owned_fd = conn.stream().as_fd().try_clone_to_owned()?; + let async_fd = AsyncFd::with_interest(XFd(owned_fd), Interest::READABLE)?; + + let mut sigint = signal(SignalKind::interrupt())?; + let mut sigterm = signal(SignalKind::terminate())?; + + info!("entering event loop"); + loop { + tokio::select! { + // X11 events + guard = async_fd.readable() => { + let mut guard = guard?; + // Drain all pending X11 events. + while let Some(event) = conn.poll_for_event()? { + if let Err(e) = manager.handle_event(&event).await { + error!("event handling error: {e}"); + } + } + guard.clear_ready(); + } + // D-Bus commands (click injection) + Some(cmd) = cmd_rx.recv() => { + manager.handle_dbus_command(&cmd); + } + // Graceful shutdown + _ = sigint.recv() => { + info!("received SIGINT, shutting down"); + break; + } + _ = sigterm.recv() => { + info!("received SIGTERM, shutting down"); + break; + } + } + } + + manager.shutdown().await; + info!("clean exit"); + Ok(()) +} diff --git a/src/sni_dbus.rs b/src/sni_dbus.rs new file mode 100644 index 0000000..9d9347d --- /dev/null +++ b/src/sni_dbus.rs @@ -0,0 +1,378 @@ +use std::sync::Arc; +use tokio::sync::{mpsc, Mutex}; +use tracing::{debug, info, warn}; +use zbus::object_server::SignalEmitter; +use zbus::zvariant::{ObjectPath, Value}; +use zbus::{connection::Builder, interface, Connection}; + +use crate::DbusCommand; + +/// Icon pixel data in SNI format: (width, height, ARGB_bytes). +type IconPixmap = (i32, i32, Vec); + +/// Shared state for a single SNI icon, updated from the X11 side. +struct SniState { + icon_pixmap: IconPixmap, + client_window: u32, + cmd_tx: mpsc::UnboundedSender, +} + +// --------------------------------------------------------------------------- +// StatusNotifierItem interface +// --------------------------------------------------------------------------- + +struct StatusNotifierItem { + state: Arc>, +} + +#[interface(name = "org.kde.StatusNotifierItem")] +impl StatusNotifierItem { + #[zbus(property)] + async fn category(&self) -> &str { + "ApplicationStatus" + } + + #[zbus(property)] + async fn id(&self) -> String { + let state = self.state.lock().await; + format!("xembed-proxy-{:x}", state.client_window) + } + + #[zbus(property)] + async fn title(&self) -> &str { + "XEmbed Tray Icon" + } + + #[zbus(property)] + async fn status(&self) -> &str { + "Active" + } + + #[zbus(property)] + async fn window_id(&self) -> u32 { + let state = self.state.lock().await; + state.client_window + } + + #[zbus(property, name = "IconName")] + async fn icon_name(&self) -> &str { + "" + } + + #[zbus(property, name = "IconPixmap")] + async fn icon_pixmap(&self) -> Vec<(i32, i32, Vec)> { + let state = self.state.lock().await; + vec![state.icon_pixmap.clone()] + } + + #[zbus(property, name = "OverlayIconName")] + async fn overlay_icon_name(&self) -> &str { + "" + } + + #[zbus(property, name = "OverlayIconPixmap")] + async fn overlay_icon_pixmap(&self) -> Vec<(i32, i32, Vec)> { + vec![] + } + + #[zbus(property, name = "AttentionIconName")] + async fn attention_icon_name(&self) -> &str { + "" + } + + #[zbus(property, name = "AttentionIconPixmap")] + async fn attention_icon_pixmap(&self) -> Vec<(i32, i32, Vec)> { + vec![] + } + + #[zbus(property, name = "AttentionMovieName")] + async fn attention_movie_name(&self) -> &str { + "" + } + + #[zbus(property, name = "ToolTip")] + async fn tool_tip(&self) -> (&str, Vec<(i32, i32, Vec)>, &str, &str) { + ("", vec![], "", "") + } + + #[zbus(property, name = "IconThemePath")] + async fn icon_theme_path(&self) -> &str { + "" + } + + #[zbus(property, name = "Menu")] + async fn menu(&self) -> ObjectPath<'_> { + ObjectPath::try_from("/MenuBar").unwrap() + } + + #[zbus(property, name = "ItemIsMenu")] + async fn item_is_menu(&self) -> bool { + false + } + + async fn activate(&self, x: i32, y: i32) { + self.send_click(x, y, 1); + } + + async fn secondary_activate(&self, x: i32, y: i32) { + self.send_click(x, y, 2); + } + + async fn context_menu(&self, x: i32, y: i32) { + self.send_click(x, y, 3); + } + + async fn scroll(&self, _delta: i32, _orientation: &str) {} + + #[zbus(signal)] + async fn new_icon(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; + + #[zbus(signal)] + async fn new_title(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; + + #[zbus(signal)] + async fn new_status(emitter: &SignalEmitter<'_>, status: &str) -> zbus::Result<()>; +} + +impl StatusNotifierItem { + fn send_click(&self, x: i32, y: i32, button: u8) { + let state = self.state.clone(); + tokio::spawn(async move { + let s = state.lock().await; + let _ = s.cmd_tx.send(DbusCommand::Click { + client_window: s.client_window, + x, + y, + button, + }); + }); + } +} + +// --------------------------------------------------------------------------- +// Minimal com.canonical.dbusmenu interface +// --------------------------------------------------------------------------- +// Waybar uses dbusmenu for right-click context menus — it never calls +// ContextMenu() on the SNI item. We implement a stub that injects button-3 +// into the Wine app when Waybar is about to show the popup. Wine then opens +// its own native X11 context menu at the cursor position. + +/// com.canonical.dbusmenu layout item: (id, properties, children) +type MenuItem = ( + i32, + std::collections::HashMap>, + Vec>, +); + +struct DbusMenu { + state: Arc>, +} + +#[interface(name = "com.canonical.dbusmenu")] +impl DbusMenu { + async fn about_to_show(&self, _id: i32) -> bool { + false + } + + /// Waybar calls AboutToShowGroup on right-click. We inject button-3 + /// into the Wine app so it opens its native X11 context menu. + async fn about_to_show_group(&self, _ids: Vec) -> (Vec, Vec) { + debug!("AboutToShowGroup: injecting right-click"); + let s = self.state.lock().await; + let _ = s.cmd_tx.send(DbusCommand::Click { + client_window: s.client_window, + x: 0, + y: 0, + button: 3, + }); + (vec![], vec![]) + } + + /// Waybar calls GetLayout at registration to probe the menu. + /// Return an empty invisible root — Wine handles menus natively. + async fn get_layout( + &self, + _parent_id: i32, + _recursion_depth: i32, + _property_names: Vec, + ) -> (u32, MenuItem) { + let mut props = std::collections::HashMap::new(); + props.insert("visible".to_string(), Value::from(false)); + let root: MenuItem = (0, props, vec![]); + (1, root) + } + + async fn get_group_properties( + &self, + _ids: Vec, + _property_names: Vec, + ) -> Vec<(i32, std::collections::HashMap>)> { + vec![] + } + + async fn get_property(&self, _id: i32, _name: &str) -> Value<'static> { + Value::new(String::new()) + } + + async fn event( + &self, + _id: i32, + _event_id: &str, + _data: Value<'_>, + _timestamp: u32, + ) { + } + + async fn event_group( + &self, + _events: Vec<(i32, String, Value<'_>, u32)>, + ) -> Vec { + vec![] + } + + // --- Signals (required by the interface) --- + + #[zbus(signal)] + async fn items_properties_updated( + emitter: &SignalEmitter<'_>, + updated_props: &[(i32, std::collections::HashMap<&str, Value<'_>>)], + removed_props: &[(i32, Vec<&str>)], + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn layout_updated( + emitter: &SignalEmitter<'_>, + revision: u32, + parent: i32, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn item_activation_requested( + emitter: &SignalEmitter<'_>, + id: i32, + timestamp: u32, + ) -> zbus::Result<()>; + + // --- Properties --- + + #[zbus(property)] + async fn version(&self) -> u32 { + 3 + } + + #[zbus(property, name = "TextDirection")] + async fn text_direction(&self) -> &str { + "ltr" + } + + #[zbus(property)] + async fn status(&self) -> &str { + "normal" + } + + #[zbus(property, name = "IconThemePath")] + async fn icon_theme_path(&self) -> Vec { + vec![] + } +} + +// --------------------------------------------------------------------------- +// Handle + registration +// --------------------------------------------------------------------------- + +pub struct SniHandle { + connection: Connection, + state: Arc>, +} + +impl SniHandle { + pub async fn update_icon(&self, icon_data: IconPixmap) { + { + let mut state = self.state.lock().await; + state.icon_pixmap = icon_data; + } + + let iface_ref = self + .connection + .object_server() + .interface::<_, StatusNotifierItem>("/StatusNotifierItem") + .await; + + match iface_ref { + Ok(iface) => { + let emitter = iface.signal_emitter(); + if let Err(e) = StatusNotifierItem::new_icon(&emitter).await { + debug!("failed to emit NewIcon: {e}"); + } + if let Err(e) = iface.get().await.icon_pixmap_changed(&emitter).await { + debug!("failed to emit icon_pixmap property change: {e}"); + } + } + Err(e) => { + debug!("could not get interface ref: {e}"); + } + } + } + + pub async fn shutdown(self) { + debug!("D-Bus connection for SNI icon shutting down"); + } +} + +pub async fn register_sni( + client_window: u32, + initial_icon: IconPixmap, + cmd_tx: mpsc::UnboundedSender, +) -> Result> { + let state = Arc::new(Mutex::new(SniState { + icon_pixmap: initial_icon, + client_window, + cmd_tx, + })); + + let sni = StatusNotifierItem { + state: state.clone(), + }; + let dbusmenu = DbusMenu { + state: state.clone(), + }; + + let connection = Builder::session()? + .serve_at("/StatusNotifierItem", sni)? + .serve_at("/MenuBar", dbusmenu)? + .build() + .await?; + + let bus_name = connection + .unique_name() + .map(|n| n.to_string()) + .unwrap_or_default(); + + info!("registered SNI on D-Bus as {bus_name} for client 0x{client_window:x}"); + + register_with_watcher(&connection, &bus_name).await; + + Ok(SniHandle { connection, state }) +} + +async fn register_with_watcher(connection: &Connection, bus_name: &str) { + let result: Result<(), _> = connection + .call_method( + Some("org.kde.StatusNotifierWatcher"), + "/StatusNotifierWatcher", + Some("org.kde.StatusNotifierWatcher"), + "RegisterStatusNotifierItem", + &bus_name, + ) + .await + .map(|_| ()); + + match result { + Ok(()) => { + info!("registered {bus_name} with StatusNotifierWatcher"); + } + Err(e) => { + warn!("could not register with StatusNotifierWatcher (is Waybar running?): {e}"); + } + } +} diff --git a/src/sni_proxy.rs b/src/sni_proxy.rs new file mode 100644 index 0000000..8e80e50 --- /dev/null +++ b/src/sni_proxy.rs @@ -0,0 +1,357 @@ +use tokio::sync::mpsc; +use tracing::{debug, info}; +use x11rb::connection::Connection; +use x11rb::protocol::composite::ConnectionExt as _; +use x11rb::protocol::damage::{self, ConnectionExt as _}; +use x11rb::protocol::xproto::*; +use x11rb::protocol::xtest::ConnectionExt as _; +use x11rb::rust_connection::RustConnection; +use x11rb::CURRENT_TIME; + +use crate::sni_dbus::{self, SniHandle}; +use crate::tray_manager::Atoms; +use crate::DbusCommand; + +const DEFAULT_ICON_SIZE: u16 = 64; + +// XEMBED message constants. +const XEMBED_EMBEDDED_NOTIFY: u32 = 0; +const XEMBED_VERSION: u32 = 0; + +pub struct SniProxy { + conn: *const RustConnection, + container: Window, + client_window: Window, + icon_size: u16, + damage: Option, + sni_handle: SniHandle, +} + +// Safety: RustConnection is Send+Sync, and we only use it on the single-threaded runtime. +unsafe impl Send for SniProxy {} + +/// Determine the icon size for a client window. +/// Reads the client's actual geometry and WM_NORMAL_HINTS to find its preferred size. +/// Falls back to DEFAULT_ICON_SIZE, overridable via TRAY_ICON_SIZE env var. +fn determine_icon_size(conn: &RustConnection, client_window: Window) -> u16 { + // Env var override takes priority. + if let Ok(val) = std::env::var("TRAY_ICON_SIZE") { + if let Ok(size) = val.parse::() { + if size > 0 { + debug!("using TRAY_ICON_SIZE={size} from environment"); + return size; + } + } + } + + // Try reading the client's current geometry. + if let Some(geom) = conn + .get_geometry(client_window) + .ok() + .and_then(|c| c.reply().ok()) + { + let size = geom.width.max(geom.height); + // Only trust it if it's a reasonable icon size (not 1x1 placeholder). + if size >= 8 { + debug!( + "client 0x{client_window:x} native size {w}x{h}, using {size}", + w = geom.width, + h = geom.height, + ); + return size; + } + } + + DEFAULT_ICON_SIZE +} + +impl SniProxy { + pub async fn new( + conn: &RustConnection, + screen_num: usize, + client_window: Window, + atoms: &Atoms, + cmd_tx: mpsc::UnboundedSender, + ) -> Result> { + let screen = &conn.setup().roots[screen_num]; + + let icon_size = determine_icon_size(conn, client_window); + info!("icon size for 0x{client_window:x}: {icon_size}x{icon_size}"); + + // Create container window for the client. KDE's approach: use MANUAL + // composite redirect so the X server never renders the window on screen — + // pixels only exist in the offscreen pixmap. Plus stack below + opacity 0 + // as defense in depth. + let container = conn.generate_id()?; + conn.create_window( + 0, + container, + screen.root, + -2000, + -2000, + icon_size, + icon_size, + 0, + WindowClass::INPUT_OUTPUT, + screen.root_visual, + &CreateWindowAux::default() + .override_redirect(1) + .background_pixel(0), + )?; + + // Stack below all other windows. + conn.configure_window( + container, + &ConfigureWindowAux::default().stack_mode(StackMode::BELOW), + )?; + + // Map the container (must be mapped for the client to be viewable). + conn.map_window(container)?; + + // Reparent client into our container. + conn.reparent_window(client_window, container, 0, 0)?; + + // Resize client to fill container. + conn.configure_window( + client_window, + &ConfigureWindowAux::default() + .width(u32::from(icon_size)) + .height(u32::from(icon_size)), + )?; + + // Map the client window. + conn.map_window(client_window)?; + + // MANUAL redirect: the window is NOT rendered on screen by the X server. + // Pixels only go to the offscreen composite pixmap, which we read via + // get_image. This is how KDE's xembedsniproxy hides the container. + conn.composite_redirect_window( + client_window, + x11rb::protocol::composite::Redirect::MANUAL, + )?; + + // Create damage tracking on the client. + let damage_id = conn.generate_id()?; + conn.damage_create( + damage_id, + client_window, + damage::ReportLevel::NON_EMPTY, + )?; + + // Send XEMBED_EMBEDDED_NOTIFY to the client. + let event = ClientMessageEvent::new( + 32, + client_window, + atoms.xembed, + [ + CURRENT_TIME, + XEMBED_EMBEDDED_NOTIFY, + 0, // detail + container, + XEMBED_VERSION, + ], + ); + conn.send_event(false, client_window, EventMask::NO_EVENT, event)?; + conn.flush()?; + + info!( + "created proxy: client=0x{client_window:x} container=0x{container:x} damage={damage_id}" + ); + + // Do initial icon capture. + let icon_data = capture_icon(conn, client_window, icon_size); + + // Spawn a D-Bus connection for this icon. + let sni_handle = + sni_dbus::register_sni(client_window, icon_data, cmd_tx).await?; + + let proxy = Self { + conn: conn as *const RustConnection, + container, + client_window, + icon_size, + damage: Some(damage_id), + sni_handle, + }; + + Ok(proxy) + } + + fn conn(&self) -> &RustConnection { + // Safety: lifetime tied to TrayManager which holds the connection. + unsafe { &*self.conn } + } + + pub fn container(&self) -> Window { + self.container + } + + pub fn damage_id(&self) -> Option { + self.damage + } + + /// Re-capture the icon pixels and emit a NewIcon D-Bus signal. + pub async fn update_icon(&mut self) -> Result<(), Box> { + let conn = self.conn(); + + // Subtract damage so we get notified again next time. + if let Some(damage_id) = self.damage { + conn.damage_subtract(damage_id, 0u32, 0u32)?; + } + + let icon_data = capture_icon(conn, self.client_window, self.icon_size); + self.sni_handle.update_icon(icon_data).await; + Ok(()) + } + + /// Inject a click using XTest fake_input. On XWayland, send_event synthetic + /// events aren't delivered to Wine apps. XTest generates events + /// indistinguishable from real hardware input. + /// + /// Because the container lives at (-2000, -2000), XWayland has no valid + /// Wayland surface for it and pointer warping is a no-op. We temporarily + /// move the container to a real screen position, inject the click, then + /// move it back. + pub fn inject_click( + &self, + _x: i32, + _y: i32, + button: u8, + ) -> Result<(), Box> { + let conn = self.conn(); + let root = conn.setup().roots[0].root; + + // Save current pointer position. + let pointer = conn.query_pointer(root)?.reply()?; + + // Move container to the cursor position so XWayland has a valid + // surface for pointer input, and so Wine opens menus at the cursor. + let half = i32::from(self.icon_size) / 2; + conn.configure_window( + self.container, + &ConfigureWindowAux::default() + .x(i32::from(pointer.root_x) - half) + .y(i32::from(pointer.root_y) - half) + .stack_mode(StackMode::ABOVE), + )?; + + // Round-trip: ensure XWayland has processed the configure before + // we try to warp the pointer to the new surface position. + conn.get_input_focus()?.reply()?; + + // Warp pointer to the center of the container (the Wayland surface). + let half = (self.icon_size / 2) as i16; + conn.warp_pointer(0u32, self.container, 0, 0, 0, 0, half, half)?; + + // XTest fake button press + release. + conn.xtest_fake_input( + BUTTON_PRESS_EVENT, + button, + CURRENT_TIME, + root, + 0, + 0, + 0, + )?; + conn.xtest_fake_input( + BUTTON_RELEASE_EVENT, + button, + CURRENT_TIME, + root, + 0, + 0, + 0, + )?; + + // Warp pointer back to original position. + conn.warp_pointer(0u32, root, 0, 0, 0, 0, pointer.root_x, pointer.root_y)?; + + // Move container back off-screen and re-lower it. + conn.configure_window( + self.container, + &ConfigureWindowAux::default() + .x(-2000) + .y(-2000) + .stack_mode(StackMode::BELOW), + )?; + + conn.flush()?; + debug!( + "xtest button {button} on 0x{:x} via container (pointer restored to {}, {})", + self.client_window, pointer.root_x, pointer.root_y + ); + Ok(()) + } + + pub async fn shutdown(self) { + let conn = self.conn(); + + if let Some(damage_id) = self.damage { + let _ = conn.damage_destroy(damage_id); + } + let _ = conn.reparent_window( + self.client_window, + conn.setup().roots[0].root, + 0, + 0, + ); + let _ = conn.destroy_window(self.container); + let _ = conn.flush(); + + self.sni_handle.shutdown().await; + info!("proxy for 0x{:x} shut down", self.client_window); + } +} + +/// Capture the client window's pixels and convert BGRA → ARGB (network byte order). +/// Returns (width, height, argb_data) or transparent fallback on failure. +fn capture_icon(conn: &RustConnection, window: Window, icon_size: u16) -> (i32, i32, Vec) { + let size = i32::from(icon_size); + + let cookie = match conn.get_image( + ImageFormat::Z_PIXMAP, + window, + 0, + 0, + icon_size, + icon_size, + !0, // all planes + ) { + Ok(c) => c, + Err(e) => { + debug!("get_image request failed for 0x{window:x}: {e}"); + return (size, size, vec![0u8; (size * size * 4) as usize]); + } + }; + let reply = match cookie.reply() { + Ok(reply) => reply, + Err(e) => { + debug!("get_image failed for 0x{window:x}: {e}"); + return (size, size, vec![0u8; (size * size * 4) as usize]); + } + }; + + let data = &reply.data; + let pixel_count = (size * size) as usize; + let mut argb = Vec::with_capacity(pixel_count * 4); + + // X11 ZPixmap on little-endian: bytes are B, G, R, A (or X). + // SNI IconPixmap expects ARGB in network byte order (big-endian): A, R, G, B. + for i in 0..pixel_count { + let off = i * 4; + if off + 3 < data.len() { + let b = data[off]; + let g = data[off + 1]; + let r = data[off + 2]; + let a = data[off + 3]; + argb.push(a); + argb.push(r); + argb.push(g); + argb.push(b); + } else { + argb.extend_from_slice(&[0, 0, 0, 0]); + } + } + + (size, size, argb) +} diff --git a/src/tray_manager.rs b/src/tray_manager.rs new file mode 100644 index 0000000..4d6bfcd --- /dev/null +++ b/src/tray_manager.rs @@ -0,0 +1,263 @@ +use std::collections::HashMap; + +use tokio::sync::mpsc; +use tracing::{debug, info, warn}; +use x11rb::connection::{Connection, RequestConnection}; +use x11rb::protocol::composite::ConnectionExt as _; +use x11rb::protocol::damage::{self, ConnectionExt as _}; +use x11rb::protocol::xproto::*; +use x11rb::protocol::Event; +use x11rb::rust_connection::RustConnection; +use x11rb::CURRENT_TIME; + +use crate::sni_proxy::SniProxy; +use crate::DbusCommand; + +/// X atoms we intern once at startup. +pub struct Atoms { + pub net_system_tray_s0: Atom, + pub net_system_tray_opcode: Atom, + pub manager: Atom, + pub xembed_info: Atom, + pub xembed: Atom, +} + +impl Atoms { + fn intern(conn: &RustConnection) -> Result> { + let cookies = ( + conn.intern_atom(false, b"_NET_SYSTEM_TRAY_S0")?, + conn.intern_atom(false, b"_NET_SYSTEM_TRAY_OPCODE")?, + conn.intern_atom(false, b"MANAGER")?, + conn.intern_atom(false, b"_XEMBED_INFO")?, + conn.intern_atom(false, b"_XEMBED")?, + ); + Ok(Self { + net_system_tray_s0: cookies.0.reply()?.atom, + net_system_tray_opcode: cookies.1.reply()?.atom, + manager: cookies.2.reply()?.atom, + xembed_info: cookies.3.reply()?.atom, + xembed: cookies.4.reply()?.atom, + }) + } +} + +const SYSTEM_TRAY_REQUEST_DOCK: u32 = 0; + +pub struct TrayManager<'a> { + conn: &'a RustConnection, + screen_num: usize, + owner_window: Window, + atoms: Atoms, + proxies: HashMap, + damage_to_client: HashMap, + cmd_tx: mpsc::UnboundedSender, +} + +impl<'a> TrayManager<'a> { + pub fn new( + conn: &'a RustConnection, + screen_num: usize, + cmd_tx: mpsc::UnboundedSender, + ) -> Result> { + // Verify extensions are available. + conn.composite_query_version(0, 4)?.reply()?; + let damage_reply = conn.damage_query_version(1, 1)?.reply()?; + debug!( + "damage extension v{}.{}", + damage_reply.major_version, damage_reply.minor_version + ); + + // Just verify the extension is present (we don't need the event base + // since x11rb parses DamageNotify into a proper Event variant). + conn.extension_information(damage::X11_EXTENSION_NAME)? + .ok_or("damage extension not available")?; + + let atoms = Atoms::intern(conn)?; + + // Create an invisible owner window for the selection. + let screen = &conn.setup().roots[screen_num]; + let owner_window = conn.generate_id()?; + conn.create_window( + 0, // depth: copy from parent + owner_window, + screen.root, + -1, + -1, + 1, + 1, + 0, + WindowClass::INPUT_ONLY, + screen.root_visual, + &CreateWindowAux::default(), + )?; + + conn.flush()?; + + Ok(Self { + conn, + screen_num, + owner_window, + atoms, + proxies: HashMap::new(), + damage_to_client: HashMap::new(), + cmd_tx, + }) + } + + pub fn claim_selection(&mut self) -> Result<(), Box> { + let screen = &self.conn.setup().roots[self.screen_num]; + + self.conn.set_selection_owner( + self.owner_window, + self.atoms.net_system_tray_s0, + CURRENT_TIME, + )?; + + // Verify we actually got the selection. + let owner = self + .conn + .get_selection_owner(self.atoms.net_system_tray_s0)? + .reply()? + .owner; + if owner != self.owner_window { + return Err( + "failed to claim _NET_SYSTEM_TRAY_S0 — another tray host is running".into(), + ); + } + + // Broadcast MANAGER client message so existing clients know about us. + let event = ClientMessageEvent::new( + 32, + screen.root, + self.atoms.manager, + [ + CURRENT_TIME, + self.atoms.net_system_tray_s0, + self.owner_window, + 0, + 0, + ], + ); + self.conn.send_event( + false, + screen.root, + EventMask::STRUCTURE_NOTIFY, + event, + )?; + self.conn.flush()?; + + info!("claimed _NET_SYSTEM_TRAY_S0 selection"); + Ok(()) + } + + pub async fn handle_event( + &mut self, + event: &Event, + ) -> Result<(), Box> { + match event { + Event::ClientMessage(ev) => { + if ev.type_ == self.atoms.net_system_tray_opcode && ev.format == 32 { + let opcode = ev.data.as_data32()[1]; + let client_window = ev.data.as_data32()[2]; + if opcode == SYSTEM_TRAY_REQUEST_DOCK && client_window != 0 { + info!("dock request from window 0x{client_window:x}"); + self.dock(client_window).await?; + } + } + } + Event::DestroyNotify(ev) => { + if let Some(proxy) = self.proxies.remove(&ev.window) { + info!("client 0x{:x} destroyed, removing proxy", ev.window); + if let Some(damage_id) = proxy.damage_id() { + self.damage_to_client.remove(&damage_id); + } + proxy.shutdown().await; + } + } + Event::ReparentNotify(ev) => { + // Client reparented away from our container — treat as undock. + if let Some(proxy) = self.proxies.get(&ev.window) { + if ev.parent != proxy.container() { + info!("client 0x{:x} reparented away, removing proxy", ev.window); + let proxy = self.proxies.remove(&ev.window).unwrap(); + if let Some(damage_id) = proxy.damage_id() { + self.damage_to_client.remove(&damage_id); + } + proxy.shutdown().await; + } + } + } + Event::DamageNotify(ev) => { + if let Some(&client) = self.damage_to_client.get(&ev.damage) { + if let Some(proxy) = self.proxies.get_mut(&client) { + proxy.update_icon().await?; + } + } + } + _ => {} + } + Ok(()) + } + + async fn dock( + &mut self, + client_window: Window, + ) -> Result<(), Box> { + if self.proxies.contains_key(&client_window) { + warn!("duplicate dock request for 0x{client_window:x}, ignoring"); + return Ok(()); + } + + // Subscribe to StructureNotify on the client so we get DestroyNotify / ReparentNotify. + self.conn.change_window_attributes( + client_window, + &ChangeWindowAttributesAux::default().event_mask(EventMask::STRUCTURE_NOTIFY), + )?; + + let proxy = SniProxy::new( + self.conn, + self.screen_num, + client_window, + &self.atoms, + self.cmd_tx.clone(), + ) + .await?; + + if let Some(damage_id) = proxy.damage_id() { + self.damage_to_client.insert(damage_id, client_window); + } + self.proxies.insert(client_window, proxy); + Ok(()) + } + + pub fn handle_dbus_command(&mut self, cmd: &DbusCommand) { + match cmd { + DbusCommand::Click { + client_window, + x, + y, + button, + } => { + if let Some(proxy) = self.proxies.get(client_window) { + if let Err(e) = proxy.inject_click(*x, *y, *button) { + warn!("click injection failed: {e}"); + } + } + } + } + } + + pub async fn shutdown(mut self) { + info!("shutting down, cleaning up {} proxies", self.proxies.len()); + for (_, proxy) in self.proxies.drain() { + proxy.shutdown().await; + } + let _ = self.conn.set_selection_owner( + 0u32, // None — release selection + self.atoms.net_system_tray_s0, + CURRENT_TIME, + ); + let _ = self.conn.destroy_window(self.owner_window); + let _ = self.conn.flush(); + } +}