2 Commits

Author SHA1 Message Date
SellPL 052b2a4969 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-01 22:45:29 +03:00
SellPL 69602636b1 feat(linux): wire the KWin path end to end, and verify it live
The daemon now owns `vu.valiq.blocker`, loads the KWin script, and enters the
event loop on the primary backend. Verified against the running session:

```
$ blocker-daemon
blocker-daemon 0.1.0 (M0/M1)
database: /tmp/bsmoke/blocker/blocker.db
backend: kwin — native Wayland windows are visible
owning the D-Bus name; entering the event loop
enforcement: Freeze; entering the event loop
```

The process then parked until killed — which is the point. There is no tick; the
loop blocks on a focus event and on the engine's `next_wakeup`.

**Ordering is load-bearing and worth stating.** `callDBus` has nothing to resolve
until the daemon holds the bus name, so the name must be acquired *before* the
script is loaded. Loading first means a handler calling a name nobody owns, and
every focus event silently dropped.

**A real defect found by running it rather than reading it.** After the daemon
was killed, `isScriptLoaded` still returned `true` — the script was left in KWin.
`Drop` does not run on SIGTERM, so the cleanup path never executes. It is
fail-open and harmless (the script's `callDBus` fails inside the compositor,
which is exactly the section 1.5.4 behaviour), and `KwinBackend::connect`
unloads any stale script before loading a fresh one, so a restart recovers. But
"harmless" is not "correct", and the honest fix — a systemd `ExecStopPost` or a
signal handler that unloads — is not in this PR.

Also fixes the duplicated loop body: both backends now share one `run_loop`, so
the X11 and KWin paths cannot drift into enforcing differently.

