Skip to main content

sioul_app/
dnd.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Do-not-disturb during the two pauses, "Paused" and "Free time"
5//! (docs/pauses.md, "Do-not-disturb"; the research behind it:
6//! docs/research/emergency-pause.md, 7 and P7–P10), and for Sioul's own
7//! do-not-disturb on every device (`Which::Global`: its switch, the focus
8//! timer, sleep). Sioul holds its own notifications itself; here, the
9//! system's do-not-disturb, with exceptions set beforehand, where the system
10//! lets an application set it, and a sentence saying what was silenced and
11//! what could not be.
12//!
13//! - **Android**: a mode of Sioul's own for each ("Pause", "Free time",
14//!   "Do not disturb (Sioul)"), letting through what the matrix of what
15//!   reaches you lets through then, as far as Android can say it
16//!   (`Ask::silence`: calls and messages from nobody, starred contacts,
17//!   contacts or anyone, repeat callers, priority conversations), alarms,
18//!   and Sioul's dose reminders and an event's alarms on channels that pass
19//!   (android/package/src/com/aurelienpierre/sioul/PauseMode.java). Needs
20//!   Android's "Do Not Disturb access".
21//! - **Plasma**: its notification server's inhibition, held while the pause
22//!   lasts (sioul-sync's dnd.rs); it ends with Sioul's process.
23//! - **GNOME**: only the person's own switch exists; Sioul turns it on and off
24//!   again only with their yes, given in the pause's settings.
25//! - **macOS**: the person's own shortcuts. **Windows**: nothing an
26//!   application may do.
27//!
28//! Never turns off a do-not-disturb the person set while Sioul's holds: only
29//! what Sioul turned on is turned off then. Asked again unchanged, nothing is
30//! redone (the pauses ask each minute); what was turned on is kept in the
31//! state folder (dnd.toml), so that a crash is healed at the next start.
32//!
33//! Both ways (docs/do-not-disturb.md): each system's own do-not-disturb is
34//! read (`Platform::system_on`) and followed (`listen`: Plasma's `Inhibited`, GNOME's
35//! switch; Android's receiver in Java), and when Sioul's goes off on this
36//! device, the system's own is turned off too where Sioul can (`system_off`:
37//! Android 12 to 14, Plasma's own, GNOME's with the person's yes), or said
38//! (`still_on`). A switch "on" taken from this device's own system adds no
39//! mode of Sioul's over it (`Ask::stack`).
40
41use crate::backend::tr;
42use serde::{Deserialize, Serialize};
43use sioul_core::i18n::{self, Translator};
44use std::path::{Path, PathBuf};
45use std::sync::Mutex;
46
47/// Which pause.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub(crate) enum Which {
51    /// "Free time": a chosen pause, for the good weather or the energy there is now.
52    FreeTime,
53    /// "Paused": the pause for overwhelming moments.
54    Paused,
55    /// Sioul's do-not-disturb, on every device: its switch, the focus timer, sleep.
56    Global,
57}
58
59impl Which {
60    /// Its key, as Java knows it (Android only).
61    #[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    /// The system's name for its mode: "Pause", « En pause »; "Free time",
71    /// « Temps libre »; "Do not disturb (Sioul)", « Ne pas déranger (Sioul) ».
72    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/// What a pause asks of the system, as set beforehand in its settings.
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83pub(crate) struct Ask {
84    pub which: Which,
85    /// Android: calls and messages from starred contacts, and a second call
86    /// from the same number within 15 minutes, get through; false: nobody.
87    pub people: bool,
88    /// Sioul's dose reminders get through. Alarms always do (and the sound of
89    /// what the person plays).
90    pub doses: bool,
91    /// GNOME: the person's yes, given in the pause's settings, for Sioul to
92    /// switch on GNOME's own Do Not Disturb, and off again after. Ignored elsewhere.
93    pub desktop: bool,
94    /// Android: what the matrix of what reaches you lets through this mode
95    /// (`attention::Silence`): calls and messages from nobody, starred
96    /// contacts, contacts or anyone; a second call; important conversations;
97    /// an event's alarms on a channel that passes. Given to Java beside
98    /// `people` and `doses`, which say the same as far as today's Java reads.
99    #[serde(default)]
100    pub silence: Option<sioul_core::attention::Silence>,
101    /// Sioul's own mode is put on the system; false when the system's own
102    /// do-not-disturb holds the device for the switch already (a switch "on"
103    /// taken from this device's own system, `everywhere::held_by_system`):
104    /// then Sioul adds nothing over it, so that its end stays visible.
105    #[serde(default = "yes")]
106    pub stack: bool,
107}
108
109fn yes() -> bool {
110    true
111}
112
113/// What was done, in words for the screen.
114#[derive(Clone, Debug, Default, PartialEq, Eq)]
115pub(crate) struct Report {
116    /// `enter`: the system's do-not-disturb is on for this pause, on this
117    /// device. `can`: this device can do it now.
118    pub on: bool,
119    /// One or a few literal sentences, translated: what was silenced and what
120    /// could not be, shown as they are.
121    pub line: String,
122    /// What the pause's settings can offer here: "access" (Android's page
123    /// for Sioul's do-not-disturb access), "starred" (the Contacts app's
124    /// starred list), "plasma" (Plasma's notification settings at Sioul's
125    /// page), each a button for `open`; "desktop", GNOME's consent, a switch
126    /// labelled `consent`.
127    pub offers: Vec<&'static str>,
128    /// The label of GNOME's consent switch; "" elsewhere.
129    pub consent: String,
130    /// The person turned this device's do-not-disturb off in its system, and
131    /// Sioul leaves it off until it next turns it on (`on` false).
132    pub turned_off: bool,
133}
134
135impl Report {
136    /// For the window: {on, line, consent, turned_off, offers: [{key, label}]}.
137    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
143/// A change of a system's own do-not-disturb, heard as it happened: on or off.
144pub(crate) type Told = std::sync::Arc<dyn Fn(bool) + Send + Sync>;
145
146/// Sioul's do-not-disturb went off here: the system's own off too, once,
147/// where Sioul can (`consent`: GNOME's yes). Whether something was turned off.
148pub(crate) fn system_off(consent: bool) -> bool {
149    with(|layer| layer.platform.system_off(consent))
150}
151
152/// While Sioul's do-not-disturb is off here: the system's own still on, said
153/// ("… stays on: turn it off …"), with what may be opened; none when it is off.
154pub(crate) fn still_on() -> Option<Report> {
155    with(|layer| layer.platform.still_on().map(|done| done.report(tr())))
156}
157
158/// Sioul quits on a computer: whether its system stays silenced without it
159/// (GNOME's switch, a Mac's Focus, which outlive Sioul); Plasma's inhibitions
160/// end with Sioul's process.
161pub(crate) fn still_after_quit() -> bool {
162    with(|layer| layer.platform.outlives(&layer.kept))
163}
164
165/// "Silence this device again": `ask`'s mode left and entered afresh, the
166/// person's earlier "off" in the system set aside.
167pub(crate) fn again(ask: &Ask) -> Report {
168    with(|layer| {
169        layer.leave(ask.which, tr());
170        layer.enter(ask, tr())
171    })
172}
173
174/// This system's own do-not-disturb followed, each change heard as it happens
175/// told to `told` (Plasma's `Inhibited`, GNOME's switch); on Android, Java's
176/// receiver hears it. Once, while the window runs.
177pub(crate) fn listen(told: Told) {
178    with(|layer| layer.platform.listen(told));
179}
180
181/// Whether Sioul's do-not-disturb held on this device at the last apply; `on`,
182/// what holds now, kept for the next (in the state folder, across restarts).
183pub(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
194/// On Android, what Java keeps of Sioul's do-not-disturb here: its state (the
195/// tile, the receiver's filter); nothing elsewhere.
196pub(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
203/// What the pause's settings can show here, with no change made.
204pub(crate) fn can() -> Report {
205    with(|layer| layer.can(tr()))
206}
207
208/// The system's do-not-disturb on for `ask`'s pause, or brought up to date
209/// when its settings changed. Asked again unchanged, nothing is redone.
210pub(crate) fn enter(ask: &Ask) -> Report {
211    with(|layer| layer.enter(ask, tr()))
212}
213
214/// The do-not-disturb Sioul turned on for this pause, off again; nothing
215/// else. Asked again, or for a pause not on, nothing is done (but the first
216/// time in a run, which heals what a crash left).
217pub(crate) fn leave(which: Which) -> Report {
218    with(|layer| layer.leave(which, tr()))
219}
220
221/// Android: "Pause" pressed on the quick-settings tile or the home screen's
222/// shortcut since last asked (PauseOpener.java). False elsewhere.
223pub(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
230/// A label for one of `Report::offers`.
231pub(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
242/// One of `Report::offers`, opened: "access", "starred", "plasma", "modes".
243pub(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
253/// Android's own do-not-disturb settings, where the person turns it off (Sioul
254/// may not, from Android 15). Nothing elsewhere.
255pub(crate) fn open_modes() {
256    #[cfg(target_os = "android")]
257    phone::call("open", "modes");
258}
259
260/// Android's page where Sioul is given "Do Not Disturb access" (Modes
261/// access from Android 15). Nothing elsewhere.
262pub(crate) fn open_access() {
263    #[cfg(target_os = "android")]
264    phone::call("open", "access");
265}
266
267/// The Contacts app's starred list, which the person edits there: Sioul never
268/// writes stars. Nothing elsewhere.
269pub(crate) fn open_starred() {
270    #[cfg(target_os = "android")]
271    phone::call("open", "starred");
272}
273
274/// Plasma's notification settings, at Sioul's page ("Show in do not disturb
275/// mode"). Nothing elsewhere.
276pub(crate) fn open_plasma() {
277    #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
278    desktop::open_plasma_settings();
279}
280
281// ---------------------------------------------------------------- what is said
282
283/// A fact said on the pause's line or in its settings; `sentence` words it.
284// Each system says its own facts: the others' are made only on their
285// systems, and in the tests.
286#[allow(dead_code)]
287#[derive(Clone, Debug, PartialEq, Eq)]
288enum Said {
289    PhoneCan,
290    PhoneNeedsAccess,
291    PhoneNoAccess,
292    PhoneTooOld,
293    /// Emergency services' call back: their number in the person's country, where one is known.
294    PhoneCallback(Option<String>),
295    /// Silenced, with these exceptions as the mode is set (read back from Android).
296    PhoneOn(Senders),
297    /// Silenced, with these exceptions as the mode is set (read back from
298    /// Android): who may call, who may write, a second call within 15
299    /// minutes, the conversations marked priority in Android.
300    PhoneLets { calls: Senders, messages: Senders, repeat: bool, conversations: bool },
301    /// Android 10: no priority conversations to let through.
302    PhoneNoConversations,
303    /// An event's alarms during the mode: through (their channel passes), or silenced (changed in Android's settings).
304    PhoneEvents(bool),
305    /// Silenced as the mode of this name is set in Android's settings, otherwise than Sioul set it.
306    PhoneAsSet(String),
307    /// The mode is set otherwise in Android's settings than the pause asks.
308    PhoneSetThere,
309    PhoneThrough { alarms: Option<bool>, doses: Doses },
310    PhoneAlready,
311    PhoneFailed(String),
312    PhoneNoAnswer,
313    /// The mode of this name turned off in Android's settings (by the person): left off.
314    PhoneDisabled(String),
315    /// Do-not-disturb turned off on the phone during the pause (by the person): left off.
316    PhoneTurnedOff,
317    /// Silenced by the phone's own do-not-disturb, which turned Sioul's on.
318    PhoneHeld,
319    /// Sioul's do-not-disturb off, the phone's own still on (Android 15 and later, or unheard).
320    PhoneStaysOn,
321    PhoneOff,
322    PhoneStill,
323    PlasmaCan,
324    PlasmaOn,
325    /// Whether Plasma shows Sioul's dose reminders during its do-not-disturb.
326    PlasmaDoses(bool),
327    PlasmaFailed(String),
328    PlasmaOff,
329    /// Plasma's do-not-disturb turned off from its applet (Sioul's inhibition dropped with it): left off.
330    PlasmaTurnedOff,
331    /// Silenced by Plasma's own do-not-disturb, which turned Sioul's on.
332    PlasmaHeld,
333    /// Sioul's do-not-disturb off, Plasma's own still on (`[DoNotDisturb] Until` ahead).
334    PlasmaOwnStaysOn,
335    /// Sioul's do-not-disturb off, Plasma holding notifications back for another application or a full-screen window.
336    PlasmaStaysOn,
337    GnomeCan,
338    GnomeCannot,
339    GnomeOn,
340    GnomeAlready,
341    GnomeSandboxed,
342    GnomeFailed(String),
343    GnomeOff,
344    GnomeLeft,
345    /// GNOME's Do Not Disturb turned off from its top bar while Sioul held it: left off.
346    GnomeTurnedOff,
347    /// Silenced by GNOME's own Do Not Disturb, which turned Sioul's on.
348    GnomeHeld,
349    /// Sioul's do-not-disturb off, GNOME's own still on (no yes to turn it off).
350    GnomeStaysOn,
351    /// A desktop with no way for an application: its server's name, "" unknown.
352    DesktopCannot(String),
353    MacCan,
354    MacCannot,
355    MacOn,
356    MacFailed(String, String),
357    MacOff,
358    MacNoOff,
359    WindowsCannot,
360    /// Sioul's own notifications, held by the pauses, said when the system's are not silenced.
361    OwnHeld { doses: bool },
362}
363
364/// Who gets through the phone's mode, as Sioul sets it (`attention::Senders`).
365#[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
384/// "calls from your contacts, messages from starred contacts, repeat
385/// callers and priority conversations": what a phone's mode lets through.
386fn 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/// What becomes of a dose reminder during the pause, on the phone.
423#[derive(Clone, Copy, Debug, PartialEq, Eq)]
424enum Doses {
425    Through,
426    /// Not asked through: in the shade, silent.
427    Shade,
428    /// Asked through, but its channel was changed in Android's settings.
429    Blocked,
430}
431
432/// The macOS shortcuts the person makes, run at the start and the end.
433const 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/// What a platform did or can do, as facts.
519#[derive(Debug, Default, PartialEq, Eq)]
520struct Done {
521    on: bool,
522    said: Vec<Said>,
523    offers: Vec<&'static str>,
524    /// GNOME's consent switch is offered.
525    consent: bool,
526    /// Asking again may change the outcome (access given meanwhile, a server
527    /// that refused): the pauses' next minute tries again.
528    retry: bool,
529    /// The person turned it off in the system; Sioul leaves it off.
530    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    /// Turned off by the person in the system, said so.
539    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
549// ---------------------------------------------------------------- the logic, for every platform
550
551/// One system's side, behind a trait: the logic below is tested with a stand-in.
552trait Platform {
553    /// What this device allows, changing nothing.
554    fn can(&mut self, tr: &Translator) -> Done;
555    /// `ask`'s pause silenced, or brought up to date; idempotent given `kept`.
556    fn on(&mut self, ask: &Ask, kept: &mut Kept, tr: &Translator) -> Done;
557    /// `which`'s pause no longer silenced, and nothing else; `others`: the
558    /// pauses still on here.
559    fn off(&mut self, which: Which, others: &[Ask], kept: &mut Kept) -> Done;
560    /// Whether what `on` did for `which` still holds in this process.
561    fn holds(&self, _which: Which) -> bool {
562        true
563    }
564    /// This device's own do-not-disturb, as its system says it now; none
565    /// where it cannot be read. (A computer's says it through `still_on`.)
566    #[cfg_attr(not(any(target_os = "android", test)), allow(dead_code))]
567    fn system_on(&mut self) -> Option<bool> {
568        None
569    }
570    /// Sioul's do-not-disturb went off here: the system's own off too, once,
571    /// where Sioul can (`consent`: GNOME's yes). Whether something was turned off.
572    fn system_off(&mut self, _consent: bool) -> bool {
573        false
574    }
575    /// While Sioul's do-not-disturb is off here: the system's own still on, said.
576    fn still_on(&mut self) -> Option<Done> {
577        None
578    }
579    /// Follows this system's own do-not-disturb, telling each change heard.
580    fn listen(&mut self, _told: Told) {}
581    /// Whether what Sioul turned on outlives Sioul's process (`kept`, as Sioul quits).
582    fn outlives(&mut self, _kept: &Kept) -> bool {
583        false
584    }
585}
586
587/// What Sioul turned on, kept in the state folder for the next run.
588#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
589struct Kept {
590    /// GNOME: Sioul switched its Do Not Disturb on, from off; off again when
591    /// the last pause leaves.
592    #[serde(default)]
593    #[cfg_attr(any(target_os = "android", target_os = "macos", windows), allow(dead_code))]
594    gnome: bool,
595    /// macOS: the start shortcut ran; the end one runs when the last pause leaves.
596    #[serde(default)]
597    #[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))]
598    mac: bool,
599    /// Sioul's do-not-disturb held here at the last apply: its going off is
600    /// when the system's own is turned off too (`system_off`). Before
601    /// `entered`: TOML writes plain values before tables.
602    #[serde(default)]
603    on_here: bool,
604    /// The pauses entered on this device, as asked.
605    #[serde(default)]
606    entered: Vec<Ask>,
607}
608
609/// The logic: what is on, what was said, what the next run must know.
610struct Layer<P: Platform> {
611    platform: P,
612    kept: Kept,
613    /// Where `kept` is written; none in tests.
614    path: Option<PathBuf>,
615    /// Each pause entered in this run, as asked, with its report, and
616    /// whether asking again could change it.
617    said: Vec<(Ask, Report, bool)>,
618    /// The pauses known off in this run: leaving again does nothing.
619    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        // Written whole beside it, then put in place: never half a file.
675        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
686/// Where what Sioul turned on is kept.
687fn kept_path() -> PathBuf {
688    sioul_core::config::state_dir().join("dnd.toml")
689}
690
691/// This system's platform.
692#[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// ---------------------------------------------------------------- Android
711
712/// Android's answers (PauseMode.java), as facts. Compiled everywhere: tested on a computer.
713#[cfg_attr(not(any(target_os = "android", test)), allow(dead_code))]
714mod answers {
715    use super::*;
716    use serde_json::Value;
717
718    /// ZenPolicy's PEOPLE_TYPE_ANYONE, _CONTACTS, _STARRED, _NONE; STATE_ALLOW,
719    /// STATE_DISALLOW; CONVERSATION_SENDERS_ANYONE, _IMPORTANT, _NONE.
720    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    /// What a mode lets through, as asked or as Android has it: who may call,
731    /// who may write, a second call, the conversations marked priority (none:
732    /// not said, Android 10 having none).
733    #[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        /// As the mode asks it: the matrix's (`Ask::silence`), else the older "people".
743        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        /// As Java read the mode back from Android; none when a part is not one Sioul sets.
754        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        /// Whether `read` is this, as far as Android says it.
776        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        /// In words: today's two sentences where they fit, else the parts.
781        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    /// `can`: {api, access, …}; `callback`, the number emergency services
792    /// call back from in the person's country, where one is known.
793    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        // Android 10: no priority conversations, said where the modes are set up.
805        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    /// `enter`: {api, access, too_old, error, rule: {name, calls, messages,
812    /// repeat, alarms, conversations (Android 11 and later)}, doses, events,
813    /// already, disabled, turned_off}; `name`, the mode's name as Sioul gives it.
814    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        // Turned off by the person in Android's settings: Android's page offered, where it is turned on again.
830        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        // Held by the phone's own do-not-disturb, which turned Sioul's on: no mode of Sioul's over it.
837        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            // Changed in Android's settings (its modes, from Android 15): said as it is.
844            Some(read) => vec![read.said(), Said::PhoneSetThere],
845            None => vec![Said::PhoneAsSet(named)],
846        };
847        // Android 10 has no priority conversations: Always through's come as messages there.
848        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        // An event's alarms, asked through by the matrix and said by a Java that knows them.
863        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    /// `leave`: {was, still}: whether Sioul's mode was on; a do-not-disturb
873    /// on before the pause, and still on.
874    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/// The phone's side. Compiled for the tests too, so that a computer checks
887/// it; Java answers nothing there.
888#[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    // SAFETY: declared as android/main.cpp defines them: extern "C", the same types.
898    unsafe extern "C" {
899        /// PauseMode.call (android/main.cpp): a verb and its JSON, a JSON answer or null.
900        fn sioul_android_dnd(verb: *const c_char, json: *const c_char) -> *mut c_char;
901        /// An answer of `sioul_android_dnd`, given back.
902        fn sioul_android_dnd_free(text: *mut c_char);
903    }
904
905    /// Tests on a computer: no Java.
906    #[cfg(not(target_os = "android"))]
907    pub(super) fn call(_verb: &str, _json: &str) -> Value {
908        Value::Null
909    }
910
911    /// Java's answer to `verb`; null when it gave none.
912    #[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        // SAFETY: two zero-terminated texts, valid for the call.
916        let answer = unsafe { sioul_android_dnd(verb.as_ptr(), json.as_ptr()) };
917        if answer.is_null() {
918            return Value::Null;
919        }
920        // SAFETY: a zero-terminated text from sioul_android_dnd, read before it is given back.
921        let text = unsafe { CStr::from_ptr(answer) }.to_string_lossy().to_string();
922        // SAFETY: given back once, as sioul_android_dnd asks; not read after.
923        unsafe { sioul_android_dnd_free(answer) };
924        serde_json::from_str(&text).unwrap_or(Value::Null)
925    }
926
927    /// The phone: Sioul's own modes, kept by Android (and by Java's list of
928    /// the pauses on, which survives Sioul and the phone's restarts).
929    #[derive(Default)]
930    pub(crate) struct Phone;
931
932    impl Platform for Phone {
933        fn can(&mut self, _tr: &Translator) -> Done {
934            // The country whose numbers the pause shows (sioul_core::pause::country).
935            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            // What the matrix lets through this mode (docs/attention.md, §3.3), beside
957            // "people" and "doses": {calls, messages: "none" | "starred" | "contacts" |
958            // "anyone", repeat, conversations, alarms, doses, events}.
959            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            // Asked of Android at each look (a cheap question): a mode turned
973            // off in the system, unheard, is said at the next minute.
974            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            // Android 12 to 14 only: from 15, an application may end only a mode of its own.
983            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// ---------------------------------------------------------------- Linux and the BSDs
993
994/// The desktop's logic, compiled where it runs and for the tests; the session
995/// itself (D-Bus, gsettings, Plasma's files) only where it runs.
996#[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    /// Sioul's desktop file, as Plasma and GNOME know the application.
1004    pub(super) const DESKTOP_ENTRY: &str = "com.aurelienpierre.Sioul";
1005    /// dconf's word of Sioul's own switch may come this long after it: changes
1006    /// heard later are the person's.
1007    const SETTLE: Duration = Duration::from_secs(3);
1008
1009    /// What kind of desktop serves the notifications.
1010    #[derive(Clone, Debug, PartialEq, Eq)]
1011    pub(super) enum Kind {
1012        /// A server that inhibits on request (Plasma); `kde`: Plasma's own,
1013        /// whose settings say whether critical notifications show meanwhile.
1014        Inhibits { kde: bool },
1015        Gnome,
1016        /// Another, by its server's name ("" unknown).
1017        Other(String),
1018    }
1019
1020    /// The session's side, behind a trait: tested with a stand-in.
1021    pub(super) trait Desk {
1022        fn kind(&mut self) -> Kind;
1023        /// Plasma's inhibition for `reason`, held until released or dropped.
1024        fn inhibit(&mut self, reason: &str) -> Result<Box<dyn Held>, String>;
1025        /// Whether Plasma shows Sioul's notifications during its do-not-disturb.
1026        fn plasma_shows_doses(&self) -> bool;
1027        /// GNOME's banners shown (its Do Not Disturb off).
1028        fn banners(&self) -> Result<bool, String>;
1029        fn set_banners(&self, shown: bool) -> Result<(), String>;
1030        /// dconf's changes of GNOME's switch, heard from now on.
1031        fn watch_banners(&self) -> Option<Box<dyn Heard>>;
1032        /// Sioul runs in a Flatpak: GNOME's settings out of reach.
1033        fn sandboxed(&self) -> bool;
1034        /// The notification server's `Inhibited` now: Plasma's do-not-disturb, whatever its cause.
1035        fn inhibited(&self) -> Option<bool>;
1036        /// The end of Plasma's own do-not-disturb (`[DoNotDisturb] Until`, Unix seconds), when ahead.
1037        fn plasma_until(&self) -> Option<i64>;
1038        /// Plasma's own do-not-disturb ended: `Until` deleted, Plasma told.
1039        fn clear_plasma_until(&self) -> Result<(), String>;
1040        /// Follows the session's own do-not-disturb (Plasma's `Inhibited`,
1041        /// GNOME's switch), telling each change once Sioul's own settled (`quiet`).
1042        fn follow(&self, kind: &Kind, quiet: Arc<Mutex<Instant>>, told: Told) -> Option<Box<dyn Watching>>;
1043    }
1044
1045    /// An inhibition held.
1046    pub(super) trait Held: Send {
1047        fn alive(&self) -> bool;
1048        fn release(self: Box<Self>) -> Result<(), String>;
1049    }
1050
1051    /// Changes heard.
1052    pub(super) trait Heard: Send {
1053        fn since(&self, at: Instant) -> usize;
1054    }
1055
1056    /// The session's own do-not-disturb, followed.
1057    pub(super) trait Watching: Send {
1058        /// What was last read: on, off; none before it could be.
1059        fn seen(&self) -> Option<bool>;
1060    }
1061
1062    /// A computer's desktop: Plasma's inhibitions held, by pause; GNOME's
1063    /// switch and what dconf says of it.
1064    pub(crate) struct Desktop<D: Desk> {
1065        pub(super) desk: D,
1066        /// Plasma's inhibitions held, by pause, and since when.
1067        pub(super) held: Vec<(Which, Box<dyn Held>, Instant)>,
1068        /// dconf's changes of GNOME's switch while Sioul holds it, and from
1069        /// when they are the person's.
1070        heard: Option<(Box<dyn Heard>, Instant)>,
1071        /// Set before each change of Sioul's own: what follows the session's
1072        /// do-not-disturb waits until then, so as never to take it for the person's.
1073        quiet: Arc<Mutex<Instant>>,
1074        /// The session's own do-not-disturb followed, while the window runs.
1075        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        /// Plasma's inhibition for `which` ended from its applet (the person
1094        /// turned do-not-disturb off there, which drops every application's),
1095        /// as the session's `Inhibited` says: off while Sioul holds one.
1096        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        /// A change of Sioul's own comes: what follows the session waits a moment.
1102        fn hush(&self) {
1103            if let Ok(mut quiet) = self.quiet.lock() {
1104                *quiet = Instant::now() + SETTLE;
1105            }
1106        }
1107
1108        /// GNOME's switch, as asked: on only from off, only with the person's yes.
1109        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                // Turned on by Sioul already: for another pause, or before a restart.
1118                if self.heard.is_none() {
1119                    self.heard = self.desk.watch_banners().map(|heard| (heard, Instant::now()));
1120                }
1121                // Turned off by the person meanwhile, unheard (Sioul closed): left off, said.
1122                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                // Already on: the person's. Never turned off by Sioul.
1129                Ok(false) => Done::said(true, vec![Said::GnomeAlready]),
1130                Ok(true) => {
1131                    // Listening before the switch, so that its own word comes first.
1132                    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        /// GNOME's switch off again when the last pause that wanted it leaves,
1148        /// and only if nobody touched it meanwhile.
1149        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                    // Kept: tried again at the next start.
1168                    Err(why) => vec![Said::GnomeFailed(why)],
1169                },
1170                // Turned off meanwhile, by the person.
1171                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                        // Held by Plasma's own do-not-disturb, which turned Sioul's on:
1202                        // nothing of Sioul's over it, so that its end is heard.
1203                        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                            // Its end not heard as it happened: said, as turned off here.
1210                            Some(false) => Done::turned_off(vec![Said::PlasmaTurnedOff]),
1211                            _ => Done::said(true, vec![Said::PlasmaHeld]),
1212                        };
1213                    }
1214                    if self.revoked(ask.which) {
1215                        // Ended from Plasma's applet, not heard as it happened: left off.
1216                        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                // Held by GNOME's own Do Not Disturb, which turned Sioul's on: nothing of Sioul's over it.
1237                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                    // The yes taken back during the pause: switched back now,
1244                    // unless another pause on still has it.
1245                    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                // Refused: lifted already (the person, from Plasma's applet), or the server gone.
1263                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                // Plasma's own do-not-disturb, as its applet sets it; the other
1281                // causes (an application's inhibition, a full-screen window) are not Sioul's to end.
1282                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                // GNOME's switch, with the person's yes.
1287                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            // What the follower last read, where it runs; else read now.
1297            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            // GNOME's switch stays as Sioul set it; Plasma's inhibitions end with Sioul.
1309            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            // An inhibition's connection gone (the session bus lost): asked again;
1321            // one ended from Plasma's applet: said so.
1322            self.held.iter().all(|(w, held, _)| *w != which || held.alive()) && !self.revoked(which)
1323        }
1324    }
1325
1326    /// Whether Plasma shows Sioul's notifications (all sent as critical) during
1327    /// do-not-disturb, from its settings: critical ones shown then
1328    /// (`CriticalInDndMode`, true unless turned off), or Sioul allowed "Show in
1329    /// do not disturb mode" (`ShowPopupsInDndMode`); never when Sioul's
1330    /// popups are off altogether (`ShowPopups`).
1331    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    /// A date and time as KConfig writes it ("2027,10,7,22,13,41.5": year,
1337    /// month, day, hour, minute, seconds, in local time `zone`, or with a time
1338    /// zone as a seventh part): Unix seconds; none when it does not read.
1339    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    /// A key's value in a KDE settings file, in a group written as its header
1365    /// (`[Notifications]`, `[Applications][com.aurelienpierre.Sioul]`).
1366    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                // A group's header, maybe marked immutable: "[Group][$i]".
1372                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                // "Key[$i]=": immutable; "Key[fr]=": a language's, not this one.
1380                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    /// The person's session: its bus, GNOME's settings through `gsettings`,
1390    /// Plasma's through its files.
1391    #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
1392    #[derive(Default, Clone)]
1393    pub(crate) struct Session {
1394        /// Variables for `gsettings` (tests: a settings file of their own).
1395        pub(super) gsettings_env: Vec<(String, String)>,
1396        /// Where Plasma's settings are read from (tests: a folder of their own).
1397        pub(super) config_dirs: Option<Vec<PathBuf>>,
1398    }
1399
1400    /// GNOME's Do Not Disturb, inverted: banners shown.
1401    #[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        /// Plasma's settings files, the person's first: theirs, the look's
1423        /// defaults, the system's.
1424        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        /// A value of Plasma's notification settings, from the first file that sets it.
1436        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            // Tests write a settings file of their own, which dconf never tells of.
1476            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            // Tests, in a folder of their own: never the person's session.
1488            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            // KDE's own tool, as Plasma writes its settings; `--notify` tells
1501            // Plasma at once (its settings reload the group live). Tests write a
1502            // folder of their own, and tell nobody.
1503            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            // Tests write files of their own, which nothing on the bus tells of.
1523            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(&noted), Arc::clone(&reading), Arc::clone(&told), Arc::clone(&quiet));
1533                        // Read once the change held a second and Sioul's own change settled.
1534                        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    /// GNOME's switch followed: dconf's word of each change, and what was read then.
1557    #[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    /// Whether the session says it is this desktop (XDG_CURRENT_DESKTOP: "KDE", "ubuntu:GNOME").
1596    #[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    /// Plasma's notification settings, at Sioul's page, where "Show in do not
1602    /// disturb mode" is: System Settings, else its module alone.
1603    #[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                // Waited for on a thread of its own, so that it leaves no zombie behind.
1615                std::thread::spawn(move || window.wait());
1616                return;
1617            }
1618        }
1619    }
1620}
1621
1622// ---------------------------------------------------------------- macOS
1623
1624#[cfg(any(target_os = "macos", test))]
1625#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
1626mod mac {
1627    use super::*;
1628
1629    /// The Shortcuts app's side, behind a trait: tested with a stand-in.
1630    pub(super) trait Run {
1631        /// The person's shortcuts by name; none when they cannot be listed.
1632        fn list(&self) -> Option<Vec<String>>;
1633        fn run(&self, name: &str) -> Result<(), String>;
1634    }
1635
1636    /// The `shortcuts` command (macOS 12 and later).
1637    #[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    /// A Mac: macOS lets no application turn a Focus on; the person's own
1657    /// shortcuts can, run at the start of the first pause and the end of the
1658    /// last. Sioul cannot read whether a Focus was on before: the end
1659    /// shortcut does what the person made it do.
1660    #[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            // The Focus the person's shortcut turned on stays until "Sioul pause off" runs.
1694            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// ---------------------------------------------------------------- Windows, and elsewhere
1714
1715/// Windows: starting a Focus session is a Limited Access Feature, which needs
1716/// Microsoft's approval; an application can only read the mode.
1717#[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/// A system Sioul knows no do-not-disturb of.
1738#[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    /// A platform that counts its calls and answers as told.
1772    #[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        // Changed settings are brought to the system.
1814        let mut fewer = ask(Which::Paused);
1815        fewer.people = false;
1816        layer.enter(&fewer, &en);
1817        assert_eq!(calls.borrow().len(), 2);
1818        // What no longer holds (an inhibition's connection gone) is asked again.
1819        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        // One that cannot change is not.
1836        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        // The first leave of a run heals what a crash may have left.
1848        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        // Entered again after, it is asked again.
1858        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        // A crash: the next run knows the pause was entered, and what Sioul turned on.
1873        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    /// A desktop session as told: GNOME's switch in memory, inhibitions counted.
1884    #[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        /// What Plasma's server says of `Inhibited`; its own do-not-disturb's
1896        /// end; how often Sioul ended it.
1897        server: Rc<Cell<Option<bool>>>,
1898        until: Rc<Cell<Option<i64>>>,
1899        cleared: Rc<Cell<u32>>,
1900    }
1901
1902    /// The session's do-not-disturb as last read, told.
1903    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        // On before the pause: left alone, before and after.
2009        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        // Changed during the pause (heard from dconf): left as it is.
2016        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        // Nothing more at the end.
2068        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        // Plasma: one inhibition each, lifted each on its own.
2083        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        // Sioul ends without leaving; the pause ends meanwhile, elsewhere.
2100        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        // The connection lost (the session bus gone and back): asked again.
2129        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        // A pause that holds the doses anyway needs no word of it.
2143        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        // A file as Plasma writes it, critical notifications hidden in do-not-disturb.
2153        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        // Plasma's default, and Sioul allowed in do-not-disturb.
2158        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        // Sioul's popups off altogether: nothing shows.
2162        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    /// The Shortcuts app as told.
2178    #[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        // Without the person's shortcut: said, nothing run.
2207        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        // Doses held in that pause: silent, in the shade.
2222        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        // Nobody, the doses' channel changed by the person, a do-not-disturb on before.
2226        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        // Set otherwise in Android's settings (Android 15's modes): said as it is.
2231        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        // A part Sioul never sets (unset): the mode named, as set there.
2235        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        // The matrix's own (docs/attention.md, §3.3): this phone screens calls, so its contacts' calls
2238        // ring through the mode; messages from the starred; priority conversations; an event's alarms.
2239        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        // Android 10: no priority conversations (none read back), said; an older Java: no word of events.
2245        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        // Everyone, and an event alarms' channel changed by the person.
2248        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        // Nothing let through by the matrix: from everyone, as before.
2252        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        // Priority conversations alone (the starred no more): asked so, said so.
2256        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        // No access: said, with Android's page offered, and asked again later.
2260        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        // Too old, or no answer at all.
2264        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        // Leaving: said only for what was on; another do-not-disturb still on is said.
2267        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        // The mode turned off by the person, in Android's settings or during the pause: left off, said.
2271        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        // Turned off in Android's settings: said, with Android's page to turn it on again there.
2278        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        // Held by the phone's own do-not-disturb, which turned Sioul's on: silenced, nothing of Sioul's over it.
2281        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        // Setup: access missing, or given.
2285        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        // Where no callback number is known, none is said.
2290        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        // Android 10, said where the modes are set up; Android 12, nothing more.
2293        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        // Sioul's own mode first (another reason), then held: Sioul's taken away.
2406        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        // Its end not heard as it happened (Sioul busy, closed): said as turned off here.
2412        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        // A while later, the session says Plasma's do-not-disturb is off: the applet dropped it.
2425        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        // Left, then asked anew (Sioul's next "on"): inhibited again.
2433        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        // Plasma: its own do-not-disturb ended; another cause is not Sioul's to end.
2443        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        // GNOME: with the person's yes only.
2454        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        // A phone that does not answer, Windows: nothing read, nothing done.
2462        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        // Held by GNOME's own switch: nothing written.
2472        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        // Sioul turned it on, then was closed; the person turned it off meanwhile: said, left off.
2477        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        // Plasma's inhibition ends with Sioul's process.
2488        let mut layer = Layer::new(plasma(true), None);
2489        layer.enter(&ask(Which::Global), &en);
2490        assert!(!layer.platform.outlives(&layer.kept));
2491        // GNOME's switch, as Sioul set it, stays.
2492        let mut layer = Layer::new(gnome(true), None);
2493        layer.enter(&ask(Which::Global), &en);
2494        assert!(layer.platform.outlives(&layer.kept));
2495        // The person's own GNOME switch is not Sioul's to report.
2496        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        // Local time, as the applet writes "until turned off" (a year ahead).
2517        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        // With a zone of its own, the seventh part.
2519        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    /// Plasma's own do-not-disturb ended with `kwriteconfig6`, in a folder of
2526    /// the test's own, telling nobody: the person's settings are never read nor
2527    /// written. Skipped where `kwriteconfig6` is not installed.
2528    #[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    /// GNOME's real switch through `gsettings`, against a settings file of the
2552    /// test's own (GSETTINGS_BACKEND=keyfile in a folder of its own): the
2553    /// person's own settings are never read or written. Skipped where
2554    /// `gsettings` or GNOME's schema is not installed.
2555    #[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        // GIO without its virtual file systems: no gvfs daemon started for the test.
2561        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}