Files
blocker/desktop/daemon/src/runner.rs
T

178 lines
6.3 KiB
Rust

//! 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,
};
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
}
}
}