breadbar/src/notifications/mod.rs
Breadway 2cd2fbc7f6 Merge branch 'fix/audit-findings' into dev
# Conflicts:
#	assets/icons-needed.txt
#	src/main.rs
#	src/notifications/mod.rs
#	src/notifications/popup.rs
2026-07-17 14:39:23 +08:00

215 lines
6.9 KiB
Rust

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,
urgency: Urgency,
expire: Expire,
},
Close(u32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Urgency {
Low,
Normal,
Critical,
}
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 {
Urgency::Low => None,
Urgency::Normal => Some("urgency-normal"),
Urgency::Critical => Some("urgency-critical"),
}
}
}
/// 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,
}
#[zbus::interface(name = "org.freedesktop.Notifications")]
impl NotifServer {
// The org.freedesktop.Notifications spec mandates exactly these 8 parameters.
#[allow(clippy::too_many_arguments)]
async fn notify(
&self,
app_name: &str,
replaces_id: u32,
_app_icon: &str,
summary: &str,
body: &str,
_actions: Vec<String>,
hints: std::collections::HashMap<String, OwnedValue>,
expire_timeout: i32,
) -> u32 {
let id = if replaces_id != 0 {
replaces_id
} else {
self.next_id.fetch_add(1, Ordering::Relaxed)
};
// 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 {
id,
app_name: app_name.to_string(),
summary: summary.to_string(),
body: body.to_string(),
urgency,
expire,
})
.await;
id
}
async fn close_notification(&self, id: u32) {
let _ = self.tx.send(NotifEvent::Close(id)).await;
}
fn get_capabilities(&self) -> Vec<String> {
vec!["body".to_string()]
}
fn get_server_information(&self) -> (String, String, String, String) {
(
"breadbar".into(),
"breadway".into(),
env!("CARGO_PKG_VERSION").into(),
"1.2".into(),
)
}
}
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 {
tx,
next_id: AtomicU32::new(1),
};
// Builder failures here would only occur with invalid static strings — safe to unwrap.
let conn = zbus::connection::Builder::session()
.unwrap()
.name("org.freedesktop.Notifications")
.unwrap()
.serve_at("/org/freedesktop/Notifications", server)
.unwrap()
.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(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"),
}
}
}