Files
blocker/desktop/daemon/src/main.rs
T
SellPL eee8c6b996 fix(daemon): call shutdown directly rather than binding a unit value
Clippy's let_unit_value, caught in review of the previous commit: shutdown()
returns (), so the let-binding was noise.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 16:45:13 +03:00

228 lines
9.4 KiB
Rust

//! 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<blocker_platform_linux::FocusEvent>,
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 }
}