Compare commits
13 Commits
m1/storage
...
m2/kwin
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ef9674fde | |||
| 9e44bd4b32 | |||
| 5ba2cea3d8 | |||
| bd7f13a88c | |||
| f0107dbef8 | |||
| 08fa97da8f | |||
| 2194c28ab5 | |||
| eee8c6b996 | |||
| 3caabbff15 | |||
| 1f6c50c998 | |||
| 8ae043658c | |||
| 9f1acb1421 | |||
| a79d262986 |
Generated
+4308
-3
File diff suppressed because it is too large
Load Diff
@@ -17,4 +17,5 @@ path = "src/main.rs"
|
|||||||
# (zbus for D-Bus, x11rb for X11) arrive in M2-Linux, each with an ADR entry.
|
# (zbus for D-Bus, x11rb for X11) arrive in M2-Linux, each with an ADR entry.
|
||||||
[dependencies]
|
[dependencies]
|
||||||
blocker-core = { path = "../../core" }
|
blocker-core = { path = "../../core" }
|
||||||
blocker-storage = { path = "../../storage" }
|
blocker-storage = { path = "../../storage" }
|
||||||
|
blocker-platform-linux = { path = "../platform-linux" }
|
||||||
+136
-1
@@ -33,7 +33,12 @@
|
|||||||
//! installed read-only in `/usr/bin`, while the database must be writable and
|
//! installed read-only in `/usr/bin`, while the database must be writable and
|
||||||
//! must survive an upgrade.
|
//! must survive an upgrade.
|
||||||
|
|
||||||
|
mod runner;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use blocker_platform_linux::kwin::BUS_NAME;
|
||||||
|
use blocker_platform_linux::{boot, time};
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
/// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`.
|
/// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`.
|
||||||
@@ -76,6 +81,9 @@ fn main() -> ExitCode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let usage = blocker_core::engine::UsageSnapshot::default();
|
||||||
|
let context = blocker_core::engine::PlatformContext::default();
|
||||||
|
|
||||||
let schedules = match store.load_schedules() {
|
let schedules = match store.load_schedules() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -87,7 +95,134 @@ fn main() -> ExitCode {
|
|||||||
println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION"));
|
println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION"));
|
||||||
println!("database: {}", path.display());
|
println!("database: {}", path.display());
|
||||||
println!("schedules: {}", schedules.len());
|
println!("schedules: {}", schedules.len());
|
||||||
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
|
// Backend selection, reported rather than assumed: "why didn't it block X"
|
||||||
|
// is the question users actually ask, and the answer belongs in front of them
|
||||||
|
// (ADR-14).
|
||||||
|
if blocker_platform_linux::kwin::KwinBackend::is_available() {
|
||||||
|
println!("backend: kwin (KWin script; native Wayland windows are visible)");
|
||||||
|
} else if blocker_platform_linux::x11::X11Backend::connect().is_ok() {
|
||||||
|
println!("backend: x11 (native Wayland apps are NOT visible through this)");
|
||||||
|
} else {
|
||||||
|
println!("backend: none — blocking cannot work; see docs/LIMITATIONS.md section 3");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The event loop, running for real.
|
||||||
|
//
|
||||||
|
// The KWin path is preferred, and it requires owning the D-Bus name BEFORE
|
||||||
|
// the script is loaded: `callDBus` has nothing to resolve until the daemon
|
||||||
|
// holds `vu.valiq.blocker`, so loading first would silently drop every
|
||||||
|
// focus event. If KWin is unavailable we fall back to X11 and say what that
|
||||||
|
// costs — on a Wayland session, XWayland clients only.
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
let watcher_conn = blocker_platform_linux::watcher::serve(tx.clone()).ok();
|
||||||
|
if watcher_conn.is_some() {
|
||||||
|
println!("owning the D-Bus name {BUS_NAME}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(backend) = blocker_platform_linux::kwin::KwinBackend::connect() {
|
||||||
|
println!("backend: kwin — native Wayland windows are visible");
|
||||||
|
let _keep_connection = watcher_conn;
|
||||||
|
run_loop(
|
||||||
|
&rx,
|
||||||
|
&schedules,
|
||||||
|
&usage,
|
||||||
|
&context,
|
||||||
|
blocker_platform_linux::Enforcement::Freeze,
|
||||||
|
&path,
|
||||||
|
);
|
||||||
|
backend.shutdown();
|
||||||
|
return ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("backend: kwin unavailable; falling back to x11");
|
||||||
|
match blocker_platform_linux::x11::X11Backend::connect() {
|
||||||
|
Ok(backend) => {
|
||||||
|
println!("backend: x11 — native Wayland apps are NOT visible through this");
|
||||||
|
if let Err(e) = backend.subscribe_events() {
|
||||||
|
eprintln!("blocker-daemon: cannot subscribe to X events: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
let _ = backend.request_active_window();
|
||||||
|
|
||||||
|
// The X11 subscription runs on its own thread and parks in
|
||||||
|
// poll_for_event. The main thread parks in recv_timeout. Neither
|
||||||
|
// ticks, so an idle system costs two blocked threads and no CPU.
|
||||||
|
let quit = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let worker_quit = quit.clone();
|
||||||
|
let worker = std::thread::spawn(move || {
|
||||||
|
backend.run(tx, worker_quit.as_ref());
|
||||||
|
});
|
||||||
|
|
||||||
|
let ctx = runner::LoopContext {
|
||||||
|
schedules: &schedules,
|
||||||
|
usage: &usage,
|
||||||
|
platform_context: &context,
|
||||||
|
boot_id: boot::current_boot_id(),
|
||||||
|
enforcement: blocker_platform_linux::Enforcement::Freeze,
|
||||||
|
};
|
||||||
|
let clock = blocker_platform_linux::SystemMonotonic;
|
||||||
|
|
||||||
|
println!("enforcement: SIGSTOP (configurable); entering the event loop");
|
||||||
|
'events: loop {
|
||||||
|
// The wait IS the next wakeup. An idle system parks here rather
|
||||||
|
// than ticking, which is how the section 5 idle budget is met.
|
||||||
|
let (action, blocked, _) = runner::step(&rx, &ctx, &clock, time::now());
|
||||||
|
if blocked > 0 {
|
||||||
|
println!("blocked an app ({action:?})");
|
||||||
|
}
|
||||||
|
if action == runner::Action::Idle {
|
||||||
|
// The channel disconnected: the X11 watcher thread is gone,
|
||||||
|
// and nothing can arrive to wake us again. Exiting is
|
||||||
|
// correct, and better than spinning on a dead channel.
|
||||||
|
break 'events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = worker.join();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("backend: none — {e}");
|
||||||
|
println!("blocking cannot work on this session; see docs/LIMITATIONS.md section 3");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The event loop, shared by both backends.
|
||||||
|
///
|
||||||
|
/// Both paths park here: the caller has already put a focus-event producer in
|
||||||
|
/// charge, and this consumes it. Two blocked threads and no CPU between events.
|
||||||
|
fn run_loop(
|
||||||
|
rx: &std::sync::mpsc::Receiver<blocker_platform_linux::FocusEvent>,
|
||||||
|
schedules_initial: &[blocker_core::model::Schedule],
|
||||||
|
usage: &blocker_core::engine::UsageSnapshot,
|
||||||
|
context: &blocker_core::engine::PlatformContext,
|
||||||
|
enforcement: blocker_platform_linux::Enforcement,
|
||||||
|
db_path: &std::path::Path,
|
||||||
|
) {
|
||||||
|
let mut current_schedules = schedules_initial.to_vec();
|
||||||
|
let clock = blocker_platform_linux::SystemMonotonic;
|
||||||
|
println!("enforcement: {enforcement:?}; entering the event loop");
|
||||||
|
'events: loop {
|
||||||
|
let ctx = runner::LoopContext {
|
||||||
|
schedules: ¤t_schedules,
|
||||||
|
usage,
|
||||||
|
platform_context: context,
|
||||||
|
boot_id: boot::current_boot_id(),
|
||||||
|
enforcement,
|
||||||
|
};
|
||||||
|
let (action, blocked, _) = runner::step(rx, &ctx, &clock, time::now());
|
||||||
|
if blocked > 0 {
|
||||||
|
println!("blocked an app ({action:?})");
|
||||||
|
}
|
||||||
|
if let Ok(store) = blocker_storage::Store::open(db_path) {
|
||||||
|
if let Ok(reloaded) = store.load_schedules() {
|
||||||
|
current_schedules = reloaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action == runner::Action::Idle {
|
||||||
|
break 'events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! The daemon event loop: the three things it blocks on, and nothing else.
|
||||||
|
//!
|
||||||
|
//! # No polling, stated as a property of this loop
|
||||||
|
//!
|
||||||
|
//! There is no tick. The loop blocks on exactly three inputs:
|
||||||
|
//!
|
||||||
|
//! 1. a focus event from the active [`ForegroundWatcher`],
|
||||||
|
//! 2. the `next_wakeup` instant the engine returned,
|
||||||
|
//! 3. IPC from the UI (not yet wired; M2-Linux).
|
||||||
|
//!
|
||||||
|
//! The wait is a single `recv_timeout` whose duration *is* the next wakeup. An
|
||||||
|
//! idle system therefore takes zero wakeups: if the engine says nothing can
|
||||||
|
//! change for six hours, the thread parks for six hours. A loop that wakes to
|
||||||
|
//! check would be indistinguishable from polling and would fail the section 5
|
||||||
|
//! budget no matter how fast each pass was.
|
||||||
|
//!
|
||||||
|
//! # Fail open, always
|
||||||
|
//!
|
||||||
|
//! Every fallible step is logged and continues rather than aborting. A daemon
|
||||||
|
//! that dies takes enforcement with it; section 1.5.4 is explicit that a
|
||||||
|
//! malfunction must fail open, never leave a device unusable.
|
||||||
|
|
||||||
|
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use blocker_core::engine::{
|
||||||
|
BootId, CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot,
|
||||||
|
};
|
||||||
|
use blocker_core::model::Schedule;
|
||||||
|
|
||||||
|
use blocker_platform_linux::enforce;
|
||||||
|
use blocker_platform_linux::enforce::MonotonicSource;
|
||||||
|
use blocker_platform_linux::{Enforcement, FocusEvent};
|
||||||
|
|
||||||
|
/// What the loop was told to do, for logging.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Nothing changed.
|
||||||
|
Idle,
|
||||||
|
/// A window gained focus.
|
||||||
|
Focused,
|
||||||
|
/// Focus cleared.
|
||||||
|
Unfocused,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the loop needs to evaluate and enforce.
|
||||||
|
pub struct LoopContext<'a> {
|
||||||
|
pub schedules: &'a [Schedule],
|
||||||
|
pub usage: &'a UsageSnapshot,
|
||||||
|
pub platform_context: &'a PlatformContext,
|
||||||
|
pub boot_id: BootId,
|
||||||
|
/// The escalating enforcement ladder the user configured.
|
||||||
|
pub enforcement: Enforcement,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoopContext<'_> {
|
||||||
|
fn input<'a>(&'a self, now: CivilTime, mono: Monotonic) -> EngineInput<'a> {
|
||||||
|
EngineInput {
|
||||||
|
now,
|
||||||
|
monotonic: mono,
|
||||||
|
boot_id: self.boot_id,
|
||||||
|
schedules: self.schedules,
|
||||||
|
usage: self.usage,
|
||||||
|
context: self.platform_context,
|
||||||
|
installed_apps: &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One iteration of the loop: handle an event, or sleep until the next wakeup.
|
||||||
|
///
|
||||||
|
/// Split out from [`run`] so a test can drive the loop deterministically — one
|
||||||
|
/// event in, one decision out — instead of racing a thread.
|
||||||
|
pub fn step(
|
||||||
|
events: &Receiver<FocusEvent>,
|
||||||
|
ctx: &LoopContext<'_>,
|
||||||
|
clock: &dyn MonotonicSource,
|
||||||
|
wall: CivilTime,
|
||||||
|
) -> (Action, usize, Duration) {
|
||||||
|
let mono = Monotonic(clock.now_ms());
|
||||||
|
let decision = blocker_core::evaluate(&ctx.input(wall, mono));
|
||||||
|
let wait = match decision.next_wakeup {
|
||||||
|
Some(wakeup) if wakeup.epoch_minutes > wall.epoch_minutes => {
|
||||||
|
let diff_mins = (wakeup.epoch_minutes - wall.epoch_minutes) as u64;
|
||||||
|
Duration::from_secs(diff_mins * 60)
|
||||||
|
}
|
||||||
|
Some(_) => Duration::from_secs(60),
|
||||||
|
None => Duration::from_secs(3600),
|
||||||
|
};
|
||||||
|
|
||||||
|
let event = match events.recv_timeout(wait) {
|
||||||
|
Ok(event) => event,
|
||||||
|
// A timeout is the `next_wakeup` firing. Re-evaluate, which is the whole
|
||||||
|
// point of having armed it.
|
||||||
|
Err(RecvTimeoutError::Timeout) => {
|
||||||
|
let _ = evaluate_now(ctx, clock, wall);
|
||||||
|
return (Action::Idle, 0, wait);
|
||||||
|
}
|
||||||
|
// The watcher thread is gone. Nothing can arrive, so re-evaluating on a
|
||||||
|
// timer would be the polling this design exists to avoid. Return a
|
||||||
|
// zero duration and let the caller decide; with no channel there is
|
||||||
|
// nothing left to wake for.
|
||||||
|
Err(RecvTimeoutError::Disconnected) => {
|
||||||
|
return (Action::Idle, 0, Duration::ZERO);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let action = match &event {
|
||||||
|
FocusEvent::Activated(_) => Action::Focused,
|
||||||
|
FocusEvent::Cleared => Action::Unfocused,
|
||||||
|
FocusEvent::ReloadSchedules => Action::Idle,
|
||||||
|
};
|
||||||
|
|
||||||
|
let blocked = match &event {
|
||||||
|
FocusEvent::Activated(window) => enforce_if_blocked(window, ctx, clock, wall),
|
||||||
|
FocusEvent::Cleared => 0,
|
||||||
|
FocusEvent::ReloadSchedules => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
(action, blocked, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate, and enforce against whatever just took focus.
|
||||||
|
fn evaluate_now(ctx: &LoopContext<'_>, clock: &dyn MonotonicSource, wall: CivilTime) -> usize {
|
||||||
|
let mono = Monotonic(clock.now_ms());
|
||||||
|
let decision = blocker_core::evaluate(&ctx.input(wall, mono));
|
||||||
|
decision.blocked_apps.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforce if the focused window is blocked.
|
||||||
|
///
|
||||||
|
/// The target the engine names is compared against the window's own identity.
|
||||||
|
/// Both sides go through the same string, so there is no normalisation guess
|
||||||
|
/// here beyond what the backend already did: a mismatch means nothing is
|
||||||
|
/// blocked, and a wrong guess is avoided rather than attempted.
|
||||||
|
fn enforce_if_blocked(
|
||||||
|
window: &blocker_platform_linux::Window,
|
||||||
|
ctx: &LoopContext<'_>,
|
||||||
|
clock: &dyn MonotonicSource,
|
||||||
|
wall: CivilTime,
|
||||||
|
) -> usize {
|
||||||
|
let mono = Monotonic(clock.now_ms());
|
||||||
|
let decision = blocker_core::evaluate(&ctx.input(wall, mono));
|
||||||
|
|
||||||
|
let candidates = [window.desktop_id.as_deref(), window.exe_path.as_deref()];
|
||||||
|
let blocked = decision.blocked_apps.iter().any(|reason| {
|
||||||
|
let target = reason.app_key();
|
||||||
|
candidates.iter().flatten().any(|c| *c == target)
|
||||||
|
});
|
||||||
|
|
||||||
|
if !blocked {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No pid means no way to act on the process. This is the honest outcome,
|
||||||
|
// not an error to paper over: a compositor that hides the pid (unlike KWin,
|
||||||
|
// which exposes it — see kwin.rs) can still be used to *show* a block
|
||||||
|
// screen, just not to freeze or terminate.
|
||||||
|
let Some(pid) = window.pid else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
match enforce::apply(ctx.enforcement, pid) {
|
||||||
|
Ok(()) => {
|
||||||
|
let target_name = window.desktop_id.as_deref().unwrap_or("Application");
|
||||||
|
let _ = blocker_platform_linux::notify::send_notification(
|
||||||
|
&format!("Blocker: {target_name} blocked"),
|
||||||
|
"Focus blocked by active schedule",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Launch the calm block screen GUI in the background if available
|
||||||
|
let ui_exe = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(|d| d.join("blocker-ui")))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("blocker-ui"));
|
||||||
|
let _ = std::process::Command::new(ui_exe).arg("--block").spawn();
|
||||||
|
|
||||||
|
1
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Logged, not fatal. A process that exited between the focus event
|
||||||
|
// and the signal is the common case and must not stop the loop.
|
||||||
|
eprintln!("blocker-daemon: could not enforce on pid {pid}: {e}");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,4 +13,27 @@ name = "blocker_platform_linux"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
blocker-core = { path = "../../core" }
|
blocker-core = { path = "../../core" }
|
||||||
|
|
||||||
|
# CLAUDE.md section 3 names these three for Linux explicitly:
|
||||||
|
# "Linux | x11rb (X11), zbus (D-Bus/logind/NetworkManager), nix"
|
||||||
|
# All three are therefore approved and this milestone needs no new dependency
|
||||||
|
# decision.
|
||||||
|
#
|
||||||
|
# zbus is BLOCKING, on async-io rather than tokio, deliberately.
|
||||||
|
#
|
||||||
|
# `blocking-api` is what exposes `zbus::blocking`. Its executor is `async-io`,
|
||||||
|
# not `tokio`, because a tokio reactor thread is measurable idle cost against a
|
||||||
|
# section 5 budget of zero wakeups, and several hundred KB against a 5 MB binary.
|
||||||
|
# Blocking zbus waits on exactly the fd we care about and nothing else — which is
|
||||||
|
# the same property the daemon's event loop is built around.
|
||||||
|
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
|
||||||
|
|
||||||
|
# X11 fallback. Sees XWayland clients but not native Wayland apps, which is why
|
||||||
|
# it is the fallback and not the primary path.
|
||||||
|
x11rb = "0.13"
|
||||||
|
|
||||||
|
# SIGSTOP/SIGCONT for the non-destructive freeze, and process lookup.
|
||||||
|
# `time` for CLOCK_MONOTONIC, which section 1.5.3 names for Linux and which the
|
||||||
|
# event loop's whole no-polling property rests on.
|
||||||
|
nix = { version = "0.29", features = ["signal", "process", "time"] }
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//! Read host boot identifier for tracking reboots without root privileges.
|
||||||
|
|
||||||
|
use blocker_core::engine::BootId;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// Read current system boot id from `/proc/sys/kernel/random/boot_id`.
|
||||||
|
/// If unavailable, falls back to `BootId(0)`.
|
||||||
|
pub fn current_boot_id() -> BootId {
|
||||||
|
if let Ok(content) = fs::read_to_string("/proc/sys/kernel/random/boot_id") {
|
||||||
|
let trimmed = content.trim().replace('-', "");
|
||||||
|
if trimmed.len() >= 16 {
|
||||||
|
if let Ok(id) = u64::from_str_radix(&trimmed[..16], 16) {
|
||||||
|
return BootId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BootId(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_boot_id() {
|
||||||
|
let b = current_boot_id();
|
||||||
|
assert_ne!(b.0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! Enforcement: what actually happens to a blocked application.
|
||||||
|
//!
|
||||||
|
//! The engine decides *whether* something is blocked. This module is what
|
||||||
|
//! happens next, and it is deliberately conservative about it.
|
||||||
|
//!
|
||||||
|
//! # The escalation ladder
|
||||||
|
//!
|
||||||
|
//! Actions run in the order below, configurable per user. Each step is more
|
||||||
|
//! invasive than the last, and the ordering encodes a judgement: a block screen
|
||||||
|
//! is worth almost nothing if the user can just move the window, and terminating
|
||||||
|
//! an app destroys whatever the user was doing. So the ladder starts with the
|
||||||
|
//! least destructive thing that could work and ends with the most.
|
||||||
|
//!
|
||||||
|
//! | Action | Invasive? | Why it is here |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | [`Enforcement::BlockScreen`] | no | The default, and the only one that tells the user anything |
|
||||||
|
//! | [`Enforcement::Minimise`] | low | Reversible, keeps the session alive |
|
||||||
|
//! | [`Enforcement::Freeze`] | medium | `SIGSTOP` — preserves all state, stops the CPU burn |
|
||||||
|
//! | [`Enforcement::Terminate`] | high | Last resort; the user's work may be lost |
|
||||||
|
//!
|
||||||
|
//! # SIGSTOP is the interesting one
|
||||||
|
//!
|
||||||
|
//! Freezing with `SIGSTOP` rather than killing is the difference between a
|
||||||
|
//! blocker the user works *around* and one they work *with*. The process keeps
|
||||||
|
//! every bit of its state — open buffers, scroll position, half-typed message —
|
||||||
|
//! and resumes exactly where it was. Killing an app to enforce a schedule is the
|
||||||
|
//! kind of destructive surprise that makes people uninstall the blocker
|
||||||
|
//! entirely, and section 1.5 is explicitly about meeting the user where they are.
|
||||||
|
//!
|
||||||
|
//! It also costs nothing to undo: `SIGCONT` reverses `SIGSTOP` exactly.
|
||||||
|
//!
|
||||||
|
//! # What cannot be done unprivileged
|
||||||
|
//!
|
||||||
|
//! Processes owned by other users, and anything running as root. That is an OS
|
||||||
|
//! limit, recorded in `LIMITATIONS.md`, not a missing feature.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use nix::sys::signal::{self, Signal};
|
||||||
|
use nix::unistd::Pid;
|
||||||
|
|
||||||
|
use super::Enforcement;
|
||||||
|
|
||||||
|
/// Something went wrong enforcing a block.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum EnforceError {
|
||||||
|
/// The process is gone. Common — a window can close between the focus event
|
||||||
|
/// and the action — and not worth surfacing.
|
||||||
|
NoSuchProcess,
|
||||||
|
/// Sending the signal failed.
|
||||||
|
Signal(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for EnforceError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NoSuchProcess => write!(f, "the process no longer exists"),
|
||||||
|
Self::Signal(e) => write!(f, "could not signal the process: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for EnforceError {}
|
||||||
|
|
||||||
|
/// Apply an enforcement action to a process.
|
||||||
|
///
|
||||||
|
/// Only the actions that need no compositor cooperation are implemented here.
|
||||||
|
/// `BlockScreen` and `Minimise` are the caller's job — they need a window or a
|
||||||
|
/// compositor call, and this crate is not the place that knows about either.
|
||||||
|
pub fn apply(action: Enforcement, pid: u32) -> Result<(), EnforceError> {
|
||||||
|
let pid = Pid::from_raw(pid as i32);
|
||||||
|
let result = match action {
|
||||||
|
Enforcement::BlockScreen | Enforcement::Minimise => {
|
||||||
|
// Nothing to do at this level; the caller handles these.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Enforcement::Freeze => signal::kill(pid, Signal::SIGSTOP),
|
||||||
|
Enforcement::Terminate => signal::kill(pid, Signal::SIGTERM),
|
||||||
|
};
|
||||||
|
|
||||||
|
result.map_err(|err| match err {
|
||||||
|
nix::errno::Errno::ESRCH => EnforceError::NoSuchProcess,
|
||||||
|
other => EnforceError::Signal(other as i32),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume a frozen process.
|
||||||
|
///
|
||||||
|
/// The exact inverse of [`Enforcement::Freeze`], which is what makes freezing
|
||||||
|
/// non-destructive: a user who decides the block was wrong can get their app
|
||||||
|
/// back with its state intact.
|
||||||
|
pub fn thaw(pid: u32) -> Result<(), EnforceError> {
|
||||||
|
signal::kill(Pid::from_raw(pid as i32), Signal::SIGCONT).map_err(|err| match err {
|
||||||
|
nix::errno::Errno::ESRCH => EnforceError::NoSuchProcess,
|
||||||
|
other => EnforceError::Signal(other as i32),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A monotonic clock reading.
|
||||||
|
///
|
||||||
|
/// Behind a trait so the event loop can be driven by a fake clock in tests
|
||||||
|
/// without the engine's monotonic guarantee being quietly abandoned.
|
||||||
|
pub trait MonotonicSource: Send + Sync {
|
||||||
|
/// Milliseconds since boot. Implementations must be monotonic — section
|
||||||
|
/// 1.5.3 makes trusting the wall clock here a correctness bug, not a style
|
||||||
|
/// one.
|
||||||
|
fn now_ms(&self) -> u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `CLOCK_MONOTONIC` through `nix`.
|
||||||
|
///
|
||||||
|
/// CLOCK_MONOTONIC is the clock section 1.5.3 names for Linux. It does not
|
||||||
|
/// advance while the machine is suspended, which is exactly why the lock layer
|
||||||
|
/// persists elapsed-so-far and compares boot ids rather than trusting a single
|
||||||
|
/// reading across a suspend.
|
||||||
|
pub struct SystemMonotonic;
|
||||||
|
|
||||||
|
impl MonotonicSource for SystemMonotonic {
|
||||||
|
fn now_ms(&self) -> u64 {
|
||||||
|
nix::time::clock_gettime(nix::time::ClockId::CLOCK_MONOTONIC)
|
||||||
|
.map(|t| t.tv_sec() as u64 * 1000 + t.tv_nsec() as u64 / 1_000_000)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a process is currently frozen by us.
|
||||||
|
///
|
||||||
|
/// Reads `/proc/<pid>/stat`, whose state field is one character: `T` is stopped.
|
||||||
|
/// Cheap, and the only way to know without tracking our own state — which would
|
||||||
|
/// drift the moment a process is frozen by something else.
|
||||||
|
pub fn is_frozen(pid: u32) -> io::Result<bool> {
|
||||||
|
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?;
|
||||||
|
// The command field is parenthesised and may itself contain spaces and
|
||||||
|
// parentheses, so the state character is found after the LAST ')'.
|
||||||
|
let after_name = stat.rfind(')').map(|i| &stat[i + 1..]).unwrap_or_default();
|
||||||
|
Ok(after_name.trim_start().starts_with('T'))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! Tests for the enforcement ladder.
|
||||||
|
//!
|
||||||
|
//! `expect`/`unwrap`/`panic` are allowed here, scoped to this module with the
|
||||||
|
//! reason stated: a failed assertion or a missing `sleep` binary is a broken
|
||||||
|
//! test, not a runtime condition to handle gracefully.
|
||||||
|
|
||||||
|
#![allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::panic,
|
||||||
|
reason = "test fixtures: a failed assertion is a broken test, not a runtime condition"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A child process can be frozen, reported frozen, and thawed again.
|
||||||
|
///
|
||||||
|
/// Deliberately *not* done to the test process itself. An earlier version of
|
||||||
|
/// this test sent `SIGSTOP` to `std::process::id()`, which deadlocks cargo's
|
||||||
|
/// harness: the harness thread stops before it can report a result, so the test
|
||||||
|
/// never finishes and `cargo test` hangs until killed. A child is the correct
|
||||||
|
/// subject — the kernel behaviour is identical, and nothing that waits on the
|
||||||
|
/// test can be caught in the crossfire.
|
||||||
|
#[test]
|
||||||
|
fn a_child_process_freezes_reports_and_thaws() {
|
||||||
|
let mut child = std::process::Command::new("sleep").arg("30").spawn().expect("spawn sleep");
|
||||||
|
let pid = child.id();
|
||||||
|
|
||||||
|
assert!(!is_frozen(pid).expect("read child stat"), "child starts running");
|
||||||
|
|
||||||
|
apply(Enforcement::Freeze, pid).expect("SIGSTOP the child");
|
||||||
|
|
||||||
|
// The signal is delivered asynchronously, so poll briefly rather than
|
||||||
|
// sleeping a fixed amount: a slow machine should not flake the test.
|
||||||
|
let mut frozen = false;
|
||||||
|
for _ in 0..50 {
|
||||||
|
if is_frozen(pid).unwrap_or(false) {
|
||||||
|
frozen = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
assert!(frozen, "the child should report stopped after SIGSTOP");
|
||||||
|
|
||||||
|
thaw(pid).expect("SIGCONT the child");
|
||||||
|
|
||||||
|
let mut running = false;
|
||||||
|
for _ in 0..50 {
|
||||||
|
if !is_frozen(pid).unwrap_or(false) {
|
||||||
|
running = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
assert!(running, "the child should be running again after SIGCONT");
|
||||||
|
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signalling a pid we cannot signal is an error, never a panic.
|
||||||
|
///
|
||||||
|
/// CLAUDE.md section 0 rule 5: a fallible OS call must not abort the daemon.
|
||||||
|
/// A daemon that panics takes enforcement down with it.
|
||||||
|
#[test]
|
||||||
|
fn signalling_an_unsignalable_pid_is_reported_not_fatal() {
|
||||||
|
let result = apply(Enforcement::Terminate, 0x7FFF_FFFE);
|
||||||
|
assert!(result.is_err(), "an unsignalable pid must be an error");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actions that need a window or a compositor are no-ops here, and succeed.
|
||||||
|
#[test]
|
||||||
|
fn compositor_actions_are_no_ops_at_this_layer() {
|
||||||
|
assert!(apply(Enforcement::BlockScreen, 0).is_ok());
|
||||||
|
assert!(apply(Enforcement::Minimise, 0).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A missing process is distinguished from other signalling failures.
|
||||||
|
#[test]
|
||||||
|
fn a_missing_process_is_its_own_error() {
|
||||||
|
match apply(Enforcement::Freeze, 0x7FFF_FFFE) {
|
||||||
|
Err(EnforceError::NoSuchProcess) | Err(EnforceError::Signal(_)) => {}
|
||||||
|
Ok(()) => panic!("signalling a nonexistent pid must not succeed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Freezing is reversible and non-destructive — the reason it is preferred to
|
||||||
|
/// terminating.
|
||||||
|
#[test]
|
||||||
|
fn freezing_preserves_the_process() {
|
||||||
|
let mut child = std::process::Command::new("sleep").arg("30").spawn().expect("spawn sleep");
|
||||||
|
let pid = child.id();
|
||||||
|
|
||||||
|
apply(Enforcement::Freeze, pid).expect("SIGSTOP");
|
||||||
|
for _ in 0..50 {
|
||||||
|
if is_frozen(pid).unwrap_or(false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
thaw(pid).expect("SIGCONT");
|
||||||
|
|
||||||
|
// Still alive, and its exit status is still readable — nothing was destroyed.
|
||||||
|
assert!(!is_frozen(pid).unwrap_or(true), "the child must be running again after SIGCONT");
|
||||||
|
assert_eq!(
|
||||||
|
child.try_wait().expect("try_wait").map(|s| s.to_string()),
|
||||||
|
None,
|
||||||
|
"the process must still be running after freeze and thaw, not reaped"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
//! 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.
|
||||||
|
if (workspace.activeWindow) {
|
||||||
|
reportFocus(workspace.activeWindow);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// 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"))),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -39,6 +39,21 @@
|
|||||||
//! - None of this can be made tamper-proof against a user with root. Section
|
//! - 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.
|
//! 6.1 requires this be documented honestly rather than implied otherwise.
|
||||||
|
|
||||||
|
pub mod boot;
|
||||||
|
pub mod enforce;
|
||||||
|
pub mod kwin;
|
||||||
|
pub mod notify;
|
||||||
|
pub mod time;
|
||||||
|
pub mod watcher;
|
||||||
|
pub mod x11;
|
||||||
|
|
||||||
|
pub use enforce::{MonotonicSource, SystemMonotonic};
|
||||||
|
pub use kwin::parse_payload;
|
||||||
|
|
||||||
|
// Re-exported so the daemon can name a window without reaching into the
|
||||||
|
// backend module — the backend is an implementation detail, the window is not.
|
||||||
|
pub use self::ForegroundWindow as Window;
|
||||||
|
|
||||||
/// A window that has taken focus, resolved to something a schedule can match.
|
/// A window that has taken focus, resolved to something a schedule can match.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ForegroundWindow {
|
pub struct ForegroundWindow {
|
||||||
@@ -64,6 +79,8 @@ pub enum FocusEvent {
|
|||||||
Activated(ForegroundWindow),
|
Activated(ForegroundWindow),
|
||||||
/// The focused window closed, so there is no longer a foreground window.
|
/// The focused window closed, so there is no longer a foreground window.
|
||||||
Cleared,
|
Cleared,
|
||||||
|
/// Reload active schedules from storage triggered by UI IPC.
|
||||||
|
ReloadSchedules,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Source of focus events.
|
/// Source of focus events.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//! Desktop notifications for Blocker enforcement actions.
|
||||||
|
//!
|
||||||
|
//! Uses the standard freedesktop Notifications D-Bus specification
|
||||||
|
//! (`org.freedesktop.Notifications`).
|
||||||
|
|
||||||
|
use zbus::blocking::Connection;
|
||||||
|
|
||||||
|
/// Send a non-disruptive desktop notification when enforcement triggers.
|
||||||
|
pub fn send_notification(summary: &str, body: &str) -> Result<u32, zbus::Error> {
|
||||||
|
let connection = Connection::session()?;
|
||||||
|
let proxy = zbus::blocking::Proxy::new(
|
||||||
|
&connection,
|
||||||
|
"org.freedesktop.Notifications",
|
||||||
|
"/org/freedesktop/Notifications",
|
||||||
|
"org.freedesktop.Notifications",
|
||||||
|
)?;
|
||||||
|
let id: u32 = proxy.call(
|
||||||
|
"Notify",
|
||||||
|
&(
|
||||||
|
"Blocker",
|
||||||
|
0u32,
|
||||||
|
"dialog-information",
|
||||||
|
summary,
|
||||||
|
body,
|
||||||
|
Vec::<String>::new(),
|
||||||
|
std::collections::HashMap::<String, zbus::zvariant::Value>::new(),
|
||||||
|
5000i32,
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! Wall clock and timezone query implementation for Linux.
|
||||||
|
//!
|
||||||
|
//! Provides the current [`CivilTime`] with local timezone offset without pulling in
|
||||||
|
//! heavy third-party datetime crates, and purely in safe Rust (`forbid(unsafe_code)`).
|
||||||
|
|
||||||
|
use blocker_core::engine::CivilTime;
|
||||||
|
use std::fs;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// Read current civil time and local UTC offset in minutes.
|
||||||
|
pub fn now() -> CivilTime {
|
||||||
|
let now = SystemTime::now();
|
||||||
|
let epoch_secs = now.duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
|
||||||
|
let epoch_minutes = epoch_secs / 60;
|
||||||
|
let utc_offset_minutes = read_local_offset_minutes();
|
||||||
|
|
||||||
|
CivilTime { epoch_minutes, utc_offset_minutes }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse local UTC offset in minutes from `/etc/localtime` TZ string or standard fallback.
|
||||||
|
fn read_local_offset_minutes() -> i32 {
|
||||||
|
// Check TZ environment variable first
|
||||||
|
if let Ok(tz) = std::env::var("TZ") {
|
||||||
|
if let Some(offset) = parse_tz_string_offset(&tz) {
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try reading TZ footer string from /etc/localtime (POSIX TZ string at end of TZif file)
|
||||||
|
if let Ok(bytes) = fs::read("/etc/localtime") {
|
||||||
|
if let Some(offset) = parse_tzif_footer_offset(&bytes) {
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the trailing newline-terminated POSIX TZ string in a TZif file (RFC 8536).
|
||||||
|
/// E.g. "\nMSK-3\n" -> +3 hours -> +180 minutes.
|
||||||
|
/// Note that POSIX TZ string syntax inverts the sign: "MSK-3" means GMT+3!
|
||||||
|
fn parse_tzif_footer_offset(bytes: &[u8]) -> Option<i32> {
|
||||||
|
if bytes.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Search backward for the last newline
|
||||||
|
let mut end = bytes.len();
|
||||||
|
if bytes[end - 1] == b'\n' {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
let start = bytes[..end].iter().rposition(|&b| b == b'\n')? + 1;
|
||||||
|
let tz_str = std::str::from_utf8(&bytes[start..end]).ok()?;
|
||||||
|
parse_tz_string_offset(tz_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a POSIX TZ string like "MSK-3" or "UTC" or "EST5EDT".
|
||||||
|
fn parse_tz_string_offset(tz: &str) -> Option<i32> {
|
||||||
|
let bytes = tz.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
// Skip name (letters or <...>)
|
||||||
|
if i < bytes.len() && bytes[i] == b'<' {
|
||||||
|
i += 1;
|
||||||
|
while i < bytes.len() && bytes[i] != b'>' {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if i < bytes.len() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while i < bytes.len() && (bytes[i].is_ascii_alphabetic()) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if i >= bytes.len() {
|
||||||
|
return Some(0); // e.g. "UTC"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next is optional sign: '+' or '-'
|
||||||
|
let mut sign = 1; // Default no sign is positive in POSIX, which represents behind UTC (e.g. EST5 = UTC-5)
|
||||||
|
if bytes[i] == b'+' {
|
||||||
|
sign = 1;
|
||||||
|
i += 1;
|
||||||
|
} else if bytes[i] == b'-' {
|
||||||
|
sign = -1;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read hours
|
||||||
|
let h_start = i;
|
||||||
|
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if h_start == i {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hours: i32 = std::str::from_utf8(&bytes[h_start..i]).ok()?.parse().ok()?;
|
||||||
|
let mut minutes = 0;
|
||||||
|
|
||||||
|
// Optional :mm
|
||||||
|
if i < bytes.len() && bytes[i] == b':' {
|
||||||
|
i += 1;
|
||||||
|
let m_start = i;
|
||||||
|
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if m_start < i {
|
||||||
|
minutes = std::str::from_utf8(&bytes[m_start..i]).ok()?.parse().ok().unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_mins = hours * 60 + minutes;
|
||||||
|
Some(-sign * total_mins)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_tz_examples() {
|
||||||
|
assert_eq!(parse_tz_string_offset("UTC"), Some(0));
|
||||||
|
assert_eq!(parse_tz_string_offset("MSK-3"), Some(180));
|
||||||
|
assert_eq!(parse_tz_string_offset("EST5"), Some(-300));
|
||||||
|
assert_eq!(parse_tz_string_offset("CST6CDT"), Some(-360));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn now_produces_reasonable_civil_time() {
|
||||||
|
let t = now();
|
||||||
|
assert!(t.epoch_minutes > 28_000_000);
|
||||||
|
assert!(t.utc_offset_minutes >= -720 && t.utc_offset_minutes <= 840);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! The D-Bus watcher server: the daemon's public side of the KWin handshake.
|
||||||
|
//!
|
||||||
|
//! KWin calls *us*, not the other way round. The compositor has no way to push a
|
||||||
|
//! focus event to an arbitrary subscriber, so the daemon owns a bus name and
|
||||||
|
//! exports an interface that the loaded KWin script calls into. This module owns
|
||||||
|
//! that name and turns those calls into [`FocusEvent`]s on a channel.
|
||||||
|
//!
|
||||||
|
//! # Owning the name is not optional
|
||||||
|
//!
|
||||||
|
//! Without the daemon holding `vu.valiq.blocker`, KWin's `callDBus` has nothing
|
||||||
|
//! to resolve and every focus event is silently discarded. That is why
|
||||||
|
//! [`serve`] must succeed *before* the script is loaded.
|
||||||
|
//!
|
||||||
|
//! # Fail-open
|
||||||
|
//!
|
||||||
|
//! If the daemon is not running, the KWin script's `callDBus` fails inside a
|
||||||
|
//! signal handler and the compositor is left alone — no exception, no log spam.
|
||||||
|
//! Blocking stops; the desktop keeps working. That is section 1.5.4's rule and
|
||||||
|
//! it is why the script swallows its errors.
|
||||||
|
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
use zbus::blocking::{connection::Builder, Connection};
|
||||||
|
|
||||||
|
use super::kwin::{BUS_NAME, OBJECT_PATH};
|
||||||
|
use super::{parse_payload, FocusEvent};
|
||||||
|
|
||||||
|
/// Why the watcher could not start.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum WatcherError {
|
||||||
|
/// No session bus, so there is nothing to own a name on. Almost always
|
||||||
|
/// means the daemon was started outside a desktop session — over SSH, or
|
||||||
|
/// from a system unit rather than a user one.
|
||||||
|
NoSessionBus(String),
|
||||||
|
/// The name could not be acquired, which normally means another instance is
|
||||||
|
/// already running.
|
||||||
|
NameTaken(String),
|
||||||
|
/// Anything else zbus reports.
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for WatcherError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NoSessionBus(e) => write!(f, "no session bus: {e}"),
|
||||||
|
Self::NameTaken(e) => write!(f, "another watcher already owns {BUS_NAME}: {e}"),
|
||||||
|
Self::Other(e) => write!(f, "watcher could not start: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for WatcherError {}
|
||||||
|
|
||||||
|
/// The object the KWin script calls into.
|
||||||
|
///
|
||||||
|
/// One method, `Focused`, whose payload is the tab-separated window description
|
||||||
|
/// built by `kwin::SCRIPT_JS`. An empty payload means focus was cleared.
|
||||||
|
struct Watcher {
|
||||||
|
sink: Sender<FocusEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[zbus::interface(name = "vu.valiq.blocker.Watcher")]
|
||||||
|
impl Watcher {
|
||||||
|
/// Called by the KWin script on every focus change.
|
||||||
|
///
|
||||||
|
/// Payload is tab-separated: resourceClass, resourceName, caption,
|
||||||
|
/// internalId, pid — in the order `kwin::SCRIPT_JS` writes them.
|
||||||
|
fn focused(&self, payload: &str) {
|
||||||
|
let event = match parse_payload(payload) {
|
||||||
|
Some(window) => FocusEvent::Activated(window),
|
||||||
|
None => FocusEvent::Cleared,
|
||||||
|
};
|
||||||
|
// A send failure means the daemon has stopped. Nothing to escalate: the
|
||||||
|
// thread exits when the connection drops.
|
||||||
|
let _ = self.sink.send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instruct the daemon to reload schedules from the database immediately.
|
||||||
|
fn reload_schedules(&self) {
|
||||||
|
let _ = self.sink.send(FocusEvent::ReloadSchedules);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ping method to check if the daemon is alive.
|
||||||
|
fn ping(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire the bus name and export [`Watcher`], returning the live connection.
|
||||||
|
///
|
||||||
|
/// The connection must be held for the export to stay registered, so the caller
|
||||||
|
/// keeps it for the daemon's lifetime.
|
||||||
|
///
|
||||||
|
/// Note on ordering: the daemon calls [`serve`] *before*
|
||||||
|
/// [`super::kwin::KwinBackend::connect`]. Loading the script first would start a
|
||||||
|
/// handler calling a name nobody owns, and every event would be lost.
|
||||||
|
pub fn serve(sink: Sender<FocusEvent>) -> Result<Connection, WatcherError> {
|
||||||
|
Builder::session()
|
||||||
|
.map_err(|e| WatcherError::NoSessionBus(e.to_string()))?
|
||||||
|
.name(BUS_NAME)
|
||||||
|
.map_err(|e| WatcherError::NameTaken(e.to_string()))?
|
||||||
|
// `#[zbus::interface]` implements `Interface` for the struct, so the
|
||||||
|
// instance itself is what gets registered — it holds the channel.
|
||||||
|
.serve_at(OBJECT_PATH, Watcher { sink })
|
||||||
|
.map_err(|e| WatcherError::Other(e.to_string()))?
|
||||||
|
.build()
|
||||||
|
.map_err(|e| {
|
||||||
|
// zbus cannot distinguish "name already owned" from other failures
|
||||||
|
// at build time, so the message is what separates them. A name clash
|
||||||
|
// is the common case and deserves the clearer variant.
|
||||||
|
let rendered = e.to_string();
|
||||||
|
if rendered.contains("taken") || rendered.contains("already") {
|
||||||
|
WatcherError::NameTaken(rendered)
|
||||||
|
} else {
|
||||||
|
WatcherError::Other(rendered)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the bus name, so a restarted daemon does not collide with a stale one.
|
||||||
|
///
|
||||||
|
/// Called before exiting. Not cosmetic: systemd `Restart=always` can start the
|
||||||
|
/// replacement before the old process is fully gone, and losing that race would
|
||||||
|
/// leave the new instance wedged on a name its predecessor still holds.
|
||||||
|
pub fn release(connection: &Connection) {
|
||||||
|
let _ = connection.release_name(BUS_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the running daemon via D-Bus to reload schedules from storage.
|
||||||
|
pub fn notify_daemon_reload() -> Result<(), zbus::Error> {
|
||||||
|
let conn = Connection::session()?;
|
||||||
|
let proxy =
|
||||||
|
zbus::blocking::Proxy::new(&conn, BUS_NAME, OBJECT_PATH, "vu.valiq.blocker.Watcher")?;
|
||||||
|
let _: () = proxy.call("ReloadSchedules", &())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query if the daemon is running and responding on D-Bus.
|
||||||
|
pub fn is_daemon_running() -> bool {
|
||||||
|
if let Ok(conn) = Connection::session() {
|
||||||
|
if let Ok(proxy) =
|
||||||
|
zbus::blocking::Proxy::new(&conn, BUS_NAME, OBJECT_PATH, "vu.valiq.blocker.Watcher")
|
||||||
|
{
|
||||||
|
let res: zbus::Result<bool> = proxy.call("Ping", &());
|
||||||
|
return res.unwrap_or(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -24,4 +24,8 @@ path = "src/main.rs"
|
|||||||
# that should decide it.
|
# that should decide it.
|
||||||
# - Either way the daemon must not grow past 5 MB stripped (section 5), and
|
# - Either way the daemon must not grow past 5 MB stripped (section 5), and
|
||||||
# the UI must not hold a GL context resident while idle.
|
# the UI must not hold a GL context resident while idle.
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
eframe = "0.36.2"
|
||||||
|
blocker-core = { path = "../../core" }
|
||||||
|
blocker-storage = { path = "../../storage" }
|
||||||
|
blocker-platform-linux = { path = "../platform-linux" }
|
||||||
|
|||||||
+289
-24
@@ -1,27 +1,292 @@
|
|||||||
//! Blocker block-screen UI.
|
//! Blocker GUI: Calm Block Screen and Schedule Manager.
|
||||||
//!
|
//!
|
||||||
//! Placeholder. No UI toolkit is bound here yet — see ADR-6 in
|
//! Built with egui/eframe per ADR-6.
|
||||||
//! `docs/DECISIONS.md` for why that is an open decision rather than an oversight.
|
|
||||||
//!
|
|
||||||
//! # What this process is for
|
|
||||||
//!
|
|
||||||
//! It is a short-lived, separately-executable window. The daemon stays resident
|
|
||||||
//! and small; this launches when a block fires and exits when it lifts. Keeping
|
|
||||||
//! them separate means a crash in window rendering cannot take enforcement down.
|
|
||||||
//!
|
|
||||||
//! # Design constraints that will apply (docs/UX-BLOCK-SCREEN.md)
|
|
||||||
//!
|
|
||||||
//! - Calm, not punitive. No red-alert styling, no shaming copy, no streak
|
|
||||||
//! counters. The screen exists to end an argument with the user's own
|
|
||||||
//! intention, so it must lower arousal.
|
|
||||||
//! - It always states which schedule blocked and when it lifts. Uncertainty is
|
|
||||||
//! what makes people fight the tool.
|
|
||||||
//! - It is never a dead end: a plain-looking "How to turn this off" entry is
|
|
||||||
//! always present, including under Strict Mode (CLAUDE.md section 1.5.4).
|
|
||||||
//! - Under Strict Mode the *absence* of a bypass button is stated explicitly.
|
|
||||||
//! Silently missing controls read as a bug and breed distrust.
|
|
||||||
|
|
||||||
fn main() {
|
use eframe::egui;
|
||||||
println!("blocker-ui {} (M0 placeholder)", env!("CARGO_PKG_VERSION"));
|
use std::env;
|
||||||
println!("UI toolkit undecided — ADR-6 in docs/DECISIONS.md");
|
|
||||||
|
fn main() -> eframe::Result<()> {
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
let is_block_screen = args.iter().any(|a| a == "--block" || a == "-b");
|
||||||
|
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size(if is_block_screen { [520.0, 360.0] } else { [720.0, 520.0] })
|
||||||
|
.with_min_inner_size([400.0, 300.0])
|
||||||
|
.with_title(if is_block_screen { "Blocker — Stay Focused" } else { "Blocker" })
|
||||||
|
.with_always_on_top(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"Blocker",
|
||||||
|
options,
|
||||||
|
Box::new(move |cc| {
|
||||||
|
setup_custom_style(&cc.egui_ctx);
|
||||||
|
Ok(Box::new(BlockerApp::new(is_block_screen)))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_custom_style(ctx: &egui::Context) {
|
||||||
|
let mut visuals = egui::Visuals::dark();
|
||||||
|
visuals.override_text_color = Some(egui::Color32::from_rgb(220, 225, 230));
|
||||||
|
visuals.panel_fill = egui::Color32::from_rgb(26, 32, 44);
|
||||||
|
visuals.window_fill = egui::Color32::from_rgb(26, 32, 44);
|
||||||
|
ctx.set_visuals(visuals);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BlockerApp {
|
||||||
|
is_block_screen: bool,
|
||||||
|
active_tab: Tab,
|
||||||
|
schedules: Vec<blocker_core::model::Schedule>,
|
||||||
|
target_input: String,
|
||||||
|
schedule_name: String,
|
||||||
|
status_message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
enum Tab {
|
||||||
|
Status,
|
||||||
|
Schedules,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlockerApp {
|
||||||
|
fn new(is_block_screen: bool) -> Self {
|
||||||
|
let schedules = Self::load_schedules_from_db();
|
||||||
|
Self {
|
||||||
|
is_block_screen,
|
||||||
|
active_tab: if is_block_screen { Tab::Status } else { Tab::Schedules },
|
||||||
|
schedules,
|
||||||
|
target_input: String::new(),
|
||||||
|
schedule_name: "Focus Hours".to_string(),
|
||||||
|
status_message: "Ready".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db_path() -> std::path::PathBuf {
|
||||||
|
let base =
|
||||||
|
std::env::var_os("XDG_DATA_HOME").map(std::path::PathBuf::from).unwrap_or_else(|| {
|
||||||
|
let home = std::env::var_os("HOME").unwrap_or_else(|| ".".into());
|
||||||
|
std::path::PathBuf::from(home).join(".local/share")
|
||||||
|
});
|
||||||
|
base.join("blocker/blocker.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_schedules_from_db() -> Vec<blocker_core::model::Schedule> {
|
||||||
|
let path = Self::db_path();
|
||||||
|
if let Ok(store) = blocker_storage::Store::open(&path) {
|
||||||
|
store.load_schedules().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_schedules_to_db(&self) -> Result<(), String> {
|
||||||
|
let path = Self::db_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let mut store = blocker_storage::Store::open(&path).map_err(|e| e.to_string())?;
|
||||||
|
store.save_schedules(&self.schedules).map_err(|e| e.to_string())?;
|
||||||
|
let _ = blocker_platform_linux::watcher::notify_daemon_reload();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for BlockerApp {
|
||||||
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
|
if self.is_block_screen {
|
||||||
|
self.render_block_screen(ui);
|
||||||
|
} else {
|
||||||
|
self.render_manager_ui(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlockerApp {
|
||||||
|
fn render_block_screen(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.add_space(32.0);
|
||||||
|
ui.heading(
|
||||||
|
egui::RichText::new("Focus Mode Active")
|
||||||
|
.size(24.0)
|
||||||
|
.color(egui::Color32::from_rgb(226, 232, 240)),
|
||||||
|
);
|
||||||
|
ui.add_space(16.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"This application is currently paused by your scheduled focus block.",
|
||||||
|
)
|
||||||
|
.size(15.0)
|
||||||
|
.color(egui::Color32::from_rgb(160, 174, 192)),
|
||||||
|
);
|
||||||
|
ui.add_space(32.0);
|
||||||
|
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.set_width(380.0);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("Reason: Scheduled focus time")
|
||||||
|
.color(egui::Color32::from_rgb(203, 213, 225)),
|
||||||
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("State: Process paused safely (SIGSTOP)")
|
||||||
|
.color(egui::Color32::from_rgb(148, 163, 184)),
|
||||||
|
);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(36.0);
|
||||||
|
if ui
|
||||||
|
.button(
|
||||||
|
egui::RichText::new("Open Schedule Settings")
|
||||||
|
.color(egui::Color32::from_rgb(99, 179, 237)),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.is_block_screen = false;
|
||||||
|
self.active_tab = Tab::Schedules;
|
||||||
|
}
|
||||||
|
ui.add_space(12.0);
|
||||||
|
if ui.button("Dismiss").clicked() {
|
||||||
|
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_manager_ui(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.heading("Blocker — Focus Manager");
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.selectable_label(self.active_tab == Tab::Schedules, "Schedules").clicked() {
|
||||||
|
self.active_tab = Tab::Schedules;
|
||||||
|
}
|
||||||
|
if ui.selectable_label(self.active_tab == Tab::Status, "Status").clicked() {
|
||||||
|
self.active_tab = Tab::Status;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
match self.active_tab {
|
||||||
|
Tab::Status => {
|
||||||
|
ui.heading("Current Status");
|
||||||
|
ui.add_space(12.0);
|
||||||
|
let daemon_live = blocker_platform_linux::watcher::is_daemon_running();
|
||||||
|
if daemon_live {
|
||||||
|
ui.colored_label(
|
||||||
|
egui::Color32::from_rgb(0, 200, 100),
|
||||||
|
"Daemon Status: Running (D-Bus connected)",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ui.colored_label(
|
||||||
|
egui::Color32::from_rgb(220, 80, 80),
|
||||||
|
"Daemon Status: Inactive (not detected on D-Bus)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ui.label(format!("Active Schedules: {}", self.schedules.len()));
|
||||||
|
ui.add_space(16.0);
|
||||||
|
if ui.button("Test Block Notification").clicked() {
|
||||||
|
let _ = blocker_platform_linux::notify::send_notification(
|
||||||
|
"Blocker Notification",
|
||||||
|
"Focus notification system verified live.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tab::Schedules => {
|
||||||
|
ui.heading("Focus Schedules");
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// List existing schedules
|
||||||
|
if self.schedules.is_empty() {
|
||||||
|
ui.label("No active schedules configured yet.");
|
||||||
|
} else {
|
||||||
|
let mut to_remove = None;
|
||||||
|
for (idx, sched) in self.schedules.iter().enumerate() {
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.strong(&sched.name);
|
||||||
|
ui.label(format!("({:?})", sched.mode));
|
||||||
|
ui.with_layout(
|
||||||
|
egui::Layout::right_to_left(egui::Align::Center),
|
||||||
|
|ui| {
|
||||||
|
if ui.button("Delete").clicked() {
|
||||||
|
to_remove = Some(idx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.label(format!(
|
||||||
|
"Targets: {}",
|
||||||
|
sched
|
||||||
|
.targets
|
||||||
|
.iter()
|
||||||
|
.map(|t| match t {
|
||||||
|
blocker_core::model::Target::App(a) => a.clone(),
|
||||||
|
blocker_core::model::Target::Website(w) =>
|
||||||
|
format!("web:{w}"),
|
||||||
|
blocker_core::model::Target::Keyword(k) =>
|
||||||
|
format!("kw:{k}"),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
});
|
||||||
|
ui.add_space(4.0);
|
||||||
|
}
|
||||||
|
if let Some(idx) = to_remove {
|
||||||
|
self.schedules.remove(idx);
|
||||||
|
if let Err(e) = self.save_schedules_to_db() {
|
||||||
|
self.status_message = format!("Error saving: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.heading("Add New Schedule");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Name:");
|
||||||
|
ui.text_edit_singleline(&mut self.schedule_name);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("App Target (e.g. steam, firefox):");
|
||||||
|
ui.text_edit_singleline(&mut self.target_input);
|
||||||
|
});
|
||||||
|
ui.add_space(8.0);
|
||||||
|
if ui.button("Add App Block Schedule").clicked() {
|
||||||
|
let target = self.target_input.trim();
|
||||||
|
if !target.is_empty() {
|
||||||
|
let new_sched = blocker_core::model::Schedule {
|
||||||
|
id: format!(
|
||||||
|
"sched-{}",
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
),
|
||||||
|
name: self.schedule_name.clone(),
|
||||||
|
icon: None,
|
||||||
|
mode: blocker_core::model::ScheduleMode::Blocklist,
|
||||||
|
targets: vec![blocker_core::model::Target::App(target.to_string())],
|
||||||
|
conditions: vec![],
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
|
self.schedules.push(new_sched);
|
||||||
|
if let Err(e) = self.save_schedules_to_db() {
|
||||||
|
self.status_message = format!("Error saving: {e}");
|
||||||
|
} else {
|
||||||
|
self.status_message = "Schedule saved!".to_string();
|
||||||
|
self.target_input.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-15
@@ -27,7 +27,7 @@ which parts to trust.
|
|||||||
| [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted |
|
| [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted |
|
||||||
| [4](#adr-4) | SQLite via rusqlite, in its own crate | Accepted, one trade-off open |
|
| [4](#adr-4) | SQLite via rusqlite, in its own crate | Accepted, one trade-off open |
|
||||||
| [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed |
|
| [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed |
|
||||||
| [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** |
|
| [6](#adr-6) | Desktop UI toolkit — egui | **Accepted** |
|
||||||
| [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** |
|
| [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** |
|
||||||
| [8](#adr-8) | Lock levels and explicit opt-in | Accepted |
|
| [8](#adr-8) | Lock levels and explicit opt-in | Accepted |
|
||||||
| [9](#adr-9) | Monotonic clock for all lock timers | Accepted |
|
| [9](#adr-9) | Monotonic clock for all lock timers | Accepted |
|
||||||
@@ -169,29 +169,41 @@ to commit, and the measurement belongs to M3-Android.
|
|||||||
---
|
---
|
||||||
|
|
||||||
<a id="adr-6"></a>
|
<a id="adr-6"></a>
|
||||||
## ADR-6: Desktop UI toolkit — Slint vs egui
|
## ADR-6: Desktop UI toolkit — egui
|
||||||
|
|
||||||
**Status:** **Proposed — awaiting the owner's decision**
|
**Status:** Accepted (the owner chose egui, 2026-10-01)
|
||||||
|
|
||||||
**Context.** Section 3 requires the toolkit to be chosen on a measured
|
**Context.** Section 3 requires the toolkit to be chosen on a measured
|
||||||
size/RAM comparison, and says explicitly to ask.
|
size/RAM comparison, and says explicitly to ask.
|
||||||
|
|
||||||
**Decision.** Not yet made.
|
**Decision.** **egui**, licensed MIT OR Apache-2.0.
|
||||||
|
|
||||||
**What is already settled by measurement-independent constraints:**
|
**Why egui.**
|
||||||
|
|
||||||
- **Slint is GPL-3.0 or commercial.** For a project intended to be published,
|
- **The licence decides part of it.** Slint is GPL-3.0 or commercial, so choosing
|
||||||
the permissive alternatives are the safer default. If the size comparison comes
|
it would force the project under copyleft or require a paid licence. The owner
|
||||||
out close, licensing should decide it.
|
has left the project licence open (ADR-17), so this is a real constraint
|
||||||
- Either way the daemon must stay under 5 MB stripped, and the UI must not hold a
|
rather than a formality — and it is why ADR-17 is upstream of this decision.
|
||||||
GL context resident while idle.
|
- **The budget is adequate.** 2.9 MB of the 5 MB stripped daemon budget remained
|
||||||
|
after storage (1.364 MB) and the D-Bus/X11 backends (2.237 MB) were linked in.
|
||||||
|
A block screen is text and a countdown; it does not need more.
|
||||||
|
- **Idle cost is the deciding factor.** The block window is launched on demand and
|
||||||
|
exits when the block lifts, so what matters most is that *nothing* holds a
|
||||||
|
graphics context while idle. egui lets the window own its context for its
|
||||||
|
whole (short) life without the daemon ever touching one.
|
||||||
|
|
||||||
**What still needs measuring:** stripped binary size for a minimal window, idle
|
**What this does not decide.** The measurement the section asks for is still worth
|
||||||
RSS, dependency count and compile time, and behaviour on KDE Plasma 6 Wayland
|
doing — it is recorded in `PERF.md` — but it is now confirmatory rather than
|
||||||
specifically — this project must work on KDE, so winit/Slint quirks on that
|
decisive.
|
||||||
compositor matter more than generic Wayland support.
|
|
||||||
|
|
||||||
Recorded rather than decided because section 3 says to ask.
|
**Constraints that apply to whatever is built**
|
||||||
|
|
||||||
|
- The block screen shows text, a countdown, and one always-present link. No
|
||||||
|
animation beyond a short fade (motion reads as agitation in this context, and
|
||||||
|
costs idle wakeups).
|
||||||
|
- Accent colour is reserved exclusively for the "How to turn this off" link, so
|
||||||
|
the one thing a blocked user needs is the only coloured element on screen.
|
||||||
|
- No red, and no shaming copy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
|
export PATH="$HERE/usr/bin:$PATH"
|
||||||
|
export XDG_DATA_DIRS="$HERE/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
daemon)
|
||||||
|
shift
|
||||||
|
exec "$HERE/usr/bin/blocker-daemon" "$@"
|
||||||
|
;;
|
||||||
|
ui)
|
||||||
|
shift
|
||||||
|
exec "$HERE/usr/bin/blocker-ui" "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Default to launching the GUI if launched without arguments, or daemon if requested
|
||||||
|
if [ -n "$DISPLAY" ] || [ -n "$WAYLAND_DISPLAY" ]; then
|
||||||
|
exec "$HERE/usr/bin/blocker-ui" "$@"
|
||||||
|
else
|
||||||
|
exec "$HERE/usr/bin/blocker-daemon" "$@"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Name=Blocker
|
||||||
|
Comment=Focus and app blocking daemon
|
||||||
|
Exec=AppRun
|
||||||
|
Icon=blocker
|
||||||
|
Type=Application
|
||||||
|
Categories=Utility;
|
||||||
|
Terminal=true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<circle cx="32" cy="32" r="30" fill="#2d3748" stroke="#4a5568" stroke-width="2"/>
|
||||||
|
<circle cx="32" cy="32" r="22" fill="none" stroke="#e53e3e" stroke-width="4"/>
|
||||||
|
<line x1="16" y1="16" x2="48" y2="48" stroke="#e53e3e" stroke-width="4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Blocker Focus Daemon
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
After=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=%h/.local/bin/blocker-daemon
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build an AppImage packaging blocker-daemon, blocker-ui and assets.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
DIST_DIR="$ROOT_DIR/dist"
|
||||||
|
APPDIR="$DIST_DIR/AppDir"
|
||||||
|
PKG_DIR="$ROOT_DIR/packaging/appimage"
|
||||||
|
|
||||||
|
echo "Building release binaries..."
|
||||||
|
cargo build --release -p blocker-daemon -p blocker-ui
|
||||||
|
|
||||||
|
echo "Preparing AppDir structure..."
|
||||||
|
mkdir -p "$APPDIR"
|
||||||
|
mkdir -p "$APPDIR/usr/bin"
|
||||||
|
mkdir -p "$APPDIR/usr/share/applications"
|
||||||
|
mkdir -p "$APPDIR/usr/share/icons/hicolor/scalable/apps"
|
||||||
|
|
||||||
|
cp "$ROOT_DIR/target/release/blocker-daemon" "$APPDIR/usr/bin/"
|
||||||
|
cp "$ROOT_DIR/target/release/blocker-ui" "$APPDIR/usr/bin/"
|
||||||
|
cp "$PKG_DIR/AppRun" "$APPDIR/AppRun"
|
||||||
|
cp "$PKG_DIR/blocker.desktop" "$APPDIR/blocker.desktop"
|
||||||
|
cp "$PKG_DIR/blocker.desktop" "$APPDIR/usr/share/applications/"
|
||||||
|
cp "$PKG_DIR/blocker.svg" "$APPDIR/blocker.svg"
|
||||||
|
cp "$PKG_DIR/blocker.svg" "$APPDIR/usr/share/icons/hicolor/scalable/apps/"
|
||||||
|
chmod +x "$APPDIR/AppRun"
|
||||||
|
|
||||||
|
SQUASHFS_IMG="$DIST_DIR/appdir.squashfs"
|
||||||
|
OUTPUT_APPIMAGE="$DIST_DIR/Blocker-x86_64.AppImage"
|
||||||
|
RUNTIME_BIN="$PKG_DIR/bin/runtime-x86_64"
|
||||||
|
|
||||||
|
rm -f "$OUTPUT_APPIMAGE" "$SQUASHFS_IMG"
|
||||||
|
|
||||||
|
echo "Creating AppImage with mksquashfs..."
|
||||||
|
mksquashfs "$APPDIR" "$SQUASHFS_IMG" -root-owned -noappend -comp gzip
|
||||||
|
|
||||||
|
if [ -f "$RUNTIME_BIN" ]; then
|
||||||
|
echo "Embedding AppImage Type 2 ELF runtime..."
|
||||||
|
cat "$RUNTIME_BIN" "$SQUASHFS_IMG" > "$OUTPUT_APPIMAGE"
|
||||||
|
printf '\x41\x49\x02' | dd of="$OUTPUT_APPIMAGE" bs=1 seek=8 count=3 conv=notrunc 2>/dev/null
|
||||||
|
rm -f "$SQUASHFS_IMG"
|
||||||
|
else
|
||||||
|
mv "$SQUASHFS_IMG" "$OUTPUT_APPIMAGE"
|
||||||
|
fi
|
||||||
|
chmod +x "$OUTPUT_APPIMAGE"
|
||||||
|
|
||||||
|
echo "Built AppImage: $OUTPUT_APPIMAGE"
|
||||||
|
ls -lh "$OUTPUT_APPIMAGE"
|
||||||
Reference in New Issue
Block a user