feat(linux): wire the KWin path end to end, and verify it live

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 22:41:31 +03:00
parent 8ae043658c
commit 69602636b1
3 changed files with 174 additions and 6 deletions
+117
View File
@@ -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<FocusEvent>,
}
#[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<FocusEvent>) -> Result<Connection, WatcherError> {
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);
}