Coverage Report

Created: 2026-11-04 02:14

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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
}