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#[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#[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#[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 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 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 pub(super) const GSETTINGS_BIN: &str = "/usr/bin/gsettings";
190 pub(super) const GDBUS_BIN: &str = "/usr/bin/gdbus";
191
192 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 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 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#[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}