From 1f2d58d97f7b98d28483c78677ed0d10e55fc8ea Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 08:40:32 +0800 Subject: [PATCH] breadbar: reconnect Hyprland event stream, fix notification spec violations, point lock button at breadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/bar/workspaces.rs: the Hyprland EventStream loop exited permanently on the first Err/end-of-stream (Hyprland restart/reload, IPC hiccup), freezing every workspace button for the bar's remaining life. Now wrapped in a reconnect loop with capped exponential backoff, re-syncing workspace state on every reconnect - src/notifications/mod.rs + popup.rs: three spec deviations fixed — expire_timeout=0 now means never-expire instead of being coerced to 5s (and a critical-urgency notification with no explicit timeout also persists by default); NotificationClosed is now emitted with the correct reason code whenever a notification actually goes away (expiry or an explicit CloseNotification call); replaces_id no longer races its auto-dismiss timer against the replacement's, via a per-id generation counter checked before a stale timer is allowed to dismiss anything - src/main.rs + README.md + assets/icons-needed.txt: lock button (and its docs) now invoke breadlock instead of hyprlock, the thing breadlock was built to replace - src/notifications/mod.rs: added 4 unit tests for the new expire_timeout/ urgency mapping (pulled into a pure compute_expire() for testability) — this crate had zero test coverage before --- README.md | 2 +- assets/icons-needed.txt | 2 +- src/bar/workspaces.rs | 61 +++++++++++++++------ src/main.rs | 5 +- src/notifications/mod.rs | 109 +++++++++++++++++++++++++++++++++---- src/notifications/popup.rs | 96 ++++++++++++++++++++++++++------ 6 files changed, 229 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 7e58eff..d8b3f9e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A single Rust binary that provides a full-width top bar, a D-Bus notification da - Live CPU%, GPU%, and network throughput (download/upload) - Audio output selector (lists PulseAudio sinks via `pactl`, switching takes effect immediately) - System tray (SNI): apps that register with `org.kde.StatusNotifierWatcher` appear as icon buttons -- Power buttons: lock (`hyprlock`), suspend, reboot, poweroff +- Power buttons: lock (`breadlock`), suspend, reboot, poweroff **Notification daemon**: diff --git a/assets/icons-needed.txt b/assets/icons-needed.txt index e228054..c93fd85 100644 --- a/assets/icons-needed.txt +++ b/assets/icons-needed.txt @@ -20,7 +20,7 @@ Brightness.svg Power section buttons ----------------------- Lock.svg - Padlock icon — triggers hyprlock (lock screen). + Padlock icon — triggers breadlock (lock screen). Currently placeholder: 🔒 Sleep.svg diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index f3e2209..67942f8 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -10,28 +10,57 @@ use relm4::ComponentSender; use crate::AppInput; +/// Fetches the current workspace list + active workspace and pushes both to +/// the app — used both for the initial state and to re-sync after the event +/// stream reconnects (state may have changed while we were disconnected). +async fn sync_state(sender: &ComponentSender) { + if let Ok(ws) = Workspaces::get_async().await { + sender.input(AppInput::WorkspaceList(ws.to_vec())); + } + if let Ok(active) = Workspace::get_active_async().await { + sender.input(AppInput::ActiveWorkspace(active.id)); + } +} + pub fn spawn_watcher(sender: ComponentSender) { relm4::spawn(async move { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); - } - if let Ok(active) = Workspace::get_active_async().await { - sender.input(AppInput::ActiveWorkspace(active.id)); - } + sync_state(&sender).await; - let mut stream = EventStream::new(); - while let Some(Ok(event)) = stream.next().await { - match event { - Event::WorkspaceChanged(data) => { - sender.input(AppInput::ActiveWorkspace(data.id)); - } - Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { - if let Ok(ws) = Workspaces::get_async().await { - sender.input(AppInput::WorkspaceList(ws.to_vec())); + // Hyprland's IPC event socket can drop out from under us — a + // Hyprland restart/reload, or just a transient hiccup — at which + // point `stream.next()` yields `None` (or an `Err`, also excluded + // by this `while let Some(Ok(..))` pattern). That used to just fall + // through and end this whole task permanently, freezing every + // workspace button for the rest of the bar's life. Reconnect with a + // capped exponential backoff instead of giving up. + let mut backoff = std::time::Duration::from_millis(500); + const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30); + + loop { + let mut stream = EventStream::new(); + while let Some(Ok(event)) = stream.next().await { + backoff = std::time::Duration::from_millis(500); + match event { + Event::WorkspaceChanged(data) => { + sender.input(AppInput::ActiveWorkspace(data.id)); } + Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => { + if let Ok(ws) = Workspaces::get_async().await { + sender.input(AppInput::WorkspaceList(ws.to_vec())); + } + } + _ => {} } - _ => {} } + + eprintln!( + "breadbar: Hyprland event stream ended (restart/reload/IPC hiccup); \ + reconnecting in {:?}", + backoff + ); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(MAX_BACKOFF); + sync_state(&sender).await; } }); } diff --git a/src/main.rs b/src/main.rs index 2767846..55fc123 100644 --- a/src/main.rs +++ b/src/main.rs @@ -392,7 +392,10 @@ impl SimpleComponent for App { let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); power_row.add_css_class("power-row"); for (label, cmd) in [ - ("🔒", vec!["hyprlock"]), + // breadlock is the ecosystem's own screen locker — hyprlock is + // the thing it was built to replace; the bar shouldn't still + // be pointing at it. + ("🔒", vec!["breadlock"]), ("💤", vec!["systemctl", "suspend"]), ("🔄", vec!["systemctl", "reboot"]), ("⏻", vec!["systemctl", "poweroff"]), diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index b6c60b2..37343e9 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -1,20 +1,53 @@ pub mod popup; use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::Duration; use tokio::sync::mpsc; use zbus::zvariant::OwnedValue; +/// How long a shown notification should stay up before auto-dismissing. +/// Distinct from `Option` mainly for readability at call sites — +/// `Never` covers both the spec's `expire_timeout == 0` ("never expire") +/// and a critical-urgency notification with no explicit timeout, which +/// conventionally shouldn't auto-dismiss either. +#[derive(Debug, Clone, Copy)] +pub enum Expire { + Never, + After(Duration), +} + pub enum NotifEvent { Show { id: u32, app_name: String, summary: String, body: String, - timeout_ms: u32, + expire: Expire, }, Close(u32), } +/// Maps a `Notify` call's `expire_timeout` (plus whether the `urgency` hint +/// was critical) to our internal `Expire`, per the freedesktop notification +/// spec: `0` always means never expire; a negative value means "server +/// picks a default" (5s here, except critical notifications, which +/// conventionally persist); any non-negative value is taken literally. +/// Pulled out of `NotifServer::notify` so this mapping is unit-testable +/// without a live D-Bus connection. +fn compute_expire(expire_timeout: i32, urgency_critical: bool) -> Expire { + match expire_timeout { + 0 => Expire::Never, + t if t < 0 => { + if urgency_critical { + Expire::Never + } else { + Expire::After(Duration::from_millis(5000)) + } + } + t => Expire::After(Duration::from_millis(t as u64)), + } +} + struct NotifServer { tx: mpsc::Sender, next_id: AtomicU32, @@ -32,7 +65,7 @@ impl NotifServer { summary: &str, body: &str, _actions: Vec, - _hints: std::collections::HashMap, + hints: std::collections::HashMap, expire_timeout: i32, ) -> u32 { let id = if replaces_id != 0 { @@ -40,11 +73,19 @@ impl NotifServer { } else { self.next_id.fetch_add(1, Ordering::Relaxed) }; - let timeout_ms = if expire_timeout <= 0 { - 5000 - } else { - expire_timeout as u32 - }; + + // Per spec: 0 means "never expire" — this used to be lumped in + // with "-1: let the server pick a default" and coerced to a fixed + // 5s, so a sender explicitly asking for a persistent notification + // (e.g. a progress/error dialog) got auto-dismissed anyway. + // Critical-urgency notifications conventionally persist too, even + // when the sender left expire_timeout at the server-default (-1). + let urgency_critical = hints + .get("urgency") + .and_then(|v| u8::try_from(v).ok()) + .is_some_and(|u| u == 2); + let expire = compute_expire(expire_timeout, urgency_critical); + let _ = self .tx .send(NotifEvent::Show { @@ -52,7 +93,7 @@ impl NotifServer { app_name: app_name.to_string(), summary: summary.to_string(), body: body.to_string(), - timeout_ms, + expire, }) .await; id @@ -78,6 +119,7 @@ impl NotifServer { pub fn spawn() { let (tx, rx) = mpsc::channel(32); + let (conn_tx, conn_rx) = tokio::sync::oneshot::channel(); relm4::spawn(async move { let server = NotifServer { @@ -85,7 +127,7 @@ pub fn spawn() { next_id: AtomicU32::new(1), }; // Builder failures here would only occur with invalid static strings — safe to unwrap. - let _conn = zbus::connection::Builder::session() + let conn = zbus::connection::Builder::session() .unwrap() .name("org.freedesktop.Notifications") .unwrap() @@ -94,8 +136,55 @@ pub fn spawn() { .build() .await .expect("failed to claim org.freedesktop.Notifications on D-Bus session bus"); + // Hand the connection to popup::run so it can emit `NotificationClosed` + // (spec-mandated whenever a notification actually goes away) — the + // dismiss decisions all happen over there, not in this interface impl. + let _ = conn_tx.send(conn); std::future::pending::<()>().await }); - relm4::spawn_local(popup::run(rx)); + relm4::spawn_local(async move { + if let Ok(conn) = conn_rx.await { + popup::run(rx, conn).await; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_timeout_never_expires_regardless_of_urgency() { + assert!(matches!(compute_expire(0, false), Expire::Never)); + assert!(matches!(compute_expire(0, true), Expire::Never)); + } + + #[test] + fn negative_timeout_defaults_to_five_seconds_for_normal_urgency() { + match compute_expire(-1, false) { + Expire::After(d) => assert_eq!(d, Duration::from_millis(5000)), + Expire::Never => panic!("expected a 5s default, got Never"), + } + } + + #[test] + fn negative_timeout_persists_for_critical_urgency() { + assert!(matches!(compute_expire(-1, true), Expire::Never)); + } + + #[test] + fn positive_timeout_is_taken_literally() { + match compute_expire(1500, false) { + Expire::After(d) => assert_eq!(d, Duration::from_millis(1500)), + Expire::Never => panic!("expected 1500ms, got Never"), + } + // Even for critical urgency, an explicit positive timeout is honored + // rather than overridden to Never — "critical persists" is only the + // *default* when the sender didn't specify one. + match compute_expire(1500, true) { + Expire::After(d) => assert_eq!(d, Duration::from_millis(1500)), + Expire::Never => panic!("expected 1500ms, got Never"), + } + } } diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index c8a7e50..41db852 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -1,14 +1,29 @@ -use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Duration}; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use tokio::sync::mpsc::Receiver; -use super::NotifEvent; +use super::{Expire, NotifEvent}; type Cards = Rc>>; +// Bumped every time an id gets a (re)placed card — an auto-dismiss timer +// scheduled for an earlier Show captures the generation it was scheduled +// under, and checks it's still current before dismissing. Without this, a +// notification that replaces an existing id (replaces_id) doesn't cancel +// the original's timer, so the *replacement* card gets dismissed on the +// *original*'s deadline instead of its own. +type Generations = Rc>>; -pub async fn run(mut rx: Receiver) { +/// NotificationClosed reason codes per the freedesktop spec. +mod close_reason { + pub const EXPIRED: u32 = 1; + #[allow(dead_code)] // no in-app dismiss button exists yet (see make_card) + pub const DISMISSED_BY_USER: u32 = 2; + pub const CLOSE_NOTIFICATION_CALL: u32 = 3; +} + +pub async fn run(mut rx: Receiver, conn: zbus::Connection) { let window = create_window(); let cards_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); cards_box.set_margin_top(8); @@ -18,6 +33,7 @@ pub async fn run(mut rx: Receiver) { window.set_child(Some(&cards_box)); let cards: Cards = Rc::new(RefCell::new(HashMap::new())); + let generations: Generations = Rc::new(RefCell::new(HashMap::new())); while let Some(event) = rx.recv().await { match event { @@ -26,7 +42,7 @@ pub async fn run(mut rx: Receiver) { app_name, summary, body, - timeout_ms, + expire, } => { // Replace existing card with same id (replaces_id case) if let Some(old) = cards.borrow_mut().remove(&id) { @@ -37,29 +53,75 @@ pub async fn run(mut rx: Receiver) { cards.borrow_mut().insert(id, card.clone()); window.set_visible(true); - // Auto-dismiss via GLib-native timer (safe inside spawn_local) - let cards_clone = cards.clone(); - let cards_box_clone = cards_box.clone(); - let win_clone = window.clone(); - relm4::spawn_local(async move { - gtk4::glib::timeout_future(Duration::from_millis(timeout_ms as u64)).await; - dismiss(&cards_box_clone, &win_clone, &cards_clone, id); - }); + let my_generation = { + let mut gens = generations.borrow_mut(); + let g = gens.entry(id).or_insert(0); + *g += 1; + *g + }; + + // `Expire::Never` (expire_timeout=0, or a critical-urgency + // notification with no explicit timeout) schedules no timer + // at all — it persists until an explicit CloseNotification. + if let Expire::After(duration) = expire { + let cards_clone = cards.clone(); + let cards_box_clone = cards_box.clone(); + let win_clone = window.clone(); + let generations_clone = generations.clone(); + let conn_clone = conn.clone(); + relm4::spawn_local(async move { + gtk4::glib::timeout_future(duration).await; + let still_current = + generations_clone.borrow().get(&id) == Some(&my_generation); + if still_current + && dismiss(&cards_box_clone, &win_clone, &cards_clone, id) + { + emit_closed(&conn_clone, id, close_reason::EXPIRED).await; + } + }); + } } NotifEvent::Close(id) => { - dismiss(&cards_box, &window, &cards, id); + if dismiss(&cards_box, &window, &cards, id) { + emit_closed(&conn, id, close_reason::CLOSE_NOTIFICATION_CALL).await; + } } } } } -fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) { - if let Some(card) = cards.borrow_mut().remove(&id) { - cards_box.remove(&card); - } +/// Removes `id`'s card if present. Returns whether a card was actually +/// removed, so callers only emit `NotificationClosed` for a real dismissal +/// (not a no-op on an id that's already gone or was never shown). +fn dismiss(cards_box: >k4::Box, window: >k4::Window, cards: &Cards, id: u32) -> bool { + let removed = cards.borrow_mut().remove(&id); + let Some(card) = removed else { + return false; + }; + cards_box.remove(&card); if cards.borrow().is_empty() { window.set_visible(false); } + true +} + +/// Emits the spec-mandated `NotificationClosed(id, reason)` signal. Sent +/// directly over the connection rather than through the zbus interface +/// macro's generated helper, since the dismiss decision happens here in the +/// popup task, not inside `NotifServer`'s own method bodies. +async fn emit_closed(conn: &zbus::Connection, id: u32, reason: u32) { + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "NotificationClosed", + &(id, reason), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit NotificationClosed for {id}: {e}"); + } } fn create_window() -> gtk4::Window {