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:
2026-10-01 20:21:42 +03:00
parent f05836f528
commit a79d262986
7 changed files with 1572 additions and 3 deletions
+276
View File
@@ -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"))),
})
}
+3
View File
@@ -39,6 +39,9 @@
//! - None of this can be made tamper-proof against a user with root. Section
//! 6.1 requires this be documented honestly rather than implied otherwise.
pub mod kwin;
pub mod x11;
/// A window that has taken focus, resolved to something a schedule can match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForegroundWindow {
+370
View File
@@ -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
}