1#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
31use dbus::blocking::LocalConnection;
32#[cfg(any(windows, target_os = "macos"))]
33use notify_rust::{Notification, Timeout};
34#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
35use std::cell::RefCell;
36#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
37use std::rc::Rc;
38#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
39use std::sync::mpsc::{Receiver, Sender};
40use std::sync::{Arc, Condvar, Mutex};
41use std::time::Duration;
42#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
43use std::time::Instant;
44
45#[cfg(not(target_os = "android"))]
47const SHOWN: u32 = 10 * 60 * 1000;
48
49pub const FIRST_TRY: Duration = Duration::from_secs(10);
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Outcome {
57 Shown,
59 Unshown(String),
62 Maybe(String),
65 Gone,
67}
68
69pub trait DoseRecord: Send {
75 fn going(&mut self, going: Going) -> Say;
80 fn refused(&mut self);
83 fn shown(&mut self);
85 fn unshown(&mut self);
87 fn pause(&mut self, until: i64) -> bool;
90 fn over(&mut self);
92 fn settled(&mut self) -> bool;
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum Going {
100 First,
102 Late(Late),
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum Late {
111 Retry,
113 Unconfirmed,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum Say {
120 Own,
122 Words(String, String),
124 Nothing,
126}
127
128pub struct Again {
142 pub end: std::time::Instant,
143 pub quiet: Option<(std::time::Instant, std::time::Instant, i64)>,
145 pub record: Box<dyn DoseRecord>,
146}
147
148pub struct Sending(Arc<(Mutex<Slot>, Condvar)>);
152
153#[derive(Default)]
156struct Slot {
157 outcome: Option<Outcome>,
158 closed: bool,
159}
160
161impl Sending {
162 fn new() -> Sending {
163 Sending(Arc::default())
164 }
165
166 #[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
168 fn known(outcome: Outcome) -> Sending {
169 let sending = Sending::new();
170 let _ = told(&sending.0, outcome);
171 sending
172 }
173
174 pub fn outcome(&self, wait: Duration) -> Outcome {
178 let (slot, bell) = &*self.0;
179 let Ok(guard) = slot.lock() else { return Outcome::Maybe("the notification's outcome could not be read".to_string()) };
180 let Ok((mut guard, _)) = bell.wait_timeout_while(guard, wait, |slot| slot.outcome.is_none()) else { return Outcome::Maybe("the notification's outcome could not be read".to_string()) };
181 guard.closed = true;
182 guard.outcome.take().unwrap_or_else(|| Outcome::Maybe("the notification server did not answer in time".to_string()))
183 }
184}
185
186impl Drop for Sending {
187 fn drop(&mut self) {
188 if let Ok(mut slot) = self.0.0.lock() {
189 slot.closed = true;
190 }
191 }
192}
193
194fn told(sending: &Arc<(Mutex<Slot>, Condvar)>, outcome: Outcome) -> bool {
197 let (slot, bell) = &**sending;
198 let Ok(mut slot) = slot.lock() else { return false };
199 if slot.closed {
200 return false;
201 }
202 slot.outcome = Some(outcome);
203 bell.notify_all();
204 true
205}
206
207pub const BUNDLE_ID: &str = "com.aurelienpierre.Sioul";
212
213#[cfg(target_os = "macos")]
217fn as_sioul() {
218 static ONCE: std::sync::Once = std::sync::Once::new();
219 ONCE.call_once(|| {
220 let _ = notify_rust::set_application(BUNDLE_ID);
221 });
222}
223
224#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
229pub fn code(title: &str, body: &str, copy: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
230 one_button(Notice::new(title, body, "dialog-password", CRITICAL), copy)
231}
232
233#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
236pub fn remind(title: &str, body: &str, action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
237 one_button(Notice::new(title, body, "appointment-soon", CRITICAL), action)
238}
239
240#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
245pub fn remind_dose(title: &str, body: &str, button: (String, Box<dyn FnOnce() + Send>), again: Again) -> Sending {
246 let mut notice = Notice::new(title, body, "appointment-soon", CRITICAL);
247 let (label, run) = button;
248 notice.buttons.push(("copy".to_string(), label));
249 let pressed: Pressed = Box::new(move |key: &str| {
250 if key == "copy" {
251 run();
252 }
253 });
254 let sending = Sending::new();
255 let slot = Arc::clone(&sending.0);
256 std::thread::spawn(move || first_try(notice, again, pressed, &slot));
258 sending
259}
260
261#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
264pub fn remind_dose(title: &str, body: &str, button: (String, Box<dyn FnOnce() + Send>), mut again: Again) -> Sending {
265 let (title, body) = match again.record.going(Going::First) {
266 Say::Own => (title.to_string(), body.to_string()),
267 Say::Words(title, body) => (title, body),
268 Say::Nothing => return Sending::known(Outcome::Gone),
269 };
270 Sending::known(match remind(&title, &body, Some(button)) {
271 Ok(()) => {
272 again.record.shown();
273 Outcome::Shown
274 }
275 Err(e) => {
276 again.record.refused();
277 again.record.unshown();
278 Outcome::Unshown(e)
279 }
280 })
281}
282
283#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
290pub fn mail(title: &str, body: &str, through: bool, action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
291 one_button(Notice::new(title, body, "mail-unread", if through { CRITICAL } else { NORMAL }), action)
292}
293
294#[cfg(any(windows, target_os = "macos"))]
296pub fn mail(title: &str, body: &str, _through: bool, _action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
297 #[cfg(target_os = "macos")]
298 as_sioul();
299 Notification::new().appname("Sioul").summary(title).body(body).timeout(Timeout::Milliseconds(SHOWN)).show().map(drop).map_err(|e| e.to_string())
300}
301
302#[cfg(target_os = "android")]
304pub fn mail(_title: &str, _body: &str, _through: bool, _action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
305 Err("notifications are made through Java on Android".to_string())
306}
307
308#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
311pub fn remind_choices(title: &str, body: &str, choices: Vec<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
312 if choices.is_empty() {
313 return remind(title, body, None);
314 }
315 let mut notice = Notice::new(title, body, "appointment-soon", CRITICAL);
316 let mut runs: Vec<Option<Box<dyn FnOnce() + Send>>> = Vec::new();
317 for (index, (label, run)) in choices.into_iter().enumerate() {
318 notice.buttons.push((format!("choice-{index}"), label));
319 runs.push(Some(run));
320 }
321 notify(
322 notice,
323 Some(Box::new(move |key: &str| {
324 if let Some(run) = key.strip_prefix("choice-").and_then(|n| n.parse::<usize>().ok()).and_then(|n| runs.get_mut(n)).and_then(Option::take) {
325 run();
326 }
327 })),
328 )
329}
330
331#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
333pub fn remind_choices(title: &str, body: &str, _choices: Vec<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
334 remind(title, body, None)
335}
336
337pub fn remind_offering(title: &str, body: &str, choices: Vec<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
349 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
350 {
351 let mut choices = choices;
352 if choices.len() == 1 {
353 return remind(title, body, choices.pop());
354 }
355 remind_choices(title, body, choices)
356 }
357 #[cfg(target_os = "macos")]
358 {
359 as_sioul();
360 let (title, body) = (title.to_string(), body.to_string());
361 let labels: Vec<String> = choices.iter().map(|(label, _)| label.clone()).collect();
362 let mut runs: Vec<Option<Box<dyn FnOnce() + Send>>> = choices.into_iter().map(|(_, run)| Some(run)).collect();
363 std::thread::spawn(move || {
366 let mut notification = Notification::new();
367 notification.appname("Sioul").summary(&title).body(&body).timeout(Timeout::Milliseconds(SHOWN));
368 for (index, label) in labels.iter().enumerate() {
369 notification.action(&format!("choice-{index}"), label);
370 }
371 match notification.show() {
372 Ok(handle) => handle.wait_for_action(|action| {
373 if let Some(run) = pressed_choice(action).and_then(|n| runs.get_mut(n)).and_then(Option::take) {
374 run();
375 }
376 }),
377 Err(e) => eprintln!("{e}"),
378 }
379 });
380 Ok(())
381 }
382 #[cfg(windows)]
383 {
384 drop(choices);
385 remind(title, body, None)
386 }
387 #[cfg(target_os = "android")]
388 {
389 drop(choices);
390 let _ = (title, body);
391 Err("notifications are made through Java on Android".to_string())
392 }
393}
394
395pub fn pressed_choice(action: &str) -> Option<usize> {
399 if action == "default" {
400 return Some(0);
401 }
402 action.strip_prefix("choice-")?.parse().ok()
403}
404
405#[cfg(any(windows, target_os = "macos"))]
407pub fn remind(title: &str, body: &str, _action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
408 #[cfg(target_os = "macos")]
409 as_sioul();
410 Notification::new().appname("Sioul").summary(title).body(body).timeout(Timeout::Milliseconds(SHOWN)).show().map(drop).map_err(|e| e.to_string())
411}
412
413#[cfg(any(windows, target_os = "macos"))]
415pub fn code(title: &str, body: &str, _copy: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
416 #[cfg(target_os = "macos")]
417 as_sioul();
418 Notification::new().appname("Sioul").summary(title).body(body).timeout(Timeout::Milliseconds(SHOWN)).show().map(drop).map_err(|e| e.to_string())
419}
420
421#[cfg(target_os = "android")]
424pub fn code(_title: &str, _body: &str, _copy: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
425 Err("notifications are not made on Android yet".to_string())
426}
427
428#[cfg(target_os = "android")]
430pub fn remind(_title: &str, _body: &str, _action: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
431 Err("notifications are not made on Android yet".to_string())
432}
433
434#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
436const SERVER: &str = "org.freedesktop.Notifications";
437#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
438const SERVER_PATH: &str = "/org/freedesktop/Notifications";
439
440#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
445const DESKTOP_ENTRY: &str = "com.aurelienpierre.Sioul";
446
447#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
450const LISTENED: Duration = Duration::from_millis(500);
451
452#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
455const ASKED_AGAIN: Duration = Duration::from_secs(60);
456
457#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
459const ASKED: Duration = Duration::from_secs(2);
460
461#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
464const TRY: Duration = Duration::from_millis(9_500);
465
466#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
470const MATCH: Duration = Duration::from_secs(1);
471
472#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
476const TRY_AGAIN: Duration = Duration::from_secs(15);
477
478#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
480const MATCH_AGAIN: Duration = Duration::from_secs(5);
481
482#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
483const _: () = assert!(
484 TRY.as_millis() < FIRST_TRY.as_millis() && TRY.as_millis() > (2 * MATCH.as_millis() + ASKED.as_millis()) && TRY_AGAIN.as_millis() > (2 * MATCH_AGAIN.as_millis() + 2 * ASKED.as_millis())
485);
486
487#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
490const NORMAL: u8 = 1;
491#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
492const CRITICAL: u8 = 2;
493
494#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
497struct Notice {
498 title: String,
499 body: String,
500 icon: &'static str,
501 urgency: u8,
502 buttons: Vec<(String, String)>,
503}
504
505#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
506impl Notice {
507 fn new(title: &str, body: &str, icon: &'static str, urgency: u8) -> Notice {
508 Notice { title: title.to_string(), body: body.to_string(), icon, urgency, buttons: Vec::new() }
509 }
510}
511
512#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
514type Pressed = Box<dyn FnOnce(&str) + Send>;
515
516#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
519fn one_button(mut notice: Notice, button: Option<(String, Box<dyn FnOnce() + Send>)>) -> Result<(), String> {
520 let Some((label, run)) = button else { return notify(notice, None) };
521 notice.buttons.push(("copy".to_string(), label));
522 notify(
523 notice,
524 Some(Box::new(move |key: &str| {
525 if key == "copy" {
526 run();
527 }
528 })),
529 )
530}
531
532#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
537fn notify(notice: Notice, pressed: Option<Pressed>) -> Result<(), String> {
538 let Some(pressed) = pressed else {
539 let bus = session().map_err(|e| said(&e))?;
540 return send(&bus, ¬ice).map(drop).map_err(|e| said(&e));
541 };
542 std::thread::spawn(move || {
544 let Ok(bus) = session() else { return };
545 let heard = Rc::default();
546 if listened(&bus, &heard).is_err() {
547 return;
548 }
549 let Ok(id) = send(&bus, ¬ice) else { return };
550 wait_for_press(&Shown { bus, heard, replies: Rc::default(), id, capabilities: Vec::new() }, pressed, None);
551 });
552 Ok(())
553}
554
555#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
559struct Shown {
560 bus: LocalConnection,
561 heard: Rc<RefCell<Vec<Heard>>>,
562 replies: Replies,
563 id: u32,
564 capabilities: Vec<String>,
565}
566
567#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
574fn wait_for_press(shown: &Shown, pressed: Pressed, mut record: Option<&mut dyn DoseRecord>) {
575 let mut looked = Instant::now();
576 loop {
577 for item in std::mem::take(&mut *shown.heard.borrow_mut()) {
578 match item {
579 Heard::Pressed(at, key) if at == shown.id => {
580 pressed(&key);
581 return;
582 }
583 Heard::Closed(at) if at == shown.id => return,
584 _ => {}
585 }
586 }
587 if let Some(record) = record.as_deref_mut()
588 && looked.elapsed() >= LOOK
589 {
590 looked = Instant::now();
591 if record.settled() {
592 if shown.id != 0 {
593 let _: Result<(), _> = shown.bus.with_proxy(SERVER, SERVER_PATH, ASKED).method_call(SERVER, "CloseNotification", (shown.id,));
594 }
595 return;
596 }
597 }
598 if shown.bus.process(Duration::from_secs(1)).is_err() {
599 return;
600 }
601 }
602}
603
604#[cfg(all(unix, not(any(target_os = "macos", target_os = "android")), not(test)))]
608const LOOK: Duration = Duration::from_secs(30);
609#[cfg(all(unix, not(any(target_os = "macos", target_os = "android")), test))]
610const LOOK: Duration = Duration::from_secs(1);
611
612#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
619const LISTEN_LATE: Duration = Duration::from_secs(24 * 3600);
620
621#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
627fn notify_message(notice: &Notice, replaces: u32, capabilities: &[String]) -> dbus::Message {
628 use dbus::arg::{PropMap, RefArg, Variant};
629 let mut hints = PropMap::new();
630 hints.insert("suppress-sound".into(), Variant(Box::new(true) as Box<dyn RefArg>));
631 hints.insert("desktop-entry".into(), Variant(Box::new(DESKTOP_ENTRY.to_string()) as Box<dyn RefArg>));
632 hints.insert("urgency".into(), Variant(Box::new(notice.urgency) as Box<dyn RefArg>));
633 let buttons: Vec<&str> = notice.buttons.iter().flat_map(|(key, label)| [key.as_str(), label.as_str()]).collect();
634 let timeout = i32::try_from(SHOWN).unwrap_or(i32::MAX);
635 let mut call = dbus::Message::new_method_call(SERVER, SERVER_PATH, SERVER, "Notify").expect("the notification server's names");
636 call.append_all(("Sioul", replaces, notice.icon, notice.title.as_str(), body_text(¬ice.body, capabilities), buttons, hints, timeout));
637 call
638}
639
640#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
643fn capabilities(bus: &LocalConnection, limit: Duration) -> Vec<String> {
644 if limit.is_zero() {
645 return Vec::new();
646 }
647 bus.with_proxy(SERVER, SERVER_PATH, limit).method_call(SERVER, "GetCapabilities", ()).map(|(found,): (Vec<String>,)| found).unwrap_or_default()
648}
649
650#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
652fn send(bus: &LocalConnection, notice: &Notice) -> Result<u32, dbus::Error> {
653 use dbus::blocking::BlockingSender;
654 let capabilities = capabilities(bus, ASKED);
655 let answer = bus.send_with_reply_and_block(notify_message(notice, 0, &capabilities), ASKED)?;
656 answer.read1::<u32>().map_err(|e| dbus::Error::new_failed(&e.to_string()))
657}
658
659#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
662fn listened(bus: &LocalConnection, heard: &Rc<RefCell<Vec<Heard>>>) -> Result<(), dbus::Error> {
663 use dbus::message::MatchRule;
664 let (pressed, closed) = (Rc::clone(heard), Rc::clone(heard));
665 bus.add_match(MatchRule::new_signal(SERVER, "ActionInvoked").with_path(SERVER_PATH), move |(id, key): (u32, String), _: &LocalConnection, _: &dbus::Message| {
666 pressed.borrow_mut().push(Heard::Pressed(id, key));
667 true
668 })?;
669 bus.add_match(MatchRule::new_signal(SERVER, "NotificationClosed").with_path(SERVER_PATH), move |(id, _why): (u32, u32), _: &LocalConnection, _: &dbus::Message| {
670 closed.borrow_mut().push(Heard::Closed(id));
671 true
672 })?;
673 Ok(())
674}
675
676#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
679fn listened_within(bus: &LocalConnection, heard: &Rc<RefCell<Vec<Heard>>>, each: Duration) -> Result<(), dbus::Error> {
680 use dbus::channel::MatchingReceiver;
681 use dbus::message::MatchRule;
682 let pressed = MatchRule::new_signal(SERVER, "ActionInvoked").with_path(SERVER_PATH);
683 let closed = MatchRule::new_signal(SERVER, "NotificationClosed").with_path(SERVER_PATH);
684 let bus_itself = bus.with_proxy("org.freedesktop.DBus", "/org/freedesktop/DBus", each);
685 for rule in [&pressed, &closed] {
686 let () = bus_itself.method_call("org.freedesktop.DBus", "AddMatch", (rule.match_str(),))?;
687 }
688 let (on_press, on_close) = (Rc::clone(heard), Rc::clone(heard));
689 bus.start_receive(
690 pressed,
691 Box::new(move |signal: dbus::Message, _: &LocalConnection| {
692 if let Ok((id, key)) = signal.read2::<u32, String>() {
693 on_press.borrow_mut().push(Heard::Pressed(id, key));
694 }
695 true
696 }),
697 );
698 bus.start_receive(
699 closed,
700 Box::new(move |signal: dbus::Message, _: &LocalConnection| {
701 if let Ok((id, _why)) = signal.read2::<u32, u32>() {
702 on_close.borrow_mut().push(Heard::Closed(id));
703 }
704 true
705 }),
706 );
707 Ok(())
708}
709
710#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
715enum Reply {
716 Return(dbus::Message),
717 Error { undelivered: bool, why: String },
718}
719
720#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
724fn undelivered(e: &dbus::Error) -> bool {
725 matches!(e.name(), Some("org.freedesktop.DBus.Error.ServiceUnknown" | "org.freedesktop.DBus.Error.NameHasNoOwner"))
726}
727
728#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
731type Replies = Rc<RefCell<std::collections::HashMap<u32, Reply>>>;
732
733#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
735fn heard_replies(bus: &LocalConnection) -> Replies {
736 use dbus::channel::MatchingReceiver;
737 use dbus::message::{MatchRule, MessageType};
738 let replies: Replies = Rc::default();
739 for kind in [MessageType::MethodReturn, MessageType::Error] {
740 let kept = Rc::clone(&replies);
741 let mut rule = MatchRule::new();
742 rule.msg_type = Some(kind);
743 bus.start_receive(
744 rule,
745 Box::new(move |mut answer: dbus::Message, _: &LocalConnection| {
746 if let Some(serial) = answer.get_reply_serial() {
747 let failed = answer.as_result().err().map(|e| Reply::Error { undelivered: undelivered(&e), why: said(&e) });
748 kept.borrow_mut().insert(serial, failed.unwrap_or(Reply::Return(answer)));
749 }
750 true
751 }),
752 );
753 }
754 replies
755}
756
757#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
760fn answer_by(bus: &LocalConnection, replies: &Replies, serial: u32, until: Instant) -> Option<Reply> {
761 loop {
762 if let Some(reply) = replies.borrow_mut().remove(&serial) {
763 return Some(reply);
764 }
765 let left = until.saturating_duration_since(Instant::now());
766 if left.is_zero() || bus.process(left).is_err() {
767 return None;
768 }
769 }
770}
771
772#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
774enum Tried {
775 Shown(Shown),
777 Unshown(String),
779 Maybe(String, Shown, u32),
782 Lost(String),
785 Done,
788}
789
790#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
798fn try_once(notice: &mut Notice, bus: Option<LocalConnection>, words: &mut dyn FnMut(&mut Notice) -> bool) -> Tried {
799 let (all, matching) = if bus.is_none() { (TRY, MATCH) } else { (TRY_AGAIN, MATCH_AGAIN) };
800 let deadline = Instant::now() + all;
801 let left = || deadline.saturating_duration_since(Instant::now());
802 let bus = match bus {
803 Some(bus) => bus,
804 None => {
805 let Some(limit) = left().checked_sub(2 * matching + ASKED).filter(|limit| !limit.is_zero()) else { return Tried::Unshown("no time left to connect".to_string()) };
807 match session_within(limit.min(crate::bus::SETUP)) {
808 Ok(bus) => bus,
809 Err(e) => return Tried::Unshown(said(&e)),
810 }
811 }
812 };
813 let heard = Rc::default();
814 let each = left().saturating_sub(ASKED).min(2 * matching) / 2;
815 if each.is_zero() {
816 return Tried::Unshown("no time left to listen".to_string());
817 }
818 if let Err(e) = listened_within(&bus, &heard, each) {
819 return Tried::Unshown(said(&e));
820 }
821 let capabilities = capabilities(&bus, left().saturating_sub(ASKED).min(ASKED));
822 if !words(notice) {
824 return Tried::Done;
825 }
826 if left() < ASKED {
827 return Tried::Unshown("no time left to send".to_string());
828 }
829 let replies = heard_replies(&bus);
830 use dbus::channel::Sender as _;
831 let Ok(serial) = bus.send(notify_message(notice, 0, &capabilities)) else { return Tried::Unshown("the connection was gone".to_string()) };
832 let answer = answer_by(&bus, &replies, serial, Instant::now() + ASKED);
833 let mut shown = Shown { bus, heard, replies, id: 0, capabilities };
834 match answer {
835 Some(Reply::Return(answer)) => {
836 shown.id = answer.read1::<u32>().unwrap_or(0);
837 Tried::Shown(shown)
838 }
839 Some(Reply::Error { undelivered: true, why }) => Tried::Unshown(why),
841 Some(Reply::Error { why, .. }) => Tried::Lost(why),
842 None => Tried::Maybe("the notification server did not answer in time".to_string(), shown, serial),
843 }
844}
845
846#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
849fn said_now(notice: &mut Notice, record: &mut dyn DoseRecord, going: Going) -> bool {
850 match record.going(going) {
851 Say::Own => true,
852 Say::Words(title, body) => {
853 (notice.title, notice.body) = (title, body);
854 true
855 }
856 Say::Nothing => false,
857 }
858}
859
860#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
862fn waited_for(slot: &Arc<(Mutex<Slot>, Condvar)>) -> bool {
863 slot.0.lock().is_ok_and(|slot| !slot.closed)
864}
865
866#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
872fn first_try(mut notice: Notice, mut again: Again, pressed: Pressed, slot: &Arc<(Mutex<Slot>, Condvar)>) {
873 let (mut sent_late, mut went) = (false, false);
874 let tried = try_once(&mut notice, None, &mut |notice: &mut Notice| {
875 let going = if waited_for(slot) { Going::First } else { Going::Late(Late::Retry) };
876 sent_late = going != Going::First;
877 went = said_now(notice, &mut *again.record, going);
878 went
879 });
880 let outcome = match &tried {
881 Tried::Shown(_) => Outcome::Shown,
882 Tried::Unshown(why) => Outcome::Unshown(why.clone()),
883 Tried::Maybe(why, ..) | Tried::Lost(why) => Outcome::Maybe(why.clone()),
884 Tried::Done => Outcome::Gone,
885 };
886 let waited = told(slot, outcome);
887 let late = if sent_late { Late::Retry } else { Late::Unconfirmed };
890 match tried {
891 Tried::Shown(mut shown) => {
892 if waited || sent_late {
893 again.record.shown();
894 } else {
895 shown_late(&mut shown, &mut notice, &mut *again.record, late);
896 }
897 wait_for_press(&shown, pressed, Some(&mut *again.record));
898 }
899 Tried::Maybe(_, shown, serial) => wait_late(shown, serial, notice, again, pressed, late),
900 Tried::Lost(_) => show_again(notice, again, pressed, Late::Unconfirmed),
902 Tried::Unshown(_) => {
903 if went {
904 again.record.refused();
905 }
906 again.record.unshown();
907 show_again(notice, again, pressed, Late::Retry);
908 }
909 Tried::Done => {}
910 }
911}
912
913#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
920fn wait_late(mut shown: Shown, serial: u32, mut notice: Notice, mut again: Again, pressed: Pressed, late: Late) {
921 let until = Instant::now() + LISTEN_LATE;
922 match answer_by(&shown.bus, &shown.replies, serial, until) {
923 Some(Reply::Return(answer)) => {
924 shown.id = answer.read1::<u32>().unwrap_or(0);
925 shown_late(&mut shown, &mut notice, &mut *again.record, late);
926 wait_for_press(&shown, pressed, Some(&mut *again.record));
927 }
928 Some(Reply::Error { .. }) | None => {
929 drop(shown);
930 show_again(notice, again, pressed, Late::Unconfirmed);
932 }
933 }
934}
935
936#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
948fn shown_late(shown: &mut Shown, notice: &mut Notice, record: &mut dyn DoseRecord, late: Late) {
949 use dbus::blocking::BlockingSender;
950 let id = shown.id;
951 let pressed = shown.heard.borrow().iter().any(|heard| matches!(heard, Heard::Pressed(at, _) if *at == id));
952 let closed = shown.heard.borrow().iter().any(|heard| matches!(heard, Heard::Closed(at) if *at == id));
953 let before = (notice.title.clone(), notice.body.clone());
954 if !said_now(notice, record, Going::Late(late)) {
955 if id != 0 && !pressed {
956 let _: Result<(), _> = shown.bus.with_proxy(SERVER, SERVER_PATH, ASKED).method_call(SERVER, "CloseNotification", (id,));
957 }
958 return;
959 }
960 record.shown();
961 if id != 0 && !closed && !pressed && (¬ice.title, ¬ice.body) != (&before.0, &before.1)
962 && let Ok(answer) = shown.bus.send_with_reply_and_block(notify_message(notice, id, &shown.capabilities), ASKED)
963 && let Ok(number) = answer.read1::<u32>()
964 && number != 0
965 {
966 shown.id = number;
967 }
968}
969
970#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
979fn show_again(mut notice: Notice, mut again: Again, pressed: Pressed, mut late: Late) {
980 let mut tries = 0;
981 loop {
982 let wait = again_in(tries);
983 tries += 1;
984 let next = Instant::now() + wait;
985 if let Some((from, to, until)) = again.quiet
986 && next >= from
987 {
988 again.quiet = None;
989 if !again.record.pause(until) {
990 return;
991 }
992 std::thread::sleep(to.saturating_duration_since(Instant::now()));
993 tries = 0;
994 continue;
995 }
996 if next >= again.end {
997 again.record.over();
998 return;
999 }
1000 std::thread::sleep(wait);
1001 let Ok(bus) = session_within(crate::bus::SETUP) else { continue };
1002 let mut went = false;
1003 let tried = try_once(&mut notice, Some(bus), &mut |notice: &mut Notice| {
1004 went = said_now(notice, &mut *again.record, Going::Late(late));
1005 went
1006 });
1007 match tried {
1008 Tried::Shown(shown) => {
1009 again.record.shown();
1010 return wait_for_press(&shown, pressed, Some(&mut *again.record));
1011 }
1012 Tried::Unshown(_) if went => again.record.refused(),
1013 Tried::Unshown(_) => {}
1014 Tried::Lost(_) => late = Late::Unconfirmed,
1016 Tried::Done => return,
1017 Tried::Maybe(_, shown, serial) => return wait_late(shown, serial, notice, again, pressed, late),
1018 }
1019 }
1020}
1021
1022#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1026fn again_in(tries: u32) -> Duration {
1027 if tries < 24 { Duration::from_secs(5) } else { Duration::from_secs(30) }
1028}
1029
1030#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1032fn session() -> Result<LocalConnection, dbus::Error> {
1033 crate::bus::session().map(LocalConnection::from)
1034}
1035
1036#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1038fn session_within(limit: Duration) -> Result<LocalConnection, dbus::Error> {
1039 let address = crate::bus::session_address().ok_or_else(|| dbus::Error::new_failed("no session bus"))?;
1040 crate::bus::connect_within(&address, limit).map(LocalConnection::from)
1041}
1042
1043#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1045fn said(e: &dbus::Error) -> String {
1046 e.message().or(e.name()).unwrap_or("D-Bus").to_string()
1047}
1048
1049#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1060pub struct Ongoing {
1061 changes: Sender<Change>,
1062}
1063
1064#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1067struct Said {
1068 title: String,
1069 body: String,
1070 actions: Vec<(String, String)>,
1071}
1072
1073#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1074enum Change {
1075 Show(Said),
1076 Close(Option<Sender<()>>),
1078}
1079
1080#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1082enum Heard {
1083 Pressed(u32, String),
1084 Closed(u32),
1085}
1086
1087#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1091pub fn ongoing(title: &str, body: &str, actions: Vec<(String, String)>, on_action: Box<dyn Fn(&str) + Send>) -> Ongoing {
1092 let (changes, asked) = std::sync::mpsc::channel();
1093 let said = Said { title: title.to_string(), body: body.to_string(), actions };
1094 std::thread::spawn(move || serve(said, &asked, &*on_action));
1096 Ongoing { changes }
1097}
1098
1099#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1100impl Ongoing {
1101 pub fn update(&self, title: &str, body: &str, actions: Vec<(String, String)>) {
1103 let _ = self.changes.send(Change::Show(Said { title: title.to_string(), body: body.to_string(), actions }));
1104 }
1105
1106 pub fn close(self, wait: Duration) {
1109 let (done, closed) = std::sync::mpsc::channel();
1110 if self.changes.send(Change::Close(Some(done))).is_ok() {
1111 let _ = closed.recv_timeout(wait);
1112 }
1113 }
1114}
1115
1116#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1120struct Lasting {
1121 said: Said,
1122 fresh: bool,
1123 id: u32,
1124 refused: Option<Instant>,
1125}
1126
1127#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1134fn serve(said: Said, asked: &Receiver<Change>, on_action: &dyn Fn(&str)) {
1135 use std::sync::mpsc::RecvTimeoutError;
1136 let mut lasting = Lasting { said, fresh: true, id: 0, refused: None };
1137 loop {
1138 if let Ok(connection) = session()
1139 && let Served::Done = served(&mut lasting, asked, on_action, &connection)
1140 {
1141 return;
1142 }
1143 match asked.recv_timeout(crate::bus::SETUP) {
1144 Ok(Change::Show(next)) => (lasting.said, lasting.fresh) = (next, true),
1145 Ok(Change::Close(done)) => {
1146 if let Some(done) = done {
1147 let _ = done.send(());
1148 }
1149 return;
1150 }
1151 Err(RecvTimeoutError::Timeout) => {}
1152 Err(RecvTimeoutError::Disconnected) => return,
1153 }
1154 }
1155}
1156
1157#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1161enum Served {
1162 Done,
1163 Lost,
1164}
1165
1166#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1174fn served(lasting: &mut Lasting, asked: &Receiver<Change>, on_action: &dyn Fn(&str), connection: &LocalConnection) -> Served {
1175 use dbus::channel::Sender as _;
1176 use std::sync::mpsc::TryRecvError;
1177 let heard: Rc<RefCell<Vec<Heard>>> = Rc::default();
1178 if listened(connection, &heard).is_err() {
1179 return Served::Lost;
1180 }
1181 let replies = heard_replies(connection);
1182 let server = connection.with_proxy(SERVER, SERVER_PATH, ASKED);
1183 let mut capabilities: Vec<String> = Vec::new();
1185 let mut unanswered: Option<u32> = None;
1187 loop {
1188 while connection.process(Duration::ZERO).unwrap_or(false) {}
1190 if let Some(serial) = unanswered {
1191 let reply = replies.borrow_mut().remove(&serial);
1192 match reply {
1193 Some(Reply::Return(answer)) => {
1194 (lasting.id, lasting.refused, unanswered) = (answer.read1::<u32>().unwrap_or(0), None, None);
1195 }
1196 Some(Reply::Error { .. }) => (lasting.refused, unanswered) = (Some(Instant::now()), None),
1197 None => {}
1198 }
1199 }
1200 let news = std::mem::take(&mut *heard.borrow_mut());
1201 for item in news {
1202 match item {
1203 Heard::Pressed(at, key) if at == lasting.id && lasting.id != 0 => on_action(&key),
1204 Heard::Closed(at) if at == lasting.id => lasting.id = 0,
1206 _ => {}
1207 }
1208 }
1209 loop {
1211 match asked.try_recv() {
1212 Ok(Change::Show(next)) => (lasting.said, lasting.fresh) = (next, true),
1213 Ok(Change::Close(done)) => {
1214 close_lasting(connection, &server, &replies, unanswered, lasting.id);
1215 if let Some(done) = done {
1216 let _ = done.send(());
1217 }
1218 return Served::Done;
1219 }
1220 Err(TryRecvError::Empty) => break,
1221 Err(TryRecvError::Disconnected) => {
1222 close_lasting(connection, &server, &replies, unanswered, lasting.id);
1223 return Served::Done;
1224 }
1225 }
1226 }
1227 if unanswered.is_none() && (lasting.fresh || lasting.refused.is_some_and(|at| at.elapsed() >= ASKED_AGAIN)) {
1228 if capabilities.is_empty() {
1229 capabilities = server.method_call(SERVER, "GetCapabilities", ()).map(|(found,): (Vec<String>,)| found).unwrap_or_default();
1230 }
1231 match connection.send(lasting_message(lasting.id, &lasting.said, &capabilities)) {
1232 Ok(serial) => unanswered = Some(serial),
1233 Err(()) => return Served::Lost,
1234 }
1235 lasting.fresh = false;
1236 }
1237 if connection.process(LISTENED).is_err() {
1238 return Served::Lost;
1239 }
1240 }
1241}
1242
1243#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1247const CLOSE_LATE: Duration = Duration::from_secs(30);
1248
1249#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1251fn lasting_message(id: u32, said: &Said, capabilities: &[String]) -> dbus::Message {
1252 use dbus::arg::{PropMap, RefArg, Variant};
1253 let mut hints = PropMap::new();
1254 hints.insert("resident".into(), Variant(Box::new(true) as Box<dyn RefArg>));
1255 hints.insert("suppress-sound".into(), Variant(Box::new(true) as Box<dyn RefArg>));
1256 hints.insert("urgency".into(), Variant(Box::new(1u8) as Box<dyn RefArg>));
1257 hints.insert("desktop-entry".into(), Variant(Box::new(DESKTOP_ENTRY.to_string()) as Box<dyn RefArg>));
1258 let actions: Vec<&str> = said.actions.iter().flat_map(|(key, label)| [key.as_str(), label.as_str()]).collect();
1259 let timeout: i32 = if capabilities.iter().any(|c| c == "persistence") { -1 } else { 0 };
1261 let mut call = dbus::Message::new_method_call(SERVER, SERVER_PATH, SERVER, "Notify").expect("the notification server's names");
1262 call.append_all(("Sioul", id, "chronometer", said.title.as_str(), body_text(&said.body, capabilities), actions, hints, timeout));
1263 call
1264}
1265
1266#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1270fn close_lasting(connection: &LocalConnection, server: &dbus::blocking::Proxy<'_, &LocalConnection>, replies: &Replies, unanswered: Option<u32>, id: u32) {
1271 let id = match unanswered.and_then(|serial| answer_by(connection, replies, serial, Instant::now() + CLOSE_LATE)) {
1272 Some(Reply::Return(answer)) => answer.read1::<u32>().unwrap_or(id),
1273 _ => id,
1274 };
1275 if id != 0 {
1276 let _: Result<(), _> = server.method_call(SERVER, "CloseNotification", (id,));
1277 }
1278}
1279
1280#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
1283pub struct Ongoing;
1284
1285#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
1286pub fn ongoing(_title: &str, _body: &str, _actions: Vec<(String, String)>, _on_action: Box<dyn Fn(&str) + Send>) -> Ongoing {
1287 Ongoing
1288}
1289
1290#[cfg(not(all(unix, not(any(target_os = "macos", target_os = "android")))))]
1291impl Ongoing {
1292 pub fn update(&self, _title: &str, _body: &str, _actions: Vec<(String, String)>) {}
1293
1294 pub fn close(self, _wait: Duration) {}
1295}
1296
1297#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1304fn body_text(body: &str, capabilities: &[String]) -> String {
1305 if capabilities.is_empty() || capabilities.iter().any(|c| c == "body-markup") {
1307 body.replace('&', "&").replace('<', "<").replace('>', ">")
1308 } else {
1309 body.to_string()
1310 }
1311}
1312
1313#[cfg(test)]
1316mod bundle {
1317 #[test]
1318 fn macos_is_given_the_bundles_own_id() {
1319 let plist = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packaging/macos/Info.plist")).unwrap();
1320 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();
1321 assert_eq!(super::BUNDLE_ID, declared.trim());
1322 }
1323
1324 #[test]
1328 fn a_press_names_its_button_and_a_click_the_first() {
1329 assert_eq!(super::pressed_choice("choice-0"), Some(0));
1330 assert_eq!(super::pressed_choice("choice-1"), Some(1));
1331 assert_eq!(super::pressed_choice("default"), Some(0));
1332 assert_eq!(super::pressed_choice("__closed"), None);
1333 assert_eq!(super::pressed_choice("choice-"), None);
1334 assert_eq!(super::pressed_choice("Options"), None);
1335 }
1336}
1337
1338#[cfg(all(test, unix, not(any(target_os = "macos", target_os = "android"))))]
1339mod tests {
1340 use super::*;
1341
1342 #[test]
1343 fn bodies_are_words_not_markup() {
1344 let markup = vec!["actions".to_string(), "body-markup".to_string()];
1345 let body = "Code 482913 <img src=\"https://example.org/t.png\"> & <a href=\"https://example.org\">here</a>";
1346 let shown = body_text(body, &markup);
1347 assert!(!shown.contains('<') && !shown.contains('>'), "{shown}");
1348 assert!(shown.starts_with("Code 482913 <img") && shown.contains(" & "), "{shown}");
1349 assert_eq!(body_text("a < b", &[]), "a < b");
1351 assert_eq!(body_text("a < b & c", &["actions".to_string()]), "a < b & c");
1352 }
1353
1354 fn stand_in_bus() -> Option<dbus::blocking::LocalConnection> {
1359 use dbus::blocking::stdintf::org_freedesktop_dbus::RequestNameReply;
1360 let until = std::time::Instant::now() + Duration::from_secs(2);
1361 loop {
1362 let bus = session().ok().filter(|bus| matches!(bus.request_name(SERVER, false, false, true), Ok(RequestNameReply::PrimaryOwner)));
1363 if bus.is_some() || std::time::Instant::now() >= until {
1364 return bus;
1365 }
1366 std::thread::sleep(Duration::from_millis(50));
1367 }
1368 }
1369
1370 fn hint_names(call: &dbus::Message) -> Vec<String> {
1373 use dbus::arg::ArgType;
1374 let mut args = call.iter_init();
1375 for _ in 0..6 {
1376 args.next();
1377 }
1378 let mut names = Vec::new();
1379 if let Some(mut entries) = args.recurse(ArgType::Array) {
1380 while entries.arg_type() == ArgType::DictEntry {
1381 if let Some(mut entry) = entries.recurse(ArgType::DictEntry) {
1382 names.extend(entry.get::<String>());
1383 }
1384 entries.next();
1385 }
1386 }
1387 names.sort();
1388 names
1389 }
1390
1391 type Notices = std::sync::mpsc::Receiver<(std::time::Instant, String, u32)>;
1393
1394 fn notice_server(address: &str, mode: &'static str, told: std::sync::mpsc::Sender<(std::time::Instant, String, u32)>, stop: std::sync::Arc<std::sync::atomic::AtomicBool>) -> bool {
1412 notice_server_on(address, mode, 0, told, stop)
1413 }
1414
1415 fn notice_server_on(address: &str, mode: &'static str, bus_pid: u32, told: std::sync::mpsc::Sender<(std::time::Instant, String, u32)>, stop: std::sync::Arc<std::sync::atomic::AtomicBool>) -> bool {
1417 use dbus::blocking::LocalConnection;
1418 use dbus::blocking::stdintf::org_freedesktop_dbus::RequestNameReply;
1419 use dbus::channel::{MatchingReceiver, Sender as _};
1420 use dbus::message::MatchRule;
1421 let (ready, started) = std::sync::mpsc::channel::<bool>();
1422 let address = address.to_string();
1423 std::thread::spawn(move || {
1424 let Some(bus) = crate::bus::connect(&address).ok().map(LocalConnection::from).filter(|bus| matches!(bus.request_name(SERVER, false, false, true), Ok(RequestNameReply::PrimaryOwner))) else {
1425 let _ = ready.send(false);
1426 return;
1427 };
1428 let due: Rc<RefCell<Vec<(std::time::Instant, dbus::Message)>>> = Rc::default();
1430 let later = Rc::clone(&due);
1431 let mut next = 40u32;
1432 let mut stalled = false;
1433 let mut first_notify = true;
1435 let gone: Rc<RefCell<Option<std::time::Instant>>> = Rc::default();
1436 let leaving = Rc::clone(&gone);
1437 bus.start_receive(
1438 MatchRule::new_method_call(),
1439 Box::new(move |call: dbus::Message, bus: &LocalConnection| {
1440 let member = call.member().map(|m| m.to_string()).unwrap_or_default();
1441 if member == "Notify"
1442 && let Ok((_, replaces, _, title)) = call.read4::<String, u32, String, String>()
1443 {
1444 let _ = told.send((std::time::Instant::now(), title, replaces));
1445 }
1446 if member == "CloseNotification"
1447 && let Ok(id) = call.read1::<u32>()
1448 {
1449 let _ = told.send((std::time::Instant::now(), CLOSED.to_string(), id));
1450 if mode != "silent" {
1451 let mut closed = dbus::Message::new_signal(SERVER_PATH, SERVER, "NotificationClosed").unwrap().append2(id, 3u32);
1452 closed.set_destination(call.sender().map(|s| s.into_static()));
1453 let _ = bus.send(closed);
1454 }
1455 }
1456 if mode == "silent" {
1457 return true;
1458 }
1459 if member == "Notify" && first_notify && matches!(mode, "late-error" | "exits-fast" | "exits-late") {
1460 first_notify = false;
1461 let now = std::time::Instant::now();
1462 if mode == "late-error" {
1463 let refused = call.error(&dbus::strings::ErrorName::new("org.freedesktop.Notifications.Error.Failed").unwrap(), &std::ffi::CString::new("refused late").unwrap());
1464 later.borrow_mut().push((now + Duration::from_secs(3), refused));
1465 } else {
1466 *leaving.borrow_mut() = Some(now + if mode == "exits-fast" { Duration::from_millis(500) } else { Duration::from_secs(3) });
1467 }
1468 return true;
1469 }
1470 if mode == "stall" && member == "GetCapabilities" && !stalled {
1471 stalled = true;
1472 crate::bus::StandIns::signal(bus_pid, "-STOP");
1473 std::thread::spawn(move || {
1474 std::thread::sleep(Duration::from_secs(6));
1475 crate::bus::StandIns::signal(bus_pid, "-CONT");
1476 });
1477 }
1478 let (_, replaces) = if member == "Notify" { call.read2::<String, u32>().unwrap_or_default() } else { (String::new(), 0) };
1479 let reply = match member.as_str() {
1480 "Notify" if replaces != 0 && mode == "renumber" => call.method_return().append1(replaces + 100),
1482 "Notify" => {
1483 let id = if replaces != 0 {
1484 replaces
1485 } else {
1486 next += 1;
1487 next
1488 };
1489 call.method_return().append1(id)
1490 }
1491 "GetCapabilities" => call.method_return().append1(vec!["actions", "body"]),
1492 "CloseNotification" => call.method_return(),
1493 _ => return true,
1494 };
1495 let buttons: Vec<String> = if member == "Notify" { call.read_all::<(String, u32, String, String, String, Vec<String>, dbus::arg::PropMap, i32)>().map(|all| all.5).unwrap_or_default() } else { Vec::new() };
1496 let press = buttons.iter().any(|key| key == "copy" && !matches!(mode, "shows" | "renumber")).then(|| {
1497 let id = reply.read1::<u32>().unwrap_or(0);
1498 let mut press = dbus::Message::new_signal(SERVER_PATH, SERVER, "ActionInvoked").unwrap().append2(id, "copy");
1499 press.set_destination(call.sender().map(|s| s.into_static()));
1500 press
1501 });
1502 let now = std::time::Instant::now();
1503 if matches!(mode, "late" | "press-first" | "renumber") && member == "Notify" && replaces == 0 {
1504 later.borrow_mut().push((now + Duration::from_secs(3), reply));
1505 let pressed_at = if mode == "press-first" { Duration::from_secs(1) } else { Duration::from_millis(3_300) };
1506 if let Some(press) = press {
1507 later.borrow_mut().push((now + pressed_at, press));
1508 }
1509 } else if mode == "late" && member == "Notify" {
1510 later.borrow_mut().push((now + Duration::from_secs(3), reply));
1511 } else {
1512 let _ = bus.send(reply);
1513 if let Some(press) = press {
1514 let _ = bus.send(press);
1515 }
1516 }
1517 true
1518 }),
1519 );
1520 let _ = ready.send(true);
1521 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
1522 if gone.borrow().is_some_and(|at| std::time::Instant::now() >= at) {
1524 return;
1525 }
1526 let _ = bus.process(Duration::from_millis(50));
1527 let now = std::time::Instant::now();
1528 let ready: Vec<dbus::Message> = {
1529 let mut waiting = due.borrow_mut();
1530 let (go, stay): (Vec<_>, Vec<_>) = std::mem::take(&mut *waiting).into_iter().partition(|(at, _)| *at <= now);
1531 *waiting = stay;
1532 go.into_iter().map(|(_, message)| message).collect()
1533 };
1534 for message in ready {
1535 let _ = bus.send(message);
1536 }
1537 }
1538 });
1539 started.recv_timeout(Duration::from_secs(5)).unwrap_or(false)
1540 }
1541
1542 const CLOSED: &str = "(closed)";
1545
1546 fn sent_within(notices: &Notices, within: Duration) -> Vec<(String, u32)> {
1548 let until = std::time::Instant::now() + within;
1549 let mut seen = Vec::new();
1550 while let Ok((_, title, replaces)) = notices.recv_timeout(until.saturating_duration_since(std::time::Instant::now())) {
1551 seen.push((title, replaces));
1552 }
1553 seen
1554 }
1555
1556 struct StandIn {
1563 name: String,
1564 late: std::sync::Arc<std::sync::Mutex<Option<String>>>,
1565 shown: std::sync::mpsc::Sender<String>,
1566 log: Option<std::sync::mpsc::Sender<String>>,
1567 recorded: bool,
1568 settled: std::sync::Arc<std::sync::atomic::AtomicBool>,
1569 }
1570
1571 impl StandIn {
1572 fn say(&self, what: String) {
1573 if let Some(log) = &self.log {
1574 let _ = log.send(what);
1575 }
1576 }
1577 }
1578
1579 impl DoseRecord for StandIn {
1580 fn going(&mut self, going: Going) -> Say {
1581 self.say(format!("going {going:?}"));
1582 let words = if self.recorded { None } else { self.late.lock().unwrap().clone() };
1583 match (going, words) {
1584 (_, None) => Say::Nothing,
1585 (Going::First, Some(_)) => Say::Own,
1586 (Going::Late(Late::Retry), Some(title)) => Say::Words(title, "22:00. Could not be shown in time. Check before taking it.".into()),
1587 (Going::Late(Late::Unconfirmed), Some(title)) => Say::Words(title, "22:00. Not confirmed in time. Check before taking it.".into()),
1588 }
1589 }
1590 fn refused(&mut self) {
1591 self.say("refused".into());
1592 }
1593 fn shown(&mut self) {
1594 self.recorded = true;
1595 let _ = self.shown.send(self.name.clone());
1596 }
1597 fn unshown(&mut self) {
1598 self.say("unshown".into());
1599 }
1600 fn pause(&mut self, until: i64) -> bool {
1601 self.say(format!("pause {until}"));
1602 !self.recorded && self.late.lock().unwrap().is_some()
1603 }
1604 fn over(&mut self) {
1605 self.say("over".into());
1606 }
1607 fn settled(&mut self) -> bool {
1608 self.settled.load(std::sync::atomic::Ordering::SeqCst)
1609 }
1610 }
1611
1612 fn dose(name: &str, until: Duration, pressed: &std::sync::mpsc::Sender<String>, shown: &std::sync::mpsc::Sender<String>, late: std::sync::Arc<std::sync::Mutex<Option<String>>>) -> Sending {
1616 dose_as(name, Again { end: std::time::Instant::now() + until, quiet: None, record: Box::new(StandIn { name: name.into(), late, shown: shown.clone(), log: None, recorded: false, settled: std::sync::Arc::default() }) }, pressed)
1617 }
1618
1619 fn dose_as(name: &str, again: Again, pressed: &std::sync::mpsc::Sender<String>) -> Sending {
1621 let (pressed, pressed_name) = (pressed.clone(), name.to_string());
1622 remind_dose(name, "22:00", ("Taken".into(), Box::new(move || drop(pressed.send(pressed_name.clone())))), again)
1623 }
1624
1625 #[test]
1645 #[ignore = "starts a stand-in dbus-daemon and sets the session bus address: run alone, by name"]
1646 fn reminders_say_their_outcome_and_show_late_after_a_stall() {
1647 use crate::bus::StandIns;
1648 use std::sync::atomic::{AtomicBool, Ordering};
1649 use std::sync::mpsc::channel;
1650 use std::sync::{Arc, Mutex};
1651 use std::time::Instant;
1652 let mut stand_ins = StandIns::new("stand-in-notices");
1653 let (pid, address) = stand_ins.start("session");
1654 unsafe { std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &address) };
1656 let (told, notices) = channel::<(Instant, String, u32)>();
1657 let stop = Arc::new(AtomicBool::new(false));
1658 assert!(notice_server(&address, "answers", told.clone(), Arc::clone(&stop)), "the stand-in server");
1659 let (pressed, presses) = channel::<String>();
1660 let (late, lates) = channel::<String>();
1661 let words = |title: &str| Arc::new(Mutex::new(Some(format!("Check first: {title}"))));
1663
1664 let start = Instant::now();
1666 assert_eq!(dose("Magnesium · 300 mg", Duration::from_secs(60), &pressed, &late, words("Magnesium · 300 mg")).outcome(FIRST_TRY), Outcome::Shown);
1667 assert!(start.elapsed() < Duration::from_secs(2));
1668 assert_eq!(presses.recv_timeout(Duration::from_secs(5)).as_deref(), Ok("Magnesium · 300 mg"));
1669 assert_eq!(sent_within(¬ices, Duration::from_millis(300)), [("Magnesium · 300 mg".to_string(), 0)], "its own words");
1670 assert_eq!(lates.recv_timeout(Duration::from_secs(1)).as_deref(), Ok("Magnesium · 300 mg"), "recorded shown");
1671
1672 StandIns::signal(pid, "-STOP");
1674 std::thread::sleep(Duration::from_secs(1));
1675 let start = Instant::now();
1676 let outcome = dose("Magnesium · 300 mg", Duration::from_secs(60), &pressed, &late, words("Magnesium · 300 mg")).outcome(FIRST_TRY);
1677 let took = start.elapsed();
1678 assert!(matches!(outcome, Outcome::Unshown(_)) && took <= FIRST_TRY, "the connection never came: not gone out, said within FIRST_TRY: {outcome:?} after {took:?}");
1679 std::thread::sleep(Duration::from_secs(8).saturating_sub(start.elapsed() + Duration::from_secs(1)));
1680 StandIns::signal(pid, "-CONT");
1681 assert_eq!(lates.recv_timeout(Duration::from_secs(20)).as_deref(), Ok("Magnesium · 300 mg"), "shown late once the bus answers");
1682 assert_eq!(presses.recv_timeout(Duration::from_secs(5)).as_deref(), Ok("Magnesium · 300 mg"), "its button works");
1683 assert_eq!(sent_within(¬ices, Duration::from_millis(500)), [("Check first: Magnesium · 300 mg".to_string(), 0)], "once, the doubt first");
1684
1685 StandIns::signal(pid, "-STOP");
1687 let start = Instant::now();
1688 let mut sending = Vec::new();
1689 for (at, name) in [(1, "First"), (10, "Second"), (20, "Third")] {
1690 std::thread::sleep(Duration::from_secs(at).saturating_sub(start.elapsed()));
1691 let sent = dose(name, Duration::from_secs(90), &pressed, &late, words(name));
1692 assert!(matches!(sent.outcome(FIRST_TRY), Outcome::Unshown(_)), "{name}: not gone out");
1693 sending.push(sent);
1694 }
1695 std::thread::sleep(Duration::from_secs(30).saturating_sub(start.elapsed()));
1696 StandIns::signal(pid, "-CONT");
1697 let mut shown: Vec<String> = (0..3).filter_map(|_| lates.recv_timeout(Duration::from_secs(25)).ok()).collect();
1698 shown.sort();
1699 assert_eq!(shown, ["First", "Second", "Third"], "all three shown once the bus answers");
1700 let mut seen: Vec<String> = sent_within(¬ices, Duration::from_secs(2)).into_iter().map(|(title, _)| title).collect();
1701 seen.sort();
1702 assert_eq!(seen, ["Check first: First", "Check first: Second", "Check first: Third"]);
1703 for _ in 0..3 {
1704 let _ = presses.recv_timeout(Duration::from_secs(5));
1705 }
1706
1707 stop.store(true, Ordering::Relaxed);
1712 std::thread::sleep(Duration::from_millis(500));
1713 let quiet = Arc::new(AtomicBool::new(false));
1714 assert!(notice_server(&address, "silent", told.clone(), Arc::clone(&quiet)), "the silent server");
1715 let start = Instant::now();
1716 let outcome = dose("Magnesium · 300 mg", Duration::from_secs(12), &pressed, &late, words("Magnesium · 300 mg")).outcome(FIRST_TRY);
1717 assert!(matches!(outcome, Outcome::Maybe(_)) && start.elapsed() < FIRST_TRY, "{outcome:?} after {:?}", start.elapsed());
1718 assert_eq!(sent_within(¬ices, Duration::from_secs(15)), [("Magnesium · 300 mg".to_string(), 0)], "asked once, never again");
1719 assert!(lates.try_recv().is_err());
1720
1721 quiet.store(true, Ordering::Relaxed);
1723 std::thread::sleep(Duration::from_millis(500));
1724 let outcome = dose("Magnesium · 300 mg", Duration::from_secs(60), &pressed, &late, words("Magnesium · 300 mg")).outcome(FIRST_TRY);
1725 assert!(matches!(outcome, Outcome::Unshown(_)), "no server: {outcome:?}");
1726 std::thread::sleep(Duration::from_secs(6));
1727 let back = Arc::new(AtomicBool::new(false));
1728 assert!(notice_server(&address, "answers", told.clone(), Arc::clone(&back)));
1729 assert_eq!(lates.recv_timeout(Duration::from_secs(15)).as_deref(), Ok("Magnesium · 300 mg"), "shown once the server is there");
1730 let _ = presses.recv_timeout(Duration::from_secs(5));
1731 assert_eq!(sent_within(¬ices, Duration::from_millis(500)), [("Check first: Magnesium · 300 mg".to_string(), 0)]);
1732 back.store(true, Ordering::Relaxed);
1733 std::thread::sleep(Duration::from_millis(500));
1734 let not_any_more = words("Magnesium · 300 mg");
1735 let outcome = dose("Magnesium · 300 mg", Duration::from_secs(60), &pressed, &late, Arc::clone(¬_any_more)).outcome(FIRST_TRY);
1736 assert!(matches!(outcome, Outcome::Unshown(_)));
1737 *not_any_more.lock().unwrap() = None;
1738 let again = Arc::new(AtomicBool::new(false));
1739 assert!(notice_server(&address, "answers", told.clone(), Arc::clone(&again)));
1740 assert!(sent_within(¬ices, Duration::from_secs(12)).is_empty(), "marked meanwhile: never shown");
1741 assert!(lates.try_recv().is_err());
1742
1743 StandIns::signal(pid, "-STOP");
1745 let lasting = ongoing("Write to the bank", "Since 14:02, for 25 min", vec![("stop".to_string(), "Stop".to_string())], Box::new(|_: &str| {}));
1746 std::thread::sleep(Duration::from_secs(8));
1747 StandIns::signal(pid, "-CONT");
1748 assert_eq!(sent_within(¬ices, Duration::from_secs(20)).first().map(|(title, _)| title.as_str()), Some("Write to the bank"), "not lost for the session");
1749 lasting.close(Duration::from_secs(3));
1750 again.store(true, Ordering::Relaxed);
1751 }
1752
1753 #[test]
1772 #[ignore = "starts a stand-in dbus-daemon and sets the session bus address: run alone, by name"]
1773 fn reminders_whose_fate_was_not_known_say_the_doubt_once_known_shown() {
1774 use crate::bus::StandIns;
1775 use std::sync::atomic::{AtomicBool, Ordering};
1776 use std::sync::mpsc::channel;
1777 use std::sync::{Arc, Mutex};
1778 use std::time::Instant;
1779 let mut stand_ins = StandIns::new("stand-in-fates");
1780 let (pid, address) = stand_ins.start("session");
1781 unsafe { std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &address) };
1783 let (told, notices) = channel::<(Instant, String, u32)>();
1784 let (pressed, presses) = channel::<String>();
1785 let (late, lates) = channel::<String>();
1786 let words = || Arc::new(Mutex::new(Some("Check first: Levothyroxine · 75 µg".to_string())));
1787
1788 let slow = Arc::new(AtomicBool::new(false));
1790 assert!(notice_server(&address, "late", told.clone(), Arc::clone(&slow)));
1791 let start = Instant::now();
1792 let outcome = dose("Levothyroxine · 75 µg", Duration::from_secs(60), &pressed, &late, words()).outcome(FIRST_TRY);
1793 assert!(matches!(outcome, Outcome::Maybe(_)) && start.elapsed() < Duration::from_secs(3), "sent, not answered in time: maybe ({outcome:?} after {:?})", start.elapsed());
1794 assert_eq!(lates.recv_timeout(Duration::from_secs(5)).as_deref(), Ok("Levothyroxine · 75 µg"), "its late answer heard: recorded shown");
1795 let sent = sent_within(¬ices, Duration::from_secs(2));
1796 assert_eq!(sent.len(), 2, "{sent:?}");
1797 assert_eq!(sent[0], ("Levothyroxine · 75 µg".to_string(), 0));
1798 assert!(sent[1].0 == "Check first: Levothyroxine · 75 µg" && sent[1].1 != 0, "said again in place, the doubt first: {sent:?}");
1799 assert_eq!(presses.recv_timeout(Duration::from_secs(5)).as_deref(), Ok("Levothyroxine · 75 µg"), "its Taken, pressed after, counts");
1800 assert!(presses.recv_timeout(Duration::from_secs(4)).is_err(), "once");
1801 slow.store(true, Ordering::Relaxed);
1802 std::thread::sleep(Duration::from_millis(500));
1803
1804 let slow = Arc::new(AtomicBool::new(false));
1806 assert!(notice_server(&address, "late", told.clone(), Arc::clone(&slow)));
1807 let marked = words();
1808 let start = Instant::now();
1809 let outcome = dose("Levothyroxine · 75 µg", Duration::from_secs(60), &pressed, &late, Arc::clone(&marked)).outcome(FIRST_TRY);
1810 assert!(matches!(outcome, Outcome::Maybe(_)) && start.elapsed() < Duration::from_secs(3), "{outcome:?} after {:?}", start.elapsed());
1811 *marked.lock().unwrap() = None;
1812 let sent = sent_within(¬ices, Duration::from_secs(3));
1813 assert_eq!(sent.first(), Some(&("Levothyroxine · 75 µg".to_string(), 0)), "{sent:?}");
1814 assert!(sent.iter().any(|(title, id)| title == CLOSED && *id != 0), "taken away by its number: {sent:?}");
1815 assert!(!sent.iter().any(|(title, _)| title.starts_with("Check first")), "{sent:?}");
1816 assert!(lates.recv_timeout(Duration::from_secs(1)).is_err(), "never recorded a reminder");
1817 assert!(presses.recv_timeout(Duration::from_secs(1)).is_err(), "its button gone with it");
1818 slow.store(true, Ordering::Relaxed);
1819 std::thread::sleep(Duration::from_millis(500));
1820
1821 let stalling = Arc::new(AtomicBool::new(false));
1823 assert!(notice_server_on(&address, "stall", pid, told.clone(), Arc::clone(&stalling)));
1824 let start = Instant::now();
1825 let outcome = dose("Levothyroxine · 75 µg", Duration::from_secs(60), &pressed, &late, words()).outcome(FIRST_TRY);
1826 assert!(matches!(outcome, Outcome::Maybe(_)) && start.elapsed() < FIRST_TRY, "sent into the stall: maybe ({outcome:?} after {:?})", start.elapsed());
1827 assert_eq!(lates.recv_timeout(Duration::from_secs(10)).as_deref(), Ok("Levothyroxine · 75 µg"), "let go: shown, late");
1828 let sent = sent_within(¬ices, Duration::from_secs(3));
1829 assert_eq!(sent.first(), Some(&("Levothyroxine · 75 µg".to_string(), 0)), "{sent:?}");
1830 assert!(sent.iter().any(|(title, replaces)| title == "Check first: Levothyroxine · 75 µg" && *replaces != 0), "the doubt first, in place: {sent:?}");
1831 assert_eq!(presses.recv_timeout(Duration::from_secs(5)).as_deref(), Ok("Levothyroxine · 75 µg"), "its Taken counts");
1832 stalling.store(true, Ordering::Relaxed);
1833 std::thread::sleep(Duration::from_millis(500));
1834
1835 let quick = Arc::new(AtomicBool::new(false));
1838 assert!(notice_server(&address, "answers", told.clone(), Arc::clone(&quick)));
1839 StandIns::signal(pid, "-STOP");
1840 drop(dose("Levothyroxine · 75 µg", Duration::from_secs(60), &pressed, &late, words()));
1841 std::thread::sleep(Duration::from_millis(300));
1842 StandIns::signal(pid, "-CONT");
1843 assert_eq!(lates.recv_timeout(Duration::from_secs(5)).as_deref(), Ok("Levothyroxine · 75 µg"), "recorded shown");
1844 assert_eq!(sent_within(¬ices, Duration::from_secs(2)), [("Check first: Levothyroxine · 75 µg".to_string(), 0)], "the doubt first at once, one notice");
1845 assert_eq!(presses.recv_timeout(Duration::from_secs(5)).as_deref(), Ok("Levothyroxine · 75 µg"), "its Taken counts");
1846 assert!(presses.recv_timeout(Duration::from_secs(1)).is_err(), "once");
1847
1848 StandIns::signal(pid, "-STOP");
1850 let gone = words();
1851 let dropped = dose("Levothyroxine · 75 µg", Duration::from_secs(60), &pressed, &late, Arc::clone(&gone));
1852 *gone.lock().unwrap() = None;
1853 drop(dropped);
1854 std::thread::sleep(Duration::from_millis(300));
1855 StandIns::signal(pid, "-CONT");
1856 assert!(sent_within(¬ices, Duration::from_secs(8)).is_empty(), "never sent");
1857 assert!(lates.try_recv().is_err());
1858 quick.store(true, Ordering::Relaxed);
1859 }
1860
1861 fn slow_relay(bus: &str, delay: Duration, stop: std::sync::Arc<std::sync::atomic::AtomicBool>) -> String {
1866 use std::io::{Read, Write};
1867 use std::os::unix::net::{UnixListener, UnixStream};
1868 use std::sync::Arc;
1869 use std::sync::atomic::{AtomicBool, Ordering};
1870 let target = crate::bus::unix_path(bus).expect("a unix:path= address");
1871 let socket = target.with_file_name("slow-relay");
1872 let _ = std::fs::remove_file(&socket);
1873 let listener = UnixListener::bind(&socket).unwrap();
1874 listener.set_nonblocking(true).unwrap();
1875 std::thread::spawn(move || {
1876 while !stop.load(Ordering::Relaxed) {
1877 let Ok((client, _)) = listener.accept() else {
1878 std::thread::sleep(Duration::from_millis(20));
1879 continue;
1880 };
1881 let _ = client.set_nonblocking(false);
1882 let Ok(upstream) = UnixStream::connect(&target) else { continue };
1883 let begun = Arc::new(AtomicBool::new(false));
1884 let (mut from_client, mut to_bus, seen) = (client.try_clone().unwrap(), upstream.try_clone().unwrap(), Arc::clone(&begun));
1886 std::thread::spawn(move || {
1887 let (mut buffer, mut said) = ([0u8; 65_536], Vec::new());
1888 while let Ok(n) = from_client.read(&mut buffer) {
1889 if n == 0 {
1890 break;
1891 }
1892 if !seen.load(Ordering::SeqCst) {
1893 said.extend_from_slice(&buffer[..n]);
1894 if said.windows(7).any(|w| w == b"BEGIN\r\n") {
1895 seen.store(true, Ordering::SeqCst);
1896 }
1897 }
1898 if to_bus.write_all(&buffer[..n]).is_err() {
1899 break;
1900 }
1901 }
1902 let _ = to_bus.shutdown(std::net::Shutdown::Both);
1903 });
1904 let (mut from_bus, mut to_client) = (upstream, client);
1906 let (queued, queue) = std::sync::mpsc::channel::<(std::time::Instant, Vec<u8>)>();
1907 std::thread::spawn(move || {
1908 while let Ok((at, bytes)) = queue.recv() {
1909 std::thread::sleep(at.saturating_duration_since(std::time::Instant::now()));
1910 if to_client.write_all(&bytes).is_err() {
1911 break;
1912 }
1913 }
1914 let _ = to_client.shutdown(std::net::Shutdown::Both);
1915 });
1916 std::thread::spawn(move || {
1917 let mut buffer = [0u8; 65_536];
1918 while let Ok(n) = from_bus.read(&mut buffer) {
1919 if n == 0 {
1920 break;
1921 }
1922 let at = std::time::Instant::now() + if begun.load(Ordering::SeqCst) { delay } else { Duration::ZERO };
1923 if queued.send((at, buffer[..n].to_vec())).is_err() {
1924 break;
1925 }
1926 }
1927 });
1928 }
1929 });
1930 format!("unix:path={}", crate::bus::escaped(&socket))
1931 }
1932
1933 #[test]
1943 #[ignore = "starts a stand-in dbus-daemon and sets the session bus address: run alone, by name"]
1944 fn a_slow_bus_shows_the_reminder_late_the_doubt_first() {
1945 use crate::bus::StandIns;
1946 use std::sync::atomic::{AtomicBool, Ordering};
1947 use std::sync::mpsc::channel;
1948 use std::sync::{Arc, Mutex};
1949 use std::time::Instant;
1950 let mut stand_ins = StandIns::new("stand-in-slow");
1951 let (_pid, bus) = stand_ins.start("session");
1952 let stop = Arc::new(AtomicBool::new(false));
1953 let address = slow_relay(&bus, Duration::from_millis(3_400), Arc::clone(&stop));
1954 unsafe { std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &address) };
1956 let (told, notices) = channel::<(Instant, String, u32)>();
1957 assert!(notice_server(&bus, "answers", told, Arc::clone(&stop)), "the stand-in server");
1959 let (pressed, presses) = channel::<String>();
1960 let (late, lates) = channel::<String>();
1961 let start = Instant::now();
1962 let words = Arc::new(Mutex::new(Some("Check first: Levothyroxine · 75 µg".to_string())));
1963 let outcome = dose("Levothyroxine · 75 µg", Duration::from_secs(120), &pressed, &late, words).outcome(FIRST_TRY);
1964 assert!(matches!(outcome, Outcome::Unshown(_)) && start.elapsed() < FIRST_TRY, "its matches not answered in time, nothing sent: {outcome:?} after {:?}", start.elapsed());
1965 assert_eq!(lates.recv_timeout(Duration::from_secs(45)).as_deref(), Ok("Levothyroxine · 75 µg"), "shown by the try after it, recorded");
1966 assert_eq!(sent_within(¬ices, Duration::from_secs(1)), [("Check first: Levothyroxine · 75 µg".to_string(), 0)], "once, the doubt first");
1967 assert_eq!(presses.recv_timeout(Duration::from_secs(10)).as_deref(), Ok("Levothyroxine · 75 µg"), "its Taken counts");
1968 assert!(presses.recv_timeout(Duration::from_secs(5)).is_err(), "once");
1969 stop.store(true, Ordering::Relaxed);
1970 }
1971
1972 #[test]
1988 #[ignore = "starts a stand-in dbus-daemon and sets the session bus address: run alone, by name"]
1989 fn reminders_keep_their_word() {
1990 use crate::bus::StandIns;
1991 use std::sync::atomic::{AtomicBool, Ordering};
1992 use std::sync::mpsc::channel;
1993 use std::sync::{Arc, Mutex};
1994 use std::time::Instant;
1995 let mut stand_ins = StandIns::new("stand-in-word");
1996 let (_pid, address) = stand_ins.start("session");
1997 unsafe { std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &address) };
1999 let (told, notices) = channel::<(Instant, String, u32)>();
2000 let (pressed, presses) = channel::<String>();
2001 let (shown, shows) = channel::<String>();
2002 let (log, logged) = channel::<String>();
2003 let words = |title: &str| Arc::new(Mutex::new(Some(format!("Check first: {title}"))));
2004 let record = |name: &str, settled: &Arc<AtomicBool>| Box::new(StandIn { name: name.into(), late: words(name), shown: shown.clone(), log: Some(log.clone()), recorded: false, settled: Arc::clone(settled) });
2005 let drain = |within: Duration| -> Vec<String> {
2006 let until = Instant::now() + within;
2007 let mut seen = Vec::new();
2008 while let Ok(item) = logged.recv_timeout(until.saturating_duration_since(Instant::now())) {
2009 seen.push(item);
2010 }
2011 seen
2012 };
2013 let unsettled: Arc<AtomicBool> = Arc::default();
2014
2015 let start = Instant::now();
2017 let outcome = dose_as("Iron", Again { end: start + Duration::from_secs(7), quiet: None, record: record("Iron", &unsettled) }, &pressed).outcome(FIRST_TRY);
2018 assert!(matches!(outcome, Outcome::Unshown(_)), "{outcome:?}");
2019 let seen = drain(Duration::from_secs(9));
2020 assert_eq!(seen.first().map(String::as_str), Some("going First"), "{seen:?}");
2021 assert_eq!(seen.get(1).map(String::as_str), Some("refused"), "{seen:?}");
2022 assert_eq!(seen.get(2).map(String::as_str), Some("unshown"), "{seen:?}");
2023 assert_eq!(seen.last().map(String::as_str), Some("over"), "{seen:?}");
2024 assert!(seen.iter().filter(|s| s.starts_with("going Late(Retry)")).count() >= 1, "tried again, the doubt first: {seen:?}");
2025
2026 let start = Instant::now();
2028 let unix = 1_800_000_016;
2029 let again = Again { end: start + Duration::from_secs(40), quiet: Some((start + Duration::from_secs(8), start + Duration::from_secs(16), unix)), record: record("Zinc", &unsettled) };
2030 assert!(matches!(dose_as("Zinc", again, &pressed).outcome(FIRST_TRY), Outcome::Unshown(_)));
2031 std::thread::sleep(Duration::from_secs(9).saturating_sub(start.elapsed()));
2032 let up = Arc::new(AtomicBool::new(false));
2033 assert!(notice_server(&address, "answers", told.clone(), Arc::clone(&up)));
2034 assert_eq!(shows.recv_timeout(Duration::from_secs(20)).as_deref(), Ok("Zinc"), "shown after the quiet");
2035 assert!(start.elapsed() >= Duration::from_secs(16), "none during the quiet: {:?}", start.elapsed());
2036 let seen = drain(Duration::from_millis(300));
2037 assert!(seen.contains(&format!("pause {unix}")), "promised before it began: {seen:?}");
2038 assert_eq!(sent_within(¬ices, Duration::from_millis(300)).first().map(|(title, _)| title.as_str()), Some("Check first: Zinc"));
2039 let _ = presses.recv_timeout(Duration::from_secs(3));
2040 up.store(true, Ordering::Relaxed);
2041 std::thread::sleep(Duration::from_millis(500));
2042
2043 let quiet_server = Arc::new(AtomicBool::new(false));
2045 assert!(notice_server(&address, "shows", told.clone(), Arc::clone(&quiet_server)));
2046 let settled = Arc::new(AtomicBool::new(false));
2047 let outcome = dose_as("Folate", Again { end: Instant::now() + Duration::from_secs(60), quiet: None, record: record("Folate", &settled) }, &pressed).outcome(FIRST_TRY);
2048 assert_eq!(outcome, Outcome::Shown);
2049 let first = sent_within(¬ices, Duration::from_millis(500));
2050 assert_eq!(first.len(), 1, "{first:?}");
2051 settled.store(true, Ordering::SeqCst);
2052 let closed = sent_within(¬ices, Duration::from_secs(4));
2053 assert!(closed.iter().any(|(title, id)| title == CLOSED && *id != 0), "taken away: {closed:?}");
2054 quiet_server.store(true, Ordering::Relaxed);
2055 std::thread::sleep(Duration::from_millis(500));
2056 let _ = shows.try_iter().count();
2057
2058 let pressing = Arc::new(AtomicBool::new(false));
2060 assert!(notice_server(&address, "press-first", told.clone(), Arc::clone(&pressing)));
2061 let outcome = dose("Iodine", Duration::from_secs(60), &pressed, &shown, words("Iodine")).outcome(FIRST_TRY);
2062 assert!(matches!(outcome, Outcome::Maybe(_)), "{outcome:?}");
2063 assert_eq!(shows.recv_timeout(Duration::from_secs(5)).as_deref(), Ok("Iodine"), "recorded shown at its late answer");
2064 assert_eq!(presses.recv_timeout(Duration::from_secs(3)).as_deref(), Ok("Iodine"), "its Taken counts");
2065 assert!(presses.recv_timeout(Duration::from_secs(2)).is_err(), "once");
2066 let sent = sent_within(¬ices, Duration::from_millis(500));
2067 assert_eq!(sent, [("Iodine".to_string(), 0)], "never said again over the press");
2068 pressing.store(true, Ordering::Relaxed);
2069 std::thread::sleep(Duration::from_millis(500));
2070
2071 let renumbering = Arc::new(AtomicBool::new(false));
2073 assert!(notice_server(&address, "renumber", told.clone(), Arc::clone(&renumbering)));
2074 let settled = Arc::new(AtomicBool::new(false));
2075 let outcome = dose_as("Calcium", Again { end: Instant::now() + Duration::from_secs(60), quiet: None, record: record("Calcium", &settled) }, &pressed).outcome(FIRST_TRY);
2076 assert!(matches!(outcome, Outcome::Maybe(_)), "{outcome:?}");
2077 assert_eq!(shows.recv_timeout(Duration::from_secs(5)).as_deref(), Ok("Calcium"));
2078 let sent = sent_within(¬ices, Duration::from_secs(1));
2079 let replaced = sent.iter().find(|(title, replaces)| title == "Check first: Calcium" && *replaces != 0).map(|(_, replaces)| *replaces);
2080 assert!(replaced.is_some(), "said again in place: {sent:?}");
2081 settled.store(true, Ordering::SeqCst);
2082 let closed = sent_within(¬ices, Duration::from_secs(4));
2083 assert!(closed.iter().any(|(title, id)| title == CLOSED && Some(*id) == replaced.map(|r| r + 100)), "closed by its new number: {closed:?}");
2084 renumbering.store(true, Ordering::Relaxed);
2085 std::thread::sleep(Duration::from_millis(500));
2086
2087 let late_server = Arc::new(AtomicBool::new(false));
2089 assert!(notice_server(&address, "late", told.clone(), Arc::clone(&late_server)));
2090 let lasting = ongoing("Write to the bank", "Since 14:02, for 25 min", vec![("stop".to_string(), "Stop".to_string())], Box::new(|_: &str| {}));
2091 std::thread::sleep(Duration::from_millis(500));
2092 lasting.close(Duration::from_millis(500));
2093 let seen = sent_within(¬ices, Duration::from_secs(6));
2094 assert_eq!(seen.first().map(|(title, _)| title.as_str()), Some("Write to the bank"), "{seen:?}");
2095 assert!(seen.iter().any(|(title, id)| title == CLOSED && *id != 0), "closed by its number once answered: {seen:?}");
2096 late_server.store(true, Ordering::Relaxed);
2097 }
2098
2099 #[test]
2106 #[ignore = "starts a stand-in dbus-daemon and sets the session bus address: run alone, by name"]
2107 fn reminders_go_on_after_a_late_refusal_or_a_lost_server() {
2108 use crate::bus::StandIns;
2109 use std::sync::atomic::{AtomicBool, Ordering};
2110 use std::sync::mpsc::channel;
2111 use std::sync::{Arc, Mutex};
2112 use std::time::Instant;
2113 let mut stand_ins = StandIns::new("stand-in-lost");
2114 let (_pid, address) = stand_ins.start("session");
2115 unsafe { std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &address) };
2117 let (told, notices) = channel::<(Instant, String, u32)>();
2118 let (pressed, presses) = channel::<String>();
2119 let (shown, shows) = channel::<String>();
2120 let (log, logged) = channel::<String>();
2121 let record = |name: &str| Box::new(StandIn { name: name.into(), late: Arc::new(Mutex::new(Some(format!("Check first: {name}")))), shown: shown.clone(), log: Some(log.clone()), recorded: false, settled: Arc::default() });
2122 let again = |name: &str| Again { end: Instant::now() + Duration::from_secs(40), quiet: None, record: record(name) };
2123 for (mode, name) in [("late-error", "Iron"), ("exits-late", "Zinc"), ("exits-fast", "Folate")] {
2124 let stop = Arc::new(AtomicBool::new(false));
2125 assert!(notice_server(&address, mode, told.clone(), Arc::clone(&stop)), "{mode}");
2126 let outcome = dose_as(name, again(name), &pressed).outcome(FIRST_TRY);
2127 assert!(matches!(outcome, Outcome::Maybe(_)), "{mode}: sent, its fate not known: {outcome:?}");
2128 std::thread::sleep(Duration::from_secs(4));
2130 let back = Arc::new(AtomicBool::new(false));
2131 if mode != "late-error" {
2132 assert!(notice_server(&address, "answers", told.clone(), Arc::clone(&back)), "{mode}: a server back");
2133 }
2134 assert_eq!(shows.recv_timeout(Duration::from_secs(20)).as_deref(), Ok(name), "{mode}: shown again, late");
2135 let sent = sent_within(¬ices, Duration::from_millis(500));
2136 assert_eq!(sent.first(), Some(&(name.to_string(), 0)), "{mode}: {sent:?}");
2137 assert_eq!(sent.last().map(|(title, _)| title.as_str()), Some(&*format!("Check first: {name}")), "{mode}: the doubt first: {sent:?}");
2138 let seen: Vec<String> = logged.try_iter().collect();
2139 assert!(!seen.contains(&"refused".to_string()), "{mode}: it may have shown: never taken back: {seen:?}");
2140 assert!(seen.contains(&"going Late(Unconfirmed)".to_string()) && !seen.contains(&"going Late(Retry)".to_string()), "{mode}: not confirmed in time, never \"could not be shown\" (the seventh review's L2): {seen:?}");
2141 let _ = presses.recv_timeout(Duration::from_secs(3));
2142 stop.store(true, Ordering::Relaxed);
2143 back.store(true, Ordering::Relaxed);
2144 std::thread::sleep(Duration::from_millis(600));
2145 }
2146 }
2147
2148 #[test]
2155 fn a_lasting_notice_answered_late_is_never_shown_twice() {
2156 use std::sync::atomic::{AtomicBool, Ordering};
2157 let _alone = crate::bus::NOTIFICATION_SERVER.lock().unwrap_or_else(|e| e.into_inner());
2158 let Some(address) = crate::bus::session_address() else {
2159 eprintln!("No bus of the test's own (dbus-run-session): skipped.");
2160 return;
2161 };
2162 let (told, notices) = std::sync::mpsc::channel();
2163 let stop = std::sync::Arc::new(AtomicBool::new(false));
2164 let until = std::time::Instant::now() + Duration::from_secs(2);
2166 while !notice_server(&address, "late", told.clone(), std::sync::Arc::clone(&stop)) {
2167 if std::time::Instant::now() >= until {
2168 eprintln!("No bus of the test's own (dbus-run-session), or a notification server on it already: skipped.");
2169 return;
2170 }
2171 std::thread::sleep(Duration::from_millis(100));
2172 }
2173 let lasting = ongoing("Write to the bank", "Since 14:02, for 25 min", vec![("stop".to_string(), "Stop".to_string())], Box::new(|_: &str| {}));
2174 std::thread::sleep(Duration::from_secs(1));
2175 lasting.update("Write to the bank", "Paused, 1 min so far", vec![("resume".to_string(), "Go on".to_string())]);
2176 let sent = sent_within(¬ices, Duration::from_secs(9));
2177 assert_eq!(sent.first(), Some(&("Write to the bank".to_string(), 0)), "{sent:?}");
2178 assert!(sent.iter().skip(1).all(|(_, replaces)| *replaces != 0), "never a second with no number to replace: {sent:?}");
2179 assert!(sent.len() >= 2, "the change went, in place: {sent:?}");
2180 lasting.close(Duration::from_secs(5));
2181 stop.store(true, Ordering::Relaxed);
2182 }
2183
2184 #[test]
2193 fn notifications_go_as_before_and_hear_their_button() {
2194 use dbus::blocking::LocalConnection;
2195 use dbus::channel::{MatchingReceiver, Sender as _};
2196 use dbus::message::MatchRule;
2197 use std::sync::mpsc::channel;
2198
2199 #[derive(Debug, PartialEq)]
2201 struct Sent {
2202 app: String,
2203 replaces: u32,
2204 icon: String,
2205 title: String,
2206 body: String,
2207 buttons: Vec<String>,
2208 hints: Vec<String>,
2209 sound_off: bool,
2210 entry: String,
2211 urgency: u8,
2212 timeout: i32,
2213 }
2214 let _alone = crate::bus::NOTIFICATION_SERVER.lock().unwrap_or_else(|e| e.into_inner());
2215 let (told, sent) = channel::<Sent>();
2216 let (ready, started) = channel::<bool>();
2217 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2218 let stopped = std::sync::Arc::clone(&stop);
2219 std::thread::spawn(move || {
2220 let Some(bus) = stand_in_bus() else {
2221 let _ = ready.send(false);
2222 return;
2223 };
2224 let mut next = 20u32;
2225 bus.start_receive(
2226 MatchRule::new_method_call(),
2227 Box::new(move |call: dbus::Message, bus: &LocalConnection| {
2228 let mut press = None;
2229 let reply = match call.member().as_deref() {
2230 Some("GetCapabilities") => call.method_return().append1(vec!["actions", "body", "body-markup"]),
2231 Some("Notify") => {
2232 let (app, replaces, icon, title, body, buttons, hints, timeout): (String, u32, String, String, String, Vec<String>, dbus::arg::PropMap, i32) = call.read_all().unwrap();
2233 next += 1;
2234 if buttons.len() >= 2 {
2236 press = Some(buttons[buttons.len() - 2].clone());
2237 }
2238 let _ = told.send(Sent {
2239 app,
2240 replaces,
2241 icon,
2242 title,
2243 body,
2244 buttons,
2245 hints: hint_names(&call),
2246 sound_off: dbus::arg::prop_cast::<bool>(&hints, "suppress-sound").copied().unwrap_or(false),
2247 entry: dbus::arg::prop_cast::<String>(&hints, "desktop-entry").cloned().unwrap_or_default(),
2248 urgency: dbus::arg::prop_cast::<u8>(&hints, "urgency").copied().unwrap_or(9),
2249 timeout,
2250 });
2251 call.method_return().append1(next)
2252 }
2253 _ => return true,
2254 };
2255 let _ = bus.send(reply);
2256 if let Some(key) = press {
2257 let mut signal = dbus::Message::new_signal(SERVER_PATH, SERVER, "ActionInvoked").unwrap().append2(next, key);
2258 signal.set_destination(call.sender().map(|s| s.into_static()));
2260 let _ = bus.send(signal);
2261 }
2262 true
2263 }),
2264 );
2265 let _ = ready.send(true);
2266 let until = std::time::Instant::now() + Duration::from_secs(30);
2267 while !stopped.load(std::sync::atomic::Ordering::Relaxed) && std::time::Instant::now() < until {
2268 let _ = bus.process(Duration::from_millis(100));
2269 }
2270 });
2271 if !started.recv_timeout(Duration::from_secs(5)).unwrap_or(false) {
2272 eprintln!("No bus of the test's own (dbus-run-session), or a notification server on it already: skipped.");
2273 return;
2274 }
2275 let wait = Duration::from_secs(5);
2276 let as_before = |icon: &str, title: &str, body: &str, buttons: &[&str], urgency: u8| Sent {
2277 app: "Sioul".into(),
2278 replaces: 0,
2279 icon: icon.into(),
2280 title: title.into(),
2281 body: body.into(),
2282 buttons: buttons.iter().map(|b| b.to_string()).collect(),
2283 hints: vec!["desktop-entry".into(), "suppress-sound".into(), "urgency".into()],
2284 sound_off: true,
2285 entry: DESKTOP_ENTRY.into(),
2286 urgency,
2287 timeout: 600_000,
2288 };
2289 let (copied, copies) = channel::<()>();
2291 code("Code from example.org", "482913 <b>now</b> & then", Some(("Copy".into(), Box::new(move || {
2292 let _ = copied.send(());
2293 })))).unwrap();
2294 assert_eq!(sent.recv_timeout(wait).unwrap(), as_before("dialog-password", "Code from example.org", "482913 <b>now</b> & then", &["copy", "Copy"], CRITICAL));
2295 assert_eq!(copies.recv_timeout(wait), Ok(()));
2296 mail("New mail", "From Noa", false, None).unwrap();
2298 assert_eq!(sent.recv_timeout(wait).unwrap(), as_before("mail-unread", "New mail", "From Noa", &[], NORMAL));
2299 mail("New mail", "From Noa", true, None).unwrap();
2300 assert_eq!(sent.recv_timeout(wait).unwrap(), as_before("mail-unread", "New mail", "From Noa", &[], CRITICAL));
2301 remind("Magnesium", "300 mg", None).unwrap();
2303 assert_eq!(sent.recv_timeout(wait).unwrap(), as_before("appointment-soon", "Magnesium", "300 mg", &[], CRITICAL));
2304 let (chose, choices) = channel::<&str>();
2305 let other = chose.clone();
2306 remind_choices("The night", "Close the day?", vec![("Close the day".into(), Box::new(move || {
2307 let _ = chose.send("close");
2308 })), ("Options…".into(), Box::new(move || {
2309 let _ = other.send("options");
2310 }))]).unwrap();
2311 assert_eq!(sent.recv_timeout(wait).unwrap(), as_before("appointment-soon", "The night", "Close the day?", &["choice-0", "Close the day", "choice-1", "Options…"], CRITICAL));
2312 assert_eq!(choices.recv_timeout(wait), Ok("options"));
2313 assert!(choices.recv_timeout(Duration::from_millis(300)).is_err(), "one choice run");
2314 stop.store(true, std::sync::atomic::Ordering::Relaxed);
2315 }
2316
2317 #[test]
2321 fn a_lasting_notification_changes_in_place_hears_its_buttons_and_goes() {
2322 use dbus::blocking::LocalConnection;
2323 use dbus::channel::{MatchingReceiver, Sender as _};
2324 use dbus::message::MatchRule;
2325 use std::sync::mpsc::channel;
2326
2327 #[derive(Debug, PartialEq)]
2329 enum Asked {
2330 Notify { replaces: u32, title: String, body: String, actions: Vec<String>, resident: bool, urgency: u8, timeout: i32 },
2331 Close(u32),
2332 }
2333 let _alone = crate::bus::NOTIFICATION_SERVER.lock().unwrap_or_else(|e| e.into_inner());
2334 let (told, asked) = channel::<Asked>();
2335 let (ready, started) = channel::<bool>();
2336 std::thread::spawn(move || {
2337 let Some(bus) = stand_in_bus() else {
2339 let _ = ready.send(false);
2340 return;
2341 };
2342 let finished = std::rc::Rc::new(std::cell::Cell::new(false));
2343 let over = std::rc::Rc::clone(&finished);
2344 let mut notified = 0;
2345 bus.start_receive(
2346 MatchRule::new_method_call(),
2347 Box::new(move |call: dbus::Message, bus: &LocalConnection| {
2348 let mut presses: Vec<(u32, &str)> = Vec::new();
2349 let reply = match call.member().as_deref() {
2350 Some("GetCapabilities") => call.method_return().append1(vec!["actions", "body", "body-markup", "persistence"]),
2351 Some("Notify") => {
2352 let (_, replaces, _, title, body, actions, hints, timeout): (String, u32, String, String, String, Vec<String>, dbus::arg::PropMap, i32) = call.read_all().unwrap();
2353 let resident = dbus::arg::prop_cast::<bool>(&hints, "resident").copied().unwrap_or(false);
2354 let urgency = dbus::arg::prop_cast::<u8>(&hints, "urgency").copied().unwrap_or(9);
2355 let _ = told.send(Asked::Notify { replaces, title, body, actions, resident, urgency, timeout });
2356 notified += 1;
2357 presses = match notified {
2359 1 => vec![(7, "pause")],
2360 2 => vec![(8, "stop"), (7, "resume")],
2361 _ => Vec::new(),
2362 };
2363 call.method_return().append1(7u32)
2364 }
2365 Some("CloseNotification") => {
2366 let (id,): (u32,) = call.read_all().unwrap();
2367 let _ = told.send(Asked::Close(id));
2368 over.set(true);
2369 call.method_return()
2370 }
2371 _ => return true,
2372 };
2373 let _ = bus.send(reply);
2374 for (id, key) in presses {
2375 let mut press = dbus::Message::new_signal(SERVER_PATH, SERVER, "ActionInvoked").unwrap().append2(id, key);
2376 press.set_destination(call.sender().map(|s| s.into_static()));
2378 let _ = bus.send(press);
2379 }
2380 true
2381 }),
2382 );
2383 let _ = ready.send(true);
2384 let until = std::time::Instant::now() + Duration::from_secs(20);
2385 while !finished.get() && std::time::Instant::now() < until {
2386 let _ = bus.process(Duration::from_millis(100));
2387 }
2388 });
2389 if !started.recv_timeout(Duration::from_secs(5)).unwrap_or(false) {
2390 eprintln!("No bus of the test's own (dbus-run-session), or a notification server on it already: skipped.");
2391 return;
2392 }
2393 let wait = Duration::from_secs(5);
2394 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())];
2395 let (pressed, presses) = channel::<String>();
2396 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()))));
2397 let first = Asked::Notify {
2398 replaces: 0,
2399 title: "Write to the bank".into(),
2400 body: "Since 14:02, for 25 min".into(),
2401 actions: ["pause", "Pause", "stop", "Stop", "default", "Open"].map(String::from).to_vec(),
2402 resident: true,
2403 urgency: 1,
2404 timeout: -1,
2406 };
2407 assert_eq!(asked.recv_timeout(wait).unwrap(), first);
2408 assert_eq!(presses.recv_timeout(wait).unwrap(), "pause");
2410 shown.update("Write to the bank", "Paused, 3 min so far", buttons("resume", "Go on"));
2412 match asked.recv_timeout(wait).unwrap() {
2413 Asked::Notify { replaces, body, actions, .. } => assert_eq!((replaces, body.as_str(), actions[0].as_str()), (7, "Paused, 3 min so far", "resume")),
2414 other => panic!("{other:?}"),
2415 }
2416 assert_eq!(presses.recv_timeout(wait).unwrap(), "resume");
2417 shown.close(wait);
2419 assert_eq!(asked.recv_timeout(wait).unwrap(), Asked::Close(7));
2420 assert!(presses.try_recv().is_err(), "nothing else was pressed");
2421 }
2422}