Files
SellPL 9f1acb1421
CI / ci (pull_request) Has been cancelled
CI / android (pull_request) Has been cancelled
test(linux): verify the KWin script against the live Plasma session
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/<pid>/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 <noreply@anthropic.com>
2026-10-01 21:06:07 +03:00

297 lines
12 KiB
Rust

//! 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/<pid>/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<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();
let pid = parts.next().and_then(|p| p.trim().parse::<u32>().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"))),
})
}