Skip to main content

entracte_lib/
dnd.rs

1pub fn is_active() -> bool {
2    #[cfg(target_os = "macos")]
3    return macos::check();
4    #[cfg(target_os = "windows")]
5    return windows::check();
6    #[cfg(target_os = "linux")]
7    return linux::check();
8    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
9    return false;
10}
11
12/// True if the parsed macOS `Assertions.json` contains any active Do Not
13/// Disturb assertion (i.e. some entry's `storeAssertionRecords` array is
14/// non-empty). Kept platform-agnostic and un-gated so it compiles and is
15/// unit-tested on every OS, not just macOS — only the file read in
16/// `macos::check` is platform-specific (so in a non-test build only the
17/// macOS target actually calls it).
18#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
19pub(crate) fn parse_assertions_active(json: &str) -> bool {
20    let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json) else {
21        return false;
22    };
23    parsed
24        .get("data")
25        .and_then(|d| d.as_array())
26        .map(|arr| {
27            arr.iter().any(|entry| {
28                entry
29                    .get("storeAssertionRecords")
30                    .and_then(|r| r.as_array())
31                    .map(|records| !records.is_empty())
32                    .unwrap_or(false)
33            })
34        })
35        .unwrap_or(false)
36}
37
38/// GNOME exposes "Do Not Disturb" as the *inverse* of
39/// `org.gnome.desktop.notifications show-banners`: when banners are
40/// turned off, DnD is on. `gsettings get` prints the GVariant literal
41/// `true` / `false` (with a trailing newline). Anything we can't parse as
42/// an explicit `false` is treated as "banners on" → DnD off, so a missing
43/// key or unexpected output fails safe (breaks keep firing).
44///
45/// Un-gated so it's unit-tested on every OS; only `linux::check` calls it
46/// in a non-test build.
47#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
48pub(crate) fn parse_gnome_show_banners_dnd(gsettings_output: &str) -> bool {
49    gsettings_output.trim() == "false"
50}
51
52/// KDE Plasma exposes DnD as the boolean `Inhibited` property on
53/// `org.freedesktop.Notifications`. Read via
54/// `gdbus call … org.freedesktop.DBus.Properties.Get`, the reply is a
55/// GVariant tuple wrapping a variant, e.g. `(<true>,)` or `(<false>,)`.
56/// True only when the wrapped value is `true`; any other / unparseable
57/// output fails safe to `false`.
58///
59/// Un-gated so it's unit-tested on every OS; only `linux::check` calls it
60/// in a non-test build.
61#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
62pub(crate) fn parse_kde_inhibited(gdbus_output: &str) -> bool {
63    gdbus_output.contains("<true>") || gdbus_output.trim() == "(true,)"
64}
65
66#[cfg(target_os = "macos")]
67mod macos {
68    use super::parse_assertions_active;
69
70    pub fn check() -> bool {
71        let Some(home) = std::env::var_os("HOME") else {
72            return false;
73        };
74        let path = std::path::Path::new(&home).join("Library/DoNotDisturb/DB/Assertions.json");
75        let Ok(content) = std::fs::read_to_string(&path) else {
76            return false;
77        };
78        parse_assertions_active(&content)
79    }
80}
81
82#[cfg(target_os = "windows")]
83mod windows {
84    use std::sync::OnceLock;
85    use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
86    use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
87    use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
88
89    const WNF_FOCUS_ASSIST: u64 = 0xA3BC1875_A3BC0875;
90    // Windows 10 1809 (October 2018) was the first build where Focus
91    // Assist exposed its state through this WNF name with the
92    // six-argument NtQueryWnfStateData signature we transmute below.
93    const MIN_SUPPORTED_BUILD: u32 = 17763;
94
95    type NtQueryWnfStateDataFn = unsafe extern "system" fn(
96        *const u64,
97        *const u8,
98        *const u8,
99        *mut u32,
100        *mut u8,
101        *mut u32,
102    ) -> i32;
103
104    fn os_build() -> Option<u32> {
105        let mut info: OSVERSIONINFOW = unsafe { std::mem::zeroed() };
106        info.dwOSVersionInfoSize = std::mem::size_of::<OSVERSIONINFOW>() as u32;
107        let status = unsafe { RtlGetVersion(&mut info) };
108        if status != 0 {
109            return None;
110        }
111        Some(info.dwBuildNumber)
112    }
113
114    fn version_supported() -> bool {
115        static CACHED: OnceLock<bool> = OnceLock::new();
116        *CACHED.get_or_init(|| match os_build() {
117            Some(build) if build >= MIN_SUPPORTED_BUILD => true,
118            Some(build) => {
119                log::info!(
120                    "dnd: Windows build {build} < {MIN_SUPPORTED_BUILD}; \
121                     skipping Focus Assist probe"
122                );
123                false
124            }
125            None => {
126                log::info!("dnd: RtlGetVersion failed; skipping Focus Assist probe");
127                false
128            }
129        })
130    }
131
132    // SAFETY: The signature for `NtQueryWnfStateData` is undocumented but
133    // has been stable across Windows 10 build 17763+ and all Windows 11
134    // builds shipped to date. `version_supported` gates the transmute to
135    // those releases. On older builds, or if `RtlGetVersion` fails, we
136    // return `false` from `check()` without ever calling the symbol.
137    fn query_fn() -> Option<NtQueryWnfStateDataFn> {
138        static CACHED: OnceLock<Option<NtQueryWnfStateDataFn>> = OnceLock::new();
139        *CACHED.get_or_init(|| unsafe {
140            if !version_supported() {
141                return None;
142            }
143            let ntdll = GetModuleHandleA(c"ntdll.dll".as_ptr().cast());
144            if ntdll.is_null() {
145                return None;
146            }
147            let ptr = GetProcAddress(ntdll, c"NtQueryWnfStateData".as_ptr().cast());
148            ptr.map(|p| std::mem::transmute::<_, NtQueryWnfStateDataFn>(p))
149        })
150    }
151
152    pub fn check() -> bool {
153        let Some(query) = query_fn() else {
154            return false;
155        };
156        let state_name = WNF_FOCUS_ASSIST;
157        let mut buffer = [0u8; 4];
158        let mut buffer_size: u32 = buffer.len() as u32;
159        let mut change_stamp: u32 = 0;
160        let status = unsafe {
161            query(
162                &state_name,
163                std::ptr::null(),
164                std::ptr::null(),
165                &mut change_stamp,
166                buffer.as_mut_ptr(),
167                &mut buffer_size,
168            )
169        };
170        if status != 0 || buffer_size < 4 {
171            return false;
172        }
173        let mode = u32::from_le_bytes(buffer);
174        mode > 0
175    }
176}
177
178#[cfg(target_os = "linux")]
179mod linux {
180    use std::process::Command;
181
182    use super::{parse_gnome_show_banners_dnd, parse_kde_inhibited};
183    use crate::proc::{CommandTimeoutExt, PROBE_TIMEOUT};
184
185    // Absolute paths so a planted `gsettings` / `gdbus` earlier in `$PATH`
186    // can't intercept the probe. These are the canonical locations on the
187    // distros we target; if a session ships them elsewhere the command
188    // simply fails and we fail safe to "DnD off".
189    pub(super) const GSETTINGS_BIN: &str = "/usr/bin/gsettings";
190    pub(super) const GDBUS_BIN: &str = "/usr/bin/gdbus";
191
192    // GNOME is the most common case, so try it first; fall through to the
193    // KDE property only if GNOME didn't report DnD on. Either probe failing
194    // (tool missing, not that desktop) just yields `false` — there's no
195    // portable cross-desktop DnD signal, so unknown environments fail safe.
196    pub fn check() -> bool {
197        gnome_dnd_active() || kde_dnd_active()
198    }
199
200    fn gnome_dnd_active() -> bool {
201        let Ok(output) = Command::new(GSETTINGS_BIN)
202            .args(["get", "org.gnome.desktop.notifications", "show-banners"])
203            .output_timeout(PROBE_TIMEOUT)
204        else {
205            return false;
206        };
207        if !output.status.success() {
208            return false;
209        }
210        let Ok(text) = std::str::from_utf8(&output.stdout) else {
211            return false;
212        };
213        parse_gnome_show_banners_dnd(text)
214    }
215
216    fn kde_dnd_active() -> bool {
217        let Ok(output) = Command::new(GDBUS_BIN)
218            .args([
219                "call",
220                "--session",
221                "--dest",
222                "org.freedesktop.Notifications",
223                "--object-path",
224                "/org/freedesktop/Notifications",
225                "--method",
226                "org.freedesktop.DBus.Properties.Get",
227                "org.freedesktop.Notifications",
228                "Inhibited",
229            ])
230            .output_timeout(PROBE_TIMEOUT)
231        else {
232            return false;
233        };
234        if !output.status.success() {
235            return false;
236        }
237        let Ok(text) = std::str::from_utf8(&output.stdout) else {
238            return false;
239        };
240        parse_kde_inhibited(text)
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::parse_assertions_active;
247
248    #[test]
249    fn empty_data_array_means_inactive() {
250        assert!(!parse_assertions_active(r#"{"data":[]}"#));
251    }
252
253    #[test]
254    fn missing_data_key_means_inactive() {
255        assert!(!parse_assertions_active(r#"{"other":123}"#));
256    }
257
258    #[test]
259    fn entry_with_empty_records_means_inactive() {
260        let json = r#"{"data":[{"storeAssertionRecords":[]}]}"#;
261        assert!(!parse_assertions_active(json));
262    }
263
264    #[test]
265    fn entry_with_a_record_means_active() {
266        let json = r#"{"data":[{"storeAssertionRecords":[{"assertionDetails":"x"}]}]}"#;
267        assert!(parse_assertions_active(json));
268    }
269
270    #[test]
271    fn one_active_entry_among_inactive_means_active() {
272        let json = r#"{"data":[
273            {"storeAssertionRecords":[]},
274            {"storeAssertionRecords":[{"assertionDetails":"focus"}]}
275        ]}"#;
276        assert!(parse_assertions_active(json));
277    }
278
279    #[test]
280    fn malformed_json_means_inactive() {
281        assert!(!parse_assertions_active("not json at all"));
282        assert!(!parse_assertions_active(""));
283    }
284
285    use super::{parse_gnome_show_banners_dnd, parse_kde_inhibited};
286
287    #[test]
288    fn gnome_banners_false_means_dnd_on() {
289        // gsettings prints the GVariant literal with a trailing newline.
290        assert!(parse_gnome_show_banners_dnd("false\n"));
291        assert!(parse_gnome_show_banners_dnd("false"));
292    }
293
294    #[test]
295    fn gnome_banners_true_means_dnd_off() {
296        assert!(!parse_gnome_show_banners_dnd("true\n"));
297    }
298
299    #[test]
300    fn gnome_unexpected_output_fails_safe_to_off() {
301        assert!(!parse_gnome_show_banners_dnd(""));
302        assert!(!parse_gnome_show_banners_dnd(
303            "No such key 'show-banners'\n"
304        ));
305    }
306
307    #[test]
308    fn kde_inhibited_true_means_dnd_on() {
309        // gdbus reply for the Properties.Get of a boolean property.
310        assert!(parse_kde_inhibited("(<true>,)\n"));
311    }
312
313    #[test]
314    fn kde_inhibited_false_means_dnd_off() {
315        assert!(!parse_kde_inhibited("(<false>,)\n"));
316    }
317
318    #[test]
319    fn kde_unexpected_output_fails_safe_to_off() {
320        assert!(!parse_kde_inhibited(""));
321        assert!(!parse_kde_inhibited("Error: no such property\n"));
322    }
323}
324
325// Absolute-path sanity for the Linux probe binaries. Gated because the
326// consts only exist on Linux (mirrors the *_bin_tests in camera/video).
327#[cfg(all(test, target_os = "linux"))]
328mod linux_bin_tests {
329    use super::linux::{GDBUS_BIN, GSETTINGS_BIN};
330
331    #[test]
332    fn probe_bins_are_absolute_and_non_empty() {
333        for bin in [GSETTINGS_BIN, GDBUS_BIN] {
334            assert!(!bin.is_empty());
335            assert!(bin.starts_with('/'), "expected absolute path, got {bin}");
336        }
337    }
338}