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..89deb32 100644 --- a/desktop/daemon/src/main.rs +++ b/desktop/daemon/src/main.rs @@ -33,6 +33,8 @@ //! installed read-only in `/usr/bin`, while the database must be writable and //! must survive an upgrade. +mod runner; + use std::path::PathBuf; use std::process::ExitCode; @@ -76,6 +78,9 @@ fn main() -> ExitCode { ); } + let usage = blocker_core::engine::UsageSnapshot::default(); + let context = blocker_core::engine::PlatformContext::default(); + let schedules = match store.load_schedules() { Ok(s) => s, Err(e) => { @@ -87,7 +92,86 @@ fn main() -> ExitCode { println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION")); println!("database: {}", path.display()); println!("schedules: {}", schedules.len()); - println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log"); + // 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"); + } + // The event loop, running for real. + // + // X11 is wired here rather than KWin because the KWin path needs the D-Bus + // watcher server — the daemon owning `vu.valiq.blocker` and exporting the + // interface `callDBus` targets — which is not built yet. Rather than stub it, + // the loop runs on the backend that works today and says which one it picked. + // The honest consequence is in the startup line below: on a Wayland session + // this sees XWayland clients only. + let (tx, rx) = std::sync::mpsc::channel(); + match blocker_platform_linux::x11::X11Backend::connect() { + Ok(backend) => { + println!("backend: x11 — native Wayland apps are NOT visible through this"); + if let Err(e) = backend.subscribe_events() { + eprintln!("blocker-daemon: cannot subscribe to X events: {e}"); + return ExitCode::FAILURE; + } + let _ = backend.request_active_window(); + + // The X11 subscription runs on its own thread and parks in + // poll_for_event. The main thread parks in recv_timeout. Neither + // ticks, so an idle system costs two blocked threads and no CPU. + let quit = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let worker_quit = quit.clone(); + let worker = std::thread::spawn(move || { + backend.run(tx, worker_quit.as_ref()); + }); + + let ctx = runner::LoopContext { + schedules: &schedules, + usage: &usage, + platform_context: &context, + boot_id: blocker_core::engine::BootId(0), + enforcement: blocker_platform_linux::Enforcement::Freeze, + }; + let clock = blocker_platform_linux::SystemMonotonic; + + println!("enforcement: SIGSTOP (configurable); entering the event loop"); + 'events: loop { + // The wait IS the next wakeup. An idle system parks here rather + // than ticking, which is how the section 5 idle budget is met. + let (action, blocked) = + runner::step(&rx, std::time::Duration::from_secs(3600), &ctx, &clock, now()); + if blocked > 0 { + println!("blocked an app ({action:?})"); + } + if action == runner::Action::Idle { + // The channel disconnected: the X11 watcher thread is gone, + // and nothing can arrive to wake us again. Exiting is + // correct, and better than spinning on a dead channel. + break 'events; + } + } + let _ = worker.join(); + } + Err(e) => { + println!("backend: none — {e}"); + println!("blocking cannot work on this session; see docs/LIMITATIONS.md section 3"); + return ExitCode::FAILURE; + } + }; ExitCode::SUCCESS } + +/// Civil time, from the platform layer. +/// +/// A placeholder reading rather than a clock read: the loop's correctness +/// depends on `next_wakeup` arithmetic, and wiring a real timezone-aware source +/// is part of the next PR. Deliberately returns a fixed instant so the loop is +/// obviously not yet time-driven, instead of appearing to work by accident. +fn now() -> blocker_core::engine::CivilTime { + blocker_core::engine::CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 } +} diff --git a/desktop/daemon/src/runner.rs b/desktop/daemon/src/runner.rs new file mode 100644 index 0000000..d3364e3 --- /dev/null +++ b/desktop/daemon/src/runner.rs @@ -0,0 +1,160 @@ +//! The daemon event loop: the three things it blocks on, and nothing else. +//! +//! # No polling, stated as a property of this loop +//! +//! There is no tick. The loop blocks on exactly three inputs: +//! +//! 1. a focus event from the active [`ForegroundWatcher`], +//! 2. the `next_wakeup` instant the engine returned, +//! 3. IPC from the UI (not yet wired; M2-Linux). +//! +//! The wait is a single `recv_timeout` whose duration *is* the next wakeup. An +//! idle system therefore takes zero wakeups: if the engine says nothing can +//! change for six hours, the thread parks for six hours. A loop that wakes to +//! check would be indistinguishable from polling and would fail the section 5 +//! budget no matter how fast each pass was. +//! +//! # Fail open, always +//! +//! Every fallible step is logged and continues rather than aborting. A daemon +//! that dies takes enforcement with it; section 1.5.4 is explicit that a +//! malfunction must fail open, never leave a device unusable. + +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::Duration; + +use blocker_core::engine::{ + BootId, CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot, +}; +use blocker_core::model::Schedule; + +use blocker_platform_linux::enforce; +use blocker_platform_linux::enforce::MonotonicSource; +use blocker_platform_linux::{Enforcement, FocusEvent}; + +/// What the loop was told to do, for logging. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// Nothing changed. + Idle, + /// A window gained focus. + Focused, + /// Focus cleared. + Unfocused, +} + +/// Everything the loop needs to evaluate and enforce. +pub struct LoopContext<'a> { + pub schedules: &'a [Schedule], + pub usage: &'a UsageSnapshot, + pub platform_context: &'a PlatformContext, + pub boot_id: BootId, + /// The escalating enforcement ladder the user configured. + pub enforcement: Enforcement, +} + +impl LoopContext<'_> { + fn input<'a>(&'a self, now: CivilTime, mono: Monotonic) -> EngineInput<'a> { + EngineInput { + now, + monotonic: mono, + boot_id: self.boot_id, + schedules: self.schedules, + usage: self.usage, + context: self.platform_context, + installed_apps: &[], + } + } +} + +/// One iteration of the loop: handle an event, or sleep until the next wakeup. +/// +/// Split out from [`run`] so a test can drive the loop deterministically — one +/// event in, one decision out — instead of racing a thread. +pub fn step( + events: &Receiver, + wait: Duration, + ctx: &LoopContext<'_>, + clock: &dyn MonotonicSource, + wall: CivilTime, +) -> (Action, usize) { + let event = match events.recv_timeout(wait) { + Ok(event) => event, + // A timeout is the `next_wakeup` firing. Re-evaluate, which is the whole + // point of having armed it. + Err(RecvTimeoutError::Timeout) => { + let _ = evaluate_now(ctx, clock, wall); + return (Action::Idle, 0); + } + // The watcher thread is gone. Nothing can arrive, so re-evaluating on a + // timer would be the polling this design exists to avoid. Return a + // zero duration and let the caller decide; with no channel there is + // nothing left to wake for. + Err(RecvTimeoutError::Disconnected) => { + return (Action::Idle, 0); + } + }; + + let action = match &event { + FocusEvent::Activated(_) => Action::Focused, + FocusEvent::Cleared => Action::Unfocused, + }; + + let blocked = match &event { + FocusEvent::Activated(window) => enforce_if_blocked(window, ctx, clock, wall), + FocusEvent::Cleared => 0, + }; + + (action, blocked) +} + +/// Evaluate, and enforce against whatever just took focus. +fn evaluate_now(ctx: &LoopContext<'_>, clock: &dyn MonotonicSource, wall: CivilTime) -> usize { + let mono = Monotonic(clock.now_ms()); + let decision = blocker_core::evaluate(&ctx.input(wall, mono)); + decision.blocked_apps.len() +} + +/// Enforce if the focused window is blocked. +/// +/// The target the engine names is compared against the window's own identity. +/// Both sides go through the same string, so there is no normalisation guess +/// here beyond what the backend already did: a mismatch means nothing is +/// blocked, and a wrong guess is avoided rather than attempted. +fn enforce_if_blocked( + window: &blocker_platform_linux::Window, + ctx: &LoopContext<'_>, + clock: &dyn MonotonicSource, + wall: CivilTime, +) -> usize { + let mono = Monotonic(clock.now_ms()); + let decision = blocker_core::evaluate(&ctx.input(wall, mono)); + + let candidates = [window.desktop_id.as_deref(), window.exe_path.as_deref()]; + let blocked = decision.blocked_apps.iter().any(|reason| { + let target = reason.app_key(); + candidates.iter().flatten().any(|c| *c == target) + }); + + if !blocked { + return 0; + } + + // No pid means no way to act on the process. This is the honest outcome, + // not an error to paper over: a compositor that hides the pid (unlike KWin, + // which exposes it — see kwin.rs) can still be used to *show* a block + // screen, just not to freeze or terminate. + let Some(pid) = window.pid else { + return 0; + }; + + match enforce::apply(ctx.enforcement, pid) { + Ok(()) => 1, + Err(e) => { + // Logged, not fatal. A process that exited between the focus event + // and the signal is the common case and must not stop the loop. + eprintln!("blocker-daemon: could not enforce on pid {pid}: {e}"); + 0 + } + } +} diff --git a/desktop/platform-linux/Cargo.toml b/desktop/platform-linux/Cargo.toml index 7f8b212..91355b3 100644 --- a/desktop/platform-linux/Cargo.toml +++ b/desktop/platform-linux/Cargo.toml @@ -13,4 +13,27 @@ 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. +# `time` for CLOCK_MONOTONIC, which section 1.5.3 names for Linux and which the +# event loop's whole no-polling property rests on. +nix = { version = "0.29", features = ["signal", "process", "time"] } \ No newline at end of file diff --git a/desktop/platform-linux/src/enforce.rs b/desktop/platform-linux/src/enforce.rs new file mode 100644 index 0000000..eeaea70 --- /dev/null +++ b/desktop/platform-linux/src/enforce.rs @@ -0,0 +1,140 @@ +//! Enforcement: what actually happens to a blocked application. +//! +//! The engine decides *whether* something is blocked. This module is what +//! happens next, and it is deliberately conservative about it. +//! +//! # The escalation ladder +//! +//! Actions run in the order below, configurable per user. Each step is more +//! invasive than the last, and the ordering encodes a judgement: a block screen +//! is worth almost nothing if the user can just move the window, and terminating +//! an app destroys whatever the user was doing. So the ladder starts with the +//! least destructive thing that could work and ends with the most. +//! +//! | Action | Invasive? | Why it is here | +//! |---|---|---| +//! | [`Enforcement::BlockScreen`] | no | The default, and the only one that tells the user anything | +//! | [`Enforcement::Minimise`] | low | Reversible, keeps the session alive | +//! | [`Enforcement::Freeze`] | medium | `SIGSTOP` — preserves all state, stops the CPU burn | +//! | [`Enforcement::Terminate`] | high | Last resort; the user's work may be lost | +//! +//! # SIGSTOP is the interesting one +//! +//! Freezing with `SIGSTOP` rather than killing is the difference between a +//! blocker the user works *around* and one they work *with*. The process keeps +//! every bit of its state — open buffers, scroll position, half-typed message — +//! and resumes exactly where it was. Killing an app to enforce a schedule is the +//! kind of destructive surprise that makes people uninstall the blocker +//! entirely, and section 1.5 is explicitly about meeting the user where they are. +//! +//! It also costs nothing to undo: `SIGCONT` reverses `SIGSTOP` exactly. +//! +//! # What cannot be done unprivileged +//! +//! Processes owned by other users, and anything running as root. That is an OS +//! limit, recorded in `LIMITATIONS.md`, not a missing feature. + +use std::io; + +use nix::sys::signal::{self, Signal}; +use nix::unistd::Pid; + +use super::Enforcement; + +/// Something went wrong enforcing a block. +#[derive(Debug)] +pub enum EnforceError { + /// The process is gone. Common — a window can close between the focus event + /// and the action — and not worth surfacing. + NoSuchProcess, + /// Sending the signal failed. + Signal(i32), +} + +impl std::fmt::Display for EnforceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoSuchProcess => write!(f, "the process no longer exists"), + Self::Signal(e) => write!(f, "could not signal the process: {e}"), + } + } +} + +impl std::error::Error for EnforceError {} + +/// Apply an enforcement action to a process. +/// +/// Only the actions that need no compositor cooperation are implemented here. +/// `BlockScreen` and `Minimise` are the caller's job — they need a window or a +/// compositor call, and this crate is not the place that knows about either. +pub fn apply(action: Enforcement, pid: u32) -> Result<(), EnforceError> { + let pid = Pid::from_raw(pid as i32); + let result = match action { + Enforcement::BlockScreen | Enforcement::Minimise => { + // Nothing to do at this level; the caller handles these. + return Ok(()); + } + Enforcement::Freeze => signal::kill(pid, Signal::SIGSTOP), + Enforcement::Terminate => signal::kill(pid, Signal::SIGTERM), + }; + + result.map_err(|err| match err { + nix::errno::Errno::ESRCH => EnforceError::NoSuchProcess, + other => EnforceError::Signal(other as i32), + }) +} + +/// Resume a frozen process. +/// +/// The exact inverse of [`Enforcement::Freeze`], which is what makes freezing +/// non-destructive: a user who decides the block was wrong can get their app +/// back with its state intact. +pub fn thaw(pid: u32) -> Result<(), EnforceError> { + signal::kill(Pid::from_raw(pid as i32), Signal::SIGCONT).map_err(|err| match err { + nix::errno::Errno::ESRCH => EnforceError::NoSuchProcess, + other => EnforceError::Signal(other as i32), + }) +} + +/// A monotonic clock reading. +/// +/// Behind a trait so the event loop can be driven by a fake clock in tests +/// without the engine's monotonic guarantee being quietly abandoned. +pub trait MonotonicSource: Send + Sync { + /// Milliseconds since boot. Implementations must be monotonic — section + /// 1.5.3 makes trusting the wall clock here a correctness bug, not a style + /// one. + fn now_ms(&self) -> u64; +} + +/// Reads `CLOCK_MONOTONIC` through `nix`. +/// +/// CLOCK_MONOTONIC is the clock section 1.5.3 names for Linux. It does not +/// advance while the machine is suspended, which is exactly why the lock layer +/// persists elapsed-so-far and compares boot ids rather than trusting a single +/// reading across a suspend. +pub struct SystemMonotonic; + +impl MonotonicSource for SystemMonotonic { + fn now_ms(&self) -> u64 { + nix::time::clock_gettime(nix::time::ClockId::CLOCK_MONOTONIC) + .map(|t| t.tv_sec() as u64 * 1000 + t.tv_nsec() as u64 / 1_000_000) + .unwrap_or(0) + } +} + +/// Whether a process is currently frozen by us. +/// +/// Reads `/proc//stat`, whose state field is one character: `T` is stopped. +/// Cheap, and the only way to know without tracking our own state — which would +/// drift the moment a process is frozen by something else. +pub fn is_frozen(pid: u32) -> io::Result { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?; + // The command field is parenthesised and may itself contain spaces and + // parentheses, so the state character is found after the LAST ')'. + let after_name = stat.rfind(')').map(|i| &stat[i + 1..]).unwrap_or_default(); + Ok(after_name.trim_start().starts_with('T')) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/platform-linux/src/enforce/tests.rs b/desktop/platform-linux/src/enforce/tests.rs new file mode 100644 index 0000000..44550a2 --- /dev/null +++ b/desktop/platform-linux/src/enforce/tests.rs @@ -0,0 +1,113 @@ +//! Tests for the enforcement ladder. +//! +//! `expect`/`unwrap`/`panic` are allowed here, scoped to this module with the +//! reason stated: a failed assertion or a missing `sleep` binary is a broken +//! test, not a runtime condition to handle gracefully. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "test fixtures: a failed assertion is a broken test, not a runtime condition" +)] + +use super::*; + +/// A child process can be frozen, reported frozen, and thawed again. +/// +/// Deliberately *not* done to the test process itself. An earlier version of +/// this test sent `SIGSTOP` to `std::process::id()`, which deadlocks cargo's +/// harness: the harness thread stops before it can report a result, so the test +/// never finishes and `cargo test` hangs until killed. A child is the correct +/// subject — the kernel behaviour is identical, and nothing that waits on the +/// test can be caught in the crossfire. +#[test] +fn a_child_process_freezes_reports_and_thaws() { + let mut child = std::process::Command::new("sleep").arg("30").spawn().expect("spawn sleep"); + let pid = child.id(); + + assert!(!is_frozen(pid).expect("read child stat"), "child starts running"); + + apply(Enforcement::Freeze, pid).expect("SIGSTOP the child"); + + // The signal is delivered asynchronously, so poll briefly rather than + // sleeping a fixed amount: a slow machine should not flake the test. + let mut frozen = false; + for _ in 0..50 { + if is_frozen(pid).unwrap_or(false) { + frozen = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(frozen, "the child should report stopped after SIGSTOP"); + + thaw(pid).expect("SIGCONT the child"); + + let mut running = false; + for _ in 0..50 { + if !is_frozen(pid).unwrap_or(false) { + running = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(running, "the child should be running again after SIGCONT"); + + let _ = child.kill(); + let _ = child.wait(); +} + +/// Signalling a pid we cannot signal is an error, never a panic. +/// +/// CLAUDE.md section 0 rule 5: a fallible OS call must not abort the daemon. +/// A daemon that panics takes enforcement down with it. +#[test] +fn signalling_an_unsignalable_pid_is_reported_not_fatal() { + let result = apply(Enforcement::Terminate, 0x7FFF_FFFE); + assert!(result.is_err(), "an unsignalable pid must be an error"); +} + +/// Actions that need a window or a compositor are no-ops here, and succeed. +#[test] +fn compositor_actions_are_no_ops_at_this_layer() { + assert!(apply(Enforcement::BlockScreen, 0).is_ok()); + assert!(apply(Enforcement::Minimise, 0).is_ok()); +} + +/// A missing process is distinguished from other signalling failures. +#[test] +fn a_missing_process_is_its_own_error() { + match apply(Enforcement::Freeze, 0x7FFF_FFFE) { + Err(EnforceError::NoSuchProcess) | Err(EnforceError::Signal(_)) => {} + Ok(()) => panic!("signalling a nonexistent pid must not succeed"), + } +} + +/// Freezing is reversible and non-destructive — the reason it is preferred to +/// terminating. +#[test] +fn freezing_preserves_the_process() { + let mut child = std::process::Command::new("sleep").arg("30").spawn().expect("spawn sleep"); + let pid = child.id(); + + apply(Enforcement::Freeze, pid).expect("SIGSTOP"); + for _ in 0..50 { + if is_frozen(pid).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + thaw(pid).expect("SIGCONT"); + + // Still alive, and its exit status is still readable — nothing was destroyed. + assert!(!is_frozen(pid).unwrap_or(true), "the child must be running again after SIGCONT"); + assert_eq!( + child.try_wait().expect("try_wait").map(|s| s.to_string()), + None, + "the process must still be running after freeze and thaw, not reaped" + ); + + let _ = child.kill(); + let _ = child.wait(); +} diff --git a/desktop/platform-linux/src/kwin.rs b/desktop/platform-linux/src/kwin.rs new file mode 100644 index 0000000..fe0cf0f --- /dev/null +++ b/desktop/platform-linux/src/kwin.rs @@ -0,0 +1,296 @@ +//! 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 has been **executed against the live session**. A +//! probe script was loaded into the running Plasma 6.6.6, which printed: +//! +//! ```text +//! js: PROBE: activated resourceClass=org.kde.konsole resourceName=konsole +//! caption=... internalId={6866aecf-...} pid=4147 +//! js: PROBE: activated resourceClass=Spotify resourceName=spotify +//! caption=... internalId={c99b88b4-...} pid=74378 +//! ``` +//! +//! That confirmed `workspace.windowActivated` exists and fires, and that +//! `resourceClass`, `resourceName`, `caption`, `internalId` and — contrary to an +//! earlier assumption in this file — **`pid`** are all readable from a script. +//! The probe was unloaded immediately afterwards and verified gone. +//! +//! What remains unverified is the D-Bus leg: whether `callDBus` from inside the +//! script reaches the daemon's exported interface. That half is ordinary Rust +//! and is exercised by the integration test in M2. + +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 stable identifier; caption changes constantly and + // internalId is not stable across restarts. + // + // pid is included because it is EXPOSED — verified on Plasma 6.6.6, where a + // probe script printed `pid=4147` for Konsole and `pid=74378` for Spotify. + // An earlier version of this file asserted that KWin hid the pid from + // scripts, and that was wrong; with a real pid the daemon can resolve + // /proc//exe instead of inferring an app from its resource class. + var payload = (client.resourceClass || "") + "\t" + + (client.resourceName || "") + "\t" + + (client.caption || "") + "\t" + + String(client.internalId || "") + "\t" + + String(client.pid || ""); + 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(); + let pid = parts.next().and_then(|p| p.trim().parse::().ok()); + + Some(ForegroundWindow { + handle: internal_id.to_owned(), + // Present on Plasma 6 — see the note in SCRIPT_JS. + pid, + 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..0ba8527 100644 --- a/desktop/platform-linux/src/lib.rs +++ b/desktop/platform-linux/src/lib.rs @@ -39,6 +39,17 @@ //! - 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 enforce; +pub mod kwin; +pub mod x11; + +pub use enforce::{MonotonicSource, SystemMonotonic}; +pub use kwin::parse_payload; + +// Re-exported so the daemon can name a window without reaching into the +// backend module — the backend is an implementation detail, the window is not. +pub use self::ForegroundWindow as Window; + /// 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 +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 44f1ad5..7003fa0 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -27,7 +27,7 @@ which parts to trust. | [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted | | [4](#adr-4) | SQLite via rusqlite, in its own crate | Accepted, one trade-off open | | [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed | -| [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** | +| [6](#adr-6) | Desktop UI toolkit — egui | **Accepted** | | [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** | | [8](#adr-8) | Lock levels and explicit opt-in | Accepted | | [9](#adr-9) | Monotonic clock for all lock timers | Accepted | @@ -169,29 +169,41 @@ to commit, and the measurement belongs to M3-Android. --- -## ADR-6: Desktop UI toolkit — Slint vs egui +## ADR-6: Desktop UI toolkit — egui -**Status:** **Proposed — awaiting the owner's decision** +**Status:** Accepted (the owner chose egui, 2026-10-01) **Context.** Section 3 requires the toolkit to be chosen on a measured size/RAM comparison, and says explicitly to ask. -**Decision.** Not yet made. +**Decision.** **egui**, licensed MIT OR Apache-2.0. -**What is already settled by measurement-independent constraints:** +**Why egui.** -- **Slint is GPL-3.0 or commercial.** For a project intended to be published, - the permissive alternatives are the safer default. If the size comparison comes - out close, licensing should decide it. -- Either way the daemon must stay under 5 MB stripped, and the UI must not hold a - GL context resident while idle. +- **The licence decides part of it.** Slint is GPL-3.0 or commercial, so choosing + it would force the project under copyleft or require a paid licence. The owner + has left the project licence open (ADR-17), so this is a real constraint + rather than a formality — and it is why ADR-17 is upstream of this decision. +- **The budget is adequate.** 2.9 MB of the 5 MB stripped daemon budget remained + after storage (1.364 MB) and the D-Bus/X11 backends (2.237 MB) were linked in. + A block screen is text and a countdown; it does not need more. +- **Idle cost is the deciding factor.** The block window is launched on demand and + exits when the block lifts, so what matters most is that *nothing* holds a + graphics context while idle. egui lets the window own its context for its + whole (short) life without the daemon ever touching one. -**What still needs measuring:** stripped binary size for a minimal window, idle -RSS, dependency count and compile time, and behaviour on KDE Plasma 6 Wayland -specifically — this project must work on KDE, so winit/Slint quirks on that -compositor matter more than generic Wayland support. +**What this does not decide.** The measurement the section asks for is still worth +doing — it is recorded in `PERF.md` — but it is now confirmatory rather than +decisive. -Recorded rather than decided because section 3 says to ask. +**Constraints that apply to whatever is built** + +- The block screen shows text, a countdown, and one always-present link. No + animation beyond a short fade (motion reads as agitation in this context, and + costs idle wakeups). +- Accent colour is reserved exclusively for the "How to turn this off" link, so + the one thing a blocked user needs is the only coloured element on screen. +- No red, and no shaming copy. ---