1use crate::backend::tr;
42use serde::{Deserialize, Serialize};
43use sioul_core::i18n::{self, Translator};
44use std::path::{Path, PathBuf};
45use std::sync::Mutex;
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub(crate) enum Which {
51 FreeTime,
53 Paused,
55 Global,
57}
58
59impl Which {
60 #[cfg_attr(not(target_os = "android"), allow(dead_code))]
62 pub(crate) fn key(self) -> &'static str {
63 match self {
64 Which::FreeTime => "free-time",
65 Which::Paused => "pause",
66 Which::Global => "dnd",
67 }
68 }
69
70 fn name(self, tr: &Translator) -> String {
73 match self {
74 Which::FreeTime => tr.text("dnd-name-free-time", None),
75 Which::Paused => tr.text("dnd-name-pause", None),
76 Which::Global => tr.text("dnd-name-global", None),
77 }
78 }
79}
80
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83pub(crate) struct Ask {
84 pub which: Which,
85 pub people: bool,
88 pub doses: bool,
91 pub desktop: bool,
94 #[serde(default)]
100 pub silence: Option<sioul_core::attention::Silence>,
101 #[serde(default = "yes")]
106 pub stack: bool,
107}
108
109fn yes() -> bool {
110 true
111}
112
113#[derive(Clone, Debug, Default, PartialEq, Eq)]
115pub(crate) struct Report {
116 pub on: bool,
119 pub line: String,
122 pub offers: Vec<&'static str>,
128 pub consent: String,
130 pub turned_off: bool,
133}
134
135impl Report {
136 pub(crate) fn json(&self) -> serde_json::Value {
138 let offers: Vec<serde_json::Value> = self.offers.iter().map(|key| serde_json::json!({ "key": key, "label": offer_label(key) })).collect();
139 serde_json::json!({ "on": self.on, "line": self.line, "consent": self.consent, "turned_off": self.turned_off, "offers": offers })
140 }
141}
142
143pub(crate) type Told = std::sync::Arc<dyn Fn(bool) + Send + Sync>;
145
146pub(crate) fn system_off(consent: bool) -> bool {
149 with(|layer| layer.platform.system_off(consent))
150}
151
152pub(crate) fn still_on() -> Option<Report> {
155 with(|layer| layer.platform.still_on().map(|done| done.report(tr())))
156}
157
158pub(crate) fn still_after_quit() -> bool {
162 with(|layer| layer.platform.outlives(&layer.kept))
163}
164
165pub(crate) fn again(ask: &Ask) -> Report {
168 with(|layer| {
169 layer.leave(ask.which, tr());
170 layer.enter(ask, tr())
171 })
172}
173
174pub(crate) fn listen(told: Told) {
178 with(|layer| layer.platform.listen(told));
179}
180
181pub(crate) fn on_here(on: bool) -> bool {
184 with(|layer| {
185 let was = layer.kept.on_here;
186 if was != on {
187 layer.kept.on_here = on;
188 layer.save();
189 }
190 was
191 })
192}
193
194pub(crate) fn tell_system(on: bool) {
197 #[cfg(target_os = "android")]
198 phone::call("flags", &serde_json::json!({ "on": on }).to_string());
199 #[cfg(not(target_os = "android"))]
200 let _ = on;
201}
202
203pub(crate) fn can() -> Report {
205 with(|layer| layer.can(tr()))
206}
207
208pub(crate) fn enter(ask: &Ask) -> Report {
211 with(|layer| layer.enter(ask, tr()))
212}
213
214pub(crate) fn leave(which: Which) -> Report {
218 with(|layer| layer.leave(which, tr()))
219}
220
221pub(crate) fn take_pause_pressed() -> bool {
224 #[cfg(target_os = "android")]
225 return phone::call("pressed", "").as_bool().unwrap_or(false);
226 #[cfg(not(target_os = "android"))]
227 false
228}
229
230pub(crate) fn offer_label(key: &str) -> String {
232 match key {
233 "access" => tr().text("dnd-offer-access", None),
234 "starred" => tr().text("dnd-offer-starred", None),
235 "plasma" => tr().text("dnd-offer-plasma", None),
236 "desktop" => tr().text("dnd-gnome-consent", None),
237 "modes" => tr().text("dnd-offer-modes", None),
238 _ => String::new(),
239 }
240}
241
242pub(crate) fn open(key: &str) {
244 match key {
245 "access" => open_access(),
246 "starred" => open_starred(),
247 "plasma" => open_plasma(),
248 "modes" => open_modes(),
249 _ => {}
250 }
251}
252
253pub(crate) fn open_modes() {
256 #[cfg(target_os = "android")]
257 phone::call("open", "modes");
258}
259
260pub(crate) fn open_access() {
263 #[cfg(target_os = "android")]
264 phone::call("open", "access");
265}
266
267pub(crate) fn open_starred() {
270 #[cfg(target_os = "android")]
271 phone::call("open", "starred");
272}
273
274pub(crate) fn open_plasma() {
277 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
278 desktop::open_plasma_settings();
279}
280
281#[allow(dead_code)]
287#[derive(Clone, Debug, PartialEq, Eq)]
288enum Said {
289 PhoneCan,
290 PhoneNeedsAccess,
291 PhoneNoAccess,
292 PhoneTooOld,
293 PhoneCallback(Option<String>),
295 PhoneOn(Senders),
297 PhoneLets { calls: Senders, messages: Senders, repeat: bool, conversations: bool },
301 PhoneNoConversations,
303 PhoneEvents(bool),
305 PhoneAsSet(String),
307 PhoneSetThere,
309 PhoneThrough { alarms: Option<bool>, doses: Doses },
310 PhoneAlready,
311 PhoneFailed(String),
312 PhoneNoAnswer,
313 PhoneDisabled(String),
315 PhoneTurnedOff,
317 PhoneHeld,
319 PhoneStaysOn,
321 PhoneOff,
322 PhoneStill,
323 PlasmaCan,
324 PlasmaOn,
325 PlasmaDoses(bool),
327 PlasmaFailed(String),
328 PlasmaOff,
329 PlasmaTurnedOff,
331 PlasmaHeld,
333 PlasmaOwnStaysOn,
335 PlasmaStaysOn,
337 GnomeCan,
338 GnomeCannot,
339 GnomeOn,
340 GnomeAlready,
341 GnomeSandboxed,
342 GnomeFailed(String),
343 GnomeOff,
344 GnomeLeft,
345 GnomeTurnedOff,
347 GnomeHeld,
349 GnomeStaysOn,
351 DesktopCannot(String),
353 MacCan,
354 MacCannot,
355 MacOn,
356 MacFailed(String, String),
357 MacOff,
358 MacNoOff,
359 WindowsCannot,
360 OwnHeld { doses: bool },
362}
363
364#[derive(Clone, Copy, Debug, PartialEq, Eq)]
366enum Senders {
367 Starred,
368 Nobody,
369 Contacts,
370 Anyone,
371}
372
373impl Senders {
374 fn of(senders: sioul_core::attention::Senders) -> Senders {
375 match senders {
376 sioul_core::attention::Senders::None => Senders::Nobody,
377 sioul_core::attention::Senders::Starred => Senders::Starred,
378 sioul_core::attention::Senders::Contacts => Senders::Contacts,
379 sioul_core::attention::Senders::Anyone => Senders::Anyone,
380 }
381 }
382}
383
384fn lets(tr: &Translator, calls: Senders, messages: Senders, repeat: bool, conversations: bool) -> String {
387 let from = |senders: Senders| match senders {
388 Senders::Starred => tr.text("dnd-phone-from-starred", None),
389 Senders::Contacts => tr.text("dnd-phone-from-contacts", None),
390 Senders::Anyone => tr.text("dnd-phone-from-anyone", None),
391 Senders::Nobody => String::new(),
392 };
393 let with = |key: &str, senders: Senders| {
394 let mut args = i18n::args();
395 args.set("from", from(senders));
396 tr.text(key, Some(&args))
397 };
398 let mut parts = Vec::new();
399 if calls == messages && calls != Senders::Nobody {
400 parts.push(with("dnd-phone-lets-both", calls));
401 } else {
402 if calls != Senders::Nobody {
403 parts.push(with("dnd-phone-lets-calls", calls));
404 }
405 if messages != Senders::Nobody {
406 parts.push(with("dnd-phone-lets-messages", messages));
407 }
408 }
409 if repeat {
410 parts.push(tr.text("dnd-phone-lets-repeat", None));
411 }
412 if conversations {
413 parts.push(tr.text("dnd-phone-lets-conversations", None));
414 }
415 let Some(last) = parts.pop() else { return tr.text("dnd-phone-nobody", None) };
416 let what = if parts.is_empty() { last } else { format!("{} {} {last}", parts.join(", "), tr.text("word-and", None)) };
417 let mut args = i18n::args();
418 args.set("what", what);
419 tr.text("dnd-phone-lets", Some(&args))
420}
421
422#[derive(Clone, Copy, Debug, PartialEq, Eq)]
424enum Doses {
425 Through,
426 Shade,
428 Blocked,
430}
431
432const MAC_ON: &str = "Sioul pause on";
434const MAC_OFF: &str = "Sioul pause off";
435
436fn sentence(said: &Said, tr: &Translator) -> String {
437 let with = |key: &str, pairs: &[(&'static str, &str)]| {
438 let mut args = i18n::args();
439 for (name, value) in pairs {
440 args.set(*name, value.to_string());
441 }
442 tr.text(key, Some(&args))
443 };
444 let shortcuts = [("on", MAC_ON), ("off", MAC_OFF)];
445 match said {
446 Said::PhoneCan => tr.text("dnd-phone-can", None),
447 Said::PhoneNeedsAccess => tr.text("dnd-phone-needs-access", None),
448 Said::PhoneNoAccess => tr.text("dnd-phone-no-access", None),
449 Said::PhoneTooOld => tr.text("dnd-phone-too-old", None),
450 Said::PhoneCallback(Some(number)) => with("dnd-phone-callback", &[("number", number.as_str())]),
451 Said::PhoneCallback(None) => tr.text("dnd-phone-callback-unknown", None),
452 Said::PhoneOn(Senders::Starred) => tr.text("dnd-phone-starred", None),
453 Said::PhoneOn(Senders::Nobody) => tr.text("dnd-phone-nobody", None),
454 Said::PhoneOn(senders) => lets(tr, *senders, *senders, true, false),
455 Said::PhoneLets { calls, messages, repeat, conversations } => lets(tr, *calls, *messages, *repeat, *conversations),
456 Said::PhoneNoConversations => tr.text("dnd-phone-no-conversations", None),
457 Said::PhoneEvents(true) => tr.text("dnd-phone-events", None),
458 Said::PhoneEvents(false) => tr.text("dnd-phone-events-blocked", None),
459 Said::PhoneAsSet(name) => with("dnd-phone-as-set", &[("name", name.as_str())]),
460 Said::PhoneSetThere => tr.text("dnd-phone-set-there", None),
461 Said::PhoneThrough { alarms, doses } => {
462 let alarms = match alarms {
463 Some(false) => tr.text("dnd-phone-alarms-silenced", None),
464 _ if *doses == Doses::Through => return tr.text("dnd-phone-alarms-doses", None),
465 _ => tr.text("dnd-phone-alarms", None),
466 };
467 let doses = match doses {
468 Doses::Through => tr.text("dnd-phone-doses", None),
469 Doses::Shade => tr.text("dnd-phone-doses-shade", None),
470 Doses::Blocked => tr.text("dnd-phone-doses-blocked", None),
471 };
472 format!("{alarms} {doses}")
473 }
474 Said::PhoneAlready => tr.text("dnd-phone-already", None),
475 Said::PhoneFailed(why) => with("dnd-phone-failed", &[("why", why.as_str())]),
476 Said::PhoneNoAnswer => tr.text("dnd-phone-no-answer", None),
477 Said::PhoneDisabled(name) => with("dnd-phone-disabled", &[("name", name.as_str())]),
478 Said::PhoneTurnedOff => tr.text("dnd-phone-turned-off", None),
479 Said::PhoneHeld => tr.text("dnd-phone-held", None),
480 Said::PhoneStaysOn => tr.text("dnd-phone-stays-on", None),
481 Said::PhoneOff => tr.text("dnd-phone-off", None),
482 Said::PhoneStill => tr.text("dnd-phone-still", None),
483 Said::PlasmaCan => tr.text("dnd-plasma-can", None),
484 Said::PlasmaOn => tr.text("dnd-plasma-on", None),
485 Said::PlasmaDoses(true) => tr.text("dnd-plasma-doses", None),
486 Said::PlasmaDoses(false) => tr.text("dnd-plasma-doses-hidden", None),
487 Said::PlasmaFailed(why) => with("dnd-plasma-failed", &[("why", why.as_str())]),
488 Said::PlasmaOff => tr.text("dnd-plasma-off", None),
489 Said::PlasmaTurnedOff => tr.text("dnd-plasma-turned-off", None),
490 Said::PlasmaHeld => tr.text("dnd-plasma-held", None),
491 Said::PlasmaOwnStaysOn => tr.text("dnd-plasma-own-stays-on", None),
492 Said::PlasmaStaysOn => tr.text("dnd-plasma-stays-on", None),
493 Said::GnomeCan => tr.text("dnd-gnome-can", None),
494 Said::GnomeCannot => tr.text("dnd-gnome-cannot", None),
495 Said::GnomeOn => tr.text("dnd-gnome-on", None),
496 Said::GnomeAlready => tr.text("dnd-gnome-already", None),
497 Said::GnomeSandboxed => tr.text("dnd-gnome-sandboxed", None),
498 Said::GnomeFailed(why) => with("dnd-gnome-failed", &[("why", why.as_str())]),
499 Said::GnomeOff => tr.text("dnd-gnome-off", None),
500 Said::GnomeLeft => tr.text("dnd-gnome-left", None),
501 Said::GnomeTurnedOff => tr.text("dnd-gnome-turned-off", None),
502 Said::GnomeHeld => tr.text("dnd-gnome-held", None),
503 Said::GnomeStaysOn => tr.text("dnd-gnome-stays-on", None),
504 Said::DesktopCannot(name) if name.is_empty() => tr.text("dnd-desktop-cannot-unknown", None),
505 Said::DesktopCannot(name) => with("dnd-desktop-cannot", &[("name", name.as_str())]),
506 Said::MacCan => with("dnd-mac-can", &shortcuts),
507 Said::MacCannot => with("dnd-mac-cannot", &shortcuts),
508 Said::MacOn => with("dnd-mac-on", &shortcuts),
509 Said::MacFailed(name, why) => with("dnd-mac-failed", &[("name", name.as_str()), ("why", why.as_str())]),
510 Said::MacOff => with("dnd-mac-off", &shortcuts),
511 Said::MacNoOff => with("dnd-mac-no-off", &shortcuts),
512 Said::WindowsCannot => tr.text("dnd-windows-cannot", None),
513 Said::OwnHeld { doses: true } => tr.text("dnd-own-held-doses", None),
514 Said::OwnHeld { doses: false } => tr.text("dnd-own-held", None),
515 }
516}
517
518#[derive(Debug, Default, PartialEq, Eq)]
520struct Done {
521 on: bool,
522 said: Vec<Said>,
523 offers: Vec<&'static str>,
524 consent: bool,
526 retry: bool,
529 turned_off: bool,
531}
532
533impl Done {
534 fn said(on: bool, said: Vec<Said>) -> Done {
535 Done { on, said, ..Done::default() }
536 }
537
538 fn turned_off(said: Vec<Said>) -> Done {
540 Done { said, turned_off: true, ..Done::default() }
541 }
542
543 fn report(self, tr: &Translator) -> Report {
544 let line = self.said.iter().map(|s| sentence(s, tr)).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" ");
545 Report { on: self.on, line, offers: self.offers, consent: if self.consent { tr.text("dnd-gnome-consent", None) } else { String::new() }, turned_off: self.turned_off }
546 }
547}
548
549trait Platform {
553 fn can(&mut self, tr: &Translator) -> Done;
555 fn on(&mut self, ask: &Ask, kept: &mut Kept, tr: &Translator) -> Done;
557 fn off(&mut self, which: Which, others: &[Ask], kept: &mut Kept) -> Done;
560 fn holds(&self, _which: Which) -> bool {
562 true
563 }
564 #[cfg_attr(not(any(target_os = "android", test)), allow(dead_code))]
567 fn system_on(&mut self) -> Option<bool> {
568 None
569 }
570 fn system_off(&mut self, _consent: bool) -> bool {
573 false
574 }
575 fn still_on(&mut self) -> Option<Done> {
577 None
578 }
579 fn listen(&mut self, _told: Told) {}
581 fn outlives(&mut self, _kept: &Kept) -> bool {
583 false
584 }
585}
586
587#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
589struct Kept {
590 #[serde(default)]
593 #[cfg_attr(any(target_os = "android", target_os = "macos", windows), allow(dead_code))]
594 gnome: bool,
595 #[serde(default)]
597 #[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))]
598 mac: bool,
599 #[serde(default)]
603 on_here: bool,
604 #[serde(default)]
606 entered: Vec<Ask>,
607}
608
609struct Layer<P: Platform> {
611 platform: P,
612 kept: Kept,
613 path: Option<PathBuf>,
615 said: Vec<(Ask, Report, bool)>,
618 off: Vec<Which>,
620}
621
622impl<P: Platform> Layer<P> {
623 fn new(platform: P, path: Option<PathBuf>) -> Self {
624 let kept = path.as_deref().map(read_kept).unwrap_or_default();
625 Layer { platform, kept, path, said: Vec::new(), off: Vec::new() }
626 }
627
628 fn can(&mut self, tr: &Translator) -> Report {
629 self.platform.can(tr).report(tr)
630 }
631
632 fn enter(&mut self, ask: &Ask, tr: &Translator) -> Report {
633 if let Some((_, report, retry)) = self.said.iter().find(|(asked, _, _)| asked == ask)
634 && !*retry
635 && self.platform.holds(ask.which)
636 {
637 return report.clone();
638 }
639 let mut done = self.platform.on(ask, &mut self.kept, tr);
640 self.kept.entered.retain(|a| a.which != ask.which);
641 self.kept.entered.push(ask.clone());
642 self.save();
643 self.off.retain(|w| *w != ask.which);
644 if !done.on {
645 done.said.push(Said::OwnHeld { doses: ask.doses });
646 }
647 let retry = done.retry;
648 let report = done.report(tr);
649 self.said.retain(|(a, _, _)| a.which != ask.which);
650 self.said.push((ask.clone(), report.clone(), retry));
651 report
652 }
653
654 fn leave(&mut self, which: Which, tr: &Translator) -> Report {
655 let entered = self.kept.entered.iter().any(|a| a.which == which);
656 if !entered && self.off.contains(&which) {
657 return Report::default();
658 }
659 self.kept.entered.retain(|a| a.which != which);
660 let others = self.kept.entered.clone();
661 let done = self.platform.off(which, &others, &mut self.kept);
662 self.save();
663 self.said.retain(|(a, _, _)| a.which != which);
664 self.off.push(which);
665 Done { on: false, ..done }.report(tr)
666 }
667
668 fn save(&self) {
669 let Some(path) = &self.path else { return };
670 if let Some(dir) = path.parent() {
671 let _ = std::fs::create_dir_all(dir);
672 }
673 let text = toml::to_string(&self.kept).unwrap_or_default();
674 let fresh = path.with_extension("toml.new");
676 if std::fs::write(&fresh, text).is_ok() {
677 let _ = std::fs::rename(&fresh, path);
678 }
679 }
680}
681
682fn read_kept(path: &Path) -> Kept {
683 std::fs::read_to_string(path).ok().and_then(|text| toml::from_str(&text).ok()).unwrap_or_default()
684}
685
686fn kept_path() -> PathBuf {
688 sioul_core::config::state_dir().join("dnd.toml")
689}
690
691#[cfg(target_os = "android")]
693type System = phone::Phone;
694#[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
695type System = desktop::Desktop<desktop::Session>;
696#[cfg(target_os = "macos")]
697type System = mac::Mac<mac::Shortcuts>;
698#[cfg(windows)]
699type System = WindowsPlatform;
700#[cfg(not(any(unix, windows)))]
701type System = Nothing;
702
703static LAYER: Mutex<Option<Layer<System>>> = Mutex::new(None);
704
705fn with<R>(act: impl FnOnce(&mut Layer<System>) -> R) -> R {
706 let mut layer = LAYER.lock().unwrap_or_else(|e| e.into_inner());
707 act(layer.get_or_insert_with(|| Layer::new(System::default(), Some(kept_path()))))
708}
709
710#[cfg_attr(not(any(target_os = "android", test)), allow(dead_code))]
714mod answers {
715 use super::*;
716 use serde_json::Value;
717
718 const ANYONE: i64 = 1;
721 const CONTACTS: i64 = 2;
722 const STARRED: i64 = 3;
723 const NONE: i64 = 4;
724 const ALLOW: i64 = 1;
725 const DISALLOW: i64 = 2;
726 const CONVERSATIONS_ANY: i64 = 1;
727 const CONVERSATIONS_IMPORTANT: i64 = 2;
728 const CONVERSATIONS_NONE: i64 = 3;
729
730 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
734 struct Lets {
735 calls: Senders,
736 messages: Senders,
737 repeat: bool,
738 conversations: Option<bool>,
739 }
740
741 impl Lets {
742 fn asked(ask: &Ask) -> Lets {
744 match ask.silence {
745 Some(s) => Lets { calls: Senders::of(s.calls), messages: Senders::of(s.messages), repeat: s.repeat, conversations: Some(s.conversations) },
746 None => {
747 let who = if ask.people { Senders::Starred } else { Senders::Nobody };
748 Lets { calls: who, messages: who, repeat: ask.people, conversations: Some(false) }
749 }
750 }
751 }
752
753 fn read(rule: &Value) -> Option<Lets> {
755 let senders = |value: &Value| match value.as_i64() {
756 Some(ANYONE) => Some(Senders::Anyone),
757 Some(CONTACTS) => Some(Senders::Contacts),
758 Some(STARRED) => Some(Senders::Starred),
759 Some(NONE) => Some(Senders::Nobody),
760 _ => None,
761 };
762 let repeat = match rule["repeat"].as_i64() {
763 Some(ALLOW) => true,
764 Some(DISALLOW) => false,
765 _ => return None,
766 };
767 let conversations = match rule["conversations"].as_i64() {
768 Some(CONVERSATIONS_ANY | CONVERSATIONS_IMPORTANT) => Some(true),
769 Some(CONVERSATIONS_NONE) => Some(false),
770 _ => None,
771 };
772 Some(Lets { calls: senders(&rule["calls"])?, messages: senders(&rule["messages"])?, repeat, conversations })
773 }
774
775 fn holds(&self, read: &Lets) -> bool {
777 self.calls == read.calls && self.messages == read.messages && self.repeat == read.repeat && (read.conversations.is_none() || read.conversations == self.conversations)
778 }
779
780 fn said(&self) -> Said {
782 let conversations = self.conversations.unwrap_or(false);
783 match (self.calls, self.messages, self.repeat, conversations) {
784 (Senders::Starred, Senders::Starred, true, false) => Said::PhoneOn(Senders::Starred),
785 (Senders::Nobody, Senders::Nobody, false, false) => Said::PhoneOn(Senders::Nobody),
786 (calls, messages, repeat, conversations) => Said::PhoneLets { calls, messages, repeat, conversations },
787 }
788 }
789 }
790
791 pub(super) fn can(answer: &Value, callback: Option<String>) -> Done {
794 if answer.is_null() {
795 return Done::said(false, vec![Said::PhoneNoAnswer]);
796 }
797 if answer["too_old"] == true {
798 return Done::said(false, vec![Said::PhoneTooOld]);
799 }
800 if answer["access"] != true {
801 return Done { said: vec![Said::PhoneNeedsAccess], offers: vec!["access", "starred"], ..Done::default() };
802 }
803 let mut said = vec![Said::PhoneCan, Said::PhoneCallback(callback)];
804 if answer["api"].as_i64().is_some_and(|api| api < 30) {
806 said.push(Said::PhoneNoConversations);
807 }
808 Done { on: true, said, offers: vec!["starred"], ..Done::default() }
809 }
810
811 pub(super) fn entered(ask: &Ask, answer: &Value, name: &str) -> Done {
815 if answer.is_null() {
816 return Done { said: vec![Said::PhoneNoAnswer], retry: true, ..Done::default() };
817 }
818 if answer["too_old"] == true {
819 return Done::said(false, vec![Said::PhoneTooOld]);
820 }
821 if answer["access"] != true {
822 return Done { said: vec![Said::PhoneNoAccess], offers: vec!["access"], retry: true, ..Done::default() };
823 }
824 if let Some(error) = answer["error"].as_str().filter(|e| !e.is_empty()) {
825 return Done { said: vec![Said::PhoneFailed(error.to_string())], retry: true, ..Done::default() };
826 }
827 let rule = &answer["rule"];
828 let named = rule["name"].as_str().filter(|n| !n.is_empty()).unwrap_or(name).to_string();
829 if answer["disabled"] == true {
831 return Done { offers: vec!["modes"], ..Done::turned_off(vec![Said::PhoneDisabled(named)]) };
832 }
833 if answer["turned_off"] == true {
834 return Done::turned_off(vec![Said::PhoneTurnedOff]);
835 }
836 if answer["held"] == true {
838 return Done::said(true, vec![Said::PhoneHeld]);
839 }
840 let asked = Lets::asked(ask);
841 let mut said = match Lets::read(rule) {
842 Some(read) if asked.holds(&read) => vec![read.said()],
843 Some(read) => vec![read.said(), Said::PhoneSetThere],
845 None => vec![Said::PhoneAsSet(named)],
846 };
847 if asked.conversations == Some(true) && answer["api"].as_i64().is_some_and(|api| api < 30) {
849 said.push(Said::PhoneNoConversations);
850 }
851 let alarms = match rule["alarms"].as_i64() {
852 Some(ALLOW) => Some(true),
853 Some(DISALLOW) => Some(false),
854 _ => None,
855 };
856 let doses = match (ask.doses, answer["doses"] == true) {
857 (false, _) => Doses::Shade,
858 (true, true) => Doses::Through,
859 (true, false) => Doses::Blocked,
860 };
861 said.push(Said::PhoneThrough { alarms, doses });
862 if ask.silence.is_some_and(|s| s.events) && answer.get("events").is_some() {
864 said.push(Said::PhoneEvents(answer["events"] == true));
865 }
866 if answer["already"] == true {
867 said.push(Said::PhoneAlready);
868 }
869 Done { on: true, said, ..Done::default() }
870 }
871
872 pub(super) fn left(answer: &Value) -> Done {
875 if answer["was"] != true || answer["too_old"] == true {
876 return Done::default();
877 }
878 let mut said = vec![Said::PhoneOff];
879 if answer["still"] == true {
880 said.push(Said::PhoneStill);
881 }
882 Done::said(false, said)
883 }
884}
885
886#[cfg(any(target_os = "android", test))]
889#[cfg_attr(not(target_os = "android"), allow(dead_code))]
890mod phone {
891 use super::*;
892 use serde_json::Value;
893 #[cfg(target_os = "android")]
894 use std::ffi::{CStr, CString, c_char};
895
896 #[cfg(target_os = "android")]
897 unsafe extern "C" {
899 fn sioul_android_dnd(verb: *const c_char, json: *const c_char) -> *mut c_char;
901 fn sioul_android_dnd_free(text: *mut c_char);
903 }
904
905 #[cfg(not(target_os = "android"))]
907 pub(super) fn call(_verb: &str, _json: &str) -> Value {
908 Value::Null
909 }
910
911 #[cfg(target_os = "android")]
913 pub(super) fn call(verb: &str, json: &str) -> Value {
914 let (Ok(verb), Ok(json)) = (CString::new(verb), CString::new(json)) else { return Value::Null };
915 let answer = unsafe { sioul_android_dnd(verb.as_ptr(), json.as_ptr()) };
917 if answer.is_null() {
918 return Value::Null;
919 }
920 let text = unsafe { CStr::from_ptr(answer) }.to_string_lossy().to_string();
922 unsafe { sioul_android_dnd_free(answer) };
924 serde_json::from_str(&text).unwrap_or(Value::Null)
925 }
926
927 #[derive(Default)]
930 pub(crate) struct Phone;
931
932 impl Platform for Phone {
933 fn can(&mut self, _tr: &Translator) -> Done {
934 let config = crate::backend::load_config();
936 let callback = sioul_core::pause::callback(&sioul_core::pause::country(&config.pause, config.contacts.region.as_deref()));
937 answers::can(&call("can", ""), callback)
938 }
939
940 fn on(&mut self, ask: &Ask, _kept: &mut Kept, tr: &Translator) -> Done {
941 let trigger = match ask.which {
942 Which::FreeTime => tr.text("dnd-trigger-free-time", None),
943 Which::Paused => tr.text("dnd-trigger-pause", None),
944 Which::Global => tr.text("dnd-trigger-global", None),
945 };
946 let mut asked = serde_json::json!({
947 "kind": ask.which.key(),
948 "name": ask.which.name(tr),
949 "trigger": trigger,
950 "stack": ask.stack,
951 "people": ask.people,
952 "doses": ask.doses,
953 "channel": tr.text("dnd-doses-channel", None),
954 "events_channel": tr.text("dnd-events-channel", None),
955 });
956 if let (Some(silence), Some(fields)) = (ask.silence, asked.as_object_mut())
960 && let Ok(serde_json::Value::Object(said)) = serde_json::to_value(silence)
961 {
962 fields.extend(said);
963 }
964 answers::entered(ask, &call("enter", &asked.to_string()), &ask.which.name(tr))
965 }
966
967 fn off(&mut self, which: Which, _others: &[Ask], _kept: &mut Kept) -> Done {
968 answers::left(&call("leave", which.key()))
969 }
970
971 fn holds(&self, which: Which) -> bool {
972 call("holds", which.key())["holds"] != false
975 }
976
977 fn system_on(&mut self) -> Option<bool> {
978 call("own", "")["on"].as_bool()
979 }
980
981 fn system_off(&mut self, _consent: bool) -> bool {
982 call("quiet-off", "")["done"] == true
984 }
985
986 fn still_on(&mut self) -> Option<Done> {
987 (self.system_on() == Some(true)).then(|| Done { said: vec![Said::PhoneStaysOn], offers: vec!["modes"], ..Done::default() })
988 }
989 }
990}
991
992#[cfg(any(all(unix, not(any(target_os = "macos", target_os = "android"))), test))]
997#[cfg_attr(not(all(unix, not(any(target_os = "macos", target_os = "android")))), allow(dead_code))]
998mod desktop {
999 use super::*;
1000 use std::sync::{Arc, Mutex};
1001 use std::time::{Duration, Instant};
1002
1003 pub(super) const DESKTOP_ENTRY: &str = "com.aurelienpierre.Sioul";
1005 const SETTLE: Duration = Duration::from_secs(3);
1008
1009 #[derive(Clone, Debug, PartialEq, Eq)]
1011 pub(super) enum Kind {
1012 Inhibits { kde: bool },
1015 Gnome,
1016 Other(String),
1018 }
1019
1020 pub(super) trait Desk {
1022 fn kind(&mut self) -> Kind;
1023 fn inhibit(&mut self, reason: &str) -> Result<Box<dyn Held>, String>;
1025 fn plasma_shows_doses(&self) -> bool;
1027 fn banners(&self) -> Result<bool, String>;
1029 fn set_banners(&self, shown: bool) -> Result<(), String>;
1030 fn watch_banners(&self) -> Option<Box<dyn Heard>>;
1032 fn sandboxed(&self) -> bool;
1034 fn inhibited(&self) -> Option<bool>;
1036 fn plasma_until(&self) -> Option<i64>;
1038 fn clear_plasma_until(&self) -> Result<(), String>;
1040 fn follow(&self, kind: &Kind, quiet: Arc<Mutex<Instant>>, told: Told) -> Option<Box<dyn Watching>>;
1043 }
1044
1045 pub(super) trait Held: Send {
1047 fn alive(&self) -> bool;
1048 fn release(self: Box<Self>) -> Result<(), String>;
1049 }
1050
1051 pub(super) trait Heard: Send {
1053 fn since(&self, at: Instant) -> usize;
1054 }
1055
1056 pub(super) trait Watching: Send {
1058 fn seen(&self) -> Option<bool>;
1060 }
1061
1062 pub(crate) struct Desktop<D: Desk> {
1065 pub(super) desk: D,
1066 pub(super) held: Vec<(Which, Box<dyn Held>, Instant)>,
1068 heard: Option<(Box<dyn Heard>, Instant)>,
1071 quiet: Arc<Mutex<Instant>>,
1074 pub(super) watch: Option<Box<dyn Watching>>,
1076 }
1077
1078 impl<D: Desk + Default> Default for Desktop<D> {
1079 fn default() -> Self {
1080 Desktop::new(D::default())
1081 }
1082 }
1083
1084 impl<D: Desk> Desktop<D> {
1085 pub(super) fn new(desk: D) -> Self {
1086 Desktop { desk, held: Vec::new(), heard: None, quiet: Arc::new(Mutex::new(Instant::now())), watch: None }
1087 }
1088
1089 fn holding(&self, which: Which) -> bool {
1090 self.held.iter().any(|(w, held, _)| *w == which && held.alive())
1091 }
1092
1093 pub(super) fn revoked(&self, which: Which) -> bool {
1097 let held = self.held.iter().any(|(w, held, since)| *w == which && held.alive() && since.elapsed() >= SETTLE);
1098 held && self.watch.as_ref().is_some_and(|watch| watch.seen() == Some(false))
1099 }
1100
1101 fn hush(&self) {
1103 if let Ok(mut quiet) = self.quiet.lock() {
1104 *quiet = Instant::now() + SETTLE;
1105 }
1106 }
1107
1108 pub(super) fn gnome_on(&mut self, ask: &Ask, kept: &mut Kept) -> Done {
1110 if !ask.desktop {
1111 return Done { said: vec![Said::GnomeCannot], offers: vec!["desktop"], consent: true, ..Done::default() };
1112 }
1113 if self.desk.sandboxed() {
1114 return Done::said(false, vec![Said::GnomeSandboxed]);
1115 }
1116 if kept.gnome {
1117 if self.heard.is_none() {
1119 self.heard = self.desk.watch_banners().map(|heard| (heard, Instant::now()));
1120 }
1121 if self.desk.banners() == Ok(true) {
1123 return Done::turned_off(vec![Said::GnomeTurnedOff]);
1124 }
1125 return Done::said(true, vec![Said::GnomeOn]);
1126 }
1127 match self.desk.banners() {
1128 Ok(false) => Done::said(true, vec![Said::GnomeAlready]),
1130 Ok(true) => {
1131 let heard = self.desk.watch_banners();
1133 self.hush();
1134 match self.desk.set_banners(false) {
1135 Ok(()) => {
1136 kept.gnome = true;
1137 self.heard = heard.map(|heard| (heard, Instant::now() + SETTLE));
1138 Done::said(true, vec![Said::GnomeOn])
1139 }
1140 Err(why) => Done { said: vec![Said::GnomeFailed(why)], retry: true, ..Done::default() },
1141 }
1142 }
1143 Err(why) => Done { said: vec![Said::GnomeFailed(why)], retry: true, ..Done::default() },
1144 }
1145 }
1146
1147 pub(super) fn gnome_off(&mut self, others: &[Ask], kept: &mut Kept) -> Vec<Said> {
1150 if !kept.gnome || others.iter().any(|a| a.desktop) {
1151 return Vec::new();
1152 }
1153 let touched = self.heard.take().is_some_and(|(heard, after)| heard.since(after) > 0);
1154 if touched {
1155 kept.gnome = false;
1156 return vec![Said::GnomeLeft];
1157 }
1158 match self.desk.banners() {
1159 Ok(false) => match {
1160 self.hush();
1161 self.desk.set_banners(true)
1162 } {
1163 Ok(()) => {
1164 kept.gnome = false;
1165 vec![Said::GnomeOff]
1166 }
1167 Err(why) => vec![Said::GnomeFailed(why)],
1169 },
1170 Ok(true) => {
1172 kept.gnome = false;
1173 vec![Said::GnomeOff]
1174 }
1175 Err(why) => vec![Said::GnomeFailed(why)],
1176 }
1177 }
1178 }
1179
1180 impl<D: Desk> Platform for Desktop<D> {
1181 fn can(&mut self, _tr: &Translator) -> Done {
1182 match self.desk.kind() {
1183 Kind::Inhibits { kde } => {
1184 let mut done = Done::said(true, vec![Said::PlasmaCan]);
1185 if kde && !self.desk.plasma_shows_doses() {
1186 done.said.push(Said::PlasmaDoses(false));
1187 done.offers.push("plasma");
1188 }
1189 done
1190 }
1191 Kind::Gnome if self.desk.sandboxed() => Done::said(false, vec![Said::GnomeSandboxed]),
1192 Kind::Gnome => Done { on: true, said: vec![Said::GnomeCan], offers: vec!["desktop"], consent: true, ..Done::default() },
1193 Kind::Other(name) => Done::said(false, vec![Said::DesktopCannot(name)]),
1194 }
1195 }
1196
1197 fn on(&mut self, ask: &Ask, kept: &mut Kept, tr: &Translator) -> Done {
1198 match self.desk.kind() {
1199 Kind::Inhibits { kde } => {
1200 if !ask.stack {
1201 if let Some(at) = self.held.iter().position(|(w, _, _)| *w == ask.which) {
1204 self.hush();
1205 let (_, held, _) = self.held.remove(at);
1206 let _ = held.release();
1207 }
1208 return match self.desk.inhibited() {
1209 Some(false) => Done::turned_off(vec![Said::PlasmaTurnedOff]),
1211 _ => Done::said(true, vec![Said::PlasmaHeld]),
1212 };
1213 }
1214 if self.revoked(ask.which) {
1215 return Done::turned_off(vec![Said::PlasmaTurnedOff]);
1217 }
1218 if !self.holding(ask.which) {
1219 self.held.retain(|(w, _, _)| *w != ask.which);
1220 self.hush();
1221 match self.desk.inhibit(&ask.which.name(tr)) {
1222 Ok(held) => self.held.push((ask.which, held, Instant::now())),
1223 Err(why) => return Done { said: vec![Said::PlasmaFailed(why)], retry: true, ..Done::default() },
1224 }
1225 }
1226 let mut done = Done::said(true, vec![Said::PlasmaOn]);
1227 if ask.doses {
1228 let shown = !kde || self.desk.plasma_shows_doses();
1229 done.said.push(Said::PlasmaDoses(shown));
1230 if !shown {
1231 done.offers.push("plasma");
1232 }
1233 }
1234 done
1235 }
1236 Kind::Gnome if !ask.stack => match self.desk.banners() {
1238 Ok(false) => Done::said(true, vec![Said::GnomeHeld]),
1239 Ok(true) => Done::turned_off(vec![Said::GnomeTurnedOff]),
1240 Err(why) => Done { said: vec![Said::GnomeFailed(why)], retry: true, ..Done::default() },
1241 },
1242 Kind::Gnome if !ask.desktop && kept.gnome => {
1243 let others: Vec<Ask> = kept.entered.iter().filter(|a| a.which != ask.which).cloned().collect();
1246 let mut said = self.gnome_off(&others, kept);
1247 let mut done = self.gnome_on(ask, kept);
1248 said.append(&mut done.said);
1249 done.said = said;
1250 done
1251 }
1252 Kind::Gnome => self.gnome_on(ask, kept),
1253 Kind::Other(name) => Done::said(false, vec![Said::DesktopCannot(name)]),
1254 }
1255 }
1256
1257 fn off(&mut self, which: Which, others: &[Ask], kept: &mut Kept) -> Done {
1258 let mut said = Vec::new();
1259 if let Some(at) = self.held.iter().position(|(w, _, _)| *w == which) {
1260 let (_, held, _) = self.held.remove(at);
1261 self.hush();
1262 let _ = held.release();
1264 said.push(Said::PlasmaOff);
1265 }
1266 said.extend(self.gnome_off(others, kept));
1267 Done::said(false, said)
1268 }
1269
1270 fn system_on(&mut self) -> Option<bool> {
1271 match self.desk.kind() {
1272 Kind::Inhibits { .. } => self.desk.inhibited(),
1273 Kind::Gnome if !self.desk.sandboxed() => self.desk.banners().ok().map(|shown| !shown),
1274 _ => None,
1275 }
1276 }
1277
1278 fn system_off(&mut self, consent: bool) -> bool {
1279 match self.desk.kind() {
1280 Kind::Inhibits { kde: true } if self.desk.plasma_until().is_some() => {
1283 self.hush();
1284 self.desk.clear_plasma_until().map_err(|why| eprintln!("sioul: do-not-disturb: {why}")).is_ok()
1285 }
1286 Kind::Gnome if consent && !self.desk.sandboxed() && self.desk.banners() == Ok(false) => {
1288 self.hush();
1289 self.desk.set_banners(true).map_err(|why| eprintln!("sioul: do-not-disturb: {why}")).is_ok()
1290 }
1291 _ => false,
1292 }
1293 }
1294
1295 fn still_on(&mut self) -> Option<Done> {
1296 let seen = self.watch.as_ref().and_then(|watch| watch.seen());
1298 match self.desk.kind() {
1299 Kind::Inhibits { .. } if self.held.is_empty() && seen.or_else(|| self.desk.inhibited()) == Some(true) => {
1300 Some(Done::said(false, vec![if self.desk.plasma_until().is_some() { Said::PlasmaOwnStaysOn } else { Said::PlasmaStaysOn }]))
1301 }
1302 Kind::Gnome if !self.desk.sandboxed() && seen.or_else(|| self.desk.banners().ok().map(|shown| !shown)) == Some(true) => Some(Done::said(false, vec![Said::GnomeStaysOn])),
1303 _ => None,
1304 }
1305 }
1306
1307 fn outlives(&mut self, kept: &Kept) -> bool {
1308 matches!(self.desk.kind(), Kind::Gnome) && kept.gnome && self.desk.banners() == Ok(false)
1310 }
1311
1312 fn listen(&mut self, told: Told) {
1313 if self.watch.is_none() {
1314 let kind = self.desk.kind();
1315 self.watch = self.desk.follow(&kind, Arc::clone(&self.quiet), told);
1316 }
1317 }
1318
1319 fn holds(&self, which: Which) -> bool {
1320 self.held.iter().all(|(w, held, _)| *w != which || held.alive()) && !self.revoked(which)
1323 }
1324 }
1325
1326 pub(super) fn shows_doses(critical: Option<&str>, allowed: Option<&str>, popups: Option<&str>) -> bool {
1332 let yes = |value: Option<&str>, default: bool| value.map_or(default, |v| v.eq_ignore_ascii_case("true"));
1333 yes(popups, true) && (yes(critical, true) || yes(allowed, false))
1334 }
1335
1336 pub(super) fn kconfig_time(text: &str, zone: &jiff::tz::TimeZone) -> Option<i64> {
1340 let parts: Vec<&str> = text.split(',').map(str::trim).collect();
1341 if parts.len() < 6 {
1342 return None;
1343 }
1344 let part = |at: usize| parts[at].parse::<i64>().ok();
1345 let (year, month, day, hour, minute) = (part(0)?, part(1)?, part(2)?, part(3)?, part(4)?);
1346 let seconds = parts[5].parse::<f64>().ok().filter(|s| s.is_finite() && *s >= 0.0)?;
1347 let civil = jiff::civil::DateTime::new(
1348 i16::try_from(year).ok()?,
1349 i8::try_from(month).ok()?,
1350 i8::try_from(day).ok()?,
1351 i8::try_from(hour).ok()?,
1352 i8::try_from(minute).ok()?,
1353 i8::try_from(seconds.trunc() as i64).ok()?,
1354 0,
1355 )
1356 .ok()?;
1357 let zone = match parts.get(6).filter(|id| !id.is_empty()) {
1358 Some(id) => jiff::tz::TimeZone::get(id).ok()?,
1359 None => zone.clone(),
1360 };
1361 civil.to_zoned(zone).ok().map(|at| at.timestamp().as_second())
1362 }
1363
1364 pub(super) fn kconfig<'a>(text: &'a str, group: &str, key: &str) -> Option<&'a str> {
1367 let mut inside = false;
1368 let mut found = None;
1369 for line in text.lines().map(str::trim) {
1370 if line.starts_with('[') {
1371 inside = line.strip_suffix("[$i]").unwrap_or(line) == group;
1373 continue;
1374 }
1375 if !inside || line.starts_with('#') {
1376 continue;
1377 }
1378 if let Some((name, value)) = line.split_once('=') {
1379 let name = name.trim();
1381 if name.strip_suffix("[$i]").unwrap_or(name) == key {
1382 found = Some(value.trim());
1383 }
1384 }
1385 }
1386 found
1387 }
1388
1389 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1392 #[derive(Default, Clone)]
1393 pub(crate) struct Session {
1394 pub(super) gsettings_env: Vec<(String, String)>,
1396 pub(super) config_dirs: Option<Vec<PathBuf>>,
1398 }
1399
1400 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1402 const BANNERS: [&str; 2] = ["org.gnome.desktop.notifications", "show-banners"];
1403 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1404 const BANNERS_PATH: &str = "/org/gnome/desktop/notifications/show-banners";
1405
1406 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1407 impl Session {
1408 fn gsettings(&self, args: &[&str]) -> Result<String, String> {
1409 let mut command = std::process::Command::new("gsettings");
1410 command.args(args);
1411 for (name, value) in &self.gsettings_env {
1412 command.env(name, value);
1413 }
1414 let out = command.output().map_err(|e| format!("gsettings: {e}"))?;
1415 if !out.status.success() {
1416 let said = String::from_utf8_lossy(&out.stderr).trim().to_string();
1417 return Err(if said.is_empty() { format!("gsettings: {}", out.status) } else { said });
1418 }
1419 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1420 }
1421
1422 fn plasma_files(&self) -> Vec<PathBuf> {
1425 if let Some(dirs) = &self.config_dirs {
1426 return dirs.iter().map(|dir| dir.join("plasmanotifyrc")).collect();
1427 }
1428 let home = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")));
1429 let mut files: Vec<PathBuf> = home.iter().flat_map(|home| [home.join("plasmanotifyrc"), home.join("kdedefaults").join("plasmanotifyrc")]).collect();
1430 let system = std::env::var("XDG_CONFIG_DIRS").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| "/etc/xdg".to_string());
1431 files.extend(system.split(':').filter(|d| !d.is_empty()).map(|dir| Path::new(dir).join("plasmanotifyrc")));
1432 files
1433 }
1434
1435 fn plasma(&self, group: &str, key: &str) -> Option<String> {
1437 self.plasma_files().iter().filter_map(|file| std::fs::read_to_string(file).ok()).find_map(|text| kconfig(&text, group, key).map(str::to_string))
1438 }
1439 }
1440
1441 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1442 impl Desk for Session {
1443 fn kind(&mut self) -> Kind {
1444 match sioul_sync::dnd::server() {
1445 Some(server) if server.inhibits => Kind::Inhibits { kde: server.vendor == "KDE" },
1446 Some(server) if server.name == "gnome-shell" => Kind::Gnome,
1447 Some(server) => Kind::Other(server.name),
1448 None if desktop_is("GNOME") => Kind::Gnome,
1449 None => Kind::Other(String::new()),
1450 }
1451 }
1452
1453 fn inhibit(&mut self, reason: &str) -> Result<Box<dyn Held>, String> {
1454 sioul_sync::dnd::inhibit(DESKTOP_ENTRY, reason).map(|held| Box::new(held) as Box<dyn Held>)
1455 }
1456
1457 fn plasma_shows_doses(&self) -> bool {
1458 let group = format!("[Applications][{DESKTOP_ENTRY}]");
1459 shows_doses(self.plasma("[Notifications]", "CriticalInDndMode").as_deref(), self.plasma(&group, "ShowPopupsInDndMode").as_deref(), self.plasma(&group, "ShowPopups").as_deref())
1460 }
1461
1462 fn banners(&self) -> Result<bool, String> {
1463 match self.gsettings(&["get", BANNERS[0], BANNERS[1]])?.as_str() {
1464 "true" => Ok(true),
1465 "false" => Ok(false),
1466 other => Err(format!("gsettings: {other}")),
1467 }
1468 }
1469
1470 fn set_banners(&self, shown: bool) -> Result<(), String> {
1471 self.gsettings(&["set", BANNERS[0], BANNERS[1], if shown { "true" } else { "false" }]).map(drop)
1472 }
1473
1474 fn watch_banners(&self) -> Option<Box<dyn Heard>> {
1475 if !self.gsettings_env.is_empty() {
1477 return None;
1478 }
1479 sioul_sync::dnd::watch_dconf(BANNERS_PATH).ok().map(|changes| Box::new(changes) as Box<dyn Heard>)
1480 }
1481
1482 fn sandboxed(&self) -> bool {
1483 std::env::var_os("FLATPAK_ID").is_some() || Path::new("/.flatpak-info").exists()
1484 }
1485
1486 fn inhibited(&self) -> Option<bool> {
1487 if self.config_dirs.is_some() {
1489 return None;
1490 }
1491 sioul_sync::dnd::inhibited()
1492 }
1493
1494 fn plasma_until(&self) -> Option<i64> {
1495 let until = kconfig_time(&self.plasma("[DoNotDisturb]", "Until")?, &jiff::tz::TimeZone::system())?;
1496 (until > jiff::Timestamp::now().as_second()).then_some(until)
1497 }
1498
1499 fn clear_plasma_until(&self) -> Result<(), String> {
1500 let mut command = std::process::Command::new("kwriteconfig6");
1504 command.args(["--file", "plasmanotifyrc", "--group", "DoNotDisturb", "--key", "Until", "--delete"]);
1505 match self.config_dirs.as_ref().and_then(|dirs| dirs.first()) {
1506 Some(dir) => {
1507 command.env("XDG_CONFIG_HOME", dir);
1508 }
1509 None => {
1510 command.arg("--notify");
1511 }
1512 }
1513 let out = command.arg("").output().map_err(|e| format!("kwriteconfig6: {e}"))?;
1514 if out.status.success() {
1515 return Ok(());
1516 }
1517 let said = String::from_utf8_lossy(&out.stderr).trim().to_string();
1518 Err(if said.is_empty() { format!("kwriteconfig6: {}", out.status) } else { said })
1519 }
1520
1521 fn follow(&self, kind: &Kind, quiet: Arc<Mutex<Instant>>, told: Told) -> Option<Box<dyn Watching>> {
1522 if self.config_dirs.is_some() || !self.gsettings_env.is_empty() {
1524 return None;
1525 }
1526 match kind {
1527 Kind::Inhibits { .. } => sioul_sync::dnd::watch_inhibited(quiet, Box::new(move |on| told(on))).ok().map(|watch| Box::new(watch) as Box<dyn Watching>),
1528 Kind::Gnome if !self.sandboxed() => {
1529 let seen = Arc::new(Mutex::new(self.banners().ok().map(|shown| !shown)));
1530 let (session, noted, reading) = (self.clone(), Arc::clone(&seen), Arc::new(Mutex::new(())));
1531 let heard = move || {
1532 let (session, noted, reading, told, quiet) = (session.clone(), Arc::clone(¬ed), Arc::clone(&reading), Arc::clone(&told), Arc::clone(&quiet));
1533 std::thread::spawn(move || {
1535 std::thread::sleep(Duration::from_secs(1));
1536 while let Some(wait) = quiet.lock().ok().map(|until| until.saturating_duration_since(Instant::now())).filter(|wait| !wait.is_zero()) {
1537 std::thread::sleep(wait);
1538 }
1539 let _one = reading.lock();
1540 let Ok(shown) = session.banners() else { return };
1541 let now = !shown;
1542 let before = noted.lock().ok().and_then(|mut seen| seen.replace(now));
1543 if before.is_some_and(|before| before != now) {
1544 told(now);
1545 }
1546 });
1547 };
1548 let changes = sioul_sync::dnd::watch_dconf_told(BANNERS_PATH, Box::new(heard)).ok()?;
1549 Some(Box::new(GnomeWatch { _changes: changes, seen }))
1550 }
1551 _ => None,
1552 }
1553 }
1554 }
1555
1556 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1558 struct GnomeWatch {
1559 _changes: sioul_sync::dnd::Changes,
1560 seen: Arc<Mutex<Option<bool>>>,
1561 }
1562
1563 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1564 impl Watching for GnomeWatch {
1565 fn seen(&self) -> Option<bool> {
1566 self.seen.lock().ok().and_then(|seen| *seen)
1567 }
1568 }
1569
1570 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1571 impl Watching for sioul_sync::dnd::Watch {
1572 fn seen(&self) -> Option<bool> {
1573 sioul_sync::dnd::Watch::seen(self)
1574 }
1575 }
1576
1577 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1578 impl Held for sioul_sync::dnd::Inhibition {
1579 fn alive(&self) -> bool {
1580 sioul_sync::dnd::Inhibition::alive(self)
1581 }
1582
1583 fn release(self: Box<Self>) -> Result<(), String> {
1584 sioul_sync::dnd::Inhibition::release(*self, Duration::from_secs(3))
1585 }
1586 }
1587
1588 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1589 impl Heard for sioul_sync::dnd::Changes {
1590 fn since(&self, at: Instant) -> usize {
1591 sioul_sync::dnd::Changes::since(self, at)
1592 }
1593 }
1594
1595 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1597 fn desktop_is(name: &str) -> bool {
1598 std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|desktops| desktops.split(':').any(|d| d.eq_ignore_ascii_case(name)))
1599 }
1600
1601 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1604 pub(super) fn open_plasma_settings() {
1605 let entry = format!("--desktop-entry {DESKTOP_ENTRY}");
1606 for program in ["systemsettings", "kcmshell6"] {
1607 let started = std::process::Command::new(program)
1608 .args(["kcm_notifications", "--args", entry.as_str()])
1609 .stdin(std::process::Stdio::null())
1610 .stdout(std::process::Stdio::null())
1611 .stderr(std::process::Stdio::null())
1612 .spawn();
1613 if let Ok(mut window) = started {
1614 std::thread::spawn(move || window.wait());
1616 return;
1617 }
1618 }
1619 }
1620}
1621
1622#[cfg(any(target_os = "macos", test))]
1625#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
1626mod mac {
1627 use super::*;
1628
1629 pub(super) trait Run {
1631 fn list(&self) -> Option<Vec<String>>;
1633 fn run(&self, name: &str) -> Result<(), String>;
1634 }
1635
1636 #[derive(Default)]
1638 pub(crate) struct Shortcuts;
1639
1640 impl Run for Shortcuts {
1641 fn list(&self) -> Option<Vec<String>> {
1642 let out = std::process::Command::new("shortcuts").arg("list").output().ok().filter(|out| out.status.success())?;
1643 Some(String::from_utf8_lossy(&out.stdout).lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect())
1644 }
1645
1646 fn run(&self, name: &str) -> Result<(), String> {
1647 let out = std::process::Command::new("shortcuts").args(["run", name]).output().map_err(|e| e.to_string())?;
1648 if out.status.success() {
1649 return Ok(());
1650 }
1651 let said = String::from_utf8_lossy(&out.stderr).trim().to_string();
1652 Err(if said.is_empty() { out.status.to_string() } else { said })
1653 }
1654 }
1655
1656 #[derive(Default)]
1661 pub(crate) struct Mac<R: Run> {
1662 pub(super) shortcuts: R,
1663 }
1664
1665 impl<R: Run> Mac<R> {
1666 fn has(&self, name: &str) -> bool {
1667 self.shortcuts.list().is_some_and(|names| names.iter().any(|n| n == name))
1668 }
1669 }
1670
1671 impl<R: Run> Platform for Mac<R> {
1672 fn can(&mut self, _tr: &Translator) -> Done {
1673 if self.has(MAC_ON) { Done::said(true, vec![Said::MacCan]) } else { Done::said(false, vec![Said::MacCannot]) }
1674 }
1675
1676 fn on(&mut self, _ask: &Ask, kept: &mut Kept, _tr: &Translator) -> Done {
1677 if kept.mac {
1678 return Done::said(true, vec![Said::MacOn]);
1679 }
1680 if !self.has(MAC_ON) {
1681 return Done::said(false, vec![Said::MacCannot]);
1682 }
1683 match self.shortcuts.run(MAC_ON) {
1684 Ok(()) => {
1685 kept.mac = true;
1686 Done::said(true, vec![Said::MacOn])
1687 }
1688 Err(why) => Done { said: vec![Said::MacFailed(MAC_ON.to_string(), why)], retry: true, ..Done::default() },
1689 }
1690 }
1691
1692 fn outlives(&mut self, kept: &Kept) -> bool {
1693 kept.mac
1695 }
1696
1697 fn off(&mut self, _which: Which, others: &[Ask], kept: &mut Kept) -> Done {
1698 if !kept.mac || !others.is_empty() {
1699 return Done::default();
1700 }
1701 kept.mac = false;
1702 if !self.has(MAC_OFF) {
1703 return Done::said(false, vec![Said::MacNoOff]);
1704 }
1705 match self.shortcuts.run(MAC_OFF) {
1706 Ok(()) => Done::said(false, vec![Said::MacOff]),
1707 Err(why) => Done::said(false, vec![Said::MacFailed(MAC_OFF.to_string(), why)]),
1708 }
1709 }
1710 }
1711}
1712
1713#[cfg(any(windows, test))]
1718#[cfg_attr(not(windows), allow(dead_code))]
1719#[derive(Default)]
1720struct WindowsPlatform;
1721
1722#[cfg(any(windows, test))]
1723impl Platform for WindowsPlatform {
1724 fn can(&mut self, _tr: &Translator) -> Done {
1725 Done::said(false, vec![Said::WindowsCannot])
1726 }
1727
1728 fn on(&mut self, _ask: &Ask, _kept: &mut Kept, _tr: &Translator) -> Done {
1729 Done::said(false, vec![Said::WindowsCannot])
1730 }
1731
1732 fn off(&mut self, _which: Which, _others: &[Ask], _kept: &mut Kept) -> Done {
1733 Done::default()
1734 }
1735}
1736
1737#[cfg(not(any(unix, windows)))]
1739#[derive(Default)]
1740struct Nothing;
1741
1742#[cfg(not(any(unix, windows)))]
1743impl Platform for Nothing {
1744 fn can(&mut self, _tr: &Translator) -> Done {
1745 Done::said(false, vec![Said::DesktopCannot(String::new())])
1746 }
1747
1748 fn on(&mut self, _ask: &Ask, _kept: &mut Kept, _tr: &Translator) -> Done {
1749 Done::said(false, vec![Said::DesktopCannot(String::new())])
1750 }
1751
1752 fn off(&mut self, _which: Which, _others: &[Ask], _kept: &mut Kept) -> Done {
1753 Done::default()
1754 }
1755}
1756
1757#[cfg(test)]
1758mod tests {
1759 use super::desktop::{Desk, Desktop, Heard, Held, Kind, Watching};
1760 use super::*;
1761 use std::cell::{Cell, RefCell};
1762 use std::rc::Rc;
1763 use std::sync::{Arc, Mutex};
1764 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1765 use std::time::Instant;
1766
1767 fn ask(which: Which) -> Ask {
1768 Ask { which, people: true, doses: true, desktop: true, silence: None, stack: true }
1769 }
1770
1771 #[derive(Default)]
1773 struct Counting {
1774 on: Rc<RefCell<Vec<Ask>>>,
1775 off: Rc<RefCell<Vec<Which>>>,
1776 answer_on: bool,
1777 retry: bool,
1778 held: bool,
1779 }
1780
1781 impl Platform for Counting {
1782 fn can(&mut self, _tr: &Translator) -> Done {
1783 Done::said(true, vec![Said::PlasmaCan])
1784 }
1785
1786 fn on(&mut self, ask: &Ask, _kept: &mut Kept, _tr: &Translator) -> Done {
1787 self.on.borrow_mut().push(ask.clone());
1788 self.held = true;
1789 Done { on: self.answer_on, said: vec![if self.answer_on { Said::PlasmaOn } else { Said::DesktopCannot("dunst".into()) }], retry: self.retry, ..Done::default() }
1790 }
1791
1792 fn off(&mut self, which: Which, _others: &[Ask], _kept: &mut Kept) -> Done {
1793 self.off.borrow_mut().push(which);
1794 Done::said(false, vec![Said::PlasmaOff])
1795 }
1796
1797 fn holds(&self, _which: Which) -> bool {
1798 self.held
1799 }
1800 }
1801
1802 #[test]
1803 fn entering_twice_does_it_once_and_says_the_same() {
1804 let en = Translator::new("en");
1805 let platform = Counting { answer_on: true, ..Counting::default() };
1806 let calls = Rc::clone(&platform.on);
1807 let mut layer = Layer::new(platform, None);
1808 let first = layer.enter(&ask(Which::Paused), &en);
1809 let again = layer.enter(&ask(Which::Paused), &en);
1810 assert_eq!(first, again);
1811 assert_eq!(calls.borrow().len(), 1, "asked once of the system");
1812 assert!(first.on && first.line.contains("silenced"), "{first:?}");
1813 let mut fewer = ask(Which::Paused);
1815 fewer.people = false;
1816 layer.enter(&fewer, &en);
1817 assert_eq!(calls.borrow().len(), 2);
1818 layer.platform.held = false;
1820 layer.enter(&fewer, &en);
1821 assert_eq!(calls.borrow().len(), 3);
1822 }
1823
1824 #[test]
1825 fn a_refusal_that_may_change_is_asked_again_and_says_what_sioul_holds() {
1826 let en = Translator::new("en");
1827 let platform = Counting { answer_on: false, retry: true, ..Counting::default() };
1828 let calls = Rc::clone(&platform.on);
1829 let mut layer = Layer::new(platform, None);
1830 let report = layer.enter(&ask(Which::Paused), &en);
1831 assert!(!report.on);
1832 assert!(report.line.contains("dunst") && report.line.ends_with("Sioul holds its own notifications, except dose reminders."), "{report:?}");
1833 layer.enter(&ask(Which::Paused), &en);
1834 assert_eq!(calls.borrow().len(), 2, "access may have been given meanwhile");
1835 layer.said.iter_mut().for_each(|(_, _, retry)| *retry = false);
1837 layer.enter(&ask(Which::Paused), &en);
1838 assert_eq!(calls.borrow().len(), 2);
1839 }
1840
1841 #[test]
1842 fn leaving_twice_or_what_was_not_entered_does_nothing_more() {
1843 let en = Translator::new("en");
1844 let platform = Counting { answer_on: true, ..Counting::default() };
1845 let offs = Rc::clone(&platform.off);
1846 let mut layer = Layer::new(platform, None);
1847 layer.leave(Which::FreeTime, &en);
1849 assert_eq!(offs.borrow().len(), 1);
1850 assert_eq!(layer.leave(Which::FreeTime, &en), Report::default());
1851 assert_eq!(offs.borrow().len(), 1, "then nothing");
1852 layer.enter(&ask(Which::Paused), &en);
1853 let left = layer.leave(Which::Paused, &en);
1854 assert!(!left.on && left.line == "This desktop's notifications show again.", "{left:?}");
1855 assert_eq!(layer.leave(Which::Paused, &en), Report::default());
1856 assert_eq!(offs.borrow().as_slice(), &[Which::FreeTime, Which::Paused]);
1857 layer.enter(&ask(Which::Paused), &en);
1859 assert_eq!(layer.platform.on.borrow().len(), 2);
1860 }
1861
1862 #[test]
1863 fn what_was_turned_on_is_kept_for_the_next_run() {
1864 let dir = std::env::temp_dir().join(format!("sioul-dnd-{}-{}", std::process::id(), line!()));
1865 let path = dir.join("dnd.toml");
1866 let en = Translator::new("en");
1867 let mut layer = Layer::new(Counting { answer_on: true, ..Counting::default() }, Some(path.clone()));
1868 layer.enter(&ask(Which::Paused), &en);
1869 layer.kept.gnome = true;
1870 layer.kept.on_here = true;
1871 layer.save();
1872 let next = Layer::new(Counting::default(), Some(path.clone()));
1874 assert_eq!(next.kept.entered, vec![ask(Which::Paused)]);
1875 assert!(next.kept.gnome);
1876 assert!(next.kept.on_here, "do-not-disturb held here, for its going off: {}", std::fs::read_to_string(&path).unwrap_or_default());
1877 let mut next = next;
1878 next.leave(Which::Paused, &en);
1879 assert!(read_kept(&path).entered.is_empty());
1880 let _ = std::fs::remove_dir_all(&dir);
1881 }
1882
1883 #[derive(Default)]
1885 struct FakeDesk {
1886 kind: Option<Kind>,
1887 banners: Rc<RefCell<Option<bool>>>,
1888 writes: Rc<RefCell<Vec<bool>>>,
1889 heard: Arc<AtomicUsize>,
1890 inhibited: Arc<AtomicUsize>,
1891 released: Arc<AtomicUsize>,
1892 alive: Arc<AtomicBool>,
1893 doses_shown: bool,
1894 sandboxed: bool,
1895 server: Rc<Cell<Option<bool>>>,
1898 until: Rc<Cell<Option<i64>>>,
1899 cleared: Rc<Cell<u32>>,
1900 }
1901
1902 struct FakeWatch(Option<bool>);
1904
1905 impl Watching for FakeWatch {
1906 fn seen(&self) -> Option<bool> {
1907 self.0
1908 }
1909 }
1910
1911 struct FakeHeld {
1912 alive: Arc<AtomicBool>,
1913 released: Arc<AtomicUsize>,
1914 }
1915
1916 impl Held for FakeHeld {
1917 fn alive(&self) -> bool {
1918 self.alive.load(Ordering::Relaxed)
1919 }
1920
1921 fn release(self: Box<Self>) -> Result<(), String> {
1922 self.released.fetch_add(1, Ordering::Relaxed);
1923 Ok(())
1924 }
1925 }
1926
1927 struct FakeHeard(Arc<AtomicUsize>);
1928
1929 impl Heard for FakeHeard {
1930 fn since(&self, _at: Instant) -> usize {
1931 self.0.load(Ordering::Relaxed)
1932 }
1933 }
1934
1935 impl Desk for FakeDesk {
1936 fn kind(&mut self) -> Kind {
1937 self.kind.clone().unwrap_or(Kind::Gnome)
1938 }
1939
1940 fn inhibit(&mut self, _reason: &str) -> Result<Box<dyn Held>, String> {
1941 self.inhibited.fetch_add(1, Ordering::Relaxed);
1942 self.alive.store(true, Ordering::Relaxed);
1943 Ok(Box::new(FakeHeld { alive: Arc::clone(&self.alive), released: Arc::clone(&self.released) }))
1944 }
1945
1946 fn plasma_shows_doses(&self) -> bool {
1947 self.doses_shown
1948 }
1949
1950 fn banners(&self) -> Result<bool, String> {
1951 self.banners.borrow().ok_or_else(|| "No such schema".to_string())
1952 }
1953
1954 fn set_banners(&self, shown: bool) -> Result<(), String> {
1955 self.writes.borrow_mut().push(shown);
1956 *self.banners.borrow_mut() = Some(shown);
1957 Ok(())
1958 }
1959
1960 fn watch_banners(&self) -> Option<Box<dyn Heard>> {
1961 Some(Box::new(FakeHeard(Arc::clone(&self.heard))))
1962 }
1963
1964 fn sandboxed(&self) -> bool {
1965 self.sandboxed
1966 }
1967
1968 fn inhibited(&self) -> Option<bool> {
1969 self.server.get()
1970 }
1971
1972 fn plasma_until(&self) -> Option<i64> {
1973 self.until.get()
1974 }
1975
1976 fn clear_plasma_until(&self) -> Result<(), String> {
1977 self.cleared.set(self.cleared.get() + 1);
1978 self.until.set(None);
1979 Ok(())
1980 }
1981
1982 fn follow(&self, _kind: &Kind, _quiet: Arc<Mutex<Instant>>, _told: Told) -> Option<Box<dyn Watching>> {
1983 None
1984 }
1985 }
1986
1987 fn gnome(banners: bool) -> Desktop<FakeDesk> {
1988 Desktop::new(FakeDesk { kind: Some(Kind::Gnome), banners: Rc::new(RefCell::new(Some(banners))), ..FakeDesk::default() })
1989 }
1990
1991 #[test]
1992 fn gnome_switch_is_turned_on_with_consent_and_restored_after() {
1993 let en = Translator::new("en");
1994 let mut layer = Layer::new(gnome(true), None);
1995 let report = layer.enter(&ask(Which::Paused), &en);
1996 assert!(report.on && report.line.starts_with("This desktop's notification banners are hidden"), "{report:?}");
1997 assert_eq!(*layer.platform.desk.banners.borrow(), Some(false));
1998 assert!(layer.kept.gnome);
1999 let left = layer.leave(Which::Paused, &en);
2000 assert_eq!(left.line, "GNOME's Do Not Disturb is off again, as it was before.");
2001 assert_eq!(*layer.platform.desk.writes.borrow(), vec![false, true]);
2002 assert!(!layer.kept.gnome);
2003 }
2004
2005 #[test]
2006 fn gnome_switch_the_person_set_is_never_turned_off() {
2007 let en = Translator::new("en");
2008 let mut layer = Layer::new(gnome(false), None);
2010 let report = layer.enter(&ask(Which::Paused), &en);
2011 assert!(report.on && report.line == "GNOME's Do Not Disturb was already on; Sioul leaves it as you set it.", "{report:?}");
2012 layer.leave(Which::Paused, &en);
2013 assert!(layer.platform.desk.writes.borrow().is_empty(), "never written");
2014 assert_eq!(*layer.platform.desk.banners.borrow(), Some(false));
2015 let mut layer = Layer::new(gnome(true), None);
2017 layer.enter(&ask(Which::Paused), &en);
2018 layer.platform.desk.heard.store(1, Ordering::Relaxed);
2019 let left = layer.leave(Which::Paused, &en);
2020 assert_eq!(left.line, "GNOME's Do Not Disturb was changed meanwhile; Sioul leaves it as it is.");
2021 assert_eq!(*layer.platform.desk.writes.borrow(), vec![false], "not switched back");
2022 }
2023
2024 #[test]
2025 fn gnome_without_consent_or_from_a_sandbox_says_what_it_cannot_do() {
2026 let en = Translator::new("en");
2027 let mut layer = Layer::new(gnome(true), None);
2028 let mut no = ask(Which::Paused);
2029 no.desktop = false;
2030 let report = layer.enter(&no, &en);
2031 assert!(!report.on, "{report:?}");
2032 assert!(report.line.starts_with("This desktop's own notifications cannot be silenced by Sioul: GNOME keeps"), "{report:?}");
2033 assert!(report.offers.contains(&"desktop") && !report.consent.is_empty());
2034 assert!(layer.platform.desk.writes.borrow().is_empty());
2035 let mut sandboxed = gnome(true);
2036 sandboxed.desk.sandboxed = true;
2037 let mut layer = Layer::new(sandboxed, None);
2038 let report = layer.enter(&ask(Which::Paused), &en);
2039 assert!(!report.on && report.line.contains("Flatpak"), "{report:?}");
2040 assert!(layer.platform.desk.writes.borrow().is_empty());
2041 }
2042
2043 #[test]
2044 fn gnome_switch_stays_on_until_the_last_pause_leaves() {
2045 let en = Translator::new("en");
2046 let mut layer = Layer::new(gnome(true), None);
2047 layer.enter(&ask(Which::FreeTime), &en);
2048 layer.enter(&ask(Which::Paused), &en);
2049 assert_eq!(*layer.platform.desk.writes.borrow(), vec![false], "switched once");
2050 layer.leave(Which::FreeTime, &en);
2051 assert_eq!(*layer.platform.desk.banners.borrow(), Some(false), "the other pause still wants it");
2052 layer.leave(Which::Paused, &en);
2053 assert_eq!(*layer.platform.desk.banners.borrow(), Some(true));
2054 }
2055
2056 #[test]
2057 fn gnome_yes_taken_back_during_the_pause_switches_it_back_at_once() {
2058 let en = Translator::new("en");
2059 let mut layer = Layer::new(gnome(true), None);
2060 layer.enter(&ask(Which::Paused), &en);
2061 let mut no = ask(Which::Paused);
2062 no.desktop = false;
2063 let report = layer.enter(&no, &en);
2064 assert!(report.line.starts_with("GNOME's Do Not Disturb is off again, as it was before."), "{report:?}");
2065 assert_eq!(*layer.platform.desk.banners.borrow(), Some(true));
2066 assert!(!layer.kept.gnome);
2067 layer.leave(Which::Paused, &en);
2069 assert_eq!(*layer.platform.desk.writes.borrow(), vec![false, true]);
2070 }
2071
2072 #[test]
2073 fn global_do_not_disturb_overlaps_the_pauses_and_leaves_them_alone() {
2074 let en = Translator::new("en");
2075 let mut layer = Layer::new(gnome(true), None);
2076 layer.enter(&ask(Which::Global), &en);
2077 layer.enter(&ask(Which::Paused), &en);
2078 layer.leave(Which::Global, &en);
2079 assert_eq!(*layer.platform.desk.banners.borrow(), Some(false), "the pause still wants GNOME's switch");
2080 layer.leave(Which::Paused, &en);
2081 assert_eq!(*layer.platform.desk.writes.borrow(), vec![false, true]);
2082 let mut layer = Layer::new(plasma(true), None);
2084 layer.enter(&ask(Which::Global), &en);
2085 layer.enter(&ask(Which::FreeTime), &en);
2086 assert_eq!(layer.platform.desk.inhibited.load(Ordering::Relaxed), 2);
2087 layer.leave(Which::Global, &en);
2088 assert_eq!(layer.platform.desk.released.load(Ordering::Relaxed), 1);
2089 assert!(layer.kept.entered.iter().any(|a| a.which == Which::FreeTime));
2090 }
2091
2092 #[test]
2093 fn gnome_switch_left_by_a_crash_is_restored_at_the_next_start() {
2094 let en = Translator::new("en");
2095 let dir = std::env::temp_dir().join(format!("sioul-dnd-{}-{}", std::process::id(), line!()));
2096 let path = dir.join("dnd.toml");
2097 let mut layer = Layer::new(gnome(true), Some(path.clone()));
2098 layer.enter(&ask(Which::Paused), &en);
2099 let banners = Rc::clone(&layer.platform.desk.banners);
2101 drop(layer);
2102 let mut next = gnome(false);
2103 next.desk.banners = Rc::clone(&banners);
2104 let mut next = Layer::new(next, Some(path.clone()));
2105 let left = next.leave(Which::Paused, &en);
2106 assert_eq!(left.line, "GNOME's Do Not Disturb is off again, as it was before.");
2107 assert_eq!(*banners.borrow(), Some(true));
2108 let _ = std::fs::remove_dir_all(&dir);
2109 }
2110
2111 fn plasma(doses_shown: bool) -> Desktop<FakeDesk> {
2112 Desktop::new(FakeDesk { kind: Some(Kind::Inhibits { kde: true }), doses_shown, ..FakeDesk::default() })
2113 }
2114
2115 #[test]
2116 fn plasma_inhibits_each_pause_and_lifts_only_its_own() {
2117 let en = Translator::new("en");
2118 let mut layer = Layer::new(plasma(true), None);
2119 let report = layer.enter(&ask(Which::Paused), &en);
2120 assert_eq!(report.line, "This desktop's notifications are silenced, with Plasma's do-not-disturb. Sioul's dose reminders still show.");
2121 layer.enter(&ask(Which::Paused), &en);
2122 assert_eq!(layer.platform.desk.inhibited.load(Ordering::Relaxed), 1, "once");
2123 layer.enter(&ask(Which::FreeTime), &en);
2124 assert_eq!(layer.platform.desk.inhibited.load(Ordering::Relaxed), 2, "one per pause");
2125 let left = layer.leave(Which::FreeTime, &en);
2126 assert_eq!(left.line, "This desktop's notifications show again.");
2127 assert_eq!(layer.platform.desk.released.load(Ordering::Relaxed), 1);
2128 layer.platform.desk.alive.store(false, Ordering::Relaxed);
2130 layer.enter(&ask(Which::Paused), &en);
2131 assert_eq!(layer.platform.desk.inhibited.load(Ordering::Relaxed), 3);
2132 }
2133
2134 #[test]
2135 fn plasma_hiding_critical_notifications_is_said_with_its_settings() {
2136 let en = Translator::new("en");
2137 let mut layer = Layer::new(plasma(false), None);
2138 let report = layer.enter(&ask(Which::Paused), &en);
2139 assert!(report.on && report.line.contains("cannot show here while it silences this desktop") && report.offers == vec!["plasma"], "{report:?}");
2140 let can = layer.can(&en);
2141 assert!(can.on && can.offers == vec!["plasma"], "{can:?}");
2142 let mut no_doses = ask(Which::FreeTime);
2144 no_doses.doses = false;
2145 let report = layer.enter(&no_doses, &en);
2146 assert_eq!(report.line, "This desktop's notifications are silenced, with Plasma's do-not-disturb.");
2147 }
2148
2149 #[test]
2150 fn plasma_settings_say_whether_critical_notifications_show() {
2151 use super::desktop::{kconfig, shows_doses};
2152 let owners = "[DoNotDisturb]\nWhenScreenSharing=false\n\n[Notifications]\nCriticalInDndMode=false\nLowPriorityPopups=false\n";
2154 assert_eq!(kconfig(owners, "[Notifications]", "CriticalInDndMode"), Some("false"));
2155 assert_eq!(kconfig(owners, "[Applications][com.aurelienpierre.Sioul]", "ShowPopupsInDndMode"), None);
2156 assert!(!shows_doses(Some("false"), None, None));
2157 assert!(shows_doses(None, None, None));
2159 let allowed = "[Applications][com.aurelienpierre.Sioul]\nShowPopupsInDndMode[$i]=true\n[Notifications]\nCriticalInDndMode=false\n";
2160 assert!(shows_doses(kconfig(allowed, "[Notifications]", "CriticalInDndMode"), kconfig(allowed, "[Applications][com.aurelienpierre.Sioul]", "ShowPopupsInDndMode"), None));
2161 assert!(!shows_doses(Some("true"), Some("true"), Some("false")));
2163 }
2164
2165 #[test]
2166 fn other_desktops_and_windows_say_what_sioul_holds() {
2167 let en = Translator::new("en");
2168 let mut layer = Layer::new(Desktop::new(FakeDesk { kind: Some(Kind::Other("dunst".into())), ..FakeDesk::default() }), None);
2169 let report = layer.enter(&ask(Which::Paused), &en);
2170 assert_eq!(report.line, "This desktop's notifications cannot be silenced by Sioul: dunst gives applications no way to do it. Sioul holds its own notifications, except dose reminders.");
2171 let mut layer = Layer::new(WindowsPlatform, None);
2172 let report = layer.enter(&ask(Which::FreeTime), &en);
2173 assert!(!report.on && report.line.starts_with("This computer's notifications cannot be silenced by Sioul: Windows"), "{report:?}");
2174 assert_eq!(layer.leave(Which::FreeTime, &en).line, "");
2175 }
2176
2177 #[derive(Default)]
2179 struct FakeShortcuts {
2180 names: Vec<String>,
2181 ran: RefCell<Vec<String>>,
2182 }
2183
2184 impl mac::Run for FakeShortcuts {
2185 fn list(&self) -> Option<Vec<String>> {
2186 Some(self.names.clone())
2187 }
2188
2189 fn run(&self, name: &str) -> Result<(), String> {
2190 self.ran.borrow_mut().push(name.to_string());
2191 Ok(())
2192 }
2193 }
2194
2195 #[test]
2196 fn mac_runs_the_persons_shortcuts_once_for_overlapping_pauses() {
2197 let en = Translator::new("en");
2198 let shortcuts = FakeShortcuts { names: vec![MAC_ON.into(), MAC_OFF.into()], ..FakeShortcuts::default() };
2199 let mut layer = Layer::new(mac::Mac { shortcuts }, None);
2200 assert_eq!(layer.enter(&ask(Which::FreeTime), &en).line, "Sioul ran your shortcut “Sioul pause on”.");
2201 layer.enter(&ask(Which::Paused), &en);
2202 layer.leave(Which::FreeTime, &en);
2203 assert_eq!(*layer.platform.shortcuts.ran.borrow(), vec![MAC_ON.to_string()], "the end waits for the last pause");
2204 assert_eq!(layer.leave(Which::Paused, &en).line, "Sioul ran your shortcut “Sioul pause off”.");
2205 assert_eq!(*layer.platform.shortcuts.ran.borrow(), vec![MAC_ON.to_string(), MAC_OFF.to_string()]);
2206 let mut layer = Layer::new(mac::Mac { shortcuts: FakeShortcuts::default() }, None);
2208 let report = layer.enter(&ask(Which::Paused), &en);
2209 assert!(!report.on && report.line.contains("“Sioul pause on”"), "{report:?}");
2210 assert!(layer.platform.shortcuts.ran.borrow().is_empty());
2211 }
2212
2213 #[test]
2214 fn phone_answers_become_what_is_said() {
2215 let en = Translator::new("en");
2216 let entered = |ask: &Ask, answer: serde_json::Value| answers::entered(ask, &answer, "Pause").report(&en);
2217 let starred = serde_json::json!({ "access": true, "rule": { "calls": 3, "messages": 3, "repeat": 1, "alarms": 1 }, "doses": true });
2218 let report = entered(&ask(Which::Paused), starred.clone());
2219 assert_eq!(report.line, "Your phone's calls and messages are silenced, except starred contacts and repeat callers. Alarms and dose reminders still come.");
2220 assert!(report.on);
2221 let mut no_doses = ask(Which::Paused);
2223 no_doses.doses = false;
2224 assert_eq!(entered(&no_doses, starred).line, "Your phone's calls and messages are silenced, except starred contacts and repeat callers. Alarms still ring. Dose reminders wait in the notification shade, without a sound.");
2225 let mut nobody = ask(Which::Paused);
2227 nobody.people = false;
2228 let answer = serde_json::json!({ "access": true, "rule": { "calls": 4, "messages": 4, "repeat": 2, "alarms": 1 }, "doses": false, "already": true });
2229 assert_eq!(entered(&nobody, answer).line, "Your phone's calls and messages are silenced, from everyone. Alarms still ring. Dose reminders are silenced too: their channel was changed in Android's settings. A do-not-disturb was already on; Sioul leaves it as it was.");
2230 let edited = serde_json::json!({ "access": true, "rule": { "calls": 2, "messages": 4, "repeat": 1, "alarms": 2 }, "doses": true });
2232 let report = answers::entered(&ask(Which::Paused), &edited, "Pause").report(&en);
2233 assert_eq!(report.line, "Your phone is silenced, except calls from your contacts and repeat callers. That mode is set otherwise in Android's settings, which win. Alarms are silenced too, as that mode is set. Dose reminders still show.");
2234 let unset = serde_json::json!({ "access": true, "rule": { "calls": 0, "messages": 4, "repeat": 1, "alarms": 2 }, "doses": true });
2236 assert!(entered(&ask(Which::Paused), unset).line.starts_with("Your phone is silenced as its “Pause” mode is set in Android's settings. Alarms are silenced too"));
2237 use sioul_core::attention::{Senders as Matrix, Silence};
2240 let silence = Silence { calls: Matrix::Contacts, messages: Matrix::Starred, repeat: true, conversations: true, alarms: true, doses: true, events: true };
2241 let matrix = Ask { silence: Some(silence), ..ask(Which::Paused) };
2242 let read = serde_json::json!({ "api": 31, "access": true, "rule": { "calls": 2, "messages": 3, "repeat": 1, "alarms": 1, "conversations": 2 }, "doses": true, "events": true });
2243 assert_eq!(entered(&matrix, read).line, "Your phone is silenced, except calls from your contacts, messages from starred contacts, repeat callers and priority conversations. Alarms and dose reminders still come. Your events' alarms ring too.");
2244 let ten = serde_json::json!({ "api": 29, "access": true, "rule": { "calls": 2, "messages": 3, "repeat": 1, "alarms": 1 }, "doses": true });
2246 assert_eq!(entered(&matrix, ten).line, "Your phone is silenced, except calls from your contacts, messages from starred contacts and repeat callers. This phone's Android has no priority conversations (they came with Android 11): the conversations that always get through come here as messages do. Alarms and dose reminders still come.");
2247 let anyone = Ask { silence: Some(Silence { calls: Matrix::Anyone, messages: Matrix::Anyone, ..silence }), ..ask(Which::FreeTime) };
2249 let read = serde_json::json!({ "api": 35, "access": true, "rule": { "calls": 1, "messages": 1, "repeat": 1, "alarms": 1, "conversations": 2, "channels": 1 }, "doses": true, "events": false });
2250 assert_eq!(entered(&anyone, read).line, "Your phone is silenced, except calls and messages from anyone, repeat callers and priority conversations. Alarms and dose reminders still come. Your events' alarms are silenced too: their channel was changed in Android's settings.");
2251 let none = Ask { silence: Some(Silence { calls: Matrix::None, messages: Matrix::None, repeat: false, conversations: false, ..silence }), ..ask(Which::FreeTime) };
2253 let read = serde_json::json!({ "api": 31, "access": true, "rule": { "calls": 4, "messages": 4, "repeat": 2, "alarms": 1, "conversations": 3 }, "doses": true, "events": true });
2254 assert!(entered(&none, read).line.starts_with("Your phone's calls and messages are silenced, from everyone. Alarms and dose reminders still come."));
2255 let read = serde_json::json!({ "api": 31, "access": true, "rule": { "calls": 4, "messages": 4, "repeat": 2, "alarms": 1, "conversations": 2 }, "doses": true });
2257 let alone = Ask { silence: Some(Silence { conversations: true, ..none.silence.unwrap() }), ..ask(Which::Paused) };
2258 assert!(entered(&alone, read).line.starts_with("Your phone is silenced, except priority conversations. Alarms"));
2259 let done = answers::entered(&ask(Which::Paused), &serde_json::json!({ "access": false }), "Pause");
2261 assert!(done.retry && done.offers == vec!["access"]);
2262 assert_eq!(done.report(&en).line, "Your phone is not silenced: Sioul does not have Android's “Do Not Disturb access”.");
2263 assert_eq!(entered(&ask(Which::Paused), serde_json::json!({ "too_old": true })).line, "This phone's Android is too old for Sioul to silence it: it takes Android 10 or later.");
2265 assert!(answers::entered(&ask(Which::Paused), &serde_json::Value::Null, "Pause").retry);
2266 assert_eq!(answers::left(&serde_json::json!({ "was": false, "still": true })), Done::default());
2268 assert_eq!(answers::left(&serde_json::json!({ "was": true, "still": false })).report(&en).line, "Your phone is no longer silenced by Sioul.");
2269 assert_eq!(answers::left(&serde_json::json!({ "was": true, "still": true })).report(&en).line, "Your phone is no longer silenced by Sioul. A do-not-disturb is still on, as it was before.");
2270 let off = answers::entered(&ask(Which::Paused), &serde_json::json!({ "access": true, "disabled": true, "rule": { "name": "En pause" } }), "Pause");
2272 assert!(!off.on && !off.retry);
2273 assert_eq!(off.report(&en).line, "Your phone is not silenced: its “En pause” mode is turned off in Android's settings.");
2274 let off = answers::entered(&ask(Which::Paused), &serde_json::json!({ "access": true, "turned_off": true }), "Pause");
2275 assert!(off.turned_off && !off.on);
2276 assert_eq!(off.report(&en).line, "You turned do-not-disturb off on this phone; Sioul leaves it off until it turns it on again.");
2277 let disabled = answers::entered(&ask(Which::Paused), &serde_json::json!({ "access": true, "disabled": true, "rule": { "name": "Pause" } }), "Pause");
2279 assert!(disabled.turned_off && disabled.offers == vec!["modes"]);
2280 let held = answers::entered(&ask(Which::Global), &serde_json::json!({ "access": true, "held": true }), "Do not disturb (Sioul)").report(&en);
2282 assert!(held.on && !held.turned_off);
2283 assert_eq!(held.line, "This phone is silenced by its own do-not-disturb, as set in Android's settings, which turned Sioul's on.");
2284 let can = answers::can(&serde_json::json!({ "access": false }), None).report(&en);
2286 assert!(!can.on && can.offers == vec!["access", "starred"], "{can:?}");
2287 let can = answers::can(&serde_json::json!({ "access": true }), sioul_core::pause::callback("FR")).report(&en);
2288 assert!(can.on && can.line.ends_with("Emergency services may call back from a number you do not know (0 800 112 112): a second call within 15 minutes gets through, and you can star that number in your contacts."), "{can:?}");
2289 let can = answers::can(&serde_json::json!({ "access": true }), sioul_core::pause::callback("GB")).report(&en);
2291 assert!(can.line.ends_with("Emergency services may call back from a number you do not know: a second call within 15 minutes gets through."), "{can:?}");
2292 let can = answers::can(&serde_json::json!({ "api": 29, "access": true }), None).report(&en);
2294 assert!(can.line.ends_with("This phone's Android has no priority conversations (they came with Android 11): the conversations that always get through come here as messages do."), "{can:?}");
2295 let can = answers::can(&serde_json::json!({ "api": 31, "access": true }), None).report(&en);
2296 assert!(!can.line.contains("priority"), "{can:?}");
2297 }
2298
2299 #[test]
2300 fn a_phone_that_does_not_answer_is_said_and_asked_again() {
2301 let en = Translator::new("en");
2302 let mut layer = Layer::new(phone::Phone, None);
2303 let report = layer.enter(&ask(Which::Paused), &en);
2304 assert!(!report.on);
2305 assert_eq!(report.line, "Your phone could not be silenced: Android did not answer. Sioul holds its own notifications, except dose reminders.");
2306 assert!(layer.said.iter().all(|(_, _, retry)| *retry), "asked again at the next minute");
2307 assert_eq!(layer.leave(Which::Paused, &en), Report::default(), "nothing to say of what was not on");
2308 }
2309
2310 #[test]
2311 fn every_sentence_has_words_in_each_language() {
2312 let all = [
2313 Said::PhoneCan,
2314 Said::PhoneNeedsAccess,
2315 Said::PhoneNoAccess,
2316 Said::PhoneTooOld,
2317 Said::PhoneCallback(Some("0 800 112 112".into())),
2318 Said::PhoneCallback(None),
2319 Said::PhoneOn(Senders::Starred),
2320 Said::PhoneOn(Senders::Nobody),
2321 Said::PhoneOn(Senders::Contacts),
2322 Said::PhoneLets { calls: Senders::Contacts, messages: Senders::Starred, repeat: true, conversations: true },
2323 Said::PhoneLets { calls: Senders::Anyone, messages: Senders::Anyone, repeat: false, conversations: false },
2324 Said::PhoneLets { calls: Senders::Nobody, messages: Senders::Contacts, repeat: false, conversations: true },
2325 Said::PhoneLets { calls: Senders::Nobody, messages: Senders::Nobody, repeat: false, conversations: false },
2326 Said::PhoneNoConversations,
2327 Said::PhoneEvents(true),
2328 Said::PhoneEvents(false),
2329 Said::PhoneAsSet("Pause".into()),
2330 Said::PhoneNoAnswer,
2331 Said::PhoneDisabled("Pause".into()),
2332 Said::PhoneTurnedOff,
2333 Said::PhoneSetThere,
2334 Said::PhoneThrough { alarms: Some(true), doses: Doses::Through },
2335 Said::PhoneThrough { alarms: Some(false), doses: Doses::Through },
2336 Said::PhoneThrough { alarms: None, doses: Doses::Shade },
2337 Said::PhoneThrough { alarms: Some(true), doses: Doses::Blocked },
2338 Said::PhoneAlready,
2339 Said::PhoneFailed("x".into()),
2340 Said::PhoneOff,
2341 Said::PhoneStill,
2342 Said::PlasmaCan,
2343 Said::PlasmaOn,
2344 Said::PlasmaDoses(true),
2345 Said::PlasmaDoses(false),
2346 Said::PlasmaFailed("x".into()),
2347 Said::PlasmaOff,
2348 Said::GnomeCan,
2349 Said::GnomeCannot,
2350 Said::GnomeOn,
2351 Said::GnomeAlready,
2352 Said::GnomeSandboxed,
2353 Said::GnomeFailed("x".into()),
2354 Said::GnomeOff,
2355 Said::GnomeLeft,
2356 Said::DesktopCannot(String::new()),
2357 Said::DesktopCannot("dunst".into()),
2358 Said::MacCan,
2359 Said::MacCannot,
2360 Said::MacOn,
2361 Said::MacFailed(MAC_ON.into(), "x".into()),
2362 Said::MacOff,
2363 Said::MacNoOff,
2364 Said::WindowsCannot,
2365 Said::OwnHeld { doses: true },
2366 Said::OwnHeld { doses: false },
2367 Said::PhoneHeld,
2368 Said::PhoneStaysOn,
2369 Said::PlasmaTurnedOff,
2370 Said::PlasmaHeld,
2371 Said::PlasmaOwnStaysOn,
2372 Said::PlasmaStaysOn,
2373 Said::GnomeTurnedOff,
2374 Said::GnomeHeld,
2375 Said::GnomeStaysOn,
2376 ];
2377 for language in ["en", "fr"] {
2378 let tr = Translator::new(language);
2379 for said in &all {
2380 let words = sentence(said, &tr);
2381 assert!(!words.is_empty() && !words.contains("dnd-") && !words.contains('{'), "{language}: {said:?}: {words}");
2382 }
2383 }
2384 let fr = Translator::new("fr");
2385 assert_eq!(sentence(&Said::PhoneOn(Senders::Starred), &fr), "Les appels et messages de votre téléphone sont en silence, sauf ceux des contacts favoris et les appels répétés.");
2386 assert_eq!(
2387 sentence(&Said::PhoneLets { calls: Senders::Contacts, messages: Senders::Starred, repeat: true, conversations: true }, &fr),
2388 "Votre téléphone est en silence, sauf les appels de vos contacts, les messages des contacts favoris, les appels répétés et les conversations prioritaires."
2389 );
2390 assert_eq!(Which::Paused.name(&fr), "En pause");
2391 assert_eq!(Which::FreeTime.name(&fr), "Temps libre");
2392 assert_eq!(Which::Global.name(&fr), "Ne pas déranger (Sioul)");
2393 assert_eq!(Which::Global.name(&Translator::new("en")), "Do not disturb (Sioul)");
2394 }
2395
2396 #[test]
2397 fn plasma_held_by_its_own_adds_nothing_and_its_end_unheard_is_said() {
2398 let en = Translator::new("en");
2399 let mut layer = Layer::new(plasma(true), None);
2400 layer.platform.desk.server.set(Some(true));
2401 let held = Ask { stack: false, ..ask(Which::Global) };
2402 let report = layer.enter(&held, &en);
2403 assert!(report.on && report.line == "This computer is silenced by Plasma's own do-not-disturb, which turned Sioul's on.", "{report:?}");
2404 assert_eq!(layer.platform.desk.inhibited.load(Ordering::Relaxed), 0, "nothing of Sioul's over it");
2405 let mut layer = Layer::new(plasma(true), None);
2407 layer.platform.desk.server.set(Some(true));
2408 layer.enter(&ask(Which::Global), &en);
2409 layer.enter(&held, &en);
2410 assert_eq!(layer.platform.desk.released.load(Ordering::Relaxed), 1);
2411 layer.platform.desk.server.set(Some(false));
2413 let report = layer.enter(&Ask { doses: false, ..held.clone() }, &en);
2414 assert!(!report.on && report.turned_off, "{report:?}");
2415 assert!(report.line.starts_with("You turned Plasma's do-not-disturb off on this computer"), "{report:?}");
2416 }
2417
2418 #[test]
2419 fn plasma_inhibition_ended_from_its_applet_is_said_and_never_asked_again() {
2420 let en = Translator::new("en");
2421 let mut layer = Layer::new(plasma(true), None);
2422 layer.enter(&ask(Which::Global), &en);
2423 assert_eq!(layer.platform.desk.inhibited.load(Ordering::Relaxed), 1);
2424 for held in &mut layer.platform.held {
2426 held.2 = Instant::now().checked_sub(std::time::Duration::from_secs(5)).unwrap_or(held.2);
2427 }
2428 layer.platform.watch = Some(Box::new(FakeWatch(Some(false))));
2429 let report = layer.enter(&ask(Which::Global), &en);
2430 assert!(!report.on && report.turned_off, "{report:?}");
2431 assert_eq!(layer.platform.desk.inhibited.load(Ordering::Relaxed), 1, "not asked again over the person's off");
2432 layer.leave(Which::Global, &en);
2434 layer.platform.watch = Some(Box::new(FakeWatch(Some(true))));
2435 assert!(layer.enter(&ask(Which::Global), &en).on);
2436 assert_eq!(layer.platform.desk.inhibited.load(Ordering::Relaxed), 2);
2437 }
2438
2439 #[test]
2440 fn sioul_off_turns_the_systems_own_off_where_it_can_and_says_the_rest() {
2441 let en = Translator::new("en");
2442 let mut layer = Layer::new(plasma(true), None);
2444 layer.platform.desk.until.set(Some(4_000_000_000));
2445 assert!(layer.platform.system_off(false));
2446 assert_eq!(layer.platform.desk.cleared.get(), 1);
2447 layer.platform.desk.server.set(Some(true));
2448 let still = layer.platform.still_on().map(|done| done.report(&en).line);
2449 assert_eq!(still.as_deref(), Some("Plasma keeps this computer's notifications back for another application or a full-screen window; it ends with them."));
2450 assert!(!layer.platform.system_off(false), "nothing more of Sioul's to end");
2451 layer.platform.desk.server.set(Some(false));
2452 assert_eq!(layer.platform.still_on(), None);
2453 let mut layer = Layer::new(gnome(false), None);
2455 assert_eq!(layer.platform.system_on(), Some(true));
2456 assert!(!layer.platform.system_off(false), "no yes, no write");
2457 assert_eq!(layer.platform.still_on().map(|done| done.report(&en).line).as_deref(), Some("GNOME's Do Not Disturb stays on: turn it off in the top bar."));
2458 assert!(layer.platform.system_off(true));
2459 assert_eq!(*layer.platform.desk.banners.borrow(), Some(true));
2460 assert_eq!(layer.platform.still_on(), None);
2461 let mut phone = phone::Phone;
2463 assert_eq!((phone.system_on(), phone.system_off(true), phone.still_on()), (None, false, None));
2464 let mut windows = WindowsPlatform;
2465 assert_eq!((windows.system_on(), windows.system_off(true)), (None, false));
2466 }
2467
2468 #[test]
2469 fn gnome_held_by_its_own_and_turned_off_while_sioul_was_closed() {
2470 let en = Translator::new("en");
2471 let mut layer = Layer::new(gnome(false), None);
2473 let report = layer.enter(&Ask { stack: false, ..ask(Which::Global) }, &en);
2474 assert!(report.on && report.line == "This computer is silenced by GNOME's own Do Not Disturb, which turned Sioul's on.", "{report:?}");
2475 assert!(layer.platform.desk.writes.borrow().is_empty());
2476 let mut layer = Layer::new(gnome(true), None);
2478 layer.kept.gnome = true;
2479 let report = layer.enter(&ask(Which::Paused), &en);
2480 assert!(!report.on && report.turned_off && report.line.starts_with("You turned GNOME's Do Not Disturb off"), "{report:?}");
2481 assert!(layer.platform.desk.writes.borrow().is_empty(), "not turned on again over the person's off");
2482 }
2483
2484 #[test]
2485 fn what_outlives_sioul_as_it_quits() {
2486 let en = Translator::new("en");
2487 let mut layer = Layer::new(plasma(true), None);
2489 layer.enter(&ask(Which::Global), &en);
2490 assert!(!layer.platform.outlives(&layer.kept));
2491 let mut layer = Layer::new(gnome(true), None);
2493 layer.enter(&ask(Which::Global), &en);
2494 assert!(layer.platform.outlives(&layer.kept));
2495 let mut layer = Layer::new(gnome(false), None);
2497 layer.enter(&ask(Which::Global), &en);
2498 assert!(!layer.platform.outlives(&layer.kept));
2499 }
2500
2501 #[test]
2502 fn again_leaves_and_enters_afresh() {
2503 let en = Translator::new("en");
2504 let mut layer = Layer::new(plasma(true), None);
2505 layer.enter(&ask(Which::Global), &en);
2506 layer.leave(Which::Global, &en);
2507 let report = layer.enter(&ask(Which::Global), &en);
2508 assert!(report.on);
2509 assert_eq!((layer.platform.desk.inhibited.load(Ordering::Relaxed), layer.platform.desk.released.load(Ordering::Relaxed)), (2, 1));
2510 }
2511
2512 #[test]
2513 fn plasma_dates_read_as_kconfig_writes_them() {
2514 use super::desktop::kconfig_time;
2515 let paris = jiff::tz::TimeZone::get("Europe/Paris").unwrap();
2516 assert_eq!(kconfig_time("2027,10,7,22,13,41.5", &paris), Some(jiff::civil::date(2027, 10, 7).at(22, 13, 41, 0).to_zoned(paris.clone()).unwrap().timestamp().as_second()));
2518 assert_eq!(kconfig_time("2027,1,1,0,0,0,UTC", &paris), Some(1_798_761_600));
2520 assert_eq!(kconfig_time("", &paris), None);
2521 assert_eq!(kconfig_time("2027,13,1,0,0,0", &paris), None);
2522 assert_eq!(kconfig_time("not,a,date,at,all,x", &paris), None);
2523 }
2524
2525 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
2529 #[test]
2530 fn plasma_until_is_ended_with_kdes_own_tool_in_a_folder_of_the_tests_own() {
2531 let dir = std::env::temp_dir().join(format!("sioul-dnd-kwrite-{}", std::process::id()));
2532 let _ = std::fs::create_dir_all(&dir);
2533 let file = dir.join("plasmanotifyrc");
2534 std::fs::write(&file, "[DoNotDisturb]\nUntil=2099,1,1,0,0,0\nWhenScreenSharing=false\n").unwrap();
2535 let session = desktop::Session { gsettings_env: Vec::new(), config_dirs: Some(vec![dir.clone()]) };
2536 assert!(session.plasma_until().is_some_and(|until| until > 4_000_000_000));
2537 match session.clear_plasma_until() {
2538 Err(why) if why.starts_with("kwriteconfig6:") && why.contains("No such file") => {
2539 eprintln!("No kwriteconfig6 here: skipped.");
2540 let _ = std::fs::remove_dir_all(&dir);
2541 return;
2542 }
2543 done => done.expect("ended"),
2544 }
2545 let text = std::fs::read_to_string(&file).unwrap();
2546 assert!(!text.contains("Until") && text.contains("WhenScreenSharing=false"), "{text}");
2547 assert_eq!(session.plasma_until(), None);
2548 let _ = std::fs::remove_dir_all(&dir);
2549 }
2550
2551 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
2556 #[test]
2557 fn gnome_switch_through_gsettings_in_a_file_of_the_tests_own() {
2558 let dir = std::env::temp_dir().join(format!("sioul-dnd-gsettings-{}", std::process::id()));
2559 let _ = std::fs::create_dir_all(&dir);
2560 let own = vec![
2562 ("GSETTINGS_BACKEND".to_string(), "keyfile".to_string()),
2563 ("XDG_CONFIG_HOME".to_string(), dir.to_string_lossy().to_string()),
2564 ("GIO_USE_VFS".to_string(), "local".to_string()),
2565 ];
2566 let mut desktop = Desktop::new(desktop::Session { gsettings_env: own, config_dirs: Some(vec![dir.clone()]) });
2567 if desktop.desk.banners() != Ok(true) {
2568 eprintln!("No gsettings or no GNOME schema here: skipped.");
2569 let _ = std::fs::remove_dir_all(&dir);
2570 return;
2571 }
2572 let en = Translator::new("en");
2573 let mut kept = Kept::default();
2574 let on = desktop.gnome_on(&ask(Which::Paused), &mut kept);
2575 assert!(on.on && kept.gnome, "{on:?}");
2576 assert_eq!(desktop.desk.banners(), Ok(false));
2577 let file = std::fs::read_to_string(dir.join("glib-2.0").join("settings").join("keyfile")).unwrap_or_default();
2578 assert!(file.contains("show-banners=false"), "written in the test's own file: {file}");
2579 let said = desktop.gnome_off(&[], &mut kept);
2580 assert_eq!(Done::said(false, said).report(&en).line, "GNOME's Do Not Disturb is off again, as it was before.");
2581 assert_eq!(desktop.desk.banners(), Ok(true));
2582 assert!(!kept.gnome);
2583 let _ = std::fs::remove_dir_all(&dir);
2584 }
2585}