eee8c6b996
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>
228 lines
9.4 KiB
Rust
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 }
|
|
}
|