1#[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#[cfg(not(target_os = "android"))]
20const SHOWN: u32 = 10 * 60 * 1000;
21
22pub const BUNDLE_ID: &str = "com.aurelienpierre.Sioul";
27
28#[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#[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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
198const DESKTOP_ENTRY: &str = "com.aurelienpierre.Sioul";
199
200#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
203const LISTENED: Duration = Duration::from_millis(500);
204
205#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
208const ASKED_AGAIN: Duration = Duration::from_secs(60);
209
210#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
221pub struct Ongoing {
222 changes: Sender<Change>,
223}
224
225#[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 Close(Option<Sender<()>>),
239}
240
241#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
243enum Heard {
244 Pressed(u32, String),
245 Closed(u32),
246}
247
248#[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 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 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 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#[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 let mut capabilities: Vec<String> = Vec::new();
306 let (mut id, mut fresh, mut refused) = (0u32, true, None::<std::time::Instant>);
308 loop {
309 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 Heard::Closed(at) if at == id => id = 0,
317 _ => {}
318 }
319 }
320 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#[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 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#[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, ¬ify_rust::get_capabilities().unwrap_or_default()))
398 .icon("dialog-password")
399 .hint(Hint::SuppressSound(true))
400 .hint(Hint::DesktopEntry(DESKTOP_ENTRY.to_string()))
401 .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#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
417fn body_text(body: &str, capabilities: &[String]) -> String {
418 if capabilities.is_empty() || capabilities.iter().any(|c| c == "body-markup") {
420 body.replace('&', "&").replace('<', "<").replace('>', ">")
421 } else {
422 body.to_string()
423 }
424}
425
426#[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 <img") && shown.contains(" & "), "{shown}");
449 assert_eq!(body_text("a < b", &[]), "a < b");
451 assert_eq!(body_text("a < b & c", &["actions".to_string()]), "a < b & c");
452 }
453
454 #[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 #[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 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 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 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 timeout: -1,
543 };
544 assert_eq!(asked.recv_timeout(wait).unwrap(), first);
545 assert_eq!(presses.recv_timeout(wait).unwrap(), "pause");
547 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 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}