Verified: clippy `-D warnings` clean, `fmt --check` clean, all tests passing,
stripped daemon within the 5 MB budget.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 22:41:31 +03:00
6 changed files with 22 additions and 225 deletions
+16 -5
View File
@@ -38,7 +38,6 @@ mod runner;
use std::path::PathBuf; use std::path::PathBuf;
use blocker_platform_linux::kwin::BUS_NAME; use blocker_platform_linux::kwin::BUS_NAME;
use blocker_platform_linux::{boot, time};
use std::process::ExitCode; use std::process::ExitCode;
/// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`. /// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`.
@@ -153,7 +152,7 @@ fn main() -> ExitCode {
schedules: &schedules, schedules: &schedules,
usage: &usage, usage: &usage,
platform_context: &context, platform_context: &context,
boot_id: boot::current_boot_id(), boot_id: blocker_core::engine::BootId(0),
enforcement: blocker_platform_linux::Enforcement::Freeze, enforcement: blocker_platform_linux::Enforcement::Freeze,
}; };
let clock = blocker_platform_linux::SystemMonotonic; let clock = blocker_platform_linux::SystemMonotonic;
@@ -162,7 +161,8 @@ fn main() -> ExitCode {
'events: loop { 'events: loop {
// The wait IS the next wakeup. An idle system parks here rather // The wait IS the next wakeup. An idle system parks here rather
// than ticking, which is how the section 5 idle budget is met. // than ticking, which is how the section 5 idle budget is met.
let (action, blocked, _) = runner::step(&rx, &ctx, &clock, time::now()); let (action, blocked) =
runner::step(&rx, std::time::Duration::from_secs(3600), &ctx, &clock, now());
if blocked > 0 { if blocked > 0 {
println!("blocked an app ({action:?})"); println!("blocked an app ({action:?})");
} }
@@ -199,13 +199,14 @@ fn run_loop(
schedules, schedules,
usage, usage,
platform_context: context, platform_context: context,
boot_id: boot::current_boot_id(), boot_id: blocker_core::engine::BootId(0),
enforcement, enforcement,
}; };
let clock = blocker_platform_linux::SystemMonotonic; let clock = blocker_platform_linux::SystemMonotonic;
println!("enforcement: {enforcement:?}; entering the event loop"); println!("enforcement: {enforcement:?}; entering the event loop");
'events: loop { 'events: loop {
let (action, blocked, _) = runner::step(rx, &ctx, &clock, time::now()); let (action, blocked) =
runner::step(rx, std::time::Duration::from_secs(3600), &ctx, &clock, now());
if blocked > 0 { if blocked > 0 {
println!("blocked an app ({action:?})"); println!("blocked an app ({action:?})");
} }
@@ -214,3 +215,13 @@ 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 }
}
+6 -23
View File
@@ -73,35 +73,25 @@ impl LoopContext<'_> {
/// event in, one decision out — instead of racing a thread. /// event in, one decision out — instead of racing a thread.
pub fn step( pub fn step(
events: &Receiver<FocusEvent>, events: &Receiver<FocusEvent>,
wait: Duration,
ctx: &LoopContext<'_>, ctx: &LoopContext<'_>,
clock: &dyn MonotonicSource, clock: &dyn MonotonicSource,
wall: CivilTime, wall: CivilTime,
) -> (Action, usize, Duration) { ) -> (Action, usize) {
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) { let event = match events.recv_timeout(wait) {
Ok(event) => event, Ok(event) => event,
// A timeout is the `next_wakeup` firing. Re-evaluate, which is the whole // A timeout is the `next_wakeup` firing. Re-evaluate, which is the whole
// point of having armed it. // point of having armed it.
Err(RecvTimeoutError::Timeout) => { Err(RecvTimeoutError::Timeout) => {
let _ = evaluate_now(ctx, clock, wall); let _ = evaluate_now(ctx, clock, wall);
return (Action::Idle, 0, wait); return (Action::Idle, 0);
} }
// The watcher thread is gone. Nothing can arrive, so re-evaluating on a // 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 // timer would be the polling this design exists to avoid. Return a
// zero duration and let the caller decide; with no channel there is // zero duration and let the caller decide; with no channel there is
// nothing left to wake for. // nothing left to wake for.
Err(RecvTimeoutError::Disconnected) => { Err(RecvTimeoutError::Disconnected) => {
return (Action::Idle, 0, Duration::ZERO); return (Action::Idle, 0);
} }
}; };
@@ -115,7 +105,7 @@ pub fn step(
FocusEvent::Cleared => 0, FocusEvent::Cleared => 0,
}; };
(action, blocked, wait) (action, blocked)
} }
/// Evaluate, and enforce against whatever just took focus. /// Evaluate, and enforce against whatever just took focus.
@@ -159,14 +149,7 @@ fn enforce_if_blocked(
}; };
match enforce::apply(ctx.enforcement, pid) { match enforce::apply(ctx.enforcement, pid) {
Ok(()) => { Ok(()) => 1,
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) => { Err(e) => {
// Logged, not fatal. A process that exited between the focus event // Logged, not fatal. A process that exited between the focus event
// and the signal is the common case and must not stop the loop. // and the signal is the common case and must not stop the loop.
-29
View File
@@ -1,29 +0,0 @@
//! 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);
}
}
-3
View File
@@ -39,11 +39,8 @@
//! - None of this can be made tamper-proof against a user with root. Section //! - 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. //! 6.1 requires this be documented honestly rather than implied otherwise.
pub mod boot;
pub mod enforce; pub mod enforce;
pub mod kwin; pub mod kwin;
pub mod notify;
pub mod time;
pub mod watcher; pub mod watcher;
pub mod x11; pub mod x11;
-31
View File
@@ -1,31 +0,0 @@
//! 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<u32, zbus::Error> {
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::<String>::new(),
std::collections::HashMap::<String, zbus::zvariant::Value>::new(),
5000i32,
),
)?;
Ok(id)
}
-134
View File
@@ -1,134 +0,0 @@
//! 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<i32> {
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<i32> {
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);
}
}