Merge branch 'fix/audit-findings' into dev

# Conflicts:
#	assets/icons-needed.txt
#	src/main.rs
#	src/notifications/mod.rs
#	src/notifications/popup.rs
This commit is contained in:
Breadway 2026-07-17 14:39:23 +08:00
commit 2cd2fbc7f6
5 changed files with 232 additions and 50 deletions

View file

@ -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**:

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;
}
});
}

View file

@ -520,7 +520,10 @@ impl SimpleComponent for App {
let power_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4);
power_row.add_css_class("power-row");
for (icon_svg, cmd) in [
(bar::stats::ICON_LOCK, 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.
(bar::stats::ICON_LOCK, vec!["breadlock"]),
(bar::stats::ICON_SLEEP, vec!["systemctl", "suspend"]),
(bar::stats::ICON_RESTART, vec!["systemctl", "reboot"]),
(bar::stats::ICON_SHUTDOWN, vec!["systemctl", "poweroff"]),

View file

@ -1,17 +1,29 @@
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<Duration>` 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,
urgency: Urgency,
expire: Expire,
},
Close(u32),
}
@ -24,6 +36,15 @@ pub enum Urgency {
}
impl Urgency {
/// Spec: hints["urgency"] is a byte, 0=low, 1=normal, 2=critical (default normal).
fn from_hint(hint: Option<&OwnedValue>) -> Self {
match hint.and_then(|v| u8::try_from(v.clone()).ok()) {
Some(0) => Urgency::Low,
Some(2) => Urgency::Critical,
_ => Urgency::Normal,
}
}
/// CSS class suffix for `.notification-card.urgency-<kind>`.
pub fn css_class(self) -> Option<&'static str> {
match self {
@ -34,6 +55,27 @@ impl Urgency {
}
}
/// 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<NotifEvent>,
next_id: AtomicU32,
@ -59,17 +101,15 @@ impl NotifServer {
} else {
self.next_id.fetch_add(1, Ordering::Relaxed)
};
let timeout_ms = if expire_timeout <= 0 {
5000
} else {
expire_timeout as u32
};
// Spec: hints["urgency"] is a byte, 0=low, 1=normal, 2=critical (default normal).
let urgency = match hints.get("urgency").and_then(|v| u8::try_from(v.clone()).ok()) {
Some(0) => Urgency::Low,
Some(2) => Urgency::Critical,
_ => Urgency::Normal,
};
// 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 = Urgency::from_hint(hints.get("urgency"));
let expire = compute_expire(expire_timeout, urgency == Urgency::Critical);
let _ = self
.tx
.send(NotifEvent::Show {
@ -77,8 +117,8 @@ impl NotifServer {
app_name: app_name.to_string(),
summary: summary.to_string(),
body: body.to_string(),
timeout_ms,
urgency,
expire,
})
.await;
id
@ -104,6 +144,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 {
@ -111,7 +152,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()
@ -120,8 +161,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"),
}
}
}

View file

@ -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, Urgency};
use super::{Expire, NotifEvent, Urgency};
type Cards = Rc<RefCell<HashMap<u32, gtk4::Box>>>;
// 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<RefCell<HashMap<u32, u64>>>;
pub async fn run(mut rx: Receiver<NotifEvent>) {
/// 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<NotifEvent>, 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<NotifEvent>) {
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,8 +42,8 @@ pub async fn run(mut rx: Receiver<NotifEvent>) {
app_name,
summary,
body,
timeout_ms,
urgency,
expire,
} => {
// Replace existing card with same id (replaces_id case)
if let Some(old) = cards.borrow_mut().remove(&id) {
@ -38,29 +54,75 @@ pub async fn run(mut rx: Receiver<NotifEvent>) {
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: &gtk4::Box, window: &gtk4::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: &gtk4::Box, window: &gtk4::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 {