Skip to main content

sioul_sync/
dnd.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The desktop's do-not-disturb during Sioul's pauses (sioul-app's dnd.rs,
5//! docs/pauses.md): who serves the notifications, Plasma's inhibition held
6//! for as long as a pause lasts, and dconf's word that a GNOME setting
7//! changed. Linux and the BSDs; the session's D-Bus.
8//!
9//! Plasma lifts an inhibition when the connection that asked for it closes:
10//! each one is held by a thread of its own, which keeps that connection open
11//! until it is released or Sioul ends (a crash never leaves the desktop
12//! silenced), and asks again if Plasma's notification server starts again.
13//!
14//! Both ways (docs/do-not-disturb.md): the server's `Inhibited` followed
15//! (`watch_inhibited`), each change told once it held a second and Sioul's
16//! own change settled; dconf's changes of a key told as they come
17//! (`watch_dconf_told`), for GNOME's switch.
18
19use dbus::arg::PropMap;
20use dbus::blocking::LocalConnection;
21use dbus::message::MatchRule;
22use std::cell::{Cell, RefCell};
23use std::rc::Rc;
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::sync::mpsc::{Receiver, Sender, TryRecvError};
26use std::sync::{Arc, Mutex};
27use std::time::{Duration, Instant};
28
29/// The notification server, on the session bus.
30const SERVER: &str = "org.freedesktop.Notifications";
31const SERVER_PATH: &str = "/org/freedesktop/Notifications";
32/// The bus itself.
33const BUS: &str = "org.freedesktop.DBus";
34const BUS_PATH: &str = "/org/freedesktop/DBus";
35/// dconf's writer, which tells every change to GNOME's settings.
36const DCONF_WRITER: &str = "ca.desrt.dconf.Writer";
37
38/// How long a call to the server may take.
39const CALL_WAIT: Duration = Duration::from_secs(3);
40/// How long a holding thread listens before it looks at what Sioul asked.
41const LISTENED: Duration = Duration::from_millis(250);
42/// How long a holding thread may take to start.
43const START_WAIT: Duration = Duration::from_secs(5);
44/// How long a change of `Inhibited` must hold before it is read again and told.
45const HELD: Duration = Duration::from_secs(1);
46/// D-Bus's properties, whose `PropertiesChanged` says a change of `Inhibited`.
47const PROPERTIES: &str = "org.freedesktop.DBus.Properties";
48
49/// Who serves the session's notifications, as it says.
50#[derive(Clone, Debug, Default, PartialEq, Eq)]
51pub struct Server {
52    /// "Plasma", "gnome-shell", "dunst"…
53    pub name: String,
54    /// "KDE", "GNOME"…
55    pub vendor: String,
56    pub version: String,
57    pub capabilities: Vec<String>,
58    /// Whether it lets an application inhibit notifications (Plasma's
59    /// `Inhibit`, listed as the capability "inhibitions").
60    pub inhibits: bool,
61}
62
63/// The session's notification server; none without a session bus, or when no
64/// server is running (asking would start one: D-Bus activation).
65pub fn server() -> Option<Server> {
66    let bus = LocalConnection::new_session().ok()?;
67    if !has_owner(&bus, SERVER) {
68        return None;
69    }
70    let proxy = bus.with_proxy(SERVER, SERVER_PATH, CALL_WAIT);
71    let (name, vendor, version, _spec): (String, String, String, String) = proxy.method_call(SERVER, "GetServerInformation", ()).ok()?;
72    let capabilities = proxy.method_call(SERVER, "GetCapabilities", ()).map(|(found,): (Vec<String>,)| found).unwrap_or_default();
73    let mut inhibits = capabilities.iter().any(|c| c == "inhibitions");
74    // Plasma before it listed the capability: its methods say it.
75    if !inhibits && vendor == "KDE" {
76        use dbus::blocking::stdintf::org_freedesktop_dbus::Introspectable;
77        inhibits = proxy.introspect().is_ok_and(|xml| xml.contains("name=\"Inhibit\""));
78    }
79    Some(Server { name, vendor, version, capabilities, inhibits })
80}
81
82/// Whether a name has an owner on the bus, without starting one.
83fn has_owner(bus: &LocalConnection, name: &str) -> bool {
84    bus.with_proxy(BUS, BUS_PATH, CALL_WAIT).method_call(BUS, "NameHasOwner", (name,)).is_ok_and(|(owned,): (bool,)| owned)
85}
86
87/// The connection that owns a name now (":1.42"); none when nobody does.
88fn owner_of(bus: &LocalConnection, name: &str) -> Option<String> {
89    bus.with_proxy(BUS, BUS_PATH, CALL_WAIT).method_call(BUS, "GetNameOwner", (name,)).ok().map(|(owner,): (String,)| owner)
90}
91
92/// What the notification server says of `Inhibited` now: Plasma's do-not-disturb
93/// as its notifications applet works it out (the person's own, any
94/// application's inhibition, a full-screen window, mirrored screens). None
95/// without a server, or one that does not say it.
96pub fn inhibited() -> Option<bool> {
97    let bus = LocalConnection::new_session().ok()?;
98    if !has_owner(&bus, SERVER) {
99        return None;
100    }
101    read_inhibited(&bus)
102}
103
104fn read_inhibited(bus: &LocalConnection) -> Option<bool> {
105    use dbus::blocking::stdintf::org_freedesktop_dbus::Properties;
106    bus.with_proxy(SERVER, SERVER_PATH, CALL_WAIT).get::<bool>(SERVER, "Inhibited").ok()
107}
108
109/// The session's do-not-disturb followed, as the notification server says it
110/// (`Inhibited`): each change that held a second, read again then, is told
111/// once Sioul's own change settled (`quiet`: a time Sioul sets before each of
112/// its own changes). A new server (plasmashell started again) is read without
113/// telling: what it says then is nobody's act. Dropped, it stops.
114pub struct Watch {
115    stop: Sender<()>,
116    seen: Arc<Mutex<Option<bool>>>,
117    alive: Arc<AtomicBool>,
118}
119
120impl Watch {
121    /// The state last read; none before a server answered.
122    pub fn seen(&self) -> Option<bool> {
123        self.seen.lock().ok().and_then(|seen| *seen)
124    }
125
126    /// Whether it still follows.
127    pub fn alive(&self) -> bool {
128        self.alive.load(Ordering::Relaxed)
129    }
130}
131
132impl Drop for Watch {
133    fn drop(&mut self) {
134        let _ = self.stop.send(());
135    }
136}
137
138/// Follows the server's `Inhibited`, on a thread of its own: `told(on)` hears
139/// each change, on that thread (it should hand any long work on).
140pub fn watch_inhibited(quiet: Arc<Mutex<Instant>>, told: Box<dyn Fn(bool) + Send>) -> Result<Watch, String> {
141    let (stop, stopped) = std::sync::mpsc::channel::<()>();
142    let (ready, started) = std::sync::mpsc::channel();
143    let seen: Arc<Mutex<Option<bool>>> = Arc::default();
144    let alive = Arc::new(AtomicBool::new(true));
145    let (noted, living) = (Arc::clone(&seen), Arc::clone(&alive));
146    std::thread::spawn(move || {
147        follow_inhibited(&noted, &quiet, &*told, &stopped, &ready);
148        living.store(false, Ordering::Relaxed);
149    });
150    match started.recv_timeout(START_WAIT) {
151        Ok(Ok(())) => Ok(Watch { stop, seen, alive }),
152        Ok(Err(e)) => Err(e),
153        Err(_) => Err("the session bus did not answer".to_string()),
154    }
155}
156
157fn follow_inhibited(seen: &Mutex<Option<bool>>, quiet: &Mutex<Instant>, told: &dyn Fn(bool), stopped: &Receiver<()>, ready: &Sender<Result<(), String>>) {
158    let bus = match LocalConnection::new_session() {
159        Ok(bus) => bus,
160        Err(e) => {
161            let _ = ready.send(Err(said(&e)));
162            return;
163        }
164    };
165    // When a change was heard, to read it again once it held; and whether it
166    // came with a new server, read then without telling.
167    let heard: Rc<Cell<Option<(Instant, bool)>>> = Rc::default();
168    let owner = Rc::new(RefCell::new(owner_of(&bus, SERVER).unwrap_or_default()));
169    let (new_owner, server_changed) = (Rc::clone(&owner), Rc::clone(&heard));
170    let owners = bus.add_match(MatchRule::new_signal(BUS, "NameOwnerChanged").with_sender(BUS), move |(name, _old, new): (String, String, String), _: &LocalConnection, _: &dbus::Message| {
171        if name == SERVER {
172            *new_owner.borrow_mut() = new;
173            server_changed.set(Some((Instant::now(), true)));
174        }
175        true
176    });
177    let (from, property_changed) = (Rc::clone(&owner), Rc::clone(&heard));
178    let changes = bus.add_match(MatchRule::new_signal(PROPERTIES, "PropertiesChanged").with_path(SERVER_PATH), move |(interface, changed, _gone): (String, PropMap, Vec<String>), _: &LocalConnection, message: &dbus::Message| {
179        let ours = message.sender().is_some_and(|sender| *from.borrow() == sender.to_string());
180        if ours && interface == SERVER && changed.contains_key("Inhibited") {
181            let fresh = property_changed.get().is_some_and(|(_, fresh)| fresh);
182            property_changed.set(Some((Instant::now(), fresh)));
183        }
184        true
185    });
186    if let Err(e) = owners.and(changes) {
187        let _ = ready.send(Err(said(&e)));
188        return;
189    }
190    // The state now, read without telling: found, not heard.
191    if let Ok(mut noted) = seen.lock() {
192        *noted = read_inhibited(&bus);
193    }
194    let _ = ready.send(Ok(()));
195    loop {
196        match stopped.try_recv() {
197            Ok(()) | Err(TryRecvError::Disconnected) => return,
198            Err(TryRecvError::Empty) => {}
199        }
200        if let Some((at, fresh)) = heard.get() {
201            let settled = quiet.lock().map_or(true, |quiet| Instant::now() >= *quiet);
202            if at.elapsed() >= HELD && settled {
203                heard.set(None);
204                let now = read_inhibited(&bus);
205                let before = seen.lock().ok().and_then(|mut noted| std::mem::replace(&mut *noted, now));
206                if !fresh
207                    && let (Some(before), Some(now)) = (before, now)
208                    && before != now
209                {
210                    told(now);
211                }
212            }
213        }
214        if bus.process(LISTENED).is_err() {
215            return;
216        }
217    }
218}
219
220/// An inhibition of the desktop's notifications (Plasma's do-not-disturb,
221/// shown "While Sioul is active (reason)"), held until released or dropped.
222pub struct Inhibition {
223    orders: Sender<Sender<Result<(), String>>>,
224    alive: Arc<AtomicBool>,
225    cookie: u32,
226}
227
228impl Inhibition {
229    /// Whether its thread still holds the connection.
230    pub fn alive(&self) -> bool {
231        self.alive.load(Ordering::Relaxed)
232    }
233
234    /// The server's number for it, as first given.
235    pub fn cookie(&self) -> u32 {
236        self.cookie
237    }
238
239    /// Lifted (`UnInhibit`), waiting at most `wait` for the server's answer.
240    /// An inhibition the person ended meanwhile (from Plasma's applet) is
241    /// lifted already: the server's refusal then says so, and is harmless.
242    pub fn release(self, wait: Duration) -> Result<(), String> {
243        let (done, answer) = std::sync::mpsc::channel();
244        if self.orders.send(done).is_err() {
245            return Ok(());
246        }
247        answer.recv_timeout(wait).unwrap_or_else(|_| Err("no answer from the notification server".to_string()))
248    }
249}
250
251/// Asks the notification server to hold notifications back while Sioul
252/// pauses: `desktop_entry` names the application (its desktop file, which
253/// Plasma shows by its name), `reason` the pause. Fails when the server has
254/// no such call or refuses; the error is the server's.
255pub fn inhibit(desktop_entry: &str, reason: &str) -> Result<Inhibition, String> {
256    let (orders, asked) = std::sync::mpsc::channel();
257    let (ready, started) = std::sync::mpsc::channel();
258    let alive = Arc::new(AtomicBool::new(true));
259    let (entry, reason, living) = (desktop_entry.to_string(), reason.to_string(), Arc::clone(&alive));
260    // The D-Bus connection cannot change threads, so it is made where it waits.
261    std::thread::spawn(move || {
262        hold(&entry, &reason, &asked, &ready);
263        living.store(false, Ordering::Relaxed);
264    });
265    match started.recv_timeout(START_WAIT) {
266        Ok(Ok(cookie)) => Ok(Inhibition { orders, alive, cookie }),
267        Ok(Err(e)) => Err(e),
268        Err(_) => Err("no answer from the notification server".to_string()),
269    }
270}
271
272/// An inhibition's thread: asked for, held, asked again when the server
273/// starts again (a new server holds none), lifted when Sioul says or goes.
274fn hold(entry: &str, reason: &str, asked: &Receiver<Sender<Result<(), String>>>, ready: &Sender<Result<u32, String>>) {
275    let bus = match LocalConnection::new_session() {
276        Ok(bus) => bus,
277        Err(e) => {
278            let _ = ready.send(Err(said(&e)));
279            return;
280        }
281    };
282    // Plasma's notification server started again (plasmashell restarted): it
283    // knows no inhibition. The person ending this one from the applet does
284    // not change the name's owner, and is never undone here.
285    let restarted: Rc<Cell<bool>> = Rc::default();
286    let heard = Rc::clone(&restarted);
287    let _ = bus.add_match(MatchRule::new_signal(BUS, "NameOwnerChanged").with_sender(BUS), move |(name, _old, new): (String, String, String), _: &LocalConnection, _: &dbus::Message| {
288        if name == SERVER && !new.is_empty() {
289            heard.set(true);
290        }
291        true
292    });
293    let server = bus.with_proxy(SERVER, SERVER_PATH, CALL_WAIT);
294    let ask = || server.method_call(SERVER, "Inhibit", (entry, reason, PropMap::new())).map(|(cookie,): (u32,)| cookie);
295    let mut cookie = match ask() {
296        Ok(cookie) => cookie,
297        Err(e) => {
298            let _ = ready.send(Err(said(&e)));
299            return;
300        }
301    };
302    let _ = ready.send(Ok(cookie));
303    let lift = |cookie: u32| -> Result<(), String> {
304        if cookie == 0 {
305            return Ok(());
306        }
307        server.method_call(SERVER, "UnInhibit", (cookie,)).map_err(|e| said(&e))
308    };
309    loop {
310        match asked.try_recv() {
311            Ok(done) => {
312                let _ = done.send(lift(cookie));
313                return;
314            }
315            Err(TryRecvError::Disconnected) => {
316                let _ = lift(cookie);
317                return;
318            }
319            Err(TryRecvError::Empty) => {}
320        }
321        if restarted.replace(false) {
322            // Asked again; 0 when the new server refuses: nothing to lift then.
323            cookie = ask().unwrap_or(0);
324        }
325        if bus.process(LISTENED).is_err() {
326            return;
327        }
328    }
329}
330
331/// A D-Bus error in a few words: its message, else its name.
332fn said(e: &dbus::Error) -> String {
333    e.message().or(e.name()).unwrap_or("D-Bus").to_string()
334}
335
336/// The changes dconf tells to one key, heard while it lives: GNOME's
337/// settings (and the person's switches in its top bar) write through dconf,
338/// which says each change on the session bus. Dropped, it stops listening.
339pub struct Changes {
340    stop: Sender<()>,
341    heard: Arc<Mutex<Vec<Instant>>>,
342    alive: Arc<AtomicBool>,
343}
344
345impl Changes {
346    /// How many changes were heard after `at`.
347    pub fn since(&self, at: Instant) -> usize {
348        self.heard.lock().map_or(0, |heard| heard.iter().filter(|when| **when > at).count())
349    }
350
351    /// Whether it still listens.
352    pub fn alive(&self) -> bool {
353        self.alive.load(Ordering::Relaxed)
354    }
355}
356
357impl Drop for Changes {
358    fn drop(&mut self) {
359        let _ = self.stop.send(());
360    }
361}
362
363/// Listens to dconf's changes of `key` ("/org/gnome/desktop/notifications/show-banners").
364pub fn watch_dconf(key: &str) -> Result<Changes, String> {
365    watch(key, None)
366}
367
368/// Listens to dconf's changes of `key`, and calls `told` at each, on the
369/// listening thread (it should hand any long work on).
370pub fn watch_dconf_told(key: &str, told: Box<dyn Fn() + Send>) -> Result<Changes, String> {
371    watch(key, Some(told))
372}
373
374fn watch(key: &str, told: Option<Box<dyn Fn() + Send>>) -> Result<Changes, String> {
375    let (stop, stopped) = std::sync::mpsc::channel::<()>();
376    let (ready, started) = std::sync::mpsc::channel();
377    let heard: Arc<Mutex<Vec<Instant>>> = Arc::default();
378    let alive = Arc::new(AtomicBool::new(true));
379    let (key, noted, living) = (key.to_string(), Arc::clone(&heard), Arc::clone(&alive));
380    std::thread::spawn(move || {
381        listen(&key, &noted, told, &stopped, &ready);
382        living.store(false, Ordering::Relaxed);
383    });
384    match started.recv_timeout(START_WAIT) {
385        Ok(Ok(())) => Ok(Changes { stop, heard, alive }),
386        Ok(Err(e)) => Err(e),
387        Err(_) => Err("the session bus did not answer".to_string()),
388    }
389}
390
391fn listen(key: &str, heard: &Arc<Mutex<Vec<Instant>>>, told: Option<Box<dyn Fn() + Send>>, stopped: &Receiver<()>, ready: &Sender<Result<(), String>>) {
392    let bus = match LocalConnection::new_session() {
393        Ok(bus) => bus,
394        Err(e) => {
395            let _ = ready.send(Err(said(&e)));
396            return;
397        }
398    };
399    let (key, noted) = (key.to_string(), Arc::clone(heard));
400    let matched = bus.add_match(MatchRule::new_signal(DCONF_WRITER, "Notify"), move |(prefix, changes, _tag): (String, Vec<String>, String), _: &LocalConnection, _: &dbus::Message| {
401        if touches(&key, &prefix, &changes) {
402            if let Ok(mut noted) = noted.lock() {
403                noted.push(Instant::now());
404            }
405            if let Some(told) = &told {
406                told();
407            }
408        }
409        true
410    });
411    if let Err(e) = matched {
412        let _ = ready.send(Err(said(&e)));
413        return;
414    }
415    let _ = ready.send(Ok(()));
416    loop {
417        match stopped.try_recv() {
418            Ok(()) | Err(TryRecvError::Disconnected) => return,
419            Err(TryRecvError::Empty) => {}
420        }
421        if bus.process(LISTENED).is_err() {
422            return;
423        }
424    }
425}
426
427/// Whether dconf's change (`prefix`, and the paths under it that changed, ""
428/// for the prefix itself) touches `key`: the key itself, or a folder holding
429/// it reset whole.
430fn touches(key: &str, prefix: &str, changes: &[String]) -> bool {
431    let reaches = |path: &str| path == key || (path.ends_with('/') && key.starts_with(path));
432    if changes.is_empty() {
433        return reaches(prefix);
434    }
435    changes.iter().any(|change| reaches(&format!("{prefix}{change}")))
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use dbus::blocking::stdintf::org_freedesktop_dbus::RequestNameReply;
442    use dbus::channel::{MatchingReceiver, Sender as _};
443    use std::sync::mpsc::channel;
444
445    #[test]
446    fn a_dconf_change_touches_its_key_or_a_folder_reset() {
447        let key = "/org/gnome/desktop/notifications/show-banners";
448        assert!(touches(key, key, &[String::new()]));
449        assert!(touches(key, "/org/gnome/desktop/notifications/", &["show-banners".into(), "show-in-lock-screen".into()]));
450        assert!(touches(key, "/org/gnome/desktop/notifications/", &[String::new()]), "the folder reset whole");
451        assert!(touches(key, "/org/gnome/", &["desktop/notifications/".into()]));
452        assert!(!touches(key, "/org/gnome/desktop/notifications/", &["show-in-lock-screen".into()]));
453        assert!(!touches(key, "/org/gnome/desktop/notifications/show-banners-x", &[String::new()]));
454        assert!(!touches(key, "/org/gnome/desktop/interface/", &["color-scheme".into()]));
455    }
456
457    /// What the stand-in notification server was asked.
458    #[derive(Debug, PartialEq)]
459    enum Asked {
460        Inhibit { entry: String, reason: String, caller: String },
461        UnInhibit(u32),
462    }
463
464    /// The tests that use the bus, one at a time: each stand-in needs the
465    /// server's name to itself.
466    static ON_THE_BUS: Mutex<()> = Mutex::new(());
467
468    /// A stand-in for Plasma's notification server, on a bus of the test's
469    /// own (dbus-run-session), recording what it is asked; its cookies start
470    /// at `first`. None when the name keeps an owner (the desktop's own
471    /// server): nothing here may reach it. It ends when `stop` is set, or
472    /// after `life`, and gives up the name then.
473    fn stand_in(first: u32, told: Sender<Asked>, stop: Arc<AtomicBool>, life: Duration) -> Option<()> {
474        stand_in_saying(first, told, stop, life, None)
475    }
476
477    /// What a stand-in says of `Inhibited`, and the values it is given to
478    /// announce (`PropertiesChanged`, as Plasma's server does).
479    struct Says {
480        value: Arc<AtomicBool>,
481        set: Receiver<bool>,
482    }
483
484    /// `stand_in`, answering `Inhibited` too when `says` is given.
485    fn stand_in_saying(first: u32, told: Sender<Asked>, stop: Arc<AtomicBool>, life: Duration, says: Option<Says>) -> Option<()> {
486        let (ready, started) = channel::<bool>();
487        std::thread::spawn(move || {
488            // Never in a server's place: no replacing it, no waiting in line;
489            // a stand-in of the test before may take a moment to leave.
490            let until = Instant::now() + Duration::from_secs(2);
491            let owner = loop {
492                let bus = LocalConnection::new_session().ok().filter(|bus| matches!(bus.request_name(SERVER, false, false, true), Ok(RequestNameReply::PrimaryOwner)));
493                if bus.is_some() || Instant::now() >= until {
494                    break bus;
495                }
496                std::thread::sleep(Duration::from_millis(50));
497            };
498            let Some(bus) = owner else {
499                let _ = ready.send(false);
500                return;
501            };
502            let next = Rc::new(Cell::new(first));
503            let value = says.as_ref().map(|says| Arc::clone(&says.value));
504            bus.start_receive(
505                MatchRule::new_method_call(),
506                Box::new(move |call: dbus::Message, bus: &LocalConnection| {
507                    let reply = match call.member().as_deref() {
508                        Some("GetServerInformation") => call.method_return().append3("Plasma", "KDE", "6.7.5").append1("1.2"),
509                        Some("GetCapabilities") => call.method_return().append1(vec!["body", "actions", "persistence", "inhibitions"]),
510                        Some("Inhibit") => {
511                            let (entry, reason, _hints): (String, String, PropMap) = call.read3().unwrap();
512                            let caller = call.sender().map(|s| s.to_string()).unwrap_or_default();
513                            let _ = told.send(Asked::Inhibit { entry, reason, caller });
514                            let cookie = next.get();
515                            next.set(cookie + 1);
516                            call.method_return().append1(cookie)
517                        }
518                        Some("UnInhibit") => {
519                            let (cookie,): (u32,) = call.read_all().unwrap();
520                            let _ = told.send(Asked::UnInhibit(cookie));
521                            call.method_return()
522                        }
523                        Some("Get") if call.interface().as_deref() == Some(PROPERTIES) => {
524                            let (_interface, name): (String, String) = call.read2().unwrap();
525                            match &value {
526                                Some(value) if name == "Inhibited" => call.method_return().append1(dbus::arg::Variant(value.load(Ordering::Relaxed))),
527                                _ => return true,
528                            }
529                        }
530                        _ => return true,
531                    };
532                    let _ = bus.send(reply);
533                    true
534                }),
535            );
536            let _ = ready.send(true);
537            let until = Instant::now() + life;
538            while !stop.load(Ordering::Relaxed) && Instant::now() < until {
539                // Each value given, announced as Plasma's server does.
540                while let Some(on) = says.as_ref().and_then(|says| says.set.try_recv().ok()) {
541                    if let Some(says) = &says {
542                        says.value.store(on, Ordering::Relaxed);
543                    }
544                    let mut changed = PropMap::new();
545                    changed.insert("Inhibited".to_string(), dbus::arg::Variant(Box::new(on) as Box<dyn dbus::arg::RefArg>));
546                    let signal = dbus::Message::new_signal(SERVER_PATH, PROPERTIES, "PropertiesChanged").unwrap().append3(SERVER, changed, Vec::<String>::new());
547                    let _ = bus.send(signal);
548                }
549                let _ = bus.process(Duration::from_millis(50));
550            }
551            let _ = bus.release_name(SERVER);
552        });
553        started.recv_timeout(Duration::from_secs(5)).unwrap_or(false).then_some(())
554    }
555
556    /// Whether a connection is still on the bus (the stand-in's view of Sioul's).
557    fn connected(name: &str) -> bool {
558        LocalConnection::new_session().is_ok_and(|bus| has_owner(&bus, name))
559    }
560
561    /// Plasma's inhibition, against a stand-in server on a bus of the test's
562    /// own: run under dbus-run-session. Where a server is there already (the
563    /// desktop's), skipped: nothing here may reach it.
564    #[test]
565    fn plasma_inhibition_is_held_while_the_pause_lasts_and_lifted_after() {
566        let _alone = ON_THE_BUS.lock().unwrap_or_else(|e| e.into_inner());
567        let (told, asked) = channel();
568        let stop = Arc::new(AtomicBool::new(false));
569        if stand_in(42, told, Arc::clone(&stop), Duration::from_secs(30)).is_none() {
570            eprintln!("No bus of the test's own (dbus-run-session), or a notification server on it already: skipped.");
571            return;
572        }
573        let wait = Duration::from_secs(5);
574        let found = server().expect("the stand-in answers");
575        assert_eq!((found.name.as_str(), found.vendor.as_str(), found.inhibits), ("Plasma", "KDE", true));
576        let held = inhibit("com.aurelienpierre.Sioul", "Pause").expect("inhibited");
577        assert_eq!(held.cookie(), 42);
578        let Asked::Inhibit { entry, reason, caller } = asked.recv_timeout(wait).unwrap() else { panic!("Inhibit first") };
579        assert_eq!((entry.as_str(), reason.as_str()), ("com.aurelienpierre.Sioul", "Pause"));
580        // The connection that asked stays on the bus while the pause lasts: Plasma
581        // lifts an inhibition whose caller's connection closes.
582        std::thread::sleep(Duration::from_millis(600));
583        assert!(held.alive() && connected(&caller), "the inhibition's connection is kept");
584        held.release(wait).expect("lifted");
585        assert_eq!(asked.recv_timeout(wait).unwrap(), Asked::UnInhibit(42));
586        // Then its connection goes.
587        let until = Instant::now() + wait;
588        while connected(&caller) && Instant::now() < until {
589            std::thread::sleep(Duration::from_millis(50));
590        }
591        assert!(!connected(&caller), "the connection closed once lifted");
592        // Dropped without a word (Sioul's pause state lost), it is lifted all the same.
593        let dropped = inhibit("com.aurelienpierre.Sioul", "Free time").expect("inhibited");
594        assert!(matches!(asked.recv_timeout(wait).unwrap(), Asked::Inhibit { reason, .. } if reason == "Free time"));
595        drop(dropped);
596        assert_eq!(asked.recv_timeout(wait).unwrap(), Asked::UnInhibit(43));
597        stop.store(true, Ordering::Relaxed);
598    }
599
600    /// Plasma's server started again during a pause (plasmashell restarted)
601    /// holds no inhibition: it is asked again, and lifted there at the end.
602    #[test]
603    fn plasma_restarted_is_asked_again() {
604        let _alone = ON_THE_BUS.lock().unwrap_or_else(|e| e.into_inner());
605        let (told, asked) = channel();
606        let first_stop = Arc::new(AtomicBool::new(false));
607        if stand_in(7, told.clone(), Arc::clone(&first_stop), Duration::from_secs(30)).is_none() {
608            eprintln!("No bus of the test's own (dbus-run-session), or a notification server on it already: skipped.");
609            return;
610        }
611        let wait = Duration::from_secs(5);
612        let held = inhibit("com.aurelienpierre.Sioul", "Pause").expect("inhibited");
613        assert!(matches!(asked.recv_timeout(wait).unwrap(), Asked::Inhibit { .. }));
614        // The first server goes; another takes its name.
615        first_stop.store(true, Ordering::Relaxed);
616        let until = Instant::now() + wait;
617        let second_stop = Arc::new(AtomicBool::new(false));
618        while stand_in(100, told.clone(), Arc::clone(&second_stop), Duration::from_secs(30)).is_none() {
619            assert!(Instant::now() < until, "the second stand-in could not take the name");
620            std::thread::sleep(Duration::from_millis(50));
621        }
622        assert!(matches!(asked.recv_timeout(wait).unwrap(), Asked::Inhibit { reason, .. } if reason == "Pause"), "asked again of the new server");
623        held.release(wait).expect("lifted");
624        assert_eq!(asked.recv_timeout(wait).unwrap(), Asked::UnInhibit(100));
625        second_stop.store(true, Ordering::Relaxed);
626    }
627
628    /// The session's do-not-disturb followed (Plasma's `Inhibited`), against a
629    /// stand-in server on a bus of the test's own: a change that holds a second
630    /// is told; a flicker is not; nothing before Sioul's own change settled; a
631    /// new server is read without telling.
632    #[test]
633    fn the_session_s_do_not_disturb_is_followed() {
634        let _alone = ON_THE_BUS.lock().unwrap_or_else(|e| e.into_inner());
635        let (told, _asked) = channel();
636        let stop = Arc::new(AtomicBool::new(false));
637        let (set, announced) = channel();
638        let says = Says { value: Arc::new(AtomicBool::new(false)), set: announced };
639        if stand_in_saying(1, told.clone(), Arc::clone(&stop), Duration::from_secs(40), Some(says)).is_none() {
640            eprintln!("No bus of the test's own (dbus-run-session), or a notification server on it already: skipped.");
641            return;
642        }
643        assert_eq!(inhibited(), Some(false));
644        let quiet = Arc::new(Mutex::new(Instant::now()));
645        let (heard, hears) = channel();
646        let watch = watch_inhibited(Arc::clone(&quiet), Box::new(move |on| {
647            let _ = heard.send(on);
648        }))
649        .expect("following");
650        assert_eq!(watch.seen(), Some(false), "read at the start, not told");
651        // Turned on (the applet, a full-screen window…): told once it held.
652        set.send(true).unwrap();
653        assert_eq!(hears.recv_timeout(Duration::from_secs(5)), Ok(true));
654        assert_eq!(watch.seen(), Some(true));
655        // A flicker within the second: read again after, unchanged, not told.
656        set.send(false).unwrap();
657        set.send(true).unwrap();
658        assert!(hears.recv_timeout(Duration::from_millis(2_500)).is_err(), "a flicker is no change");
659        // Sioul's own change: nothing before it settled, then what holds.
660        *quiet.lock().unwrap() = Instant::now() + Duration::from_secs(2);
661        set.send(false).unwrap();
662        assert!(hears.recv_timeout(Duration::from_millis(1_500)).is_err(), "not before Sioul's change settled");
663        assert_eq!(hears.recv_timeout(Duration::from_secs(4)), Ok(false));
664        // On again within the settle after Sioul's own change turned it off: on
665        // before, on after, nothing told; no press, no loop.
666        set.send(true).unwrap();
667        assert_eq!(hears.recv_timeout(Duration::from_secs(5)), Ok(true));
668        *quiet.lock().unwrap() = Instant::now() + Duration::from_secs(2);
669        set.send(false).unwrap();
670        std::thread::sleep(Duration::from_millis(300));
671        set.send(true).unwrap();
672        assert!(hears.recv_timeout(Duration::from_millis(4_000)).is_err(), "on before, on after: nothing");
673        assert_eq!(watch.seen(), Some(true));
674        // plasmashell started again, its do-not-disturb on: read, not told.
675        stop.store(true, Ordering::Relaxed);
676        let wait = Duration::from_secs(5);
677        let second_stop = Arc::new(AtomicBool::new(false));
678        let (_set_again, announced_again) = channel();
679        let says_again = Says { value: Arc::new(AtomicBool::new(true)), set: announced_again };
680        // It waits for the first to leave the name (two seconds at most).
681        assert!(stand_in_saying(50, told.clone(), Arc::clone(&second_stop), Duration::from_secs(30), Some(says_again)).is_some(), "the second stand-in took the name");
682        let until = Instant::now() + wait;
683        while watch.seen() != Some(true) && Instant::now() < until {
684            std::thread::sleep(Duration::from_millis(50));
685        }
686        assert_eq!(watch.seen(), Some(true), "the new server read");
687        assert!(hears.recv_timeout(Duration::from_millis(500)).is_err(), "and not told");
688        assert!(watch.alive());
689        second_stop.store(true, Ordering::Relaxed);
690    }
691
692    /// dconf's changes of a key told as they come, on a bus of the test's own.
693    #[test]
694    fn dconf_tells_each_change_of_its_key() {
695        let _alone = ON_THE_BUS.lock().unwrap_or_else(|e| e.into_inner());
696        let free = |bus: &LocalConnection| {
697            let until = Instant::now() + Duration::from_secs(1);
698            while has_owner(bus, SERVER) && Instant::now() < until {
699                std::thread::sleep(Duration::from_millis(50));
700            }
701            !has_owner(bus, SERVER)
702        };
703        let Some(bus) = LocalConnection::new_session().ok().filter(|bus| free(bus)) else {
704            eprintln!("No bus of the test's own (dbus-run-session): skipped.");
705            return;
706        };
707        let key = "/org/gnome/desktop/notifications/show-banners";
708        let (heard, hears) = channel();
709        let _changes = watch_dconf_told(key, Box::new(move || {
710            let _ = heard.send(());
711        }))
712        .expect("listening");
713        let notify = |prefix: &str, changed: Vec<&str>| {
714            let signal = dbus::Message::new_signal("/ca/desrt/dconf/Writer/user", DCONF_WRITER, "Notify").unwrap().append3(prefix, changed, "tag");
715            let _ = bus.send(signal);
716            bus.process(Duration::from_millis(10)).ok();
717        };
718        notify("/org/gnome/desktop/interface/", vec!["color-scheme"]);
719        notify(key, vec![""]);
720        assert_eq!(hears.recv_timeout(Duration::from_secs(5)), Ok(()));
721        assert!(hears.recv_timeout(Duration::from_millis(300)).is_err(), "another key's change is not told");
722    }
723
724    /// dconf's word that a key changed, on a bus of the test's own. Skipped on
725    /// a bus with a notification server (the desktop's): no signal of the
726    /// test's may reach the person's session.
727    #[test]
728    fn dconf_changes_of_a_key_are_heard() {
729        let _alone = ON_THE_BUS.lock().unwrap_or_else(|e| e.into_inner());
730        // A stand-in of the test before may take a moment to leave the name.
731        let free = |bus: &LocalConnection| {
732            let until = Instant::now() + Duration::from_secs(1);
733            while has_owner(bus, SERVER) && Instant::now() < until {
734                std::thread::sleep(Duration::from_millis(50));
735            }
736            !has_owner(bus, SERVER)
737        };
738        let Some(bus) = LocalConnection::new_session().ok().filter(|bus| free(bus)) else {
739            eprintln!("No bus of the test's own (dbus-run-session): skipped.");
740            return;
741        };
742        let key = "/org/gnome/desktop/notifications/show-banners";
743        let changes = watch_dconf(key).expect("listening");
744        let start = Instant::now();
745        let notify = |prefix: &str, changed: Vec<&str>| {
746            let signal = dbus::Message::new_signal("/ca/desrt/dconf/Writer/user", DCONF_WRITER, "Notify").unwrap().append3(prefix, changed, "tag");
747            let _ = bus.send(signal);
748            bus.process(Duration::from_millis(10)).ok();
749        };
750        notify("/org/gnome/desktop/interface/", vec!["color-scheme"]);
751        notify(key, vec![""]);
752        notify("/org/gnome/desktop/notifications/", vec!["show-in-lock-screen", "show-banners"]);
753        let until = Instant::now() + Duration::from_secs(5);
754        while changes.since(start) < 2 && Instant::now() < until {
755            std::thread::sleep(Duration::from_millis(20));
756        }
757        std::thread::sleep(Duration::from_millis(200));
758        assert_eq!(changes.since(start), 2, "two changes of the key, one of another");
759        assert_eq!(changes.since(Instant::now()), 0);
760        assert!(changes.alive());
761    }
762}