super-tts-daemon/src/output/notification.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! Desktop notification delivery for synthesis failures. |
3 | | //! |
4 | | //! Uses the freedesktop Desktop Notifications interface |
5 | | //! (`org.freedesktop.Notifications`), which every mainstream desktop provides — |
6 | | //! GNOME, KDE Plasma, COSMIC, XFCE, MATE, Cinnamon, `LXQt` — as do the |
7 | | //! standalone servers used on bare compositors (mako, dunst, swaync). One code |
8 | | //! path covers all of them; nothing here is desktop-specific. |
9 | | //! |
10 | | //! What a bubble says is decided in [`crate::output::notice`], including how |
11 | | //! backend-authored text is made safe to put in a body; this module only carries |
12 | | //! it to the bus. |
13 | | |
14 | | use crate::output::notice::Failure; |
15 | | use anyhow::{Context, Result}; |
16 | | use log::{debug, info, warn}; |
17 | | use std::collections::HashMap; |
18 | | use super_tts_shared::models::notification_method::NotificationMethod; |
19 | | use zbus::Connection; |
20 | | use zbus::zvariant::Value; |
21 | | |
22 | | const NOTIFY_BUS: &str = "org.freedesktop.Notifications"; |
23 | | const NOTIFY_PATH: &str = "/org/freedesktop/Notifications"; |
24 | | const NOTIFY_IFACE: &str = "org.freedesktop.Notifications"; |
25 | | |
26 | | /// Sent as the notification's `app_name`, which is where the user learns who |
27 | | /// this bubble is from. The summary is free to name the failure instead. |
28 | | const APP_NAME: &str = "Super TTS"; |
29 | | /// Installed into `share/icons/hicolor/scalable/apps` by the justfile. |
30 | | const APP_ICON: &str = "super-tts-app"; |
31 | | /// 0 = low, 1 = normal, 2 = critical. |
32 | | const URGENCY_NORMAL: u8 = 1; |
33 | | /// Let the notification server pick the timeout. |
34 | | const EXPIRE_DEFAULT: i32 = -1; |
35 | | |
36 | | /// Sends failure notices to the session's notification server. |
37 | | /// |
38 | | /// `pub` (not `pub(crate)`) because it is held in a `pub` field on the `pub` |
39 | | /// `SuperTTSDaemon`. |
40 | | pub struct Notifier { |
41 | | inner: Inner, |
42 | | /// Id of the last notification sent, passed as `replaces_id` so repeated |
43 | | /// failures replace the previous bubble instead of stacking. The spec |
44 | | /// treats 0 as "do not replace". |
45 | | last_id: u32, |
46 | | } |
47 | | |
48 | | /// Every `(summary, body)` a [`Notifier::fake`] was asked to send. |
49 | | #[cfg(test)] |
50 | | type Sent = std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>; |
51 | | |
52 | | enum Inner { |
53 | | /// Session-bus connection, established on first send and cached. |
54 | | Dbus(Option<Connection>), |
55 | | #[cfg(test)] |
56 | | Fake { fail: bool, sent: Sent }, |
57 | | } |
58 | | |
59 | | impl Notifier { |
60 | | #[must_use] |
61 | 70 | pub fn dbus() -> Self { |
62 | 70 | Self { |
63 | 70 | inner: Inner::Dbus(None), |
64 | 70 | last_id: 0, |
65 | 70 | } |
66 | 70 | } |
67 | | |
68 | | /// Deliver `summary` and `body` as a desktop notification. |
69 | | /// |
70 | | /// # Errors |
71 | | /// Returns an error when no session bus is reachable, or when no |
72 | | /// notification server owns `org.freedesktop.Notifications`. Callers treat |
73 | | /// both the same way: there is nowhere to show a notification. |
74 | | /// |
75 | | /// # Panics |
76 | | /// Never in practice: the `expect` below only unwraps the connection slot |
77 | | /// this same call just populated a few lines above. |
78 | 14 | pub async fn send(&mut self, summary: &str, body: &str) -> Result<()> { |
79 | 14 | match &mut self.inner { |
80 | 5 | Inner::Dbus(slot) => { |
81 | 5 | if slot.is_none() { |
82 | 2 | *slot = Some( |
83 | 5 | Connection::session() |
84 | 5 | .await |
85 | 5 | .context("no session bus available for notifications")?3 , |
86 | | ); |
87 | 0 | } |
88 | 2 | let conn = slot.as_ref().expect("connection established above"); |
89 | 2 | let proxy = zbus::Proxy::new(conn, NOTIFY_BUS, NOTIFY_PATH, NOTIFY_IFACE) |
90 | 2 | .await |
91 | 2 | .context("could not build the notifications proxy")?0 ; |
92 | | |
93 | 2 | let mut hints: HashMap<&str, Value<'_>> = HashMap::new(); |
94 | 2 | hints.insert("urgency", Value::U8(URGENCY_NORMAL)); |
95 | 2 | let actions: Vec<&str> = Vec::new(); |
96 | | |
97 | | // Notify(app_name, replaces_id, app_icon, summary, body, |
98 | | // actions, hints, expire_timeout) -> id |
99 | 2 | let id0 : u320 = proxy |
100 | 2 | .call( |
101 | 2 | "Notify", |
102 | 2 | &( |
103 | 2 | APP_NAME, |
104 | 2 | self.last_id, |
105 | 2 | APP_ICON, |
106 | 2 | summary, |
107 | 2 | body, |
108 | 2 | actions, |
109 | 2 | hints, |
110 | 2 | EXPIRE_DEFAULT, |
111 | 2 | ), |
112 | 2 | ) |
113 | 2 | .await |
114 | 2 | .context("no notification server answered on the session bus")?; |
115 | | |
116 | 0 | self.last_id = id; |
117 | 0 | debug!("Delivered failure notification (id {id})"); |
118 | 0 | Ok(()) |
119 | | } |
120 | | #[cfg(test)] |
121 | 9 | Inner::Fake { fail, sent } => { |
122 | 9 | if *fail { |
123 | 1 | anyhow::bail!("fake notifier: delivery failed"); |
124 | 8 | } |
125 | 8 | sent.lock() |
126 | 8 | .unwrap() |
127 | 8 | .push((summary.to_string(), body.to_string())); |
128 | 8 | Ok(()) |
129 | | } |
130 | | } |
131 | 14 | } |
132 | | |
133 | | /// A notifier that records the `(summary, body)` of what it was asked to |
134 | | /// send, or fails every send when `fail` is true. |
135 | | #[cfg(test)] |
136 | 104 | pub(crate) fn fake(fail: bool) -> (Self, Sent) { |
137 | 104 | let sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); |
138 | 104 | ( |
139 | 104 | Self { |
140 | 104 | inner: Inner::Fake { |
141 | 104 | fail, |
142 | 104 | sent: std::sync::Arc::clone(&sent), |
143 | 104 | }, |
144 | 104 | last_id: 0, |
145 | 104 | }, |
146 | 104 | sent, |
147 | 104 | ) |
148 | 104 | } |
149 | | } |
150 | | |
151 | | /// Route a failure notice to the user through the configured channel. |
152 | | /// |
153 | | /// Delivery can fail with no channel left — a bare compositor with no |
154 | | /// notification server running — and that is not an error worth propagating: |
155 | | /// the caller already learned about the failure from the response it got. It |
156 | | /// goes to the log so the reason is still recoverable. |
157 | 9 | pub(crate) async fn deliver( |
158 | 9 | method: NotificationMethod, |
159 | 9 | notifier: &mut Notifier, |
160 | 9 | failure: &Failure, |
161 | 9 | ) { |
162 | 9 | match method { |
163 | | NotificationMethod::Off => { |
164 | 1 | info!( |
165 | | "Synthesis failure: {} — {} (surfacing disabled)", |
166 | | failure.summary, failure.body |
167 | | ); |
168 | | } |
169 | | NotificationMethod::Auto => { |
170 | 8 | if let Err(e6 ) = notifier.send(failure.summary, &failure.body).await { |
171 | 6 | warn!( |
172 | | "Could not deliver failure notification ({} — {}): {e}", |
173 | | failure.summary, failure.body |
174 | | ); |
175 | 2 | } |
176 | | } |
177 | | } |
178 | 9 | } |
179 | | |
180 | | #[cfg(test)] |
181 | | mod tests { |
182 | | use super::*; |
183 | | use crate::output::notice::Origin; |
184 | | |
185 | | /// A synthesis failure carrying a backend's reason — the shape the user |
186 | | /// hits most often, and the one that has both a summary and a body to check. |
187 | 2 | fn backend_failure() -> Failure { |
188 | 2 | Failure::synthesis_failed(Origin::Backend, "Could not reach the server (write_failed)") |
189 | 2 | } |
190 | | |
191 | | #[tokio::test(start_paused = true)] |
192 | 1 | async fn off_sends_nothing() { |
193 | 1 | let (mut n, sent) = Notifier::fake(false); |
194 | 1 | deliver(NotificationMethod::Off, &mut n, &backend_failure()).await; |
195 | 1 | assert!(sent.lock().unwrap().is_empty()); |
196 | 1 | } |
197 | | |
198 | | #[tokio::test(start_paused = true)] |
199 | 1 | async fn auto_sends_the_summary_and_body() { |
200 | 1 | let (mut n, sent) = Notifier::fake(false); |
201 | 1 | deliver( |
202 | 1 | NotificationMethod::Auto, |
203 | 1 | &mut n, |
204 | 1 | &Failure::could_not_open_output("Audio device disappeared mid-utterance"), |
205 | 1 | ) |
206 | 1 | .await; |
207 | | |
208 | 1 | assert_eq!( |
209 | 1 | *sent.lock().unwrap(), |
210 | 1 | vec![( |
211 | 1 | "Could not play audio".to_string(), |
212 | 1 | "Audio device disappeared mid-utterance".to_string() |
213 | 1 | )] |
214 | 1 | ); |
215 | 1 | } |
216 | | |
217 | | /// The bug this replaced: a bubble that named the app twice and the reason |
218 | | /// not at all. |
219 | | #[tokio::test(start_paused = true)] |
220 | 1 | async fn the_bubble_carries_the_reason_and_not_a_second_app_name() { |
221 | 1 | let (mut n, sent) = Notifier::fake(false); |
222 | 1 | deliver(NotificationMethod::Auto, &mut n, &backend_failure()).await; |
223 | | |
224 | 1 | let sent = sent.lock().unwrap().clone(); |
225 | 1 | let (summary, body) = sent.first().expect("one notification"); |
226 | 1 | assert_eq!(summary, "Synthesis failed"); |
227 | 1 | assert_eq!( |
228 | | body, |
229 | | "Backend error: Could not reach the server (write_failed)" |
230 | | ); |
231 | 1 | assert!( |
232 | 1 | !summary.contains(APP_NAME) && !body.contains(APP_NAME), |
233 | 1 | "the app name is the notification's own field, not text" |
234 | 1 | ); |
235 | 1 | } |
236 | | |
237 | | /// A compositor with no notification server must not take the daemon down |
238 | | /// with it: an undeliverable notice is logged, not propagated. |
239 | | #[tokio::test(start_paused = true)] |
240 | 1 | async fn a_failed_delivery_is_swallowed() { |
241 | 1 | let (mut n, _sent) = Notifier::fake(true); |
242 | 1 | deliver( |
243 | 1 | NotificationMethod::Auto, |
244 | 1 | &mut n, |
245 | 1 | &Failure::no_model_loaded(), |
246 | 1 | ) |
247 | 1 | .await; |
248 | 1 | } |
249 | | } |