//! Blocker desktop daemon. //! //! The daemon observes the OS and enforces the decisions `blocker-core` makes. //! It contains **no decision logic of its own** (CLAUDE.md section 2): if the //! daemon ever decides that something should be blocked, the Android and desktop //! builds will drift and the golden tables stop being a parity contract. //! //! # Shape //! //! Two processes, deliberately: //! //! - the **daemon** — small, long-lived, holds the block window machinery and //! the engine connection. This is the binary with the 5 MB stripped budget //! (section 5). //! - the **UI** (`blocker-ui`) — a separate process launched to show a block //! screen, which exits when the block lifts. //! //! Splitting them keeps resident memory low and means a crash in the UI cannot //! take enforcement down with it. //! //! # Event loop //! //! M2-Linux replaces the startup path below with a loop that blocks on exactly //! three things: a focus-change event from the active //! [`ForegroundWatcher`](../../blocker_platform_linux/index.html) backend, the //! `next_wakeup` instant from the engine, and IPC from the UI. There is no //! polling tick anywhere — an idle daemon takes zero wakeups, which is how the //! section 5 idle-CPU budget is met. //! //! # Where the database lives //! //! Under the XDG data directory, never beside the binary. The binary may be //! installed read-only in `/usr/bin`, while the database must be writable and //! must survive an upgrade. mod runner; use std::path::PathBuf; use blocker_platform_linux::kwin::BUS_NAME; use std::process::ExitCode; /// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`. fn database_path() -> PathBuf { let base = std::env::var_os("XDG_DATA_HOME") .map(PathBuf::from) .filter(|p| p.is_absolute()) .unwrap_or_else(|| { let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")); home.join(".local/share") }); base.join("blocker").join("blocker.db") } fn main() -> ExitCode { let path = database_path(); // Section 1.5.4: a corrupt or unusable database must never leave the device // unusable. `Store::open` handles corruption by moving the damaged file // aside and starting clean, so what reaches the `Err` arm is genuinely fatal // — an unwritable directory, say. Even then we exit with a message rather // than panicking: a daemon that crashes on startup is worse than one that // reports the problem and stops. let store = match blocker_storage::Store::open(&path) { Ok(store) => store, Err(e) => { eprintln!("blocker-daemon: cannot open storage at {}: {e}", path.display()); eprintln!("blocking is disabled until this is fixed; see docs/LIMITATIONS.md"); return ExitCode::FAILURE; } }; if store.recovered_from_corruption() { // Logged, not swallowed: section 1.5.4 wants the recovery recorded. eprintln!( "blocker-daemon: database was corrupt and has been moved to {}.corrupt; \ starting clean, so no schedules are active until you re-add them", path.display() ); } let usage = blocker_core::engine::UsageSnapshot::default(); let context = blocker_core::engine::PlatformContext::default(); let schedules = match store.load_schedules() { Ok(s) => s, Err(e) => { eprintln!("blocker-daemon: cannot read schedules: {e}"); return ExitCode::FAILURE; } }; println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION")); println!("database: {}", path.display()); println!("schedules: {}", schedules.len()); // 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(); if let Ok(backend) = blocker_platform_linux::kwin::KwinBackend::connect() { println!("backend: kwin — native Wayland windows are visible"); let _keep_connection = match blocker_platform_linux::watcher::serve(tx) { Ok(c) => c, Err(e) => { eprintln!("blocker-daemon: cannot own {BUS_NAME}: {e}"); return ExitCode::FAILURE; } }; println!("owning the D-Bus name; entering the event loop"); run_loop(&rx, &schedules, &usage, &context, blocker_platform_linux::Enforcement::Freeze); 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: blocker_core::engine::BootId(0), 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, std::time::Duration::from_secs(3600), &ctx, &clock, 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 } /// 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, schedules: &[blocker_core::model::Schedule], usage: &blocker_core::engine::UsageSnapshot, context: &blocker_core::engine::PlatformContext, enforcement: blocker_platform_linux::Enforcement, ) { let ctx = runner::LoopContext { schedules, usage, platform_context: context, boot_id: blocker_core::engine::BootId(0), enforcement, }; let clock = blocker_platform_linux::SystemMonotonic; println!("enforcement: {enforcement:?}; entering the event loop"); 'events: loop { let (action, blocked) = runner::step(rx, std::time::Duration::from_secs(3600), &ctx, &clock, now()); if blocked > 0 { println!("blocked an app ({action:?})"); } if action == runner::Action::Idle { break 'events; } } } /// Civil time, from the platform layer. /// /// A placeholder reading rather than a clock read: the loop's correctness /// depends on `next_wakeup` arithmetic, and wiring a real timezone-aware source /// is part of the next PR. Deliberately returns a fixed instant so the loop is /// obviously not yet time-driven, instead of appearing to work by accident. fn now() -> blocker_core::engine::CivilTime { blocker_core::engine::CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 } }