From a79d26298631974a8e49f50b53be969716203318 Mon Sep 17 00:00:00 2001 From: SellPL Date: Thu, 1 Oct 2026 20:21:42 +0300 Subject: [PATCH 1/3] feat(linux): add KWin and X11 focus-detection backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first enforcement-path code. CLAUDE.md section 3 names `x11rb`, `zbus` and `nix` for Linux explicitly, so all three are approved and this needed no new dependency decision. **KWin backend (primary).** Wayland has no portable focus API (section 6.3), and this machine runs Plasma 6.6.6 on Wayland — an X11-only design would be blind to most of the user's own desktop. KWin will not push focus events to an arbitrary subscriber, so this loads a JavaScript snippet via `org.kde.KWin /Scripting`, which hooks `workspace.windowActivated` and calls back over D-Bus with KWin's `callDBus` helper. The daemon owns the bus name, so KWin always has somewhere to call; if the daemon dies, `callDBus` fails silently in the script and the compositor is left alone — the fail-open behaviour section 1.5.4 requires. The D-Bus surface was introspected on the live session, not read from docs: $ busctl --user introspect org.kde.KWin /Scripting org.kde.kwin.Scripting .loadScript method s->i .start method - .unloadScript method s->b **X11 backend (fallback).** `_NET_ACTIVE_WINDOW` property subscription, resolved through `_NET_WM_PID` to `/proc//exe`. Blind to native Wayland apps under a Wayland session, which is why it is the fallback and not the primary. It is also the path that can be tested headless with Xvfb, so it is what CI would exercise. **No polling anywhere.** Both backends park in a subscription: `callDBus` from the compositor, `poll_for_event` on the X connection. Nothing in either module has a timer, which is what keeps the section 5 idle budget at zero. **zbus is blocking on async-io, deliberately.** `tokio` was available and was rejected: a reactor thread is measurable idle cost against a zero-wakeup budget, and several hundred KB against a 5 MB binary. Noted in the manifest so the decision is visible next to the version pin. **Size, measured rather than assumed:** the stripped daemon goes 1.364 MB -> **2.237 MB**, so D-Bus, X11 and the signal handling cost ~870 KB. 2.9 MB of the 5 MB budget remains. **What is verified, and what is not.** The D-Bus interface is introspected from the live session. The KWin *JavaScript* API — `workspace.windowActivated`, `client.resourceClass` — is written against the documented Plasma 6 scripting surface and has **not** been executed, because doing so would load a script into the user's running compositor. That is the honest status and LIMITATIONS.md records it. Nothing here is wired into the event loop yet, so no window is actually blocked by this PR. Several API details cost real time and are worth recording: X11 atoms are interned with a round trip at connect time rather than passed as an unrelated constant, which silently queries the wrong property; the root event filter matches on the `_NET_ACTIVE_WINDOW` *atom* rather than the property change type; and `rusqlite`/`zbus`/`x11rb` all differ from their documented shapes in ways that only compile once found. Co-Authored-By: Claude Code --- Cargo.lock | 889 ++++++++++++++++++++++++++++- desktop/daemon/Cargo.toml | 3 +- desktop/daemon/src/main.rs | 11 + desktop/platform-linux/Cargo.toml | 23 +- desktop/platform-linux/src/kwin.rs | 276 +++++++++ desktop/platform-linux/src/lib.rs | 3 + desktop/platform-linux/src/x11.rs | 370 ++++++++++++ 7 files changed, 1572 insertions(+), 3 deletions(-) create mode 100644 desktop/platform-linux/src/kwin.rs create mode 100644 desktop/platform-linux/src/x11.rs 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 +} From 9f1acb1421b22ad8dded19c58736cf25b6300e23 Mon Sep 17 00:00:00 2001 From: SellPL Date: Thu, 1 Oct 2026 21:06:07 +0300 Subject: [PATCH 2/3] test(linux): verify the KWin script against the live Plasma session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KWin JavaScript had never been executed. It is now. A probe script was loaded into the running Plasma 6.6.6 compositor, which reported: 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 The probe was unloaded immediately afterwards and `isScriptLoaded` confirmed `false`. **This proved one thing I had wrong.** The file asserted that "KWin does not expose the PID to scripts, so resolution happens on the Rust side from resource_class." It does expose it — `pid=4147` for Konsole, `pid=74378` for Spotify. The payload now carries it, and the daemon can resolve `/proc//exe` directly instead of inferring an application from its resource class. Inferring from `resourceClass` would have been a guess, and a wrong guess blocks the wrong application. Also confirmed: `workspace.windowActivated` exists and fires on real window changes, and `resourceClass`, `resourceName`, `caption` and `internalId` are all readable from a script. **Still unverified, and stated as such in the module:** the D-Bus leg — whether `callDBus` from inside the script reaches an exported daemon interface. That half is ordinary Rust and needs no compositor, so it is exercised by an integration test rather than by touching a live session again. Also records ADR-6 as accepted: the owner chose **egui** for the block screen, so the UI toolkit is no longer a blocker. Reasoning is in the ADR, including the licence entanglement — Slint is GPL-3.0, which is why ADR-17 sits upstream of this decision. Co-Authored-By: Claude Code --- desktop/platform-linux/src/kwin.rs | 42 ++++++++++++++++++++++-------- desktop/platform-linux/src/lib.rs | 2 ++ docs/DECISIONS.md | 42 +++++++++++++++++++----------- 3 files changed, 60 insertions(+), 26 deletions(-) diff --git a/desktop/platform-linux/src/kwin.rs b/desktop/platform-linux/src/kwin.rs index 8d857b9..fe0cf0f 100644 --- a/desktop/platform-linux/src/kwin.rs +++ b/desktop/platform-linux/src/kwin.rs @@ -47,11 +47,24 @@ //! .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. +//! 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; @@ -92,12 +105,19 @@ function reportFocus(client) { "Focused", "", function(err) {}, 0); return; } - // resourceClass is the most stable identifier KWin offers; caption changes - // constantly and internalId is not stable across restarts. + // 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 || ""); + String(client.internalId || "") + "\t" + + String(client.pid || ""); callDBus("vu.valiq.blocker", "/Watcher", "vu.valiq.blocker.Watcher", "Focused", payload, function(err) {}, 0); } @@ -261,12 +281,12 @@ pub fn parse_payload(payload: &str) -> Option { 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(), - // 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, + // 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()) diff --git a/desktop/platform-linux/src/lib.rs b/desktop/platform-linux/src/lib.rs index 2343550..80a93ee 100644 --- a/desktop/platform-linux/src/lib.rs +++ b/desktop/platform-linux/src/lib.rs @@ -40,6 +40,8 @@ //! 6.1 requires this be documented honestly rather than implied otherwise. pub mod kwin; + +pub use kwin::parse_payload; pub mod x11; /// A window that has taken focus, resolved to something a schedule can match. 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. --- From 8ae043658cc272a27a447914c21555043ef06318 Mon Sep 17 00:00:00 2001 From: SellPL Date: Thu, 1 Oct 2026 22:20:23 +0300 Subject: [PATCH 3/3] feat(linux): add enforcement actions and the daemon event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon now has the two pieces that turn a decision into an outcome: what to do to a blocked process, and the loop that connects focus events to the engine. **The enforcement ladder is escalating and non-destructive by default.** `SIGSTOP` freezes an app; it does not kill it. That is a deliberate choice, and the reason is section 1.5: freezing keeps every bit of the process's state — open buffers, scroll position, half-typed message — and `SIGCONT` reverses it exactly. Killing an app to enforce a schedule is the kind of destructive surprise that makes people uninstall the blocker, which defeats the entire purpose. Actions run `BlockScreen` → `Minimise` → `Freeze` → `Terminate`, each more invasive than the last. The ordering encodes a judgement: a block screen is worth almost nothing if the user can just move the window, and terminating destroys the user's work. Start with the least destructive thing that could work. **The event loop has no tick.** It blocks on exactly three things — a focus event, the engine's `next_wakeup`, and IPC — and the wait duration *is* the next wakeup. An idle system parks rather than polling, which is how the section 5 zero-wakeup budget is met. Two threads, both blocked, both costing nothing. **The monotonic clock moved into the platform layer**, behind a trait. The daemon should observe the OS *through* the platform crate, not reach for `nix` directly, and the trait is what lets tests drive the loop with a fake clock without quietly abandoning section 1.5.3's guarantee that locks never touch the wall clock. **Five enforcement tests, all against real processes.** They spawn a child, SIGSTOP it, read `/proc//stat` to confirm the kernel really stopped it, SIGCONT it, and confirm it is alive and unreaped. The first version of that test sent `SIGSTOP` to the test process itself, which **deadlocked cargo's harness** — the harness thread stops before it can report a result, so `cargo test` hangs until killed. A child is the correct subject: same kernel behaviour, and nothing waiting on the test can be caught in the crossfire. **What is not wired yet, stated plainly.** The loop runs on the X11 backend 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. The startup line says which backend was chosen and what that costs, rather than pretending. On a Wayland session this means XWayland clients only. Verified: clippy `-D warnings` clean, `fmt --check` clean, 55 tests passing, stripped daemon 2.237 MB against the 5 MB budget. Co-Authored-By: Claude Code --- desktop/daemon/src/main.rs | 75 ++++++++- desktop/daemon/src/runner.rs | 160 ++++++++++++++++++++ desktop/platform-linux/Cargo.toml | 4 +- desktop/platform-linux/src/enforce.rs | 140 +++++++++++++++++ desktop/platform-linux/src/enforce/tests.rs | 113 ++++++++++++++ desktop/platform-linux/src/lib.rs | 10 +- 6 files changed, 498 insertions(+), 4 deletions(-) create mode 100644 desktop/daemon/src/runner.rs create mode 100644 desktop/platform-linux/src/enforce.rs create mode 100644 desktop/platform-linux/src/enforce/tests.rs diff --git a/desktop/daemon/src/main.rs b/desktop/daemon/src/main.rs index e946c4e..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) => { @@ -98,7 +103,75 @@ fn main() -> ExitCode { 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"); + // 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 c62484f..91355b3 100644 --- a/desktop/platform-linux/Cargo.toml +++ b/desktop/platform-linux/Cargo.toml @@ -34,4 +34,6 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block 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 +# `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/lib.rs b/desktop/platform-linux/src/lib.rs index 80a93ee..0ba8527 100644 --- a/desktop/platform-linux/src/lib.rs +++ b/desktop/platform-linux/src/lib.rs @@ -39,11 +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 use kwin::parse_payload; 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 {