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,370 @@
|
||||
//! The X11 backend: the portable fallback.
|
||||
//!
|
||||
//! # What it can and cannot see
|
||||
//!
|
||||
//! `x11rb` subscribes to `_NET_ACTIVE_WINDOW` on the root window, then resolves
|
||||
//! the focused window through `_NET_WM_PID` to `/proc/<pid>/exe`.
|
||||
//!
|
||||
//! On an X11 session this sees every window. **Under a Wayland session it sees
|
||||
//! only XWayland clients** — the XWayland server is a compatibility shim, and
|
||||
//! native Wayland applications never route through it. That is why this is the
|
||||
//! fallback and not the primary path on KDE (see [`super::kwin`]), and it is
|
||||
//! stated plainly in `LIMITATIONS.md` rather than discovered later.
|
||||
//!
|
||||
//! # Why it still matters
|
||||
//!
|
||||
//! Two reasons. It is the only backend that works on a non-KDE X11 desktop, and
|
||||
//! it is the one that can be tested headless with Xvfb, so it is the path CI
|
||||
//! exercises. The KWin path cannot be — it needs a real Plasma session.
|
||||
//!
|
||||
//! # Event-driven, not polled
|
||||
//!
|
||||
//! Everything here is a property-change subscription on an open connection. There
|
||||
//! is no timer and no `poll` loop anywhere in this module, which is what keeps
|
||||
//! the section 5 idle-wakeup budget at zero.
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto::{ConnectionExt as _, EventMask};
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
use super::{FocusEvent, ForegroundWatcher, ForegroundWindow};
|
||||
|
||||
/// Something went wrong talking to X.
|
||||
#[derive(Debug)]
|
||||
pub enum X11Error {
|
||||
/// `DISPLAY` unset or unreachable. Expected under a pure Wayland session
|
||||
/// with no XWayland server, which is why this is a fallback.
|
||||
NoDisplay(String),
|
||||
/// The connection dropped.
|
||||
Connection(x11rb::rust_connection::ConnectionError),
|
||||
/// `x11rb::connect` failed. Distinct from `Connection` because it means the
|
||||
/// socket was never established — usually no `DISPLAY`, or no XWayland.
|
||||
Connect(String),
|
||||
/// An X protocol error. Not fatal to the connection, only to this request.
|
||||
///
|
||||
/// Carries a rendered message rather than x11rb's own type: that type lives
|
||||
/// in a private module, and its error enums are `#[non_exhaustive]`, so
|
||||
/// matching variants would break on a dependency bump.
|
||||
Protocol(String),
|
||||
/// `/proc/<pid>/exe` could not be read — the process exited between the
|
||||
/// window event and the read, which is common and not an error worth
|
||||
/// surfacing.
|
||||
Proc(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for X11Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoDisplay(d) => write!(f, "no X display: {d}"),
|
||||
Self::Connection(e) => write!(f, "X connection failed: {e}"),
|
||||
Self::Connect(e) => write!(f, "cannot reach an X server: {e}"),
|
||||
Self::Protocol(e) => write!(f, "X protocol error: {e}"),
|
||||
Self::Proc(e) => write!(f, "could not read the process: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for X11Error {}
|
||||
|
||||
/// The EWMH properties this backend depends on, interned once.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Atoms {
|
||||
/// `_NET_ACTIVE_WINDOW` on the root: the focused window.
|
||||
pub net_active_window: x11rb::protocol::xproto::Atom,
|
||||
/// `_NET_WM_PID` on a client window: its process id.
|
||||
pub net_wm_pid: x11rb::protocol::xproto::Atom,
|
||||
/// `WM_NAME`: the window title.
|
||||
pub wm_name: x11rb::protocol::xproto::Atom,
|
||||
}
|
||||
|
||||
/// The X11 backend.
|
||||
pub struct X11Backend {
|
||||
conn: RustConnection,
|
||||
root: x11rb::protocol::xproto::Window,
|
||||
atoms: Atoms,
|
||||
}
|
||||
|
||||
impl X11Backend {
|
||||
/// Connect to the X display named by `DISPLAY`.
|
||||
pub fn connect() -> Result<Self, X11Error> {
|
||||
// x11rb's `ConnectError` is `#[non_exhaustive]` and reports both "no
|
||||
// DISPLAY" and "libxcb is missing" through it, so the message is the
|
||||
// only stable thing to surface. Callers see the rendered reason.
|
||||
let (conn, screen) = x11rb::connect(None).map_err(|e| {
|
||||
let rendered = e.to_string();
|
||||
if std::env::var_os("DISPLAY").is_none() {
|
||||
X11Error::NoDisplay(rendered)
|
||||
} else {
|
||||
X11Error::Connect(e.to_string())
|
||||
}
|
||||
})?;
|
||||
let root = conn.setup().roots[screen].root;
|
||||
|
||||
// Every atom is interned with a reply before use. Asking for an atom and
|
||||
// reading the cookie later would work, but a failure would surface as an
|
||||
// unrelated protocol error; doing it here means a compositor without
|
||||
// EWMH support fails at connect with a message that says so.
|
||||
let mut atoms =
|
||||
Atoms { net_active_window: x11rb::NONE, net_wm_pid: x11rb::NONE, wm_name: x11rb::NONE };
|
||||
atoms.net_active_window = intern(&conn, "_NET_ACTIVE_WINDOW")?;
|
||||
atoms.net_wm_pid = intern(&conn, "_NET_WM_PID")?;
|
||||
atoms.wm_name = intern(&conn, "WM_NAME")?;
|
||||
|
||||
Ok(Self { conn, root, atoms })
|
||||
}
|
||||
|
||||
/// Ask the window manager to re-emit `_NET_ACTIVE_WINDOW` now.
|
||||
///
|
||||
/// Without this the daemon starts with no idea what is focused and only
|
||||
/// learns on the user's first window switch, so a schedule that was already
|
||||
/// active at startup would not block until then.
|
||||
///
|
||||
/// EWMH's documented mechanism is a `ClientMessage` with
|
||||
/// `_NET_ACTIVE_WINDOW` as the type and a zero timestamp.
|
||||
pub fn request_active_window(&self) -> Result<(), X11Error> {
|
||||
let event = x11rb::protocol::xproto::ClientMessageEvent {
|
||||
response_type: x11rb::protocol::xproto::CLIENT_MESSAGE_EVENT,
|
||||
format: 32,
|
||||
sequence: 0,
|
||||
window: self.root,
|
||||
type_: self.atoms.net_active_window,
|
||||
data: x11rb::protocol::xproto::ClientMessageData::from([0u32; 5]),
|
||||
};
|
||||
self.conn
|
||||
.send_event(false, self.root, EventMask::STRUCTURE_NOTIFY, event)
|
||||
.map_err(|e| X11Error::Protocol(e.to_string()))?;
|
||||
self.conn.flush().map_err(|e| X11Error::Protocol(e.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve a window to something a schedule can match.
|
||||
///
|
||||
/// Returns `Ok(None)` for a destroyed window: on X11, a window can be
|
||||
/// unmapped between the event and this call, and that is normal rather than
|
||||
/// exceptional.
|
||||
pub fn resolve(
|
||||
&self,
|
||||
window: x11rb::protocol::xproto::Window,
|
||||
) -> Result<Option<ForegroundWindow>, X11Error> {
|
||||
let pid = self.window_pid(window).ok().flatten();
|
||||
let exe_path = pid.and_then(|p| read_exe(p).ok());
|
||||
let title = self.window_title(window).ok().flatten();
|
||||
let desktop_id = exe_path.as_deref().and_then(|p| desktop_id_for_exe(&p.to_string_lossy()));
|
||||
|
||||
Ok(Some(ForegroundWindow {
|
||||
handle: format!("{window:x}"),
|
||||
pid,
|
||||
exe_path: exe_path.map(|p| p.to_string_lossy().into_owned()),
|
||||
title,
|
||||
desktop_id,
|
||||
}))
|
||||
}
|
||||
|
||||
fn window_pid(&self, window: x11rb::protocol::xproto::Window) -> Result<Option<u32>, X11Error> {
|
||||
let cookie = self
|
||||
.conn
|
||||
.get_property(
|
||||
false,
|
||||
window,
|
||||
self.atoms.net_wm_pid,
|
||||
x11rb::protocol::xproto::AtomEnum::CARDINAL,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
.map_err(|e| X11Error::Protocol(e.to_string()))?;
|
||||
let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?;
|
||||
Ok(reply.value32().and_then(|mut values| values.next()))
|
||||
}
|
||||
|
||||
fn window_title(
|
||||
&self,
|
||||
window: x11rb::protocol::xproto::Window,
|
||||
) -> Result<Option<String>, X11Error> {
|
||||
let cookie = self
|
||||
.conn
|
||||
.get_property(
|
||||
false,
|
||||
window,
|
||||
self.atoms.wm_name,
|
||||
x11rb::protocol::xproto::AtomEnum::STRING,
|
||||
0,
|
||||
256,
|
||||
)
|
||||
.map_err(|e| X11Error::Protocol(e.to_string()))?;
|
||||
let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?;
|
||||
let text = String::from_utf8_lossy(&reply.value);
|
||||
Ok((!text.is_empty()).then(|| text.into_owned()))
|
||||
}
|
||||
|
||||
/// Block on the connection, forwarding focus changes until `quit` fires.
|
||||
///
|
||||
/// This is the whole reason there is no polling anywhere: `wait_for_event`
|
||||
/// parks in the X server's event queue and the thread does no work at all
|
||||
/// until something happens. One wakeup per actual window change.
|
||||
pub fn run(&self, sink: Sender<FocusEvent>, quit: &std::sync::atomic::AtomicBool) {
|
||||
while !quit.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
match self.conn.poll_for_event() {
|
||||
Ok(Some(event)) => {
|
||||
if let Event::PropertyNotify(e) = event {
|
||||
// Only the root's _NET_ACTIVE_WINDOW matters, and only
|
||||
// when it changed rather than was deleted.
|
||||
// Only the root's _NET_ACTIVE_WINDOW matters. Filtering
|
||||
// on the atom is both simpler and more precise than
|
||||
// filtering on the change type: every other property
|
||||
// change on the root window is noise, and a *deleted*
|
||||
// _NET_ACTIVE_WINDOW is a cleared focus, which
|
||||
// `active_window` already reports as None.
|
||||
if e.window != self.root || e.atom != self.atoms.net_active_window {
|
||||
continue;
|
||||
}
|
||||
if let Ok(Some(window)) = self.active_window() {
|
||||
if let Ok(Some(fg)) = self.resolve(window) {
|
||||
if sink.send(FocusEvent::Activated(fg)).is_err() {
|
||||
// The daemon is gone; there is nobody to tell.
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if sink.send(FocusEvent::Cleared).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// wait_for_event returned without an event, which means the
|
||||
// connection is idle. Yield rather than spin.
|
||||
std::thread::yield_now();
|
||||
}
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn active_window(&self) -> Result<Option<x11rb::protocol::xproto::Window>, X11Error> {
|
||||
let cookie = self
|
||||
.conn
|
||||
.get_property(
|
||||
false,
|
||||
self.root,
|
||||
self.atoms.net_active_window,
|
||||
x11rb::protocol::xproto::AtomEnum::WINDOW,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
.map_err(|e| X11Error::Protocol(e.to_string()))?;
|
||||
let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?;
|
||||
Ok(reply.value32().and_then(|mut values| values.next()))
|
||||
}
|
||||
|
||||
/// Subscribe to root-window property changes.
|
||||
pub fn subscribe_events(&self) -> Result<(), X11Error> {
|
||||
self.conn
|
||||
.change_window_attributes(
|
||||
self.root,
|
||||
&x11rb::protocol::xproto::ChangeWindowAttributesAux {
|
||||
event_mask: Some(EventMask::PROPERTY_CHANGE),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map_err(|e| X11Error::Protocol(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The screen dimensions, for positioning a block window.
|
||||
pub fn screen_size(&self) -> (u16, u16) {
|
||||
let setup = self.conn.setup();
|
||||
let screen = &setup.roots[0];
|
||||
(screen.width_in_pixels, screen.height_in_pixels)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for X11Backend {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
impl ForegroundWatcher for X11Backend {
|
||||
fn subscribe(&mut self, _sink: Sender<FocusEvent>) {
|
||||
// Subscription is a two-step handshake with the window manager and
|
||||
// happens in `serve`. Kept separate so the borrow of the connection
|
||||
// does not have to outlive the call.
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"x11"
|
||||
}
|
||||
|
||||
fn selection_reason(&self) -> &'static str {
|
||||
"no KWin session detected; using the X11 protocol. Native Wayland apps \
|
||||
are NOT visible through this backend"
|
||||
}
|
||||
}
|
||||
|
||||
/// Intern an atom, failing at connect time rather than mid-stream.
|
||||
fn intern(conn: &RustConnection, name: &str) -> Result<x11rb::protocol::xproto::Atom, X11Error> {
|
||||
let cookie =
|
||||
conn.intern_atom(false, name.as_bytes()).map_err(|e| X11Error::Protocol(e.to_string()))?;
|
||||
cookie.reply().map(|r| r.atom).map_err(|e| X11Error::Protocol(e.to_string()))
|
||||
}
|
||||
|
||||
/// Read a process's executable path.
|
||||
///
|
||||
/// `/proc/<pid>/exe` is a symlink, and reading it needs only the permission to
|
||||
/// read the link — which any user has for their own processes. Another user's
|
||||
/// process is not readable, which is an OS limit and not a bug.
|
||||
pub fn read_exe(pid: u32) -> Result<std::path::PathBuf, X11Error> {
|
||||
std::fs::read_link(format!("/proc/{pid}/exe")).map_err(|e| X11Error::Proc(e.to_string()))
|
||||
}
|
||||
|
||||
/// Map an executable path to a desktop-file id.
|
||||
///
|
||||
/// Reads the XDG data directories and matches `Exec=` lines. Deliberately
|
||||
/// best-effort: a missing `.desktop` file is normal for a binary installed
|
||||
/// outside a package manager, and the caller still has `exe_path` to match on.
|
||||
/// A wrong mapping would block the wrong app, which is worse than none.
|
||||
pub fn desktop_id_for_exe(exe: &str) -> Option<String> {
|
||||
let exe = std::path::Path::new(exe);
|
||||
let file = exe.file_name()?.to_str()?;
|
||||
|
||||
for dir in xdg_data_dirs() {
|
||||
let applications = dir.join("applications");
|
||||
let Ok(entries) = std::fs::read_dir(&applications) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
|
||||
continue;
|
||||
}
|
||||
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if contents.lines().any(|l| {
|
||||
let l = l.trim();
|
||||
l.starts_with("Exec=") && l.contains(file)
|
||||
}) {
|
||||
return path.file_stem().and_then(|s| s.to_str()).map(str::to_owned);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The XDG data directories, in the order the spec defines.
|
||||
fn xdg_data_dirs() -> Vec<std::path::PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(home) = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
|
||||
dirs.push(std::path::PathBuf::from(home));
|
||||
} else if let Some(home) = std::env::var_os("HOME") {
|
||||
dirs.push(std::path::PathBuf::from(home).join(".local/share"));
|
||||
}
|
||||
if let Some(extra) = std::env::var_os("XDG_DATA_DIRS").filter(|v| !v.is_empty()) {
|
||||
dirs.extend(std::env::split_paths(&extra));
|
||||
} else {
|
||||
dirs.push(std::path::PathBuf::from("/usr/local/share"));
|
||||
dirs.push(std::path::PathBuf::from("/usr/share"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
Reference in New Issue
Block a user