Skip to main content

sioul_sync/
notify.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The one exception to the admin windows: a quiet desktop notification for a
5//! verified code, with the code in it (docs/porch.md, "Right now"); a gentle
6//! reminder; new mail at the times it may come, once per batch
7//! (docs/porch.md, "Notifications"); and the time running, while a focus
8//! session runs (docs/tasks.md).
9
10#[cfg(not(target_os = "android"))]
11use notify_rust::{Notification, Timeout};
12#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
13use notify_rust::{Hint, Urgency};
14#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
15use std::sync::mpsc::{Receiver, Sender};
16use std::time::Duration;
17
18/// How long the notification stays: about the life of a code.
19#[cfg(not(target_os = "android"))]
20const SHOWN: u32 = 10 * 60 * 1000;
21
22/// Sioul's bundle id on macOS, as `packaging/macos/Info.plist` names it
23/// (`CFBundleIdentifier`). macOS shows a notification as coming from the
24/// application whose id it is given; without one, notify-rust's macOS side
25/// (mac-notification-sys) names another application.
26pub const BUNDLE_ID: &str = "com.aurelienpierre.Sioul";
27
28/// macOS: Sioul's notifications said to come from Sioul (`BUNDLE_ID`), once
29/// per process, before the first is shown: macOS takes the application only
30/// once.
31#[cfg(target_os = "macos")]
32fn as_sioul() {
33    static ONCE: std::sync::Once = std::sync::Once::new();
34    ONCE.call_once(|| {
35        let _ = notify_rust::set_application(BUNDLE_ID);
36    });
37}
38
39/// Shows a code: `title` says what and from whom, `body` the code and how long
40/// it lasts. No sound. With `copy`, the notification offers a button labelled
41/// with its text, and its action runs when the button is pressed; the
42/// notification then lives on its own thread, which waits for the button.
43#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
44pub fn code(title: &str, body: &str, copy: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
45    let Some((label, on_copy)) = copy else {
46        return build(title, body, None).show().map(drop).map_err(|e| e.to_string());
47    };
48    let (title, body) = (title.to_string(), body.to_string());
49    // The D-Bus handle cannot change threads, so it is made where it waits.
50    std::thread::spawn(move || {
51        let Ok(handle) = build(&title, &body, Some(&label)).show() else { return };
52        let mut on_copy = Some(on_copy);
53        let _ = handle.wait_for_action(|action: &str| {
54            if action == "copy"
55                && let Some(run) = on_copy.take()
56            {
57                run();
58            }
59        });
60    });
61    Ok(())
62}
63
64/// A gentle reminder (a dose to take): one notification, no sound, with one
65/// button whose action runs when pressed.
66#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
67pub fn remind(title: &str, body: &str, action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
68    let Some((label, on_press)) = action else {
69        return build(title, body, None).icon("appointment-soon").show().map(drop).map_err(|e| e.to_string());
70    };
71    let (title, body) = (title.to_string(), body.to_string());
72    std::thread::spawn(move || {
73        let Ok(handle) = build(&title, &body, Some(&label)).icon("appointment-soon").show() else { return };
74        let mut on_press = Some(on_press);
75        let _ = handle.wait_for_action(|action: &str| {
76            if action == "copy"
77                && let Some(run) = on_press.take()
78            {
79                run();
80            }
81        });
82    });
83    Ok(())
84}
85
86/// New mail at the times it may come (docs/porch.md, "Notifications"): one
87/// notification for the batch, no sound, with one button ("Open": the
88/// Porch) whose action runs when pressed. At normal urgency, unlike codes
89/// and reminders: the desktop's own do-not-disturb holds it; but at critical
90/// urgency when someone Always through is in it (`through`), which passes it
91/// (docs/attention.md, Q10).
92#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
93pub fn mail(title: &str, body: &str, through: bool, action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
94    let urgency = if through { Urgency::Critical } else { Urgency::Normal };
95    let Some((label, on_press)) = action else {
96        return build(title, body, None).icon("mail-unread").urgency(urgency).show().map(drop).map_err(|e| e.to_string());
97    };
98    let (title, body) = (title.to_string(), body.to_string());
99    std::thread::spawn(move || {
100        let Ok(handle) = build(&title, &body, Some(&label)).icon("mail-unread").urgency(urgency).show() else { return };
101        let mut on_press = Some(on_press);
102        let _ = handle.wait_for_action(|action: &str| {
103            if action == "copy"
104                && let Some(run) = on_press.take()
105            {
106                run();
107            }
108        });
109    });
110    Ok(())
111}
112
113/// Windows and macOS: the mail's words alone; their notifications carry no button here.
114#[cfg(any(windows, target_os = "macos"))]
115pub fn mail(title: &str, body: &str, _through: bool, _action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
116    #[cfg(target_os = "macos")]
117    as_sioul();
118    Notification::new().appname("Sioul").summary(title).body(body).timeout(Timeout::Milliseconds(SHOWN)).show().map(drop).map_err(|e| e.to_string())
119}
120
121/// Android: made on the window's side, through Java (sioul-app's `eventalarms`).
122#[cfg(target_os = "android")]
123pub fn mail(_title: &str, _body: &str, _through: bool, _action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
124    Err("notifications are made through Java on Android".to_string())
125}
126
127/// A gentle reminder with several buttons, each its label and its action:
128/// the one pressed runs (the night's notice: "Close the day" and "Options…").
129#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
130pub fn remind_choices(title: &str, body: &str, choices: Vec<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
131    if choices.is_empty() {
132        return remind(title, body, None);
133    }
134    let (title, body) = (title.to_string(), body.to_string());
135    std::thread::spawn(move || {
136        let mut notification = build(&title, &body, None);
137        notification.icon("appointment-soon");
138        for (index, (label, _)) in choices.iter().enumerate() {
139            notification.action(&format!("choice-{index}"), label);
140        }
141        let Ok(handle) = notification.show() else { return };
142        let mut runs: Vec<Option<Box<dyn FnOnce() + Send>>> = choices.into_iter().map(|(_, run)| Some(run)).collect();
143        let _ = handle.wait_for_action(|action: &str| {
144            if let Some(run) = action.strip_prefix("choice-").and_then(|n| n.parse::<usize>().ok()).and_then(|n| runs.get_mut(n)).and_then(Option::take) {
145                run();
146            }
147        });
148    });
149    Ok(())
150}
151
152/// Elsewhere, as a reminder without buttons (`remind`).
153#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
154pub fn remind_choices(title: &str, body: &str, _choices: Vec<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
155    remind(title, body, None)
156}
157
158/// Windows and macOS: the reminder is in the text; no button here.
159#[cfg(any(windows, target_os = "macos"))]
160pub fn remind(title: &str, body: &str, _action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
161    #[cfg(target_os = "macos")]
162    as_sioul();
163    Notification::new().appname("Sioul").summary(title).body(body).timeout(Timeout::Milliseconds(SHOWN)).show().map(drop).map_err(|e| e.to_string())
164}
165
166/// Windows and macOS: the code is in the text; their notifications carry no button here.
167#[cfg(any(windows, target_os = "macos"))]
168pub fn code(title: &str, body: &str, _copy: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
169    #[cfg(target_os = "macos")]
170    as_sioul();
171    Notification::new().appname("Sioul").summary(title).body(body).timeout(Timeout::Milliseconds(SHOWN)).show().map(drop).map_err(|e| e.to_string())
172}
173
174/// Android: no notifications yet (Android's own are reached through Java);
175/// the code shows in the window only.
176#[cfg(target_os = "android")]
177pub fn code(_title: &str, _body: &str, _copy: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
178    Err("notifications are not made on Android yet".to_string())
179}
180
181/// Android: no notifications yet; a reminder is not shown (docs/android.md).
182#[cfg(target_os = "android")]
183pub fn remind(_title: &str, _body: &str, _action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
184    Err("notifications are not made on Android yet".to_string())
185}
186
187/// The notification server, on the session bus.
188#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
189const SERVER: &str = "org.freedesktop.Notifications";
190#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
191const SERVER_PATH: &str = "/org/freedesktop/Notifications";
192
193/// Sioul's desktop file, named on each notification ("desktop-entry"): the
194/// desktop knows them as Sioul's, and lists Sioul in its notification
195/// settings (Plasma's "Show in do not disturb mode", for the doses during a
196/// pause: docs/pauses.md).
197#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
198const DESKTOP_ENTRY: &str = "com.aurelienpierre.Sioul";
199
200/// How long a lasting notification's thread listens to the server before it
201/// looks at what Sioul asked: a change shows within it.
202#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
203const LISTENED: Duration = Duration::from_millis(500);
204
205/// A notification the server did not take (one starting with the session,
206/// after Sioul) is asked again this often.
207#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
208const ASKED_AGAIN: Duration = Duration::from_secs(60);
209
210/// The notification of something going on, the time running while a focus
211/// session runs (docs/tasks.md): shown until it is closed, changed in place,
212/// its buttons pressed any number of times. Its own thread holds the D-Bus
213/// connection that shows it, and listens there: the server tells a press to
214/// that connection alone (Plasma addresses it). Resident, kept when a button
215/// is pressed; at normal urgency, for Plasma shows a low one nowhere unless
216/// told to; without sound. A server that keeps what it shows ("persistence":
217/// its history, its tray) takes it there after its popup, buttons alive;
218/// elsewhere it does not expire. No session bus, no server: nothing shows,
219/// nothing breaks. Dropped, it is taken away.
220#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
221pub struct Ongoing {
222    changes: Sender<Change>,
223}
224
225/// What a lasting notification says: its buttons as (key, label), "default"
226/// for a click on it.
227#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
228struct Said {
229    title: String,
230    body: String,
231    actions: Vec<(String, String)>,
232}
233
234#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
235enum Change {
236    Show(Said),
237    /// Taken away; said done on the sender, if any.
238    Close(Option<Sender<()>>),
239}
240
241/// What the server said, read between two looks.
242#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
243enum Heard {
244    Pressed(u32, String),
245    Closed(u32),
246}
247
248/// Shows the notification of something going on. `actions` are its buttons
249/// as (key, label), "default" for a click on it; `on_action` runs with the
250/// key of each one pressed, on the notification's own thread.
251#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
252pub fn ongoing(title: &str, body: &str, actions: Vec<(String, String)>, on_action: Box<dyn Fn(&str) + Send>) -> Ongoing {
253    let (changes, asked) = std::sync::mpsc::channel();
254    let said = Said { title: title.to_string(), body: body.to_string(), actions };
255    // The D-Bus connection cannot change threads, so it is made where it listens.
256    std::thread::spawn(move || serve(said, &asked, &*on_action));
257    Ongoing { changes }
258}
259
260#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
261impl Ongoing {
262    /// Changes it in place; shown again if it was closed meanwhile.
263    pub fn update(&self, title: &str, body: &str, actions: Vec<(String, String)>) {
264        let _ = self.changes.send(Change::Show(Said { title: title.to_string(), body: body.to_string(), actions }));
265    }
266
267    /// Takes it away, waiting at most `wait` for the server to have done it:
268    /// as Sioul quits, before the connection goes with the process.
269    pub fn close(self, wait: Duration) {
270        let (done, closed) = std::sync::mpsc::channel();
271        if self.changes.send(Change::Close(Some(done))).is_ok() {
272            let _ = closed.recv_timeout(wait);
273        }
274    }
275}
276
277/// A lasting notification's thread: what Sioul asks shown, what the server
278/// says heard, until it is taken away.
279#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
280fn serve(mut said: Said, asked: &Receiver<Change>, on_action: &dyn Fn(&str)) {
281    use dbus::blocking::LocalConnection;
282    use dbus::message::MatchRule;
283    use std::cell::RefCell;
284    use std::rc::Rc;
285    use std::sync::mpsc::TryRecvError;
286    let Ok(connection) = LocalConnection::new_session() else { return };
287    let heard: Rc<RefCell<Vec<Heard>>> = Rc::default();
288    let (pressed, closed) = (Rc::clone(&heard), Rc::clone(&heard));
289    let listened = connection
290        .add_match(MatchRule::new_signal(SERVER, "ActionInvoked").with_path(SERVER_PATH), move |(id, key): (u32, String), _: &LocalConnection, _: &dbus::Message| {
291            pressed.borrow_mut().push(Heard::Pressed(id, key));
292            true
293        })
294        .and_then(|_| {
295            connection.add_match(MatchRule::new_signal(SERVER, "NotificationClosed").with_path(SERVER_PATH), move |(id, _why): (u32, u32), _: &LocalConnection, _: &dbus::Message| {
296                closed.borrow_mut().push(Heard::Closed(id));
297                true
298            })
299        });
300    if listened.is_err() {
301        return;
302    }
303    let server = connection.with_proxy(SERVER, SERVER_PATH, Duration::from_secs(2));
304    // Asked again while not known: a server starting after Sioul answers later.
305    let mut capabilities: Vec<String> = Vec::new();
306    // The server's number for it, 0 while none shows; what is to show; when the server last refused it.
307    let (mut id, mut fresh, mut refused) = (0u32, true, None::<std::time::Instant>);
308    loop {
309        // What the server said meanwhile, all of it: buttons pressed, notifications closed.
310        while connection.process(Duration::ZERO).unwrap_or(false) {}
311        let news = std::mem::take(&mut *heard.borrow_mut());
312        for item in news {
313            match item {
314                Heard::Pressed(at, key) if at == id && id != 0 => on_action(&key),
315                // Closed by you, or by the server: shown again at the next change.
316                Heard::Closed(at) if at == id => id = 0,
317                _ => {}
318            }
319        }
320        // What Sioul asked meanwhile: the last of it counts.
321        loop {
322            match asked.try_recv() {
323                Ok(Change::Show(next)) => (said, fresh) = (next, true),
324                Ok(Change::Close(done)) => {
325                    close_lasting(&server, id);
326                    if let Some(done) = done {
327                        let _ = done.send(());
328                    }
329                    return;
330                }
331                Err(TryRecvError::Empty) => break,
332                Err(TryRecvError::Disconnected) => return close_lasting(&server, id),
333            }
334        }
335        if fresh || refused.is_some_and(|at| at.elapsed() >= ASKED_AGAIN) {
336            if capabilities.is_empty() {
337                capabilities = server.method_call(SERVER, "GetCapabilities", ()).map(|(found,): (Vec<String>,)| found).unwrap_or_default();
338            }
339            match show_lasting(&server, id, &said, &capabilities) {
340                Ok(shown) => (id, refused) = (shown, None),
341                Err(_) => refused = Some(std::time::Instant::now()),
342            }
343            fresh = false;
344        }
345        if connection.process(LISTENED).is_err() {
346            return;
347        }
348    }
349}
350
351/// Shows it, in place of `id` if not 0: the server's number for it.
352#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
353fn show_lasting(server: &dbus::blocking::Proxy<'_, &dbus::blocking::LocalConnection>, id: u32, said: &Said, capabilities: &[String]) -> Result<u32, dbus::Error> {
354    use dbus::arg::{PropMap, RefArg, Variant};
355    let mut hints = PropMap::new();
356    hints.insert("resident".into(), Variant(Box::new(true) as Box<dyn RefArg>));
357    hints.insert("suppress-sound".into(), Variant(Box::new(true) as Box<dyn RefArg>));
358    hints.insert("urgency".into(), Variant(Box::new(1u8) as Box<dyn RefArg>));
359    hints.insert("desktop-entry".into(), Variant(Box::new(DESKTOP_ENTRY.to_string()) as Box<dyn RefArg>));
360    let actions: Vec<&str> = said.actions.iter().flat_map(|(key, label)| [key.as_str(), label.as_str()]).collect();
361    // Kept by the server once its popup goes, or never expiring where nothing keeps it.
362    let timeout: i32 = if capabilities.iter().any(|c| c == "persistence") { -1 } else { 0 };
363    let (shown,): (u32,) = server.method_call(SERVER, "Notify", ("Sioul", id, "chronometer", said.title.as_str(), body_text(&said.body, capabilities), actions, hints, timeout))?;
364    Ok(shown)
365}
366
367#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
368fn close_lasting(server: &dbus::blocking::Proxy<'_, &dbus::blocking::LocalConnection>, id: u32) {
369    if id != 0 {
370        let _: Result<(), _> = server.method_call(SERVER, "CloseNotification", (id,));
371    }
372}
373
374/// Windows and macOS: no lasting notification yet, the focus window shows
375/// the time. Android: Android's own, made on the window's side (sioul-app).
376#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
377pub struct Ongoing;
378
379#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
380pub fn ongoing(_title: &str, _body: &str, _actions: Vec<(String, String)>, _on_action: Box<dyn Fn(&str) + Send>) -> Ongoing {
381    Ongoing
382}
383
384#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
385impl Ongoing {
386    pub fn update(&self, _title: &str, _body: &str, _actions: Vec<(String, String)>) {}
387
388    pub fn close(self, _wait: Duration) {}
389}
390
391#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
392fn build(title: &str, body: &str, copy_label: Option<&str>) -> Notification {
393    let mut notification = Notification::new();
394    notification
395        .appname("Sioul")
396        .summary(title)
397        .body(&body_text(body, &notify_rust::get_capabilities().unwrap_or_default()))
398        .icon("dialog-password")
399        .hint(Hint::SuppressSound(true))
400        .hint(Hint::DesktopEntry(DESKTOP_ENTRY.to_string()))
401        // Sioul filters its own notifications already: what it sends always gets through.
402        .urgency(Urgency::Critical)
403        .timeout(Timeout::Milliseconds(SHOWN));
404    if let Some(label) = copy_label {
405        notification.action("copy", label);
406    }
407    notification
408}
409
410/// A body as the notification server reads it. Most read markup there
411/// ("body-markup": `<b>`, `<a href>`, `<img src>`), and bodies carry a mail's
412/// or a site's words: there, `&`, `<` and `>` are written as entities, so
413/// that the words show as they came and nothing in them is a link or a
414/// picture to fetch. A server that reads no markup shows the text as it is.
415/// The summary is plain text for every server (the specification), and goes as it is.
416#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
417fn body_text(body: &str, capabilities: &[String]) -> String {
418    // A server that could not be asked is taken to read markup: entities at worst.
419    if capabilities.is_empty() || capabilities.iter().any(|c| c == "body-markup") {
420        body.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
421    } else {
422        body.to_string()
423    }
424}
425
426/// The bundle id macOS is given is the one Sioul's macOS bundle declares:
427/// any other would attribute its notifications to another application.
428#[cfg(test)]
429mod bundle {
430    #[test]
431    fn macos_is_given_the_bundles_own_id() {
432        let plist = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packaging/macos/Info.plist")).unwrap();
433        let declared = plist.split("<key>CFBundleIdentifier</key>").nth(1).and_then(|rest| rest.split("<string>").nth(1)).and_then(|rest| rest.split("</string>").next()).unwrap();
434        assert_eq!(super::BUNDLE_ID, declared.trim());
435    }
436}
437
438#[cfg(all(test, unix, not(any(target_os = "macos", target_os = "android"))))]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn bodies_are_words_not_markup() {
444        let markup = vec!["actions".to_string(), "body-markup".to_string()];
445        let body = "Code 482913 <img src=\"https://example.org/t.png\"> & <a href=\"https://example.org\">here</a>";
446        let shown = body_text(body, &markup);
447        assert!(!shown.contains('<') && !shown.contains('>'), "{shown}");
448        assert!(shown.starts_with("Code 482913 &lt;img") && shown.contains(" &amp; "), "{shown}");
449        // Not asked, read as markup; a server without markup gets the words as they are.
450        assert_eq!(body_text("a < b", &[]), "a &lt; b");
451        assert_eq!(body_text("a < b & c", &["actions".to_string()]), "a < b & c");
452    }
453
454    /// The time running, against a notification server of the test's own on a
455    /// bus of its own: run under dbus-run-session. Where a server is there
456    /// already (the desktop's), skipped: nothing here may reach it.
457    #[test]
458    fn a_lasting_notification_changes_in_place_hears_its_buttons_and_goes() {
459        use dbus::blocking::LocalConnection;
460        use dbus::blocking::stdintf::org_freedesktop_dbus::RequestNameReply;
461        use dbus::channel::{MatchingReceiver, Sender as _};
462        use dbus::message::MatchRule;
463        use std::sync::mpsc::channel;
464
465        /// What the stand-in server was asked.
466        #[derive(Debug, PartialEq)]
467        enum Asked {
468            Notify { replaces: u32, title: String, body: String, actions: Vec<String>, resident: bool, urgency: u8, timeout: i32 },
469            Close(u32),
470        }
471        let (told, asked) = channel::<Asked>();
472        let (ready, started) = channel::<bool>();
473        std::thread::spawn(move || {
474            // Never in a server's place: no replacing it, no waiting in line.
475            let Some(bus) = LocalConnection::new_session().ok().filter(|bus| matches!(bus.request_name(SERVER, false, false, true), Ok(RequestNameReply::PrimaryOwner))) else {
476                let _ = ready.send(false);
477                return;
478            };
479            let finished = std::rc::Rc::new(std::cell::Cell::new(false));
480            let over = std::rc::Rc::clone(&finished);
481            let mut notified = 0;
482            bus.start_receive(
483                MatchRule::new_method_call(),
484                Box::new(move |call: dbus::Message, bus: &LocalConnection| {
485                    let mut presses: Vec<(u32, &str)> = Vec::new();
486                    let reply = match call.member().as_deref() {
487                        Some("GetCapabilities") => call.method_return().append1(vec!["actions", "body", "body-markup", "persistence"]),
488                        Some("Notify") => {
489                            let (_, replaces, _, title, body, actions, hints, timeout): (String, u32, String, String, String, Vec<String>, dbus::arg::PropMap, i32) = call.read_all().unwrap();
490                            let resident = dbus::arg::prop_cast::<bool>(&hints, "resident").copied().unwrap_or(false);
491                            let urgency = dbus::arg::prop_cast::<u8>(&hints, "urgency").copied().unwrap_or(9);
492                            let _ = told.send(Asked::Notify { replaces, title, body, actions, resident, urgency, timeout });
493                            notified += 1;
494                            // Pressed once shown; then another notification's button, and this one's.
495                            presses = match notified {
496                                1 => vec![(7, "pause")],
497                                2 => vec![(8, "stop"), (7, "resume")],
498                                _ => Vec::new(),
499                            };
500                            call.method_return().append1(7u32)
501                        }
502                        Some("CloseNotification") => {
503                            let (id,): (u32,) = call.read_all().unwrap();
504                            let _ = told.send(Asked::Close(id));
505                            over.set(true);
506                            call.method_return()
507                        }
508                        _ => return true,
509                    };
510                    let _ = bus.send(reply);
511                    for (id, key) in presses {
512                        let mut press = dbus::Message::new_signal(SERVER_PATH, SERVER, "ActionInvoked").unwrap().append2(id, key);
513                        // To the connection that showed it alone, as Plasma does.
514                        press.set_destination(call.sender().map(|s| s.into_static()));
515                        let _ = bus.send(press);
516                    }
517                    true
518                }),
519            );
520            let _ = ready.send(true);
521            let until = std::time::Instant::now() + Duration::from_secs(20);
522            while !finished.get() && std::time::Instant::now() < until {
523                let _ = bus.process(Duration::from_millis(100));
524            }
525        });
526        if !started.recv_timeout(Duration::from_secs(5)).unwrap_or(false) {
527            eprintln!("No bus of the test's own (dbus-run-session), or a notification server on it already: skipped.");
528            return;
529        }
530        let wait = Duration::from_secs(5);
531        let buttons = |first: &str, label: &str| vec![(first.to_string(), label.to_string()), ("stop".to_string(), "Stop".to_string()), ("default".to_string(), "Open".to_string())];
532        let (pressed, presses) = channel::<String>();
533        let shown = ongoing("Write to the bank", "Since 14:02, for 25 min", buttons("pause", "Pause"), Box::new(move |key: &str| drop(pressed.send(key.to_string()))));
534        let first = Asked::Notify {
535            replaces: 0,
536            title: "Write to the bank".into(),
537            body: "Since 14:02, for 25 min".into(),
538            actions: ["pause", "Pause", "stop", "Stop", "default", "Open"].map(String::from).to_vec(),
539            resident: true,
540            urgency: 1,
541            // The server keeps it once its popup goes: its own time.
542            timeout: -1,
543        };
544        assert_eq!(asked.recv_timeout(wait).unwrap(), first);
545        // A button pressed at the server, heard here.
546        assert_eq!(presses.recv_timeout(wait).unwrap(), "pause");
547        // Changed in place; another notification's button is not this one's.
548        shown.update("Write to the bank", "Paused, 3 min so far", buttons("resume", "Go on"));
549        match asked.recv_timeout(wait).unwrap() {
550            Asked::Notify { replaces, body, actions, .. } => assert_eq!((replaces, body.as_str(), actions[0].as_str()), (7, "Paused, 3 min so far", "resume")),
551            other => panic!("{other:?}"),
552        }
553        assert_eq!(presses.recv_timeout(wait).unwrap(), "resume");
554        // Taken away before Sioul goes on.
555        shown.close(wait);
556        assert_eq!(asked.recv_timeout(wait).unwrap(), Asked::Close(7));
557        assert!(presses.try_recv().is_err(), "nothing else was pressed");
558    }
559}