a79d262986
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>
119 lines
5.2 KiB
Rust
119 lines
5.2 KiB
Rust
//! Linux platform layer.
|
|
//!
|
|
//! Observes the OS and enforces decisions. Contains **no decision logic**
|
|
//! (CLAUDE.md section 2).
|
|
//!
|
|
//! # The trait this crate exists to implement
|
|
//!
|
|
//! Linux has no portable way to ask "which window has focus" under Wayland.
|
|
//! The [`ForegroundWatcher`] trait below is the seam, and it will have three
|
|
//! implementations, tried in order at startup:
|
|
//!
|
|
//! 1. **`KwinBackend`** — the primary path on this machine. Loads a small
|
|
//! KWin script over the `org.kde.KWin /Scripting` D-Bus interface, which
|
|
//! hooks `windowActivated` and reports back to the daemon. Verified
|
|
//! available on KDE Plasma 6.6.6: `loadScript`, `start`, `unloadScript`,
|
|
//! `isScriptLoaded` are all present.
|
|
//!
|
|
//! 2. **`X11Backend`** — the portable fallback. `x11rb` subscribes to
|
|
//! `_NET_ACTIVE_WINDOW` property changes, resolves `_NET_WM_PID`, then reads
|
|
//! `/proc/<pid>/exe`. Works on any X11 session and is testable headless with
|
|
//! Xvfb, so this is also the path CI exercises.
|
|
//!
|
|
//! 3. **`ProcessBackend`** — a degraded last resort with no idea what has focus,
|
|
//! which acts on process creation instead. It cannot show a meaningful block
|
|
//! screen and should never be reached on a working KDE session.
|
|
//!
|
|
//! # Honest limits (CLAUDE.md section 6)
|
|
//!
|
|
//! - `X11Backend` is **blind to native Wayland apps** under a Wayland session.
|
|
//! Under XWayland it sees only XWayland clients. This is why it is a fallback
|
|
//! and not the primary.
|
|
//! - `/proc/connectors` does not exist on the development kernel, so the
|
|
//! netlink proc-connector process watcher is unavailable and
|
|
//! `ProcessBackend` must use eBPF or `/proc` polling — a worse experience
|
|
//! than a block screen, by construction.
|
|
//! - The KWin backend is KDE-specific. wlroots
|
|
//! (`foreign-toplevel-management`) and GNOME backends remain in M7 per
|
|
//! section 8, because no portable API exists to build them against.
|
|
//! - 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 {
|
|
/// Backend-native window handle. KWin gives a `uuid` string; X11 gives an
|
|
/// XID. Kept as a string so the trait is not tied to one backend.
|
|
pub handle: String,
|
|
/// Resolved process id, where the backend can supply one.
|
|
pub pid: Option<u32>,
|
|
/// Absolute path of the executable, read from `/proc/<pid>/exe`.
|
|
pub exe_path: Option<String>,
|
|
/// Window title, for display on the block screen.
|
|
pub title: Option<String>,
|
|
/// Desktop-file id or WM_CLASS, which is what a Linux user actually
|
|
/// recognises as "the app" and what schedules should match against.
|
|
pub desktop_id: Option<String>,
|
|
}
|
|
|
|
/// Events a watcher can raise. The daemon blocks on this channel; there is no
|
|
/// polling (section 2).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum FocusEvent {
|
|
/// A window gained focus.
|
|
Activated(ForegroundWindow),
|
|
/// The focused window closed, so there is no longer a foreground window.
|
|
Cleared,
|
|
}
|
|
|
|
/// Source of focus events.
|
|
///
|
|
/// Implemented once per backend. M2-Linux fills these in.
|
|
pub trait ForegroundWatcher {
|
|
/// Begin delivering events to `sink`.
|
|
///
|
|
/// Implementations must be event-driven: they subscribe to an X11 property
|
|
/// change or a compositor callback, never to a timer. An implementation
|
|
/// that polls would break the section 5 idle budget even if it functioned.
|
|
fn subscribe(&mut self, sink: std::sync::mpsc::Sender<FocusEvent>);
|
|
|
|
/// Which backend this is, for the troubleshooting screen and the log.
|
|
fn name(&self) -> &'static str;
|
|
|
|
/// Why this backend was chosen, in plain language for the user.
|
|
fn selection_reason(&self) -> &'static str;
|
|
}
|
|
|
|
/// Enforcement actions, in escalating order and configurable by the user
|
|
/// (CLAUDE.md section 4.3).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Enforcement {
|
|
/// Show the block screen. The default, and the only non-destructive option.
|
|
BlockScreen,
|
|
/// Ask the compositor to minimise the window.
|
|
Minimise,
|
|
/// `SIGSTOP` the process. Freezes the app rather than closing it, so the
|
|
/// user's state is preserved and the resume is honest.
|
|
Freeze,
|
|
/// Terminate the process. Destructive, and the last resort.
|
|
Terminate,
|
|
}
|
|
|
|
/// What the platform layer may and may not do unprivileged.
|
|
///
|
|
/// Recorded here because section 0 rule 2 requires honesty rather than a stub
|
|
/// that pretends. Each of these is a real limit of an unprivileged user
|
|
/// daemon, not a missing feature.
|
|
pub const UNPRIVILEGED_LIMITS: &[&str] = &[
|
|
"cannot prevent a process from launching, only react to it after the fact",
|
|
"cannot mask a systemd unit (that needs root); tamper resistance is \
|
|
Restart=always plus a watchdog, nothing stronger",
|
|
"cannot make its own config tamper-proof against the same user, who owns the \
|
|
files — lock state lives in the DB instead (section 1.5.3)",
|
|
"cannot read /proc/<pid>/exe for processes owned by other users",
|
|
"cannot block a process that is already running as root",
|
|
];
|