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
17 changed files with 89 additions and 4133 deletions
Generated
+23 -3441
View File
File diff suppressed because it is too large Load Diff
+32 -33
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`.
@@ -115,22 +114,17 @@ fn main() -> ExitCode {
// costs — on a Wayland session, XWayland clients only. // costs — on a Wayland session, XWayland clients only.
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
let watcher_conn = blocker_platform_linux::watcher::serve(tx.clone()).ok();
if watcher_conn.is_some() {
println!("owning the D-Bus name {BUS_NAME}");
}
if let Ok(backend) = blocker_platform_linux::kwin::KwinBackend::connect() { if let Ok(backend) = blocker_platform_linux::kwin::KwinBackend::connect() {
println!("backend: kwin — native Wayland windows are visible"); println!("backend: kwin — native Wayland windows are visible");
let _keep_connection = watcher_conn; let _keep_connection = match blocker_platform_linux::watcher::serve(tx) {
run_loop( Ok(c) => c,
&rx, Err(e) => {
&schedules, eprintln!("blocker-daemon: cannot own {BUS_NAME}: {e}");
&usage, return ExitCode::FAILURE;
&context, }
blocker_platform_linux::Enforcement::Freeze, };
&path, println!("owning the D-Bus name; entering the event loop");
); run_loop(&rx, &schedules, &usage, &context, blocker_platform_linux::Enforcement::Freeze);
backend.shutdown(); backend.shutdown();
return ExitCode::SUCCESS; return ExitCode::SUCCESS;
} }
@@ -158,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;
@@ -167,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:?})");
} }
@@ -195,34 +190,38 @@ fn main() -> ExitCode {
/// charge, and this consumes it. Two blocked threads and no CPU between events. /// charge, and this consumes it. Two blocked threads and no CPU between events.
fn run_loop( fn run_loop(
rx: &std::sync::mpsc::Receiver<blocker_platform_linux::FocusEvent>, rx: &std::sync::mpsc::Receiver<blocker_platform_linux::FocusEvent>,
schedules_initial: &[blocker_core::model::Schedule], schedules: &[blocker_core::model::Schedule],
usage: &blocker_core::engine::UsageSnapshot, usage: &blocker_core::engine::UsageSnapshot,
context: &blocker_core::engine::PlatformContext, context: &blocker_core::engine::PlatformContext,
enforcement: blocker_platform_linux::Enforcement, enforcement: blocker_platform_linux::Enforcement,
db_path: &std::path::Path,
) { ) {
let mut current_schedules = schedules_initial.to_vec(); let ctx = runner::LoopContext {
schedules,
usage,
platform_context: context,
boot_id: blocker_core::engine::BootId(0),
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 ctx = runner::LoopContext { let (action, blocked) =
schedules: &current_schedules, runner::step(rx, std::time::Duration::from_secs(3600), &ctx, &clock, now());
usage,
platform_context: context,
boot_id: boot::current_boot_id(),
enforcement,
};
let (action, blocked, _) = runner::step(rx, &ctx, &clock, time::now());
if blocked > 0 { if blocked > 0 {
println!("blocked an app ({action:?})"); println!("blocked an app ({action:?})");
} }
if let Ok(store) = blocker_storage::Store::open(db_path) {
if let Ok(reloaded) = store.load_schedules() {
current_schedules = reloaded;
}
}
if action == runner::Action::Idle { if action == runner::Action::Idle {
break 'events; 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 }
}
+6 -33
View File
@@ -73,51 +73,39 @@ 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);
} }
}; };
let action = match &event { let action = match &event {
FocusEvent::Activated(_) => Action::Focused, FocusEvent::Activated(_) => Action::Focused,
FocusEvent::Cleared => Action::Unfocused, FocusEvent::Cleared => Action::Unfocused,
FocusEvent::ReloadSchedules => Action::Idle,
}; };
let blocked = match &event { let blocked = match &event {
FocusEvent::Activated(window) => enforce_if_blocked(window, ctx, clock, wall), FocusEvent::Activated(window) => enforce_if_blocked(window, ctx, clock, wall),
FocusEvent::Cleared => 0, FocusEvent::Cleared => 0,
FocusEvent::ReloadSchedules => 0,
}; };
(action, blocked, wait) (action, blocked)
} }
/// Evaluate, and enforce against whatever just took focus. /// Evaluate, and enforce against whatever just took focus.
@@ -161,22 +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",
);
// Launch the calm block screen GUI in the background if available
let ui_exe = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("blocker-ui")))
.unwrap_or_else(|| std::path::PathBuf::from("blocker-ui"));
let _ = std::process::Command::new(ui_exe).arg("--block").spawn();
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 -2
View File
@@ -126,8 +126,9 @@ workspace.windowActivated.connect(reportFocus);
// Report whatever is already focused, so the daemon starts with a correct state // Report whatever is already focused, so the daemon starts with a correct state
// instead of waiting for the user to alt-tab. // instead of waiting for the user to alt-tab.
if (workspace.activeWindow) { for (var i = 0; i < workspace.windowList().length; i++) {
reportFocus(workspace.activeWindow); var w = workspace.windowList()[i];
if (w.active) { reportFocus(w); break; }
} }
"#; "#;
-5
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;
@@ -79,8 +76,6 @@ pub enum FocusEvent {
Activated(ForegroundWindow), Activated(ForegroundWindow),
/// The focused window closed, so there is no longer a foreground window. /// The focused window closed, so there is no longer a foreground window.
Cleared, Cleared,
/// Reload active schedules from storage triggered by UI IPC.
ReloadSchedules,
} }
/// Source of focus events. /// Source of focus events.
-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);
}
}
-32
View File
@@ -74,16 +74,6 @@ impl Watcher {
// thread exits when the connection drops. // thread exits when the connection drops.
let _ = self.sink.send(event); let _ = self.sink.send(event);
} }
/// Instruct the daemon to reload schedules from the database immediately.
fn reload_schedules(&self) {
let _ = self.sink.send(FocusEvent::ReloadSchedules);
}
/// Ping method to check if the daemon is alive.
fn ping(&self) -> bool {
true
}
} }
/// Acquire the bus name and export [`Watcher`], returning the live connection. /// Acquire the bus name and export [`Watcher`], returning the live connection.
@@ -125,25 +115,3 @@ pub fn serve(sink: Sender<FocusEvent>) -> Result<Connection, WatcherError> {
pub fn release(connection: &Connection) { pub fn release(connection: &Connection) {
let _ = connection.release_name(BUS_NAME); let _ = connection.release_name(BUS_NAME);
} }
/// Tell the running daemon via D-Bus to reload schedules from storage.
pub fn notify_daemon_reload() -> Result<(), zbus::Error> {
let conn = Connection::session()?;
let proxy =
zbus::blocking::Proxy::new(&conn, BUS_NAME, OBJECT_PATH, "vu.valiq.blocker.Watcher")?;
let _: () = proxy.call("ReloadSchedules", &())?;
Ok(())
}
/// Query if the daemon is running and responding on D-Bus.
pub fn is_daemon_running() -> bool {
if let Ok(conn) = Connection::session() {
if let Ok(proxy) =
zbus::blocking::Proxy::new(&conn, BUS_NAME, OBJECT_PATH, "vu.valiq.blocker.Watcher")
{
let res: zbus::Result<bool> = proxy.call("Ping", &());
return res.unwrap_or(false);
}
}
false
}
-4
View File
@@ -25,7 +25,3 @@ path = "src/main.rs"
# - Either way the daemon must not grow past 5 MB stripped (section 5), and # - Either way the daemon must not grow past 5 MB stripped (section 5), and
# the UI must not hold a GL context resident while idle. # the UI must not hold a GL context resident while idle.
[dependencies] [dependencies]
eframe = "0.36.2"
blocker-core = { path = "../../core" }
blocker-storage = { path = "../../storage" }
blocker-platform-linux = { path = "../platform-linux" }
+24 -289
View File
@@ -1,292 +1,27 @@
//! Blocker GUI: Calm Block Screen and Schedule Manager. //! Blocker block-screen UI.
//! //!
//! Built with egui/eframe per ADR-6. //! Placeholder. No UI toolkit is bound here yet — see ADR-6 in
//! `docs/DECISIONS.md` for why that is an open decision rather than an oversight.
//!
//! # What this process is for
//!
//! It is a short-lived, separately-executable window. The daemon stays resident
//! and small; this launches when a block fires and exits when it lifts. Keeping
//! them separate means a crash in window rendering cannot take enforcement down.
//!
//! # Design constraints that will apply (docs/UX-BLOCK-SCREEN.md)
//!
//! - Calm, not punitive. No red-alert styling, no shaming copy, no streak
//! counters. The screen exists to end an argument with the user's own
//! intention, so it must lower arousal.
//! - It always states which schedule blocked and when it lifts. Uncertainty is
//! what makes people fight the tool.
//! - It is never a dead end: a plain-looking "How to turn this off" entry is
//! always present, including under Strict Mode (CLAUDE.md section 1.5.4).
//! - Under Strict Mode the *absence* of a bypass button is stated explicitly.
//! Silently missing controls read as a bug and breed distrust.
use eframe::egui; fn main() {
use std::env; println!("blocker-ui {} (M0 placeholder)", env!("CARGO_PKG_VERSION"));
println!("UI toolkit undecided — ADR-6 in docs/DECISIONS.md");
fn main() -> eframe::Result<()> {
let args: Vec<String> = env::args().collect();
let is_block_screen = args.iter().any(|a| a == "--block" || a == "-b");
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size(if is_block_screen { [520.0, 360.0] } else { [720.0, 520.0] })
.with_min_inner_size([400.0, 300.0])
.with_title(if is_block_screen { "Blocker — Stay Focused" } else { "Blocker" })
.with_always_on_top(),
..Default::default()
};
eframe::run_native(
"Blocker",
options,
Box::new(move |cc| {
setup_custom_style(&cc.egui_ctx);
Ok(Box::new(BlockerApp::new(is_block_screen)))
}),
)
}
fn setup_custom_style(ctx: &egui::Context) {
let mut visuals = egui::Visuals::dark();
visuals.override_text_color = Some(egui::Color32::from_rgb(220, 225, 230));
visuals.panel_fill = egui::Color32::from_rgb(26, 32, 44);
visuals.window_fill = egui::Color32::from_rgb(26, 32, 44);
ctx.set_visuals(visuals);
}
struct BlockerApp {
is_block_screen: bool,
active_tab: Tab,
schedules: Vec<blocker_core::model::Schedule>,
target_input: String,
schedule_name: String,
status_message: String,
}
#[derive(PartialEq)]
enum Tab {
Status,
Schedules,
}
impl BlockerApp {
fn new(is_block_screen: bool) -> Self {
let schedules = Self::load_schedules_from_db();
Self {
is_block_screen,
active_tab: if is_block_screen { Tab::Status } else { Tab::Schedules },
schedules,
target_input: String::new(),
schedule_name: "Focus Hours".to_string(),
status_message: "Ready".to_string(),
}
}
fn db_path() -> std::path::PathBuf {
let base =
std::env::var_os("XDG_DATA_HOME").map(std::path::PathBuf::from).unwrap_or_else(|| {
let home = std::env::var_os("HOME").unwrap_or_else(|| ".".into());
std::path::PathBuf::from(home).join(".local/share")
});
base.join("blocker/blocker.db")
}
fn load_schedules_from_db() -> Vec<blocker_core::model::Schedule> {
let path = Self::db_path();
if let Ok(store) = blocker_storage::Store::open(&path) {
store.load_schedules().unwrap_or_default()
} else {
Vec::new()
}
}
fn save_schedules_to_db(&self) -> Result<(), String> {
let path = Self::db_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut store = blocker_storage::Store::open(&path).map_err(|e| e.to_string())?;
store.save_schedules(&self.schedules).map_err(|e| e.to_string())?;
let _ = blocker_platform_linux::watcher::notify_daemon_reload();
Ok(())
}
}
impl eframe::App for BlockerApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
if self.is_block_screen {
self.render_block_screen(ui);
} else {
self.render_manager_ui(ui);
}
}
}
impl BlockerApp {
fn render_block_screen(&mut self, ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.add_space(32.0);
ui.heading(
egui::RichText::new("Focus Mode Active")
.size(24.0)
.color(egui::Color32::from_rgb(226, 232, 240)),
);
ui.add_space(16.0);
ui.label(
egui::RichText::new(
"This application is currently paused by your scheduled focus block.",
)
.size(15.0)
.color(egui::Color32::from_rgb(160, 174, 192)),
);
ui.add_space(32.0);
ui.group(|ui| {
ui.set_width(380.0);
ui.add_space(8.0);
ui.label(
egui::RichText::new("Reason: Scheduled focus time")
.color(egui::Color32::from_rgb(203, 213, 225)),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new("State: Process paused safely (SIGSTOP)")
.color(egui::Color32::from_rgb(148, 163, 184)),
);
ui.add_space(8.0);
});
ui.add_space(36.0);
if ui
.button(
egui::RichText::new("Open Schedule Settings")
.color(egui::Color32::from_rgb(99, 179, 237)),
)
.clicked()
{
self.is_block_screen = false;
self.active_tab = Tab::Schedules;
}
ui.add_space(12.0);
if ui.button("Dismiss").clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
});
}
fn render_manager_ui(&mut self, ui: &mut egui::Ui) {
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.heading("Blocker — Focus Manager");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.selectable_label(self.active_tab == Tab::Schedules, "Schedules").clicked() {
self.active_tab = Tab::Schedules;
}
if ui.selectable_label(self.active_tab == Tab::Status, "Status").clicked() {
self.active_tab = Tab::Status;
}
});
});
ui.separator();
ui.add_space(8.0);
match self.active_tab {
Tab::Status => {
ui.heading("Current Status");
ui.add_space(12.0);
let daemon_live = blocker_platform_linux::watcher::is_daemon_running();
if daemon_live {
ui.colored_label(
egui::Color32::from_rgb(0, 200, 100),
"Daemon Status: Running (D-Bus connected)",
);
} else {
ui.colored_label(
egui::Color32::from_rgb(220, 80, 80),
"Daemon Status: Inactive (not detected on D-Bus)",
);
}
ui.label(format!("Active Schedules: {}", self.schedules.len()));
ui.add_space(16.0);
if ui.button("Test Block Notification").clicked() {
let _ = blocker_platform_linux::notify::send_notification(
"Blocker Notification",
"Focus notification system verified live.",
);
}
}
Tab::Schedules => {
ui.heading("Focus Schedules");
ui.add_space(8.0);
// List existing schedules
if self.schedules.is_empty() {
ui.label("No active schedules configured yet.");
} else {
let mut to_remove = None;
for (idx, sched) in self.schedules.iter().enumerate() {
ui.group(|ui| {
ui.horizontal(|ui| {
ui.strong(&sched.name);
ui.label(format!("({:?})", sched.mode));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if ui.button("Delete").clicked() {
to_remove = Some(idx);
}
},
);
});
ui.label(format!(
"Targets: {}",
sched
.targets
.iter()
.map(|t| match t {
blocker_core::model::Target::App(a) => a.clone(),
blocker_core::model::Target::Website(w) =>
format!("web:{w}"),
blocker_core::model::Target::Keyword(k) =>
format!("kw:{k}"),
})
.collect::<Vec<_>>()
.join(", ")
));
});
ui.add_space(4.0);
}
if let Some(idx) = to_remove {
self.schedules.remove(idx);
if let Err(e) = self.save_schedules_to_db() {
self.status_message = format!("Error saving: {e}");
}
}
}
ui.add_space(16.0);
ui.separator();
ui.add_space(8.0);
ui.heading("Add New Schedule");
ui.horizontal(|ui| {
ui.label("Name:");
ui.text_edit_singleline(&mut self.schedule_name);
});
ui.horizontal(|ui| {
ui.label("App Target (e.g. steam, firefox):");
ui.text_edit_singleline(&mut self.target_input);
});
ui.add_space(8.0);
if ui.button("Add App Block Schedule").clicked() {
let target = self.target_input.trim();
if !target.is_empty() {
let new_sched = blocker_core::model::Schedule {
id: format!(
"sched-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
),
name: self.schedule_name.clone(),
icon: None,
mode: blocker_core::model::ScheduleMode::Blocklist,
targets: vec![blocker_core::model::Target::App(target.to_string())],
conditions: vec![],
paused: false,
};
self.schedules.push(new_sched);
if let Err(e) = self.save_schedules_to_db() {
self.status_message = format!("Error saving: {e}");
} else {
self.status_message = "Schedule saved!".to_string();
self.target_input.clear();
}
}
}
}
}
}
} }
-24
View File
@@ -1,24 +0,0 @@
#!/bin/sh
set -e
HERE="$(dirname "$(readlink -f "$0")")"
export PATH="$HERE/usr/bin:$PATH"
export XDG_DATA_DIRS="$HERE/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
case "$1" in
daemon)
shift
exec "$HERE/usr/bin/blocker-daemon" "$@"
;;
ui)
shift
exec "$HERE/usr/bin/blocker-ui" "$@"
;;
*)
# Default to launching the GUI if launched without arguments, or daemon if requested
if [ -n "$DISPLAY" ] || [ -n "$WAYLAND_DISPLAY" ]; then
exec "$HERE/usr/bin/blocker-ui" "$@"
else
exec "$HERE/usr/bin/blocker-daemon" "$@"
fi
;;
esac
Binary file not shown.
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Name=Blocker
Comment=Focus and app blocking daemon
Exec=AppRun
Icon=blocker
Type=Application
Categories=Utility;
Terminal=true
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<circle cx="32" cy="32" r="30" fill="#2d3748" stroke="#4a5568" stroke-width="2"/>
<circle cx="32" cy="32" r="22" fill="none" stroke="#e53e3e" stroke-width="4"/>
<line x1="16" y1="16" x2="48" y2="48" stroke="#e53e3e" stroke-width="4"/>
</svg>

Before

Width:  |  Height:  |  Size: 332 B

-13
View File
@@ -1,13 +0,0 @@
[Unit]
Description=Blocker Focus Daemon
PartOf=graphical-session.target
After=graphical-session.target
[Service]
Type=simple
ExecStart=%h/.local/bin/blocker-daemon
Restart=always
RestartSec=3
[Install]
WantedBy=graphical-session.target
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
# Build an AppImage packaging blocker-daemon, blocker-ui and assets.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIST_DIR="$ROOT_DIR/dist"
APPDIR="$DIST_DIR/AppDir"
PKG_DIR="$ROOT_DIR/packaging/appimage"
echo "Building release binaries..."
cargo build --release -p blocker-daemon -p blocker-ui
echo "Preparing AppDir structure..."
mkdir -p "$APPDIR"
mkdir -p "$APPDIR/usr/bin"
mkdir -p "$APPDIR/usr/share/applications"
mkdir -p "$APPDIR/usr/share/icons/hicolor/scalable/apps"
cp "$ROOT_DIR/target/release/blocker-daemon" "$APPDIR/usr/bin/"
cp "$ROOT_DIR/target/release/blocker-ui" "$APPDIR/usr/bin/"
cp "$PKG_DIR/AppRun" "$APPDIR/AppRun"
cp "$PKG_DIR/blocker.desktop" "$APPDIR/blocker.desktop"
cp "$PKG_DIR/blocker.desktop" "$APPDIR/usr/share/applications/"
cp "$PKG_DIR/blocker.svg" "$APPDIR/blocker.svg"
cp "$PKG_DIR/blocker.svg" "$APPDIR/usr/share/icons/hicolor/scalable/apps/"
chmod +x "$APPDIR/AppRun"
SQUASHFS_IMG="$DIST_DIR/appdir.squashfs"
OUTPUT_APPIMAGE="$DIST_DIR/Blocker-x86_64.AppImage"
RUNTIME_BIN="$PKG_DIR/bin/runtime-x86_64"
rm -f "$OUTPUT_APPIMAGE" "$SQUASHFS_IMG"
echo "Creating AppImage with mksquashfs..."
mksquashfs "$APPDIR" "$SQUASHFS_IMG" -root-owned -noappend -comp gzip
if [ -f "$RUNTIME_BIN" ]; then
echo "Embedding AppImage Type 2 ELF runtime..."
cat "$RUNTIME_BIN" "$SQUASHFS_IMG" > "$OUTPUT_APPIMAGE"
printf '\x41\x49\x02' | dd of="$OUTPUT_APPIMAGE" bs=1 seek=8 count=3 conv=notrunc 2>/dev/null
rm -f "$SQUASHFS_IMG"
else
mv "$SQUASHFS_IMG" "$OUTPUT_APPIMAGE"
fi
chmod +x "$OUTPUT_APPIMAGE"
echo "Built AppImage: $OUTPUT_APPIMAGE"
ls -lh "$OUTPUT_APPIMAGE"