//! 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, 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, }; let blocked = match &event { FocusEvent::Activated(window) => enforce_if_blocked(window, ctx, clock, wall), FocusEvent::Cleared => 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", ); 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 } } }