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("&"),
300 '<' => out.push_str("<"),
301 '>' => out.push_str(">"),
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 "<b>invoice</b> & <i>co</i>.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}