Skip to main content

slipcase_open/present/
freedesktop.rs

1//! Concept 9's channel on Linux: `org.freedesktop.Notifications`.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! A notification carrying actions is what the design needs, and this interface
7//! has carried an actions parameter for as long as it has existed. GNOME Shell
8//! renders them as buttons and keeps the notification in its message list,
9//! which is concept 6.2's *somewhere to look when they expect an edit to have
10//! landed* without a tray — and Linux has no dependable tray, which is the
11//! reason concept 9 inverted its own layering.
12//!
13//! **A D-Bus client rather than a shell out to `notify-send`.** That tool can
14//! pass an action and print back the key that was pressed, so the objection is
15//! not that it cannot hear the answer. It is the shape: one blocked process per
16//! outstanding notification, no way to withdraw one, and no guarantee it is
17//! installed. The bus interface is what a desktop provides.
18//!
19//! ## How an answer gets back
20//!
21//! `Notify` returns an identifier, and the service emits `ActionInvoked` with
22//! that identifier and the key of the button. So the identifier is recorded
23//! against the session the question was about, one thread does nothing but
24//! read signals, and the resident loop collects what that thread has put down
25//! (concept 8, which will not have the loop waiting on anything but its own
26//! tick).
27//!
28//! ## Where a service has no actions
29//!
30//! The capability is optional and a few implementations do without it. A
31//! question shown by such a service would appear with no buttons and no way to
32//! answer, so it is asked as a statement instead, with the two commands that
33//! reach the same decision in the body. That is the same fallback the terminal
34//! makes, arriving by a different road.
35
36use std::collections::HashMap;
37use std::sync::{Arc, Mutex};
38
39use zbus::blocking::{Connection, Proxy};
40use zbus::zvariant::Value;
41
42use super::{Answer, Channel, Choice, Question, Report, Weight};
43
44const SERVICE: &str = "org.freedesktop.Notifications";
45const OBJECT: &str = "/org/freedesktop/Notifications";
46
47/// The desktop entry the service attributes a notification to, which is how a
48/// shell finds the name and icon to draw beside it. The installed file's
49/// basename, from `packaging/linux/slipcase-open.desktop`; a hint naming an
50/// entry that is not there is ignored rather than refused, so a mismatch here
51/// shows up as an unattributed notification and nothing else.
52const DESKTOP_ENTRY: &str = "slipcase-open";
53
54/// Until an icon of this project's own ships with the package. A stock name
55/// rather than nothing, because a missing icon renders as a blank square and
56/// reads as a broken notification.
57const ICON: &str = "document-open";
58
59/// What each outstanding notification is about, so a signal naming a number can
60/// be turned into an answer naming a session.
61type Outstanding = Arc<Mutex<HashMap<u32, String>>>;
62
63/// Notifications, and the answers that come back from them.
64pub struct Desktop {
65    connection: Connection,
66    outstanding: Outstanding,
67    answers: Arc<Mutex<Vec<Answer>>>,
68    /// Whether the service will render buttons.
69    actions: bool,
70    /// Whether the service reads the body as markup, in which case what goes
71    /// into it has to be escaped.
72    markup: bool,
73}
74
75impl Desktop {
76    /// Reach the session bus and the notification service.
77    ///
78    /// # Errors
79    ///
80    /// Where there is no session bus, or nothing implements the interface —
81    /// both of which are ordinary rather than exceptional. A session over SSH
82    /// has neither, and the answer is concept 9's floor rather than a failure.
83    pub fn connect() -> Result<Self, zbus::Error> {
84        let connection = Connection::session()?;
85        let proxy = notifications(&connection)?;
86        // Asked once rather than per notification. A round trip before every
87        // message would put a blocking call on the resident loop's own thread,
88        // and a service that gained or lost actions mid-session is not a case
89        // the specification contemplates.
90        let capabilities: Vec<String> = proxy.call("GetCapabilities", &())?;
91        let actions = capabilities.iter().any(|c| c == "actions");
92        let markup = capabilities.iter().any(|c| c == "body-markup");
93
94        let desktop = Self {
95            connection,
96            outstanding: Outstanding::default(),
97            answers: Arc::default(),
98            actions,
99            markup,
100        };
101        desktop.listen()?;
102        Ok(desktop)
103    }
104
105    /// Start the thread that turns signals into answers.
106    fn listen(&self) -> Result<(), zbus::Error> {
107        let connection = self.connection.clone();
108        let outstanding = Arc::clone(&self.outstanding);
109        let answers = Arc::clone(&self.answers);
110        let proxy = notifications(&connection)?;
111        std::thread::spawn(move || {
112            let Ok(signals) = proxy.receive_all_signals() else {
113                return;
114            };
115            for message in signals {
116                let header = message.header();
117                match header.member().map(zbus::names::MemberName::as_str) {
118                    Some("ActionInvoked") => {
119                        let Ok((id, key)) = message.body().deserialize::<(u32, String)>() else {
120                            continue;
121                        };
122                        // The record stays: the engine withdraws a question
123                        // when it has acted on it, and `NotificationClosed`
124                        // takes care of the rest.
125                        let Some(about) = outstanding.lock().map_or(None, |o| o.get(&id).cloned())
126                        else {
127                            continue;
128                        };
129                        // A key this build does not know is a button somebody
130                        // else's service invented, or one from a version of
131                        // this tool that has since been replaced.
132                        if let Some(choice) = Choice::from_key(&key) {
133                            if let Ok(mut answers) = answers.lock() {
134                                answers.push(Answer { about, choice });
135                            }
136                        }
137                    }
138                    Some("NotificationClosed") => {
139                        if let Ok((id, _reason)) = message.body().deserialize::<(u32, u32)>() {
140                            if let Ok(mut outstanding) = outstanding.lock() {
141                                outstanding.remove(&id);
142                            }
143                        }
144                    }
145                    _ => {}
146                }
147            }
148        });
149        Ok(())
150    }
151
152    /// Send one, and answer with the identifier the service gave it.
153    fn notify(
154        &self,
155        summary: &str,
156        body: &str,
157        actions: &[&str],
158        weight: Weight,
159    ) -> Result<u32, zbus::Error> {
160        let proxy = notifications(&self.connection)?;
161        // A content file's name is attacker-controlled — SPEC 2.3 constrains it
162        // only to being a plain filename — and a service advertising `body-markup`
163        // parses the body as Pango. A name carrying `<b>` would then style the
164        // sentence somebody is being asked to judge, and one carrying a stray
165        // `&` would break the parse and take the whole body with it. SPEC 3
166        // already makes a refusal message a display path; this is the same
167        // rule at the channel that has a markup parser behind it.
168        let body = if self.markup {
169            escape(body)
170        } else {
171            body.to_string()
172        };
173        let mut hints: HashMap<&str, Value<'_>> = HashMap::new();
174        hints.insert("desktop-entry", Value::from(DESKTOP_ENTRY));
175        hints.insert(
176            "urgency",
177            // Low for the routine ones, so a desktop that sorts by urgency can
178            // put them where they belong even where the threshold has let them
179            // through.
180            Value::from(match weight {
181                Weight::Routine => 0u8,
182                Weight::Ordinary => 1u8,
183                Weight::Interrupt => 2u8,
184            }),
185        );
186        // Never expiring for anything that has to be come back to, which is
187        // concept 9's *persists somewhere the user can return to*: a question,
188        // and a warning that concept 5.1 says earns an interrupt. Everything
189        // else takes the service's own timeout, because a write-back notice
190        // that had to be dismissed would make the ordinary case the noisy one.
191        let timeout: i32 = if actions.is_empty() && weight != Weight::Interrupt {
192            -1
193        } else {
194            0
195        };
196        proxy.call(
197            "Notify",
198            &(
199                "slipcase-open",
200                0u32,
201                ICON,
202                summary,
203                body.as_str(),
204                actions,
205                hints,
206                timeout,
207            ),
208        )
209    }
210
211    /// The notifications this instance has open about `about`.
212    fn identifiers_for(&self, about: &str) -> Vec<u32> {
213        self.outstanding.lock().map_or_else(
214            |_| Vec::new(),
215            |o| {
216                o.iter()
217                    .filter(|(_, held)| held.as_str() == about)
218                    .map(|(id, _)| *id)
219                    .collect()
220            },
221        )
222    }
223}
224
225impl Channel for Desktop {
226    fn report(&self, report: &Report) {
227        // A failed notification is not worth an error path of its own. The
228        // service may have gone away mid-session, and the record on disk plus
229        // `slipcase-open sessions` is the answer to that, as it is to a crash.
230        let _ = self.notify(
231            &report.summary,
232            &report.detail.join("\n"),
233            &[],
234            report.weight,
235        );
236    }
237
238    fn ask(&self, question: &Question) {
239        let mut body = question.detail.clone();
240        let mut actions: Vec<&str> = Vec::new();
241        if self.actions {
242            for choice in &question.choices {
243                actions.push(choice.key());
244                actions.push(choice.label());
245            }
246        } else {
247            body.push(String::new());
248            body.push(format!(
249                "slipcase-open recover {} --write-back",
250                question.about
251            ));
252            body.push(format!(
253                "slipcase-open recover {} --discard",
254                question.about
255            ));
256        }
257        // Asked at interrupt weight whether or not the buttons render, because
258        // it is asked at all: nothing happens to the session until somebody
259        // answers, and a question nobody sees is a session that sits there.
260        if let Ok(id) = self.notify(
261            &question.summary,
262            &body.join("\n"),
263            &actions,
264            Weight::Interrupt,
265        ) {
266            if let Ok(mut outstanding) = self.outstanding.lock() {
267                outstanding.insert(id, question.about.clone());
268            }
269        }
270    }
271
272    fn withdraw(&self, about: &str) {
273        let Ok(proxy) = notifications(&self.connection) else {
274            return;
275        };
276        for id in self.identifiers_for(about) {
277            let _: Result<(), _> = proxy.call("CloseNotification", &(id,));
278            if let Ok(mut outstanding) = self.outstanding.lock() {
279                outstanding.remove(&id);
280            }
281        }
282    }
283
284    fn answers(&self) -> Vec<Answer> {
285        self.answers
286            .lock()
287            .map_or_else(|_| Vec::new(), |mut a| std::mem::take(&mut *a))
288    }
289}
290
291/// The three characters Pango reads as markup.
292///
293/// The summary is not markup by the specification and is left alone; the body
294/// is, wherever the service says so.
295fn escape(text: &str) -> String {
296    let mut out = String::with_capacity(text.len());
297    for c in text.chars() {
298        match c {
299            '&' => out.push_str("&amp;"),
300            '<' => out.push_str("&lt;"),
301            '>' => out.push_str("&gt;"),
302            other => out.push(other),
303        }
304    }
305    out
306}
307
308/// A proxy onto the notification service.
309fn notifications(connection: &Connection) -> Result<Proxy<'static>, zbus::Error> {
310    Proxy::new(connection, SERVICE, OBJECT, SERVICE)
311}
312
313#[cfg(test)]
314mod tests {
315    use super::{escape, Desktop};
316    use crate::present::{Channel, Choice, Question, Report};
317
318    #[test]
319    fn a_content_name_cannot_put_markup_in_the_body() {
320        // `content.file` is attacker-controlled and the body is parsed as Pango
321        // wherever the service says `body-markup`. A name carrying a tag would
322        // otherwise style the sentence somebody is being asked to judge, and a
323        // bare ampersand would break the parse and lose the body with it.
324        assert_eq!(
325            escape("<b>invoice</b> & <i>co</i>.pdf"),
326            "&lt;b&gt;invoice&lt;/b&gt; &amp; &lt;i&gt;co&lt;/i&gt;.pdf"
327        );
328        // And an ordinary name is left exactly as it was.
329        assert_eq!(escape("quarterly report.pdf"), "quarterly report.pdf");
330    }
331
332    /// Talks to the real session bus, so it is not part of the suite.
333    ///
334    /// What it checks is the one thing no unit test can: that the arguments
335    /// this builds match the signature `Notify` actually takes. Everything
336    /// else here is a `HashMap` and a `Vec`; a wrong type in that tuple is a
337    /// runtime error on a machine with a desktop and nothing at all on the
338    /// build server.
339    ///
340    /// `cargo test --lib -- --ignored notifications` and watch the screen.
341    #[test]
342    #[ignore = "needs a session bus and a notification service"]
343    fn notifications_reach_a_real_service() {
344        let desktop = Desktop::connect().expect("no notification service");
345        desktop.report(&Report::ordinary("slipcase-open: a report").and("with a line under it"));
346        desktop.ask(&Question {
347            about: "test-0".into(),
348            summary: "slipcase-open: a question".into(),
349            detail: vec!["It should carry three buttons.".into()],
350            choices: vec![Choice::WriteBack, Choice::Discard, Choice::Reveal],
351        });
352        assert!(
353            !desktop.identifiers_for("test-0").is_empty(),
354            "the question was not given an identifier"
355        );
356        desktop.withdraw("test-0");
357        assert!(desktop.identifiers_for("test-0").is_empty());
358    }
359
360    /// Concept 9 rests on a notification that persists somewhere the person can
361    /// return to: a recovery question is worth nothing if it goes while they
362    /// are finishing a sentence. That is the desktop's behaviour rather than
363    /// this code's, so it is put on a real one and looked at.
364    ///
365    /// **Measured on GNOME Shell 48.7: a notification does not outlive the
366    /// process that sent it.** While the sender is alive the question is on
367    /// screen and in the message list, with its buttons; once the sender's bus
368    /// connection goes, the shell removes it. So concept 9's *persists
369    /// somewhere the user can return to* holds only for as long as the instance
370    /// does, and concept 8's fallback is the record on disk rather than the
371    /// notification. Amended in both places.
372    ///
373    /// Holds for a minute so there is time to look, and leaves the question
374    /// standing. Dismiss it by hand.
375    ///
376    /// `cargo test --lib -- --ignored persists`
377    #[test]
378    #[ignore = "needs a session bus, and leaves a notification behind"]
379    fn a_question_persists_in_the_message_list() {
380        let desktop = Desktop::connect().expect("no notification service");
381        desktop.ask(&Question {
382            about: "persistence-0".into(),
383            summary: "slipcase-open: does this stay?".into(),
384            detail: vec![
385                "It should still be in the message list a minute from now.".into(),
386                "Dismiss it by hand when you have looked.".into(),
387            ],
388            choices: vec![Choice::WriteBack, Choice::Discard, Choice::Reveal],
389        });
390        let held = desktop.identifiers_for("persistence-0");
391        assert_eq!(held.len(), 1);
392        println!("notification {} sent. Holding for 60 seconds.", held[0]);
393        // Alive on purpose. GNOME Shell watches the sending bus name, and what
394        // this is asking is whether a notification outlives the process that
395        // sent it — which is what concept 9 assumes and concept 8's exit rule
396        // depends on.
397        std::thread::sleep(std::time::Duration::from_secs(60));
398        println!("exiting now; watch whether it goes with me");
399    }
400}