breadbar: reconnect Hyprland event stream, fix notification spec violations, point lock button at breadlock

- 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
This commit is contained in:
Breadway 2026-07-17 08:40:32 +08:00
parent e8d5fd5a52
commit 1f2d58d97f
6 changed files with 229 additions and 46 deletions

View file

@ -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<crate::App>) {
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<crate::App>) {
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;
}
});
}