9f1acb1421
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>
297 lines
12 KiB
Rust
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"))),
|
|
})
|
|
}
|