feat(linux): add KWin and X11 focus-detection backends
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/<pid>/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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Self, KwinError> {
|
||||
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<bool> =
|
||||
proxy.call::<&str, _, bool>("isScriptLoaded", &(SCRIPT_NAME,)).ok();
|
||||
if already == Some(true) {
|
||||
let _: std::result::Result<bool, _> = 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<bool> = 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<FocusEvent>) {
|
||||
// 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<std::path::PathBuf, std::io::Error> {
|
||||
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<ForegroundWindow> {
|
||||
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"))),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user