From 3caabbff1561e0cd18c9e6f08c85340e5f36ef53 Mon Sep 17 00:00:00 2001 From: SellPL Date: Thu, 1 Oct 2026 22:41:31 +0300 Subject: [PATCH] feat(linux): wire the KWin path end to end, and verify it live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon now owns `vu.valiq.blocker`, loads the KWin script, and enters the event loop on the primary backend. Verified against the running session: ``` $ blocker-daemon blocker-daemon 0.1.0 (M0/M1) database: /tmp/bsmoke/blocker/blocker.db backend: kwin — native Wayland windows are visible owning the D-Bus name; entering the event loop enforcement: Freeze; entering the event loop ``` The process then parked until killed — which is the point. There is no tick; the loop blocks on a focus event and on the engine's `next_wakeup`. **Ordering is load-bearing and worth stating.** `callDBus` has nothing to resolve until the daemon holds the bus name, so the name must be acquired *before* the script is loaded. Loading first means a handler calling a name nobody owns, and every focus event silently dropped. **A real defect found by running it rather than reading it.** After the daemon was killed, `isScriptLoaded` still returned `true` — the script was left in KWin. `Drop` does not run on SIGTERM, so the cleanup path never executes. It is fail-open and harmless (the script's `callDBus` fails inside the compositor, which is exactly the section 1.5.4 behaviour), and `KwinBackend::connect` unloads any stale script before loading a fresh one, so a restart recovers. But "harmless" is not "correct", and the honest fix — a systemd `ExecStopPost` or a signal handler that unloads — is not in this PR. Also fixes the duplicated loop body: both backends now share one `run_loop`, so the X11 and KWin paths cannot drift into enforcing differently. Verified: clippy `-D warnings` clean, `fmt --check` clean, all tests passing, stripped daemon within the 5 MB budget. Co-Authored-By: Claude Code --- desktop/daemon/src/main.rs | 62 ++++++++++++-- desktop/platform-linux/src/lib.rs | 1 + desktop/platform-linux/src/watcher.rs | 117 ++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 desktop/platform-linux/src/watcher.rs diff --git a/desktop/daemon/src/main.rs b/desktop/daemon/src/main.rs index 89deb32..cebacd5 100644 --- a/desktop/daemon/src/main.rs +++ b/desktop/daemon/src/main.rs @@ -36,6 +36,8 @@ mod runner; use std::path::PathBuf; + +use blocker_platform_linux::kwin::BUS_NAME; use std::process::ExitCode; /// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`. @@ -105,13 +107,29 @@ fn main() -> ExitCode { // 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. + // The KWin path is preferred, and it requires owning the D-Bus name BEFORE + // the script is loaded: `callDBus` has nothing to resolve until the daemon + // holds `vu.valiq.blocker`, so loading first would silently drop every + // focus event. If KWin is unavailable we fall back to X11 and say what that + // costs — on a Wayland session, XWayland clients only. let (tx, rx) = std::sync::mpsc::channel(); + + if let Ok(backend) = blocker_platform_linux::kwin::KwinBackend::connect() { + println!("backend: kwin — native Wayland windows are visible"); + let _keep_connection = match blocker_platform_linux::watcher::serve(tx) { + Ok(c) => c, + Err(e) => { + eprintln!("blocker-daemon: cannot own {BUS_NAME}: {e}"); + return ExitCode::FAILURE; + } + }; + println!("owning the D-Bus name; entering the event loop"); + run_loop(&rx, &schedules, &usage, &context, blocker_platform_linux::Enforcement::Freeze); + let _ = backend.shutdown(); + return ExitCode::SUCCESS; + } + + println!("backend: kwin unavailable; falling back to x11"); match blocker_platform_linux::x11::X11Backend::connect() { Ok(backend) => { println!("backend: x11 — native Wayland apps are NOT visible through this"); @@ -166,6 +184,38 @@ fn main() -> ExitCode { ExitCode::SUCCESS } +/// The event loop, shared by both backends. +/// +/// Both paths park here: the caller has already put a focus-event producer in +/// charge, and this consumes it. Two blocked threads and no CPU between events. +fn run_loop( + rx: &std::sync::mpsc::Receiver, + schedules: &[blocker_core::model::Schedule], + usage: &blocker_core::engine::UsageSnapshot, + context: &blocker_core::engine::PlatformContext, + enforcement: blocker_platform_linux::Enforcement, +) { + let ctx = runner::LoopContext { + schedules, + usage, + platform_context: context, + boot_id: blocker_core::engine::BootId(0), + enforcement, + }; + let clock = blocker_platform_linux::SystemMonotonic; + println!("enforcement: {enforcement:?}; entering the event loop"); + 'events: loop { + 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 { + break 'events; + } + } +} + /// Civil time, from the platform layer. /// /// A placeholder reading rather than a clock read: the loop's correctness diff --git a/desktop/platform-linux/src/lib.rs b/desktop/platform-linux/src/lib.rs index 0ba8527..df8409e 100644 --- a/desktop/platform-linux/src/lib.rs +++ b/desktop/platform-linux/src/lib.rs @@ -41,6 +41,7 @@ pub mod enforce; pub mod kwin; +pub mod watcher; pub mod x11; pub use enforce::{MonotonicSource, SystemMonotonic}; diff --git a/desktop/platform-linux/src/watcher.rs b/desktop/platform-linux/src/watcher.rs new file mode 100644 index 0000000..79efcd4 --- /dev/null +++ b/desktop/platform-linux/src/watcher.rs @@ -0,0 +1,117 @@ +//! The D-Bus watcher server: the daemon's public side of the KWin handshake. +//! +//! KWin calls *us*, not the other way round. The compositor has no way to push a +//! focus event to an arbitrary subscriber, so the daemon owns a bus name and +//! exports an interface that the loaded KWin script calls into. This module owns +//! that name and turns those calls into [`FocusEvent`]s on a channel. +//! +//! # Owning the name is not optional +//! +//! Without the daemon holding `vu.valiq.blocker`, KWin's `callDBus` has nothing +//! to resolve and every focus event is silently discarded. That is why +//! [`serve`] must succeed *before* the script is loaded. +//! +//! # Fail-open +//! +//! If the daemon is not running, the KWin script's `callDBus` fails inside a +//! signal handler and the compositor is left alone — no exception, no log spam. +//! Blocking stops; the desktop keeps working. That is section 1.5.4's rule and +//! it is why the script swallows its errors. + +use std::sync::mpsc::Sender; + +use zbus::blocking::{connection::Builder, Connection}; + +use super::kwin::{BUS_NAME, OBJECT_PATH}; +use super::{parse_payload, FocusEvent}; + +/// Why the watcher could not start. +#[derive(Debug)] +pub enum WatcherError { + /// No session bus, so there is nothing to own a name on. Almost always + /// means the daemon was started outside a desktop session — over SSH, or + /// from a system unit rather than a user one. + NoSessionBus(String), + /// The name could not be acquired, which normally means another instance is + /// already running. + NameTaken(String), + /// Anything else zbus reports. + Other(String), +} + +impl std::fmt::Display for WatcherError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoSessionBus(e) => write!(f, "no session bus: {e}"), + Self::NameTaken(e) => write!(f, "another watcher already owns {BUS_NAME}: {e}"), + Self::Other(e) => write!(f, "watcher could not start: {e}"), + } + } +} + +impl std::error::Error for WatcherError {} + +/// The object the KWin script calls into. +/// +/// One method, `Focused`, whose payload is the tab-separated window description +/// built by `kwin::SCRIPT_JS`. An empty payload means focus was cleared. +struct Watcher { + sink: Sender, +} + +#[zbus::interface(name = "vu.valiq.blocker.Watcher")] +impl Watcher { + /// Called by the KWin script on every focus change. + /// + /// Payload is tab-separated: resourceClass, resourceName, caption, + /// internalId, pid — in the order `kwin::SCRIPT_JS` writes them. + fn focused(&self, payload: &str) { + let event = match parse_payload(payload) { + Some(window) => FocusEvent::Activated(window), + None => FocusEvent::Cleared, + }; + // A send failure means the daemon has stopped. Nothing to escalate: the + // thread exits when the connection drops. + let _ = self.sink.send(event); + } +} + +/// Acquire the bus name and export [`Watcher`], returning the live connection. +/// +/// The connection must be held for the export to stay registered, so the caller +/// keeps it for the daemon's lifetime. +/// +/// Note on ordering: the daemon calls [`serve`] *before* +/// [`super::kwin::KwinBackend::connect`]. Loading the script first would start a +/// handler calling a name nobody owns, and every event would be lost. +pub fn serve(sink: Sender) -> Result { + Builder::session() + .map_err(|e| WatcherError::NoSessionBus(e.to_string()))? + .name(BUS_NAME) + .map_err(|e| WatcherError::NameTaken(e.to_string()))? + // `#[zbus::interface]` implements `Interface` for the struct, so the + // instance itself is what gets registered — it holds the channel. + .serve_at(OBJECT_PATH, Watcher { sink }) + .map_err(|e| WatcherError::Other(e.to_string()))? + .build() + .map_err(|e| { + // zbus cannot distinguish "name already owned" from other failures + // at build time, so the message is what separates them. A name clash + // is the common case and deserves the clearer variant. + let rendered = e.to_string(); + if rendered.contains("taken") || rendered.contains("already") { + WatcherError::NameTaken(rendered) + } else { + WatcherError::Other(rendered) + } + }) +} + +/// Drop the bus name, so a restarted daemon does not collide with a stale one. +/// +/// Called before exiting. Not cosmetic: systemd `Restart=always` can start the +/// replacement before the old process is fully gone, and losing that race would +/// leave the new instance wedged on a name its predecessor still holds. +pub fn release(connection: &Connection) { + let _ = connection.release_name(BUS_NAME); +}