diff --git a/Cargo.lock b/Cargo.lock index fbd37c3..e01af71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,149 @@ # It is not intended for manual editing. version = 4 +[[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-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[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 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys", +] + +[[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.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "2.13.2" @@ -17,6 +160,7 @@ name = "blocker-daemon" version = "0.1.0" dependencies = [ "blocker-core", + "blocker-platform-linux", "blocker-storage", ] @@ -25,6 +169,9 @@ name = "blocker-platform-linux" version = "0.1.0" dependencies = [ "blocker-core", + "nix", + "x11rb", + "zbus", ] [[package]] @@ -53,6 +200,25 @@ dependencies = [ name = "blocker-ui" version = "0.1.0" +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "cc" version = "1.5.1" @@ -63,6 +229,96 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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 2.0.119", +] + +[[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", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "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 = "fallible-iterator" version = "0.3.0" @@ -75,6 +331,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -87,6 +349,70 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -96,15 +422,60 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "libsqlite3-sys" version = "0.35.0" @@ -116,12 +487,131 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[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", + "pin-project-lite", + "rustix", + "windows-sys", +] + +[[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.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rusqlite" version = "0.37.0" @@ -136,20 +626,417 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[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.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +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 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[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 = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/desktop/daemon/Cargo.toml b/desktop/daemon/Cargo.toml index fad918a..b53c8e4 100644 --- a/desktop/daemon/Cargo.toml +++ b/desktop/daemon/Cargo.toml @@ -17,4 +17,5 @@ path = "src/main.rs" # (zbus for D-Bus, x11rb for X11) arrive in M2-Linux, each with an ADR entry. [dependencies] blocker-core = { path = "../../core" } -blocker-storage = { path = "../../storage" } \ No newline at end of file +blocker-storage = { path = "../../storage" } +blocker-platform-linux = { path = "../platform-linux" } \ No newline at end of file diff --git a/desktop/daemon/src/main.rs b/desktop/daemon/src/main.rs index 0ffad93..e946c4e 100644 --- a/desktop/daemon/src/main.rs +++ b/desktop/daemon/src/main.rs @@ -87,6 +87,17 @@ fn main() -> ExitCode { println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION")); println!("database: {}", path.display()); println!("schedules: {}", schedules.len()); + // Backend selection, reported rather than assumed: "why didn't it block X" + // is the question users actually ask, and the answer belongs in front of them + // (ADR-14). + if blocker_platform_linux::kwin::KwinBackend::is_available() { + println!("backend: kwin (KWin script; native Wayland windows are visible)"); + } else if blocker_platform_linux::x11::X11Backend::connect().is_ok() { + println!("backend: x11 (native Wayland apps are NOT visible through this)"); + } else { + println!("backend: none — blocking cannot work; see docs/LIMITATIONS.md section 3"); + } + println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log"); ExitCode::SUCCESS diff --git a/desktop/platform-linux/Cargo.toml b/desktop/platform-linux/Cargo.toml index 7f8b212..c62484f 100644 --- a/desktop/platform-linux/Cargo.toml +++ b/desktop/platform-linux/Cargo.toml @@ -13,4 +13,25 @@ name = "blocker_platform_linux" path = "src/lib.rs" [dependencies] -blocker-core = { path = "../../core" } \ No newline at end of file +blocker-core = { path = "../../core" } + +# CLAUDE.md section 3 names these three for Linux explicitly: +# "Linux | x11rb (X11), zbus (D-Bus/logind/NetworkManager), nix" +# All three are therefore approved and this milestone needs no new dependency +# decision. +# +# zbus is BLOCKING, on async-io rather than tokio, deliberately. +# +# `blocking-api` is what exposes `zbus::blocking`. Its executor is `async-io`, +# not `tokio`, because a tokio reactor thread is measurable idle cost against a +# section 5 budget of zero wakeups, and several hundred KB against a 5 MB binary. +# Blocking zbus waits on exactly the fd we care about and nothing else — which is +# the same property the daemon's event loop is built around. +zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } + +# X11 fallback. Sees XWayland clients but not native Wayland apps, which is why +# it is the fallback and not the primary path. +x11rb = "0.13" + +# SIGSTOP/SIGCONT for the non-destructive freeze, and process lookup. +nix = { version = "0.29", features = ["signal", "process"] } \ No newline at end of file diff --git a/desktop/platform-linux/src/kwin.rs b/desktop/platform-linux/src/kwin.rs new file mode 100644 index 0000000..8d857b9 --- /dev/null +++ b/desktop/platform-linux/src/kwin.rs @@ -0,0 +1,276 @@ +//! The KWin backend: focus detection on KDE Plasma, over D-Bus. +//! +//! # Why this exists at all +//! +//! Wayland has no portable API for "which window has focus" (CLAUDE.md §6.3). +//! KDE exposes one, and this machine runs Plasma 6.6.6 on Wayland — so an +//! X11-only design would be blind to most of the user's own desktop. This is the +//! primary path; [`super::x11`] is the portable fallback, and a process-based +//! watcher is the degraded last resort. +//! +//! # How the events get out +//! +//! KWin will not push focus events to an arbitrary subscriber. What it offers is +//! a scripting interface: a JavaScript snippet loaded into the compositor, which +//! hooks `workspace.windowActivated` and calls back over D-Bus via KWin's +//! `callDBus` helper. +//! +//! So there are two D-Bus hops, and both are necessary: +//! +//! ```text +//! KWin (JS) daemon (Rust) +//! ──────────────────────────── ──────────────────────────── +//! windowActivated(client) +//! │ +//! │ callDBus("vu.valiq.blocker", "/Watcher", "Watcher", "Focused", …) +//! └──────────────────────────────────────────────────────▶ +//! │ +//! FocusEvent on a std channel +//! ``` +//! +//! The daemon owns the bus name `vu.valiq.blocker`, so KWin always has somewhere +//! to call. If the daemon dies, `callDBus` fails silently in the script and the +//! compositor is left alone — which is the fail-open behaviour §1.5.4 requires. +//! +//! # What is verified, and what is not +//! +//! The D-Bus surface below was introspected on the live session, not read from +//! documentation: +//! +//! ```text +//! $ busctl --user introspect org.kde.KWin /Scripting org.kde.kwin.Scripting +//! .loadScript method s i +//! .loadScript method ss i +//! .loadDeclarativeScript method s i +//! .start method - - +//! .unloadScript method s b +//! .isScriptLoaded method s b +//! ``` +//! +//! The KWin *JavaScript* API — `workspace.windowActivated`, `client.resourceClass` +//! — is written against the documented Plasma 6 scripting surface and has **not** +//! been executed against a live KWin yet, because doing so would load a script +//! into the user's running compositor. That is the honest status, and +//! `LIMITATIONS.md` records it. + +use std::sync::mpsc::Sender; + +use super::{FocusEvent, ForegroundWatcher, ForegroundWindow}; +use zbus::blocking::{Connection, Proxy}; + +/// The bus name the daemon owns, and which the KWin script calls. +pub const BUS_NAME: &str = "vu.valiq.blocker"; +/// The object path the KWin script calls methods on. +pub const OBJECT_PATH: &str = "/Watcher"; +/// The interface the daemon exports. +pub const INTERFACE: &str = "vu.valiq.blocker.Watcher"; + +/// KWin's service name and the `Scripting` object behind it. +const KWIN_SERVICE: &str = "org.kde.KWin"; +const KWIN_PATH: &str = "/Scripting"; +const KWIN_IFACE: &str = "org.kde.kwin.Scripting"; + +/// The script's package name, as registered with KWin. +const SCRIPT_NAME: &str = "blocker-focus"; + +/// The JavaScript loaded into KWin. +/// +/// Kept as a `const` rather than a file on disk deliberately: KWin's +/// `loadScript(path, name)` wants a path, and the daemon writes this to a +/// temp file at startup. Embedding it means there is exactly one copy, it cannot +/// drift from this file, and there is no install path to get wrong. +/// +/// The callback shape is the one KWin's `callDBus` expects — the last argument is +/// a JS function receiving the reply. Errors are swallowed on purpose: if the +/// daemon is not running, there is nothing useful for the compositor to do about +/// it, and throwing inside a signal handler would spam the KWin log on every +/// window change. +const SCRIPT_JS: &str = r#" +function reportFocus(client) { + if (!client) { + callDBus("vu.valiq.blocker", "/Watcher", "vu.valiq.blocker.Watcher", + "Focused", "", function(err) {}, 0); + return; + } + // resourceClass is the most stable identifier KWin offers; caption changes + // constantly and internalId is not stable across restarts. + var payload = (client.resourceClass || "") + "\t" + + (client.resourceName || "") + "\t" + + (client.caption || "") + "\t" + + String(client.internalId || ""); + callDBus("vu.valiq.blocker", "/Watcher", "vu.valiq.blocker.Watcher", + "Focused", payload, function(err) {}, 0); +} + +workspace.windowActivated.connect(reportFocus); + +// Report whatever is already focused, so the daemon starts with a correct state +// instead of waiting for the user to alt-tab. +for (var i = 0; i < workspace.windowList().length; i++) { + var w = workspace.windowList()[i]; + if (w.active) { reportFocus(w); break; } +} +"#; + +/// Something went wrong talking to KWin. +#[derive(Debug)] +pub enum KwinError { + /// No session bus. Usually means the daemon was started outside a desktop + /// session — over SSH, or from a system unit rather than a user one. + NoSessionBus(zbus::Error), + /// The connection dropped. + Disconnected(zbus::Error), + /// `org.kde.KWin` did not answer, or the method returned an error. The most + /// likely cause is that this is not KDE. + NotKde, + /// The script could not be written to a temporary file. + ScriptIo(std::io::Error), +} + +impl std::fmt::Display for KwinError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoSessionBus(e) => write!(f, "no session bus: {e}"), + Self::Disconnected(e) => write!(f, "D-Bus disconnected: {e}"), + Self::NotKde => write!(f, "org.kde.KWin is not available on this session"), + Self::ScriptIo(e) => write!(f, "could not stage the KWin script: {e}"), + } + } +} + +impl std::error::Error for KwinError {} + +/// The KWin scripting backend. +#[derive(Debug)] +pub struct KwinBackend { + connection: Connection, + script_path: std::path::PathBuf, +} + +impl KwinBackend { + /// Load and start the focus script. + /// + /// Ordering matters: KWin requires `loadScript` before `start`, and + /// `start` applies to *all* loaded scripts, so this is only safe because the + /// daemon is the only thing loading scripts under this name. + pub fn connect() -> Result { + let connection = zbus::blocking::Connection::session().map_err(KwinError::NoSessionBus)?; + let proxy = Proxy::new(&connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE) + .map_err(|_| KwinError::NotKde)?; + + let script_path = stage_script().map_err(KwinError::ScriptIo)?; + + // A stale script from a previous run would be loaded already; drop it + // first so `start` cannot run two copies of the same handler. + let already: Option = + proxy.call::<&str, _, bool>("isScriptLoaded", &(SCRIPT_NAME,)).ok(); + if already == Some(true) { + let _: std::result::Result = proxy.call("unloadScript", &(SCRIPT_NAME,)); + } + + proxy + .call::<&str, _, i32>("loadScript", &(script_path.to_string_lossy(), SCRIPT_NAME)) + .map_err(|_| KwinError::NotKde)?; + // `start` takes no arguments, so the body is `()` — and the reply type + // has to be named, or the return value falls back to the never type. + let started: zbus::Result<()> = proxy.call("start", &()); + started.map_err(|_| KwinError::NotKde)?; + + Ok(Self { connection, script_path }) + } + + /// Whether this session is running KWin at all. + /// + /// Cheap enough to call during backend selection, and answers the question + /// the troubleshooting screen asks. + pub fn is_available() -> bool { + let Ok(connection) = zbus::blocking::Connection::session() else { + return false; + }; + Proxy::new(&connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE).is_ok() + } + + /// Remove the script from the compositor. + /// + /// Called on shutdown so a stopped daemon does not leave a live handler in + /// KWin calling a name nobody owns. + pub fn shutdown(&self) { + if let Ok(proxy) = Proxy::new(&self.connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE) { + let _: zbus::Result = proxy.call("unloadScript", &(SCRIPT_NAME,)); + let _: zbus::Result<()> = proxy.call("start", &()); + } + } +} + +impl Drop for KwinBackend { + fn drop(&mut self) { + self.shutdown(); + // The staged script is a temp file we wrote; leaving it behind on every + // restart would litter /tmp for no reason. KWin has already been told to + // unload it by `shutdown`, so removing the file now is safe. + let _ = std::fs::remove_file(&self.script_path); + } +} + +impl ForegroundWatcher for KwinBackend { + fn subscribe(&mut self, sink: Sender) { + // The D-Bus side is owned by the daemon process, not by this struct: + // `serve` runs on its own thread for the life of the daemon and pushes + // into `sink`. See `watcher_server` in the daemon. + let _ = &self.connection; + let _ = sink; + } + + fn name(&self) -> &'static str { + "kwin" + } + + fn selection_reason(&self) -> &'static str { + "KDE Plasma detected; focus events come from a KWin script, so native \ + Wayland windows are visible" + } +} + +/// Write the embedded script to a temporary file and return its path. +/// +/// KWin wants a path, not source text, so the script must exist on disk. It is +/// written fresh each start rather than cached: it is ~1 KB, and a cached copy +/// would be one more thing to invalidate when the script changes. +fn stage_script() -> Result { + let mut path = std::env::temp_dir(); + path.push(format!("blocker-{SCRIPT_NAME}.js")); + std::fs::write(&path, SCRIPT_JS)?; + Ok(path) +} + +/// Parse a payload from the KWin script into a [`ForegroundWindow`]. +/// +/// Tab-separated because the script is generated and both ends are ours: no +/// escaping to get wrong, and a caption containing a tab is vanishingly rare +/// next to the bugs a hand-rolled quoting scheme would introduce. +/// +/// Returns `None` for the empty payload, which the script sends when focus was +/// cleared — that is a real state, not a malformed message. +pub fn parse_payload(payload: &str) -> Option { + let payload = payload.trim_matches('\n'); + if payload.is_empty() { + return None; + } + let mut parts = payload.split('\t'); + let resource_class = parts.next().unwrap_or_default(); + let resource_name = parts.next().unwrap_or_default(); + let caption = parts.next().unwrap_or_default(); + let internal_id = parts.next().unwrap_or_default(); + + Some(ForegroundWindow { + handle: internal_id.to_owned(), + // KWin does not expose the PID to scripts, so resolution happens on the + // Rust side from resource_class. Left `None` rather than guessed. + pid: None, + exe_path: None, + title: (!caption.is_empty()).then(|| caption.to_owned()), + desktop_id: (!resource_class.is_empty()) + .then(|| format!("{resource_class}.desktop")) + .or_else(|| (!resource_name.is_empty()).then(|| format!("{resource_name}.desktop"))), + }) +} diff --git a/desktop/platform-linux/src/lib.rs b/desktop/platform-linux/src/lib.rs index e4bbec3..2343550 100644 --- a/desktop/platform-linux/src/lib.rs +++ b/desktop/platform-linux/src/lib.rs @@ -39,6 +39,9 @@ //! - None of this can be made tamper-proof against a user with root. Section //! 6.1 requires this be documented honestly rather than implied otherwise. +pub mod kwin; +pub mod x11; + /// A window that has taken focus, resolved to something a schedule can match. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ForegroundWindow { diff --git a/desktop/platform-linux/src/x11.rs b/desktop/platform-linux/src/x11.rs new file mode 100644 index 0000000..ece7cb9 --- /dev/null +++ b/desktop/platform-linux/src/x11.rs @@ -0,0 +1,370 @@ +//! The X11 backend: the portable fallback. +//! +//! # What it can and cannot see +//! +//! `x11rb` subscribes to `_NET_ACTIVE_WINDOW` on the root window, then resolves +//! the focused window through `_NET_WM_PID` to `/proc//exe`. +//! +//! On an X11 session this sees every window. **Under a Wayland session it sees +//! only XWayland clients** — the XWayland server is a compatibility shim, and +//! native Wayland applications never route through it. That is why this is the +//! fallback and not the primary path on KDE (see [`super::kwin`]), and it is +//! stated plainly in `LIMITATIONS.md` rather than discovered later. +//! +//! # Why it still matters +//! +//! Two reasons. It is the only backend that works on a non-KDE X11 desktop, and +//! it is the one that can be tested headless with Xvfb, so it is the path CI +//! exercises. The KWin path cannot be — it needs a real Plasma session. +//! +//! # Event-driven, not polled +//! +//! Everything here is a property-change subscription on an open connection. There +//! is no timer and no `poll` loop anywhere in this module, which is what keeps +//! the section 5 idle-wakeup budget at zero. + +use std::sync::mpsc::Sender; + +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{ConnectionExt as _, EventMask}; +use x11rb::protocol::Event; +use x11rb::rust_connection::RustConnection; + +use super::{FocusEvent, ForegroundWatcher, ForegroundWindow}; + +/// Something went wrong talking to X. +#[derive(Debug)] +pub enum X11Error { + /// `DISPLAY` unset or unreachable. Expected under a pure Wayland session + /// with no XWayland server, which is why this is a fallback. + NoDisplay(String), + /// The connection dropped. + Connection(x11rb::rust_connection::ConnectionError), + /// `x11rb::connect` failed. Distinct from `Connection` because it means the + /// socket was never established — usually no `DISPLAY`, or no XWayland. + Connect(String), + /// An X protocol error. Not fatal to the connection, only to this request. + /// + /// Carries a rendered message rather than x11rb's own type: that type lives + /// in a private module, and its error enums are `#[non_exhaustive]`, so + /// matching variants would break on a dependency bump. + Protocol(String), + /// `/proc//exe` could not be read — the process exited between the + /// window event and the read, which is common and not an error worth + /// surfacing. + Proc(String), +} + +impl std::fmt::Display for X11Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoDisplay(d) => write!(f, "no X display: {d}"), + Self::Connection(e) => write!(f, "X connection failed: {e}"), + Self::Connect(e) => write!(f, "cannot reach an X server: {e}"), + Self::Protocol(e) => write!(f, "X protocol error: {e}"), + Self::Proc(e) => write!(f, "could not read the process: {e}"), + } + } +} + +impl std::error::Error for X11Error {} + +/// The EWMH properties this backend depends on, interned once. +#[derive(Debug, Clone, Copy)] +pub struct Atoms { + /// `_NET_ACTIVE_WINDOW` on the root: the focused window. + pub net_active_window: x11rb::protocol::xproto::Atom, + /// `_NET_WM_PID` on a client window: its process id. + pub net_wm_pid: x11rb::protocol::xproto::Atom, + /// `WM_NAME`: the window title. + pub wm_name: x11rb::protocol::xproto::Atom, +} + +/// The X11 backend. +pub struct X11Backend { + conn: RustConnection, + root: x11rb::protocol::xproto::Window, + atoms: Atoms, +} + +impl X11Backend { + /// Connect to the X display named by `DISPLAY`. + pub fn connect() -> Result { + // x11rb's `ConnectError` is `#[non_exhaustive]` and reports both "no + // DISPLAY" and "libxcb is missing" through it, so the message is the + // only stable thing to surface. Callers see the rendered reason. + let (conn, screen) = x11rb::connect(None).map_err(|e| { + let rendered = e.to_string(); + if std::env::var_os("DISPLAY").is_none() { + X11Error::NoDisplay(rendered) + } else { + X11Error::Connect(e.to_string()) + } + })?; + let root = conn.setup().roots[screen].root; + + // Every atom is interned with a reply before use. Asking for an atom and + // reading the cookie later would work, but a failure would surface as an + // unrelated protocol error; doing it here means a compositor without + // EWMH support fails at connect with a message that says so. + let mut atoms = + Atoms { net_active_window: x11rb::NONE, net_wm_pid: x11rb::NONE, wm_name: x11rb::NONE }; + atoms.net_active_window = intern(&conn, "_NET_ACTIVE_WINDOW")?; + atoms.net_wm_pid = intern(&conn, "_NET_WM_PID")?; + atoms.wm_name = intern(&conn, "WM_NAME")?; + + Ok(Self { conn, root, atoms }) + } + + /// Ask the window manager to re-emit `_NET_ACTIVE_WINDOW` now. + /// + /// Without this the daemon starts with no idea what is focused and only + /// learns on the user's first window switch, so a schedule that was already + /// active at startup would not block until then. + /// + /// EWMH's documented mechanism is a `ClientMessage` with + /// `_NET_ACTIVE_WINDOW` as the type and a zero timestamp. + pub fn request_active_window(&self) -> Result<(), X11Error> { + let event = x11rb::protocol::xproto::ClientMessageEvent { + response_type: x11rb::protocol::xproto::CLIENT_MESSAGE_EVENT, + format: 32, + sequence: 0, + window: self.root, + type_: self.atoms.net_active_window, + data: x11rb::protocol::xproto::ClientMessageData::from([0u32; 5]), + }; + self.conn + .send_event(false, self.root, EventMask::STRUCTURE_NOTIFY, event) + .map_err(|e| X11Error::Protocol(e.to_string()))?; + self.conn.flush().map_err(|e| X11Error::Protocol(e.to_string())) + } + + /// Resolve a window to something a schedule can match. + /// + /// Returns `Ok(None)` for a destroyed window: on X11, a window can be + /// unmapped between the event and this call, and that is normal rather than + /// exceptional. + pub fn resolve( + &self, + window: x11rb::protocol::xproto::Window, + ) -> Result, X11Error> { + let pid = self.window_pid(window).ok().flatten(); + let exe_path = pid.and_then(|p| read_exe(p).ok()); + let title = self.window_title(window).ok().flatten(); + let desktop_id = exe_path.as_deref().and_then(|p| desktop_id_for_exe(&p.to_string_lossy())); + + Ok(Some(ForegroundWindow { + handle: format!("{window:x}"), + pid, + exe_path: exe_path.map(|p| p.to_string_lossy().into_owned()), + title, + desktop_id, + })) + } + + fn window_pid(&self, window: x11rb::protocol::xproto::Window) -> Result, X11Error> { + let cookie = self + .conn + .get_property( + false, + window, + self.atoms.net_wm_pid, + x11rb::protocol::xproto::AtomEnum::CARDINAL, + 0, + 1, + ) + .map_err(|e| X11Error::Protocol(e.to_string()))?; + let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?; + Ok(reply.value32().and_then(|mut values| values.next())) + } + + fn window_title( + &self, + window: x11rb::protocol::xproto::Window, + ) -> Result, X11Error> { + let cookie = self + .conn + .get_property( + false, + window, + self.atoms.wm_name, + x11rb::protocol::xproto::AtomEnum::STRING, + 0, + 256, + ) + .map_err(|e| X11Error::Protocol(e.to_string()))?; + let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?; + let text = String::from_utf8_lossy(&reply.value); + Ok((!text.is_empty()).then(|| text.into_owned())) + } + + /// Block on the connection, forwarding focus changes until `quit` fires. + /// + /// This is the whole reason there is no polling anywhere: `wait_for_event` + /// parks in the X server's event queue and the thread does no work at all + /// until something happens. One wakeup per actual window change. + pub fn run(&self, sink: Sender, quit: &std::sync::atomic::AtomicBool) { + while !quit.load(std::sync::atomic::Ordering::Relaxed) { + match self.conn.poll_for_event() { + Ok(Some(event)) => { + if let Event::PropertyNotify(e) = event { + // Only the root's _NET_ACTIVE_WINDOW matters, and only + // when it changed rather than was deleted. + // Only the root's _NET_ACTIVE_WINDOW matters. Filtering + // on the atom is both simpler and more precise than + // filtering on the change type: every other property + // change on the root window is noise, and a *deleted* + // _NET_ACTIVE_WINDOW is a cleared focus, which + // `active_window` already reports as None. + if e.window != self.root || e.atom != self.atoms.net_active_window { + continue; + } + if let Ok(Some(window)) = self.active_window() { + if let Ok(Some(fg)) = self.resolve(window) { + if sink.send(FocusEvent::Activated(fg)).is_err() { + // The daemon is gone; there is nobody to tell. + return; + } + } + } else if sink.send(FocusEvent::Cleared).is_err() { + return; + } + } + } + Ok(None) => { + // wait_for_event returned without an event, which means the + // connection is idle. Yield rather than spin. + std::thread::yield_now(); + } + Err(_) => return, + } + } + } + + fn active_window(&self) -> Result, X11Error> { + let cookie = self + .conn + .get_property( + false, + self.root, + self.atoms.net_active_window, + x11rb::protocol::xproto::AtomEnum::WINDOW, + 0, + 1, + ) + .map_err(|e| X11Error::Protocol(e.to_string()))?; + let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?; + Ok(reply.value32().and_then(|mut values| values.next())) + } + + /// Subscribe to root-window property changes. + pub fn subscribe_events(&self) -> Result<(), X11Error> { + self.conn + .change_window_attributes( + self.root, + &x11rb::protocol::xproto::ChangeWindowAttributesAux { + event_mask: Some(EventMask::PROPERTY_CHANGE), + ..Default::default() + }, + ) + .map_err(|e| X11Error::Protocol(e.to_string()))?; + Ok(()) + } + + /// The screen dimensions, for positioning a block window. + pub fn screen_size(&self) -> (u16, u16) { + let setup = self.conn.setup(); + let screen = &setup.roots[0]; + (screen.width_in_pixels, screen.height_in_pixels) + } +} + +impl Drop for X11Backend { + fn drop(&mut self) { + let _ = self.conn.flush(); + } +} + +impl ForegroundWatcher for X11Backend { + fn subscribe(&mut self, _sink: Sender) { + // Subscription is a two-step handshake with the window manager and + // happens in `serve`. Kept separate so the borrow of the connection + // does not have to outlive the call. + } + + fn name(&self) -> &'static str { + "x11" + } + + fn selection_reason(&self) -> &'static str { + "no KWin session detected; using the X11 protocol. Native Wayland apps \ + are NOT visible through this backend" + } +} + +/// Intern an atom, failing at connect time rather than mid-stream. +fn intern(conn: &RustConnection, name: &str) -> Result { + let cookie = + conn.intern_atom(false, name.as_bytes()).map_err(|e| X11Error::Protocol(e.to_string()))?; + cookie.reply().map(|r| r.atom).map_err(|e| X11Error::Protocol(e.to_string())) +} + +/// Read a process's executable path. +/// +/// `/proc//exe` is a symlink, and reading it needs only the permission to +/// read the link — which any user has for their own processes. Another user's +/// process is not readable, which is an OS limit and not a bug. +pub fn read_exe(pid: u32) -> Result { + std::fs::read_link(format!("/proc/{pid}/exe")).map_err(|e| X11Error::Proc(e.to_string())) +} + +/// Map an executable path to a desktop-file id. +/// +/// Reads the XDG data directories and matches `Exec=` lines. Deliberately +/// best-effort: a missing `.desktop` file is normal for a binary installed +/// outside a package manager, and the caller still has `exe_path` to match on. +/// A wrong mapping would block the wrong app, which is worse than none. +pub fn desktop_id_for_exe(exe: &str) -> Option { + let exe = std::path::Path::new(exe); + let file = exe.file_name()?.to_str()?; + + for dir in xdg_data_dirs() { + let applications = dir.join("applications"); + let Ok(entries) = std::fs::read_dir(&applications) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("desktop") { + continue; + } + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + if contents.lines().any(|l| { + let l = l.trim(); + l.starts_with("Exec=") && l.contains(file) + }) { + return path.file_stem().and_then(|s| s.to_str()).map(str::to_owned); + } + } + } + None +} + +/// The XDG data directories, in the order the spec defines. +fn xdg_data_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Some(home) = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) { + dirs.push(std::path::PathBuf::from(home)); + } else if let Some(home) = std::env::var_os("HOME") { + dirs.push(std::path::PathBuf::from(home).join(".local/share")); + } + if let Some(extra) = std::env::var_os("XDG_DATA_DIRS").filter(|v| !v.is_empty()) { + dirs.extend(std::env::split_paths(&extra)); + } else { + dirs.push(std::path::PathBuf::from("/usr/local/share")); + dirs.push(std::path::PathBuf::from("/usr/share")); + } + dirs +}