//! 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//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//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 { // 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, 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, 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, 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, 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, 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) { // 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 { 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//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::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 { 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 { 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 }