//! The KWin backend: focus detection on KDE Plasma, over D-Bus. //! //! # Why this exists at all //! //! Wayland has no portable API for "which window has focus" (CLAUDE.md §6.3). //! KDE exposes one, and this machine runs Plasma 6.6.6 on Wayland — so an //! X11-only design would be blind to most of the user's own desktop. This is the //! primary path; [`super::x11`] is the portable fallback, and a process-based //! watcher is the degraded last resort. //! //! # How the events get out //! //! KWin will not push focus events to an arbitrary subscriber. What it offers is //! a scripting interface: a JavaScript snippet loaded into the compositor, which //! hooks `workspace.windowActivated` and calls back over D-Bus via KWin's //! `callDBus` helper. //! //! So there are two D-Bus hops, and both are necessary: //! //! ```text //! KWin (JS) daemon (Rust) //! ──────────────────────────── ──────────────────────────── //! windowActivated(client) //! │ //! │ callDBus("vu.valiq.blocker", "/Watcher", "Watcher", "Focused", …) //! └──────────────────────────────────────────────────────▶ //! │ //! FocusEvent on a std channel //! ``` //! //! The daemon owns the bus name `vu.valiq.blocker`, so KWin always has somewhere //! to call. If the daemon dies, `callDBus` fails silently in the script and the //! compositor is left alone — which is the fail-open behaviour §1.5.4 requires. //! //! # What is verified, and what is not //! //! The D-Bus surface below was introspected on the live session, not read from //! documentation: //! //! ```text //! $ busctl --user introspect org.kde.KWin /Scripting org.kde.kwin.Scripting //! .loadScript method s i //! .loadScript method ss i //! .loadDeclarativeScript method s i //! .start method - - //! .unloadScript method s b //! .isScriptLoaded method s b //! ``` //! //! The KWin *JavaScript* API has been **executed against the live session**. A //! probe script was loaded into the running Plasma 6.6.6, which printed: //! //! ```text //! js: PROBE: activated resourceClass=org.kde.konsole resourceName=konsole //! caption=... internalId={6866aecf-...} pid=4147 //! js: PROBE: activated resourceClass=Spotify resourceName=spotify //! caption=... internalId={c99b88b4-...} pid=74378 //! ``` //! //! That confirmed `workspace.windowActivated` exists and fires, and that //! `resourceClass`, `resourceName`, `caption`, `internalId` and — contrary to an //! earlier assumption in this file — **`pid`** are all readable from a script. //! The probe was unloaded immediately afterwards and verified gone. //! //! What remains unverified is the D-Bus leg: whether `callDBus` from inside the //! script reaches the daemon's exported interface. That half is ordinary Rust //! and is exercised by the integration test in M2. use std::sync::mpsc::Sender; use super::{FocusEvent, ForegroundWatcher, ForegroundWindow}; use zbus::blocking::{Connection, Proxy}; /// The bus name the daemon owns, and which the KWin script calls. pub const BUS_NAME: &str = "vu.valiq.blocker"; /// The object path the KWin script calls methods on. pub const OBJECT_PATH: &str = "/Watcher"; /// The interface the daemon exports. pub const INTERFACE: &str = "vu.valiq.blocker.Watcher"; /// KWin's service name and the `Scripting` object behind it. const KWIN_SERVICE: &str = "org.kde.KWin"; const KWIN_PATH: &str = "/Scripting"; const KWIN_IFACE: &str = "org.kde.kwin.Scripting"; /// The script's package name, as registered with KWin. const SCRIPT_NAME: &str = "blocker-focus"; /// The JavaScript loaded into KWin. /// /// Kept as a `const` rather than a file on disk deliberately: KWin's /// `loadScript(path, name)` wants a path, and the daemon writes this to a /// temp file at startup. Embedding it means there is exactly one copy, it cannot /// drift from this file, and there is no install path to get wrong. /// /// The callback shape is the one KWin's `callDBus` expects — the last argument is /// a JS function receiving the reply. Errors are swallowed on purpose: if the /// daemon is not running, there is nothing useful for the compositor to do about /// it, and throwing inside a signal handler would spam the KWin log on every /// window change. const SCRIPT_JS: &str = r#" function reportFocus(client) { if (!client) { callDBus("vu.valiq.blocker", "/Watcher", "vu.valiq.blocker.Watcher", "Focused", "", function(err) {}, 0); return; } // resourceClass is the stable identifier; caption changes constantly and // internalId is not stable across restarts. // // pid is included because it is EXPOSED — verified on Plasma 6.6.6, where a // probe script printed `pid=4147` for Konsole and `pid=74378` for Spotify. // An earlier version of this file asserted that KWin hid the pid from // scripts, and that was wrong; with a real pid the daemon can resolve // /proc//exe instead of inferring an app from its resource class. var payload = (client.resourceClass || "") + "\t" + (client.resourceName || "") + "\t" + (client.caption || "") + "\t" + String(client.internalId || "") + "\t" + String(client.pid || ""); callDBus("vu.valiq.blocker", "/Watcher", "vu.valiq.blocker.Watcher", "Focused", payload, function(err) {}, 0); } workspace.windowActivated.connect(reportFocus); // Report whatever is already focused, so the daemon starts with a correct state // instead of waiting for the user to alt-tab. for (var i = 0; i < workspace.windowList().length; i++) { var w = workspace.windowList()[i]; if (w.active) { reportFocus(w); break; } } "#; /// Something went wrong talking to KWin. #[derive(Debug)] pub enum KwinError { /// No session bus. Usually means the daemon was started outside a desktop /// session — over SSH, or from a system unit rather than a user one. NoSessionBus(zbus::Error), /// The connection dropped. Disconnected(zbus::Error), /// `org.kde.KWin` did not answer, or the method returned an error. The most /// likely cause is that this is not KDE. NotKde, /// The script could not be written to a temporary file. ScriptIo(std::io::Error), } impl std::fmt::Display for KwinError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NoSessionBus(e) => write!(f, "no session bus: {e}"), Self::Disconnected(e) => write!(f, "D-Bus disconnected: {e}"), Self::NotKde => write!(f, "org.kde.KWin is not available on this session"), Self::ScriptIo(e) => write!(f, "could not stage the KWin script: {e}"), } } } impl std::error::Error for KwinError {} /// The KWin scripting backend. #[derive(Debug)] pub struct KwinBackend { connection: Connection, script_path: std::path::PathBuf, } impl KwinBackend { /// Load and start the focus script. /// /// Ordering matters: KWin requires `loadScript` before `start`, and /// `start` applies to *all* loaded scripts, so this is only safe because the /// daemon is the only thing loading scripts under this name. pub fn connect() -> Result { let connection = zbus::blocking::Connection::session().map_err(KwinError::NoSessionBus)?; let proxy = Proxy::new(&connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE) .map_err(|_| KwinError::NotKde)?; let script_path = stage_script().map_err(KwinError::ScriptIo)?; // A stale script from a previous run would be loaded already; drop it // first so `start` cannot run two copies of the same handler. let already: Option = proxy.call::<&str, _, bool>("isScriptLoaded", &(SCRIPT_NAME,)).ok(); if already == Some(true) { let _: std::result::Result = proxy.call("unloadScript", &(SCRIPT_NAME,)); } proxy .call::<&str, _, i32>("loadScript", &(script_path.to_string_lossy(), SCRIPT_NAME)) .map_err(|_| KwinError::NotKde)?; // `start` takes no arguments, so the body is `()` — and the reply type // has to be named, or the return value falls back to the never type. let started: zbus::Result<()> = proxy.call("start", &()); started.map_err(|_| KwinError::NotKde)?; Ok(Self { connection, script_path }) } /// Whether this session is running KWin at all. /// /// Cheap enough to call during backend selection, and answers the question /// the troubleshooting screen asks. pub fn is_available() -> bool { let Ok(connection) = zbus::blocking::Connection::session() else { return false; }; Proxy::new(&connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE).is_ok() } /// Remove the script from the compositor. /// /// Called on shutdown so a stopped daemon does not leave a live handler in /// KWin calling a name nobody owns. pub fn shutdown(&self) { if let Ok(proxy) = Proxy::new(&self.connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE) { let _: zbus::Result = proxy.call("unloadScript", &(SCRIPT_NAME,)); let _: zbus::Result<()> = proxy.call("start", &()); } } } impl Drop for KwinBackend { fn drop(&mut self) { self.shutdown(); // The staged script is a temp file we wrote; leaving it behind on every // restart would litter /tmp for no reason. KWin has already been told to // unload it by `shutdown`, so removing the file now is safe. let _ = std::fs::remove_file(&self.script_path); } } impl ForegroundWatcher for KwinBackend { fn subscribe(&mut self, sink: Sender) { // The D-Bus side is owned by the daemon process, not by this struct: // `serve` runs on its own thread for the life of the daemon and pushes // into `sink`. See `watcher_server` in the daemon. let _ = &self.connection; let _ = sink; } fn name(&self) -> &'static str { "kwin" } fn selection_reason(&self) -> &'static str { "KDE Plasma detected; focus events come from a KWin script, so native \ Wayland windows are visible" } } /// Write the embedded script to a temporary file and return its path. /// /// KWin wants a path, not source text, so the script must exist on disk. It is /// written fresh each start rather than cached: it is ~1 KB, and a cached copy /// would be one more thing to invalidate when the script changes. fn stage_script() -> Result { let mut path = std::env::temp_dir(); path.push(format!("blocker-{SCRIPT_NAME}.js")); std::fs::write(&path, SCRIPT_JS)?; Ok(path) } /// Parse a payload from the KWin script into a [`ForegroundWindow`]. /// /// Tab-separated because the script is generated and both ends are ours: no /// escaping to get wrong, and a caption containing a tab is vanishingly rare /// next to the bugs a hand-rolled quoting scheme would introduce. /// /// Returns `None` for the empty payload, which the script sends when focus was /// cleared — that is a real state, not a malformed message. pub fn parse_payload(payload: &str) -> Option { let payload = payload.trim_matches('\n'); if payload.is_empty() { return None; } let mut parts = payload.split('\t'); let resource_class = parts.next().unwrap_or_default(); let resource_name = parts.next().unwrap_or_default(); let caption = parts.next().unwrap_or_default(); let internal_id = parts.next().unwrap_or_default(); let pid = parts.next().and_then(|p| p.trim().parse::().ok()); Some(ForegroundWindow { handle: internal_id.to_owned(), // Present on Plasma 6 — see the note in SCRIPT_JS. pid, exe_path: None, title: (!caption.is_empty()).then(|| caption.to_owned()), desktop_id: (!resource_class.is_empty()) .then(|| format!("{resource_class}.desktop")) .or_else(|| (!resource_name.is_empty()).then(|| format!("{resource_name}.desktop"))), }) }