From 2194c28ab53234c9385a712b573413eefda37756 Mon Sep 17 00:00:00 2001 From: SellPL Date: Fri, 2 Oct 2026 17:00:31 +0300 Subject: [PATCH] feat(linux): integrate real civil time, boot id, next_wakeup timer, and dbus notifications --- desktop/daemon/src/main.rs | 21 +---- desktop/daemon/src/runner.rs | 29 ++++-- desktop/platform-linux/src/boot.rs | 29 ++++++ desktop/platform-linux/src/lib.rs | 3 + desktop/platform-linux/src/notify.rs | 31 +++++++ desktop/platform-linux/src/time.rs | 134 +++++++++++++++++++++++++++ 6 files changed, 225 insertions(+), 22 deletions(-) create mode 100644 desktop/platform-linux/src/boot.rs create mode 100644 desktop/platform-linux/src/notify.rs create mode 100644 desktop/platform-linux/src/time.rs diff --git a/desktop/daemon/src/main.rs b/desktop/daemon/src/main.rs index 5e74121..879cedf 100644 --- a/desktop/daemon/src/main.rs +++ b/desktop/daemon/src/main.rs @@ -38,6 +38,7 @@ mod runner; use std::path::PathBuf; use blocker_platform_linux::kwin::BUS_NAME; +use blocker_platform_linux::{boot, time}; use std::process::ExitCode; /// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`. @@ -152,7 +153,7 @@ fn main() -> ExitCode { schedules: &schedules, usage: &usage, platform_context: &context, - boot_id: blocker_core::engine::BootId(0), + boot_id: boot::current_boot_id(), enforcement: blocker_platform_linux::Enforcement::Freeze, }; let clock = blocker_platform_linux::SystemMonotonic; @@ -161,8 +162,7 @@ fn main() -> ExitCode { '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()); + let (action, blocked, _) = runner::step(&rx, &ctx, &clock, time::now()); if blocked > 0 { println!("blocked an app ({action:?})"); } @@ -199,14 +199,13 @@ fn run_loop( schedules, usage, platform_context: context, - boot_id: blocker_core::engine::BootId(0), + boot_id: boot::current_boot_id(), 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()); + let (action, blocked, _) = runner::step(rx, &ctx, &clock, time::now()); if blocked > 0 { println!("blocked an app ({action:?})"); } @@ -215,13 +214,3 @@ fn run_loop( } } } - -/// 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 } -} diff --git a/desktop/daemon/src/runner.rs b/desktop/daemon/src/runner.rs index d3364e3..a1cd953 100644 --- a/desktop/daemon/src/runner.rs +++ b/desktop/daemon/src/runner.rs @@ -73,25 +73,35 @@ impl LoopContext<'_> { /// event in, one decision out — instead of racing a thread. pub fn step( events: &Receiver, - wait: Duration, ctx: &LoopContext<'_>, clock: &dyn MonotonicSource, wall: CivilTime, -) -> (Action, usize) { +) -> (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); + 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); + return (Action::Idle, 0, Duration::ZERO); } }; @@ -105,7 +115,7 @@ pub fn step( FocusEvent::Cleared => 0, }; - (action, blocked) + (action, blocked, wait) } /// Evaluate, and enforce against whatever just took focus. @@ -149,7 +159,14 @@ fn enforce_if_blocked( }; match enforce::apply(ctx.enforcement, pid) { - Ok(()) => 1, + 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. diff --git a/desktop/platform-linux/src/boot.rs b/desktop/platform-linux/src/boot.rs new file mode 100644 index 0000000..0351c87 --- /dev/null +++ b/desktop/platform-linux/src/boot.rs @@ -0,0 +1,29 @@ +//! Read host boot identifier for tracking reboots without root privileges. + +use blocker_core::engine::BootId; +use std::fs; + +/// Read current system boot id from `/proc/sys/kernel/random/boot_id`. +/// If unavailable, falls back to `BootId(0)`. +pub fn current_boot_id() -> BootId { + if let Ok(content) = fs::read_to_string("/proc/sys/kernel/random/boot_id") { + let trimmed = content.trim().replace('-', ""); + if trimmed.len() >= 16 { + if let Ok(id) = u64::from_str_radix(&trimmed[..16], 16) { + return BootId(id); + } + } + } + BootId(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_boot_id() { + let b = current_boot_id(); + assert_ne!(b.0, 0); + } +} diff --git a/desktop/platform-linux/src/lib.rs b/desktop/platform-linux/src/lib.rs index df8409e..6af5a60 100644 --- a/desktop/platform-linux/src/lib.rs +++ b/desktop/platform-linux/src/lib.rs @@ -39,8 +39,11 @@ //! - None of this can be made tamper-proof against a user with root. Section //! 6.1 requires this be documented honestly rather than implied otherwise. +pub mod boot; pub mod enforce; pub mod kwin; +pub mod notify; +pub mod time; pub mod watcher; pub mod x11; diff --git a/desktop/platform-linux/src/notify.rs b/desktop/platform-linux/src/notify.rs new file mode 100644 index 0000000..84a3549 --- /dev/null +++ b/desktop/platform-linux/src/notify.rs @@ -0,0 +1,31 @@ +//! Desktop notifications for Blocker enforcement actions. +//! +//! Uses the standard freedesktop Notifications D-Bus specification +//! (`org.freedesktop.Notifications`). + +use zbus::blocking::Connection; + +/// Send a non-disruptive desktop notification when enforcement triggers. +pub fn send_notification(summary: &str, body: &str) -> Result { + let connection = Connection::session()?; + let proxy = zbus::blocking::Proxy::new( + &connection, + "org.freedesktop.Notifications", + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + )?; + let id: u32 = proxy.call( + "Notify", + &( + "Blocker", + 0u32, + "dialog-information", + summary, + body, + Vec::::new(), + std::collections::HashMap::::new(), + 5000i32, + ), + )?; + Ok(id) +} diff --git a/desktop/platform-linux/src/time.rs b/desktop/platform-linux/src/time.rs new file mode 100644 index 0000000..a45da43 --- /dev/null +++ b/desktop/platform-linux/src/time.rs @@ -0,0 +1,134 @@ +//! Wall clock and timezone query implementation for Linux. +//! +//! Provides the current [`CivilTime`] with local timezone offset without pulling in +//! heavy third-party datetime crates, and purely in safe Rust (`forbid(unsafe_code)`). + +use blocker_core::engine::CivilTime; +use std::fs; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Read current civil time and local UTC offset in minutes. +pub fn now() -> CivilTime { + let now = SystemTime::now(); + let epoch_secs = now.duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + let epoch_minutes = epoch_secs / 60; + let utc_offset_minutes = read_local_offset_minutes(); + + CivilTime { epoch_minutes, utc_offset_minutes } +} + +/// Parse local UTC offset in minutes from `/etc/localtime` TZ string or standard fallback. +fn read_local_offset_minutes() -> i32 { + // Check TZ environment variable first + if let Ok(tz) = std::env::var("TZ") { + if let Some(offset) = parse_tz_string_offset(&tz) { + return offset; + } + } + + // Try reading TZ footer string from /etc/localtime (POSIX TZ string at end of TZif file) + if let Ok(bytes) = fs::read("/etc/localtime") { + if let Some(offset) = parse_tzif_footer_offset(&bytes) { + return offset; + } + } + + 0 +} + +/// Parses the trailing newline-terminated POSIX TZ string in a TZif file (RFC 8536). +/// E.g. "\nMSK-3\n" -> +3 hours -> +180 minutes. +/// Note that POSIX TZ string syntax inverts the sign: "MSK-3" means GMT+3! +fn parse_tzif_footer_offset(bytes: &[u8]) -> Option { + if bytes.len() < 2 { + return None; + } + // Search backward for the last newline + let mut end = bytes.len(); + if bytes[end - 1] == b'\n' { + end -= 1; + } + let start = bytes[..end].iter().rposition(|&b| b == b'\n')? + 1; + let tz_str = std::str::from_utf8(&bytes[start..end]).ok()?; + parse_tz_string_offset(tz_str) +} + +/// Parses a POSIX TZ string like "MSK-3" or "UTC" or "EST5EDT". +fn parse_tz_string_offset(tz: &str) -> Option { + let bytes = tz.as_bytes(); + let mut i = 0; + // Skip name (letters or <...>) + if i < bytes.len() && bytes[i] == b'<' { + i += 1; + while i < bytes.len() && bytes[i] != b'>' { + i += 1; + } + if i < bytes.len() { + i += 1; + } + } else { + while i < bytes.len() && (bytes[i].is_ascii_alphabetic()) { + i += 1; + } + } + + if i >= bytes.len() { + return Some(0); // e.g. "UTC" + } + + // Next is optional sign: '+' or '-' + let mut sign = 1; // Default no sign is positive in POSIX, which represents behind UTC (e.g. EST5 = UTC-5) + if bytes[i] == b'+' { + sign = 1; + i += 1; + } else if bytes[i] == b'-' { + sign = -1; + i += 1; + } + + // Read hours + let h_start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if h_start == i { + return None; + } + let hours: i32 = std::str::from_utf8(&bytes[h_start..i]).ok()?.parse().ok()?; + let mut minutes = 0; + + // Optional :mm + if i < bytes.len() && bytes[i] == b':' { + i += 1; + let m_start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if m_start < i { + minutes = std::str::from_utf8(&bytes[m_start..i]).ok()?.parse().ok().unwrap_or(0); + } + } + + let total_mins = hours * 60 + minutes; + Some(-sign * total_mins) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_tz_examples() { + assert_eq!(parse_tz_string_offset("UTC"), Some(0)); + assert_eq!(parse_tz_string_offset("MSK-3"), Some(180)); + assert_eq!(parse_tz_string_offset("EST5"), Some(-300)); + assert_eq!(parse_tz_string_offset("CST6CDT"), Some(-360)); + } + + #[test] + fn now_produces_reasonable_civil_time() { + let t = now(); + assert!(t.epoch_minutes > 28_000_000); + assert!(t.utc_offset_minutes >= -720 && t.utc_offset_minutes <= 840); + } +}