Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ef9674fde | |||
| 9e44bd4b32 | |||
| 5ba2cea3d8 | |||
| bd7f13a88c | |||
| f0107dbef8 | |||
| 08fa97da8f | |||
| 2194c28ab5 | |||
| eee8c6b996 | |||
| 3caabbff15 | |||
| 1f6c50c998 |
Generated
+3441
-23
File diff suppressed because it is too large
Load Diff
+33
-32
@@ -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`.
|
||||
@@ -114,17 +115,22 @@ fn main() -> ExitCode {
|
||||
// costs — on a Wayland session, XWayland clients only.
|
||||
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() {
|
||||
println!("backend: kwin — native Wayland windows are visible");
|
||||
let _keep_connection = match blocker_platform_linux::watcher::serve(tx) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("blocker-daemon: cannot own {BUS_NAME}: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
println!("owning the D-Bus name; entering the event loop");
|
||||
run_loop(&rx, &schedules, &usage, &context, blocker_platform_linux::Enforcement::Freeze);
|
||||
let _keep_connection = watcher_conn;
|
||||
run_loop(
|
||||
&rx,
|
||||
&schedules,
|
||||
&usage,
|
||||
&context,
|
||||
blocker_platform_linux::Enforcement::Freeze,
|
||||
&path,
|
||||
);
|
||||
backend.shutdown();
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
@@ -152,7 +158,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 +167,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:?})");
|
||||
}
|
||||
@@ -190,38 +195,34 @@ fn main() -> ExitCode {
|
||||
/// charge, and this consumes it. Two blocked threads and no CPU between events.
|
||||
fn run_loop(
|
||||
rx: &std::sync::mpsc::Receiver<blocker_platform_linux::FocusEvent>,
|
||||
schedules: &[blocker_core::model::Schedule],
|
||||
schedules_initial: &[blocker_core::model::Schedule],
|
||||
usage: &blocker_core::engine::UsageSnapshot,
|
||||
context: &blocker_core::engine::PlatformContext,
|
||||
enforcement: blocker_platform_linux::Enforcement,
|
||||
db_path: &std::path::Path,
|
||||
) {
|
||||
let ctx = runner::LoopContext {
|
||||
schedules,
|
||||
usage,
|
||||
platform_context: context,
|
||||
boot_id: blocker_core::engine::BootId(0),
|
||||
enforcement,
|
||||
};
|
||||
let mut current_schedules = schedules_initial.to_vec();
|
||||
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 ctx = runner::LoopContext {
|
||||
schedules: ¤t_schedules,
|
||||
usage,
|
||||
platform_context: context,
|
||||
boot_id: boot::current_boot_id(),
|
||||
enforcement,
|
||||
};
|
||||
let (action, blocked, _) = runner::step(rx, &ctx, &clock, time::now());
|
||||
if blocked > 0 {
|
||||
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 {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -73,39 +73,51 @@ impl LoopContext<'_> {
|
||||
/// event in, one decision out — instead of racing a thread.
|
||||
pub fn step(
|
||||
events: &Receiver<FocusEvent>,
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
let action = match &event {
|
||||
FocusEvent::Activated(_) => Action::Focused,
|
||||
FocusEvent::Cleared => Action::Unfocused,
|
||||
FocusEvent::ReloadSchedules => Action::Idle,
|
||||
};
|
||||
|
||||
let blocked = match &event {
|
||||
FocusEvent::Activated(window) => enforce_if_blocked(window, ctx, clock, wall),
|
||||
FocusEvent::Cleared => 0,
|
||||
FocusEvent::ReloadSchedules => 0,
|
||||
};
|
||||
|
||||
(action, blocked)
|
||||
(action, blocked, wait)
|
||||
}
|
||||
|
||||
/// Evaluate, and enforce against whatever just took focus.
|
||||
@@ -149,7 +161,22 @@ 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",
|
||||
);
|
||||
|
||||
// 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) => {
|
||||
// Logged, not fatal. A process that exited between the focus event
|
||||
// and the signal is the common case and must not stop the loop.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -126,9 +126,8 @@ workspace.windowActivated.connect(reportFocus);
|
||||
|
||||
// Report whatever is already focused, so the daemon starts with a correct state
|
||||
// instead of waiting for the user to alt-tab.
|
||||
for (var i = 0; i < workspace.windowList().length; i++) {
|
||||
var w = workspace.windowList()[i];
|
||||
if (w.active) { reportFocus(w); break; }
|
||||
if (workspace.activeWindow) {
|
||||
reportFocus(workspace.activeWindow);
|
||||
}
|
||||
"#;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -76,6 +79,8 @@ pub enum FocusEvent {
|
||||
Activated(ForegroundWindow),
|
||||
/// The focused window closed, so there is no longer a foreground window.
|
||||
Cleared,
|
||||
/// Reload active schedules from storage triggered by UI IPC.
|
||||
ReloadSchedules,
|
||||
}
|
||||
|
||||
/// Source of focus events.
|
||||
|
||||
@@ -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<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)
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,16 @@ impl Watcher {
|
||||
// thread exits when the connection drops.
|
||||
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.
|
||||
@@ -115,3 +125,25 @@ pub fn serve(sink: Sender<FocusEvent>) -> Result<Connection, WatcherError> {
|
||||
pub fn release(connection: &Connection) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -24,4 +24,8 @@ path = "src/main.rs"
|
||||
# that should decide it.
|
||||
# - 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.
|
||||
[dependencies]
|
||||
[dependencies]
|
||||
eframe = "0.36.2"
|
||||
blocker-core = { path = "../../core" }
|
||||
blocker-storage = { path = "../../storage" }
|
||||
blocker-platform-linux = { path = "../platform-linux" }
|
||||
|
||||
+289
-24
@@ -1,27 +1,292 @@
|
||||
//! Blocker block-screen UI.
|
||||
//! Blocker GUI: Calm Block Screen and Schedule Manager.
|
||||
//!
|
||||
//! 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.
|
||||
//! Built with egui/eframe per ADR-6.
|
||||
|
||||
fn main() {
|
||||
println!("blocker-ui {} (M0 placeholder)", env!("CARGO_PKG_VERSION"));
|
||||
println!("UI toolkit undecided — ADR-6 in docs/DECISIONS.md");
|
||||
use eframe::egui;
|
||||
use std::env;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/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.
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Name=Blocker
|
||||
Comment=Focus and app blocking daemon
|
||||
Exec=AppRun
|
||||
Icon=blocker
|
||||
Type=Application
|
||||
Categories=Utility;
|
||||
Terminal=true
|
||||
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1,13 @@
|
||||
[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
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user