Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd7f13a88c | |||
| f0107dbef8 | |||
| 08fa97da8f | |||
| 2194c28ab5 | |||
| eee8c6b996 | |||
| 3caabbff15 |
+53
-16
@@ -36,6 +36,9 @@
|
|||||||
mod runner;
|
mod runner;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
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`.
|
||||||
@@ -105,13 +108,27 @@ fn main() -> ExitCode {
|
|||||||
|
|
||||||
// The event loop, running for real.
|
// The event loop, running for real.
|
||||||
//
|
//
|
||||||
// X11 is wired here rather than KWin because the KWin path needs the D-Bus
|
// The KWin path is preferred, and it requires owning the D-Bus name BEFORE
|
||||||
// watcher server — the daemon owning `vu.valiq.blocker` and exporting the
|
// the script is loaded: `callDBus` has nothing to resolve until the daemon
|
||||||
// interface `callDBus` targets — which is not built yet. Rather than stub it,
|
// holds `vu.valiq.blocker`, so loading first would silently drop every
|
||||||
// the loop runs on the backend that works today and says which one it picked.
|
// focus event. If KWin is unavailable we fall back to X11 and say what that
|
||||||
// The honest consequence is in the startup line below: on a Wayland session
|
// costs — on a Wayland session, XWayland clients only.
|
||||||
// this sees 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() {
|
||||||
|
println!("backend: kwin — native Wayland windows are visible");
|
||||||
|
let _keep_connection = watcher_conn;
|
||||||
|
run_loop(&rx, &schedules, &usage, &context, blocker_platform_linux::Enforcement::Freeze);
|
||||||
|
backend.shutdown();
|
||||||
|
return ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("backend: kwin unavailable; falling back to x11");
|
||||||
match blocker_platform_linux::x11::X11Backend::connect() {
|
match blocker_platform_linux::x11::X11Backend::connect() {
|
||||||
Ok(backend) => {
|
Ok(backend) => {
|
||||||
println!("backend: x11 — native Wayland apps are NOT visible through this");
|
println!("backend: x11 — native Wayland apps are NOT visible through this");
|
||||||
@@ -134,7 +151,7 @@ fn main() -> ExitCode {
|
|||||||
schedules: &schedules,
|
schedules: &schedules,
|
||||||
usage: &usage,
|
usage: &usage,
|
||||||
platform_context: &context,
|
platform_context: &context,
|
||||||
boot_id: blocker_core::engine::BootId(0),
|
boot_id: boot::current_boot_id(),
|
||||||
enforcement: blocker_platform_linux::Enforcement::Freeze,
|
enforcement: blocker_platform_linux::Enforcement::Freeze,
|
||||||
};
|
};
|
||||||
let clock = blocker_platform_linux::SystemMonotonic;
|
let clock = blocker_platform_linux::SystemMonotonic;
|
||||||
@@ -143,8 +160,7 @@ 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) =
|
let (action, blocked, _) = runner::step(&rx, &ctx, &clock, time::now());
|
||||||
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:?})");
|
||||||
}
|
}
|
||||||
@@ -166,12 +182,33 @@ fn main() -> ExitCode {
|
|||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Civil time, from the platform layer.
|
/// The event loop, shared by both backends.
|
||||||
///
|
///
|
||||||
/// A placeholder reading rather than a clock read: the loop's correctness
|
/// Both paths park here: the caller has already put a focus-event producer in
|
||||||
/// depends on `next_wakeup` arithmetic, and wiring a real timezone-aware source
|
/// charge, and this consumes it. Two blocked threads and no CPU between events.
|
||||||
/// is part of the next PR. Deliberately returns a fixed instant so the loop is
|
fn run_loop(
|
||||||
/// obviously not yet time-driven, instead of appearing to work by accident.
|
rx: &std::sync::mpsc::Receiver<blocker_platform_linux::FocusEvent>,
|
||||||
fn now() -> blocker_core::engine::CivilTime {
|
schedules: &[blocker_core::model::Schedule],
|
||||||
blocker_core::engine::CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 }
|
usage: &blocker_core::engine::UsageSnapshot,
|
||||||
|
context: &blocker_core::engine::PlatformContext,
|
||||||
|
enforcement: blocker_platform_linux::Enforcement,
|
||||||
|
) {
|
||||||
|
let ctx = runner::LoopContext {
|
||||||
|
schedules,
|
||||||
|
usage,
|
||||||
|
platform_context: context,
|
||||||
|
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, &ctx, &clock, time::now());
|
||||||
|
if blocked > 0 {
|
||||||
|
println!("blocked an app ({action:?})");
|
||||||
|
}
|
||||||
|
if action == runner::Action::Idle {
|
||||||
|
break 'events;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,25 +73,35 @@ 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) {
|
) -> (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) {
|
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);
|
return (Action::Idle, 0, wait);
|
||||||
}
|
}
|
||||||
// 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);
|
return (Action::Idle, 0, Duration::ZERO);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,7 +115,7 @@ pub fn step(
|
|||||||
FocusEvent::Cleared => 0,
|
FocusEvent::Cleared => 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
(action, blocked)
|
(action, blocked, wait)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate, and enforce against whatever just took focus.
|
/// Evaluate, and enforce against whatever just took focus.
|
||||||
@@ -149,7 +159,14 @@ fn enforce_if_blocked(
|
|||||||
};
|
};
|
||||||
|
|
||||||
match enforce::apply(ctx.enforcement, pid) {
|
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) => {
|
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.
|
||||||
|
|||||||
@@ -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
|
// 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.
|
||||||
for (var i = 0; i < workspace.windowList().length; i++) {
|
if (workspace.activeWindow) {
|
||||||
var w = workspace.windowList()[i];
|
reportFocus(workspace.activeWindow);
|
||||||
if (w.active) { reportFocus(w); break; }
|
|
||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,12 @@
|
|||||||
//! - 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 x11;
|
pub mod x11;
|
||||||
|
|
||||||
pub use enforce::{MonotonicSource, SystemMonotonic};
|
pub use enforce::{MonotonicSource, SystemMonotonic};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//! The D-Bus watcher server: the daemon's public side of the KWin handshake.
|
||||||
|
//!
|
||||||
|
//! KWin calls *us*, not the other way round. The compositor has no way to push a
|
||||||
|
//! focus event to an arbitrary subscriber, so the daemon owns a bus name and
|
||||||
|
//! exports an interface that the loaded KWin script calls into. This module owns
|
||||||
|
//! that name and turns those calls into [`FocusEvent`]s on a channel.
|
||||||
|
//!
|
||||||
|
//! # Owning the name is not optional
|
||||||
|
//!
|
||||||
|
//! Without the daemon holding `vu.valiq.blocker`, KWin's `callDBus` has nothing
|
||||||
|
//! to resolve and every focus event is silently discarded. That is why
|
||||||
|
//! [`serve`] must succeed *before* the script is loaded.
|
||||||
|
//!
|
||||||
|
//! # Fail-open
|
||||||
|
//!
|
||||||
|
//! If the daemon is not running, the KWin script's `callDBus` fails inside a
|
||||||
|
//! signal handler and the compositor is left alone — no exception, no log spam.
|
||||||
|
//! Blocking stops; the desktop keeps working. That is section 1.5.4's rule and
|
||||||
|
//! it is why the script swallows its errors.
|
||||||
|
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
use zbus::blocking::{connection::Builder, Connection};
|
||||||
|
|
||||||
|
use super::kwin::{BUS_NAME, OBJECT_PATH};
|
||||||
|
use super::{parse_payload, FocusEvent};
|
||||||
|
|
||||||
|
/// Why the watcher could not start.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum WatcherError {
|
||||||
|
/// No session bus, so there is nothing to own a name on. Almost always
|
||||||
|
/// means the daemon was started outside a desktop session — over SSH, or
|
||||||
|
/// from a system unit rather than a user one.
|
||||||
|
NoSessionBus(String),
|
||||||
|
/// The name could not be acquired, which normally means another instance is
|
||||||
|
/// already running.
|
||||||
|
NameTaken(String),
|
||||||
|
/// Anything else zbus reports.
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for WatcherError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NoSessionBus(e) => write!(f, "no session bus: {e}"),
|
||||||
|
Self::NameTaken(e) => write!(f, "another watcher already owns {BUS_NAME}: {e}"),
|
||||||
|
Self::Other(e) => write!(f, "watcher could not start: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for WatcherError {}
|
||||||
|
|
||||||
|
/// The object the KWin script calls into.
|
||||||
|
///
|
||||||
|
/// One method, `Focused`, whose payload is the tab-separated window description
|
||||||
|
/// built by `kwin::SCRIPT_JS`. An empty payload means focus was cleared.
|
||||||
|
struct Watcher {
|
||||||
|
sink: Sender<FocusEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[zbus::interface(name = "vu.valiq.blocker.Watcher")]
|
||||||
|
impl Watcher {
|
||||||
|
/// Called by the KWin script on every focus change.
|
||||||
|
///
|
||||||
|
/// Payload is tab-separated: resourceClass, resourceName, caption,
|
||||||
|
/// internalId, pid — in the order `kwin::SCRIPT_JS` writes them.
|
||||||
|
fn focused(&self, payload: &str) {
|
||||||
|
let event = match parse_payload(payload) {
|
||||||
|
Some(window) => FocusEvent::Activated(window),
|
||||||
|
None => FocusEvent::Cleared,
|
||||||
|
};
|
||||||
|
// A send failure means the daemon has stopped. Nothing to escalate: the
|
||||||
|
// thread exits when the connection drops.
|
||||||
|
let _ = self.sink.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire the bus name and export [`Watcher`], returning the live connection.
|
||||||
|
///
|
||||||
|
/// The connection must be held for the export to stay registered, so the caller
|
||||||
|
/// keeps it for the daemon's lifetime.
|
||||||
|
///
|
||||||
|
/// Note on ordering: the daemon calls [`serve`] *before*
|
||||||
|
/// [`super::kwin::KwinBackend::connect`]. Loading the script first would start a
|
||||||
|
/// handler calling a name nobody owns, and every event would be lost.
|
||||||
|
pub fn serve(sink: Sender<FocusEvent>) -> Result<Connection, WatcherError> {
|
||||||
|
Builder::session()
|
||||||
|
.map_err(|e| WatcherError::NoSessionBus(e.to_string()))?
|
||||||
|
.name(BUS_NAME)
|
||||||
|
.map_err(|e| WatcherError::NameTaken(e.to_string()))?
|
||||||
|
// `#[zbus::interface]` implements `Interface` for the struct, so the
|
||||||
|
// instance itself is what gets registered — it holds the channel.
|
||||||
|
.serve_at(OBJECT_PATH, Watcher { sink })
|
||||||
|
.map_err(|e| WatcherError::Other(e.to_string()))?
|
||||||
|
.build()
|
||||||
|
.map_err(|e| {
|
||||||
|
// zbus cannot distinguish "name already owned" from other failures
|
||||||
|
// at build time, so the message is what separates them. A name clash
|
||||||
|
// is the common case and deserves the clearer variant.
|
||||||
|
let rendered = e.to_string();
|
||||||
|
if rendered.contains("taken") || rendered.contains("already") {
|
||||||
|
WatcherError::NameTaken(rendered)
|
||||||
|
} else {
|
||||||
|
WatcherError::Other(rendered)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the bus name, so a restarted daemon does not collide with a stale one.
|
||||||
|
///
|
||||||
|
/// Called before exiting. Not cosmetic: systemd `Restart=always` can start the
|
||||||
|
/// replacement before the old process is fully gone, and losing that race would
|
||||||
|
/// leave the new instance wedged on a name its predecessor still holds.
|
||||||
|
pub fn release(connection: &Connection) {
|
||||||
|
let _ = connection.release_name(BUS_NAME);
|
||||||
|
}
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/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" "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exec "$HERE/usr/bin/blocker-daemon" "$@"
|
||||||
|
;;
|
||||||
|
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