# Conflicts: # assets/icons-needed.txt # src/main.rs # src/notifications/mod.rs # src/notifications/popup.rs
215 lines
6.9 KiB
Rust
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"),
|
|
}
|
|
}
|
|
}
|