Skip to main content

entracte_lib/
video.rs

1//! "Pause during fullscreen video" detection.
2//!
3//! Each platform's `check()` combines two signals:
4//!
5//! 1. **Display-wake assertion** — `pmset` / `powercfg` / `systemd-inhibit`
6//!    tell us whether *anything* on the system is asking the display to
7//!    stay awake. Most video players (browser HTML5, VLC, video calls)
8//!    set this, but so do tiny background-tab videos. On its own it's a
9//!    false-positive magnet.
10//! 2. **Fullscreen window present** — at least one on-screen, normal
11//!    application window has bounds matching one of the connected
12//!    monitors. This narrows (1) to the "I'm actually committed to
13//!    watching this" case, which is what the user-facing setting
14//!    "Pause during fullscreen video" actually promises.
15//!
16//! Combined: `active = assertion && fullscreen_window_present`. The
17//! assertion check is the fast path — we skip the window enumeration
18//! when nothing is keeping the display awake.
19//!
20//! Wayland is a known degraded case: there is no portable way to
21//! enumerate windows from outside the compositor, so we treat the
22//! fullscreen check as always-true and fall back to assertion-only
23//! behaviour. A one-time `log::info!` at startup records the
24//! degradation.
25
26use std::sync::atomic::AtomicBool;
27use std::sync::Arc;
28
29pub fn spawn_monitor(active: Arc<AtomicBool>) {
30    #[cfg(target_os = "macos")]
31    macos::spawn(active);
32    #[cfg(target_os = "windows")]
33    windows::spawn(active);
34    #[cfg(target_os = "linux")]
35    linux::spawn(active);
36    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
37    let _ = active;
38}
39
40// 10-second poll interval: hot enough that a video call started
41// before a break gets caught in time, but cool enough that we're not
42// fork-bombing `pmset` / `powercfg` / `systemd-inhibit` 43k times a
43// day per monitor. Latency budget: the user starting a call and a
44// break firing within the next 10s is the worst case.
45const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
46
47// `Rect` / `rect_matches` / `any_window_is_fullscreen` /
48// `FULLSCREEN_TOLERANCE_PX` are used by the macOS and Windows fullscreen
49// checks. The Linux check goes through xprop's `_NET_WM_STATE_FULLSCREEN`
50// flag instead, so on Linux these are dead in non-test code (tests still
51// exercise them on every OS via the cross-platform truth-table suite).
52// Hence the per-symbol `cfg_attr(linux, allow(dead_code))`.
53
54/// Pixel tolerance when comparing window bounds to monitor bounds.
55/// Native fullscreen on macOS/Windows is exact, but X11 reparenting
56/// window managers (i3, openbox) sometimes leave a 1–2px border. Keep
57/// this small — a 5px tolerance would catch maximised-but-not-fullscreen
58/// windows on some setups.
59#[cfg_attr(target_os = "linux", allow(dead_code))]
60const FULLSCREEN_TOLERANCE_PX: i32 = 2;
61
62/// Plain rectangle used by the bounds-comparison helpers below. Decoupled
63/// from any platform type so the matching logic is pure and testable.
64#[cfg_attr(target_os = "linux", allow(dead_code))]
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub(crate) struct Rect {
67    pub x: i32,
68    pub y: i32,
69    pub w: i32,
70    pub h: i32,
71}
72
73/// True if `window` covers `monitor` within `FULLSCREEN_TOLERANCE_PX`
74/// on every edge.
75#[cfg_attr(target_os = "linux", allow(dead_code))]
76pub(crate) fn rect_matches(window: Rect, monitor: Rect) -> bool {
77    (window.x - monitor.x).abs() <= FULLSCREEN_TOLERANCE_PX
78        && (window.y - monitor.y).abs() <= FULLSCREEN_TOLERANCE_PX
79        && (window.w - monitor.w).abs() <= FULLSCREEN_TOLERANCE_PX
80        && (window.h - monitor.h).abs() <= FULLSCREEN_TOLERANCE_PX
81}
82
83/// True if any window in `windows` matches any monitor in `monitors`.
84/// This is the platform-independent core of the fullscreen check.
85#[cfg_attr(target_os = "linux", allow(dead_code))]
86pub(crate) fn any_window_is_fullscreen(windows: &[Rect], monitors: &[Rect]) -> bool {
87    windows
88        .iter()
89        .any(|w| monitors.iter().any(|m| rect_matches(*w, *m)))
90}
91
92/// Whether the platform can answer "is a fullscreen window present?".
93/// Linux Wayland uses `Unknowable`; everywhere else passes a concrete
94/// `Fullscreen(bool)`. Exists so the combining logic in
95/// [`pause_decision`] is one pure function with one truth-table test,
96/// instead of three near-identical chains duplicated per platform.
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub(crate) enum WindowKnowledge {
99    Fullscreen(bool),
100    // Only constructed on Linux Wayland; the truth-table tests exercise
101    // it everywhere, but in non-test builds on macOS / Windows nothing
102    // produces this variant — silence the dead-code lint.
103    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
104    Unknowable,
105}
106
107/// Should breaks pause right now, given the two signals?
108///
109/// Locked truth table — touch with care:
110/// - `(false, _)`                        → false (no media keeping screen awake)
111/// - `(true, Fullscreen(true))`          → true (real fullscreen video)
112/// - `(true, Fullscreen(false))`         → false (small-window video → DON'T pause)
113/// - `(true, Unknowable)`                → true (Wayland fallback: assertion-only)
114///
115/// The `Fullscreen(false) → false` row is the bug fix in the parent
116/// PR. If a contributor "simplifies" this back to `assertion`, the
117/// unit test catches it before merge.
118pub(crate) fn pause_decision(assertion_active: bool, window: WindowKnowledge) -> bool {
119    if !assertion_active {
120        return false;
121    }
122    match window {
123        WindowKnowledge::Fullscreen(b) => b,
124        WindowKnowledge::Unknowable => true,
125    }
126}
127
128#[cfg(target_os = "macos")]
129mod macos {
130    use std::process::Command;
131    use std::sync::atomic::{AtomicBool, Ordering};
132    use std::sync::Arc;
133    use std::thread;
134
135    use core_foundation::array::{CFArray, CFArrayRef};
136    use core_foundation::base::{TCFType, ToVoid};
137    use core_foundation::dictionary::CFDictionary;
138    use core_foundation::number::CFNumber;
139    use core_foundation::string::CFString;
140    use core_graphics::display::CGDisplay;
141    use core_graphics::window::{
142        kCGNullWindowID, kCGWindowListExcludeDesktopElements, kCGWindowListOptionOnScreenOnly,
143        CGWindowListCopyWindowInfo,
144    };
145
146    use super::{any_window_is_fullscreen, pause_decision, Rect, WindowKnowledge};
147
148    // Pin to the absolute path so `$PATH` shenanigans can't swap in a
149    // shim. `/usr/bin/pmset` is the OS-shipped location on every
150    // supported macOS release.
151    pub(super) const PMSET_BIN: &str = "/usr/bin/pmset";
152
153    pub fn spawn(active: Arc<AtomicBool>) {
154        thread::spawn(move || loop {
155            active.store(check(), Ordering::Relaxed);
156            thread::sleep(super::POLL_INTERVAL);
157        });
158    }
159
160    fn check() -> bool {
161        let assertion = display_assertion_active();
162        // Fast path: skip the (more expensive) window enumeration when
163        // nothing is even claiming to play media.
164        if !assertion {
165            return false;
166        }
167        pause_decision(
168            assertion,
169            WindowKnowledge::Fullscreen(fullscreen_window_present()),
170        )
171    }
172
173    fn display_assertion_active() -> bool {
174        let Ok(output) = Command::new(PMSET_BIN).args(["-g", "assertions"]).output() else {
175            return false;
176        };
177        if !output.status.success() {
178            return false;
179        }
180        let Ok(text) = std::str::from_utf8(&output.stdout) else {
181            return false;
182        };
183        parse_display_sleep_blocked(text)
184    }
185
186    pub(super) fn parse_display_sleep_blocked(text: &str) -> bool {
187        for line in text.lines() {
188            let trimmed = line.trim_start();
189            let Some(rest) = trimmed.strip_prefix("PreventUserIdleDisplaySleep") else {
190                continue;
191            };
192            let count: u32 = rest.trim().parse().unwrap_or(0);
193            return count > 0;
194        }
195        false
196    }
197
198    fn fullscreen_window_present() -> bool {
199        let Some(monitors) = active_display_bounds() else {
200            return false;
201        };
202        let windows = onscreen_app_window_bounds();
203        any_window_is_fullscreen(&windows, &monitors)
204    }
205
206    fn active_display_bounds() -> Option<Vec<Rect>> {
207        let ids = CGDisplay::active_displays().ok()?;
208        let mut out = Vec::with_capacity(ids.len());
209        for id in ids {
210            let b = CGDisplay::new(id).bounds();
211            out.push(Rect {
212                x: b.origin.x as i32,
213                y: b.origin.y as i32,
214                w: b.size.width as i32,
215                h: b.size.height as i32,
216            });
217        }
218        Some(out)
219    }
220
221    fn onscreen_app_window_bounds() -> Vec<Rect> {
222        // SAFETY: CGWindowListCopyWindowInfo returns a +1-refcount CFArray
223        // (or NULL on failure). We wrap with `CFArray::wrap_under_create_rule`
224        // which takes ownership and releases on drop.
225        let array_ref: CFArrayRef = unsafe {
226            CGWindowListCopyWindowInfo(
227                kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
228                kCGNullWindowID,
229            )
230        };
231        if array_ref.is_null() {
232            return Vec::new();
233        }
234        let array: CFArray<CFDictionary> = unsafe { CFArray::wrap_under_create_rule(array_ref) };
235
236        let mut out = Vec::new();
237        for dict in array.iter() {
238            let dict: &CFDictionary = &dict;
239            if !is_normal_app_window(dict) {
240                continue;
241            }
242            if let Some(rect) = window_bounds(dict) {
243                out.push(rect);
244            }
245        }
246        out
247    }
248
249    fn is_normal_app_window(dict: &CFDictionary) -> bool {
250        // Layer 0 = normal application window. Status-bar items,
251        // wallpaper, the dock etc. live on higher / lower layers and
252        // would otherwise be false-positives (the wallpaper's bounds
253        // match the screen exactly).
254        let key = CFString::from_static_string("kCGWindowLayer");
255        let raw = match dict.find(key.to_void()) {
256            Some(v) => v,
257            None => return false,
258        };
259        let num = unsafe { CFNumber::wrap_under_get_rule(*raw as _) };
260        num.to_i32() == Some(0)
261    }
262
263    fn window_bounds(dict: &CFDictionary) -> Option<Rect> {
264        let key = CFString::from_static_string("kCGWindowBounds");
265        let raw = dict.find(key.to_void())?;
266        let bounds_dict: CFDictionary = unsafe { CFDictionary::wrap_under_get_rule(*raw as _) };
267        Some(Rect {
268            x: dict_f64(&bounds_dict, "X")? as i32,
269            y: dict_f64(&bounds_dict, "Y")? as i32,
270            w: dict_f64(&bounds_dict, "Width")? as i32,
271            h: dict_f64(&bounds_dict, "Height")? as i32,
272        })
273    }
274
275    fn dict_f64(dict: &CFDictionary, key: &str) -> Option<f64> {
276        let key = CFString::new(key);
277        let raw = dict.find(key.to_void())?;
278        let num = unsafe { CFNumber::wrap_under_get_rule(*raw as _) };
279        num.to_f64()
280    }
281}
282
283#[cfg(target_os = "windows")]
284mod windows {
285    use std::process::Command;
286    use std::sync::atomic::{AtomicBool, Ordering};
287    use std::sync::Arc;
288    use std::thread;
289
290    use windows_sys::Win32::Foundation::{BOOL, HWND, LPARAM, RECT, TRUE};
291    use windows_sys::Win32::Graphics::Gdi::{
292        EnumDisplayMonitors, GetMonitorInfoW, HDC, HMONITOR, MONITORINFO,
293    };
294    use windows_sys::Win32::UI::WindowsAndMessaging::{
295        EnumWindows, GetWindowRect, IsWindowVisible,
296    };
297
298    use super::{any_window_is_fullscreen, pause_decision, Rect, WindowKnowledge};
299
300    // Absolute `System32` path so a planted `powercfg.exe` earlier in
301    // `%PATH%` can't intercept the call. Raw string keeps the
302    // backslashes literal without escaping noise.
303    pub(super) const POWERCFG_BIN: &str = r"C:\Windows\System32\powercfg.exe";
304
305    pub fn spawn(active: Arc<AtomicBool>) {
306        thread::spawn(move || loop {
307            active.store(check(), Ordering::Relaxed);
308            thread::sleep(super::POLL_INTERVAL);
309        });
310    }
311
312    fn check() -> bool {
313        let assertion = display_request_active();
314        if !assertion {
315            return false;
316        }
317        pause_decision(
318            assertion,
319            WindowKnowledge::Fullscreen(fullscreen_window_present()),
320        )
321    }
322
323    fn display_request_active() -> bool {
324        let Ok(output) = Command::new(POWERCFG_BIN).arg("/requests").output() else {
325            return false;
326        };
327        if !output.status.success() {
328            return false;
329        }
330        let Ok(text) = std::str::from_utf8(&output.stdout) else {
331            return false;
332        };
333        parse_display_request(text)
334    }
335
336    pub(super) fn parse_display_request(text: &str) -> bool {
337        let mut in_display = false;
338        for line in text.lines() {
339            let trimmed = line.trim();
340            if trimmed.eq_ignore_ascii_case("DISPLAY:") {
341                in_display = true;
342                continue;
343            }
344            if trimmed.ends_with(':') && !trimmed.eq_ignore_ascii_case("DISPLAY:") {
345                in_display = false;
346                continue;
347            }
348            if in_display && !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("None.") {
349                return true;
350            }
351        }
352        false
353    }
354
355    fn fullscreen_window_present() -> bool {
356        let monitors = enumerate_monitors();
357        if monitors.is_empty() {
358            return false;
359        }
360        let windows = enumerate_visible_windows();
361        any_window_is_fullscreen(&windows, &monitors)
362    }
363
364    fn enumerate_monitors() -> Vec<Rect> {
365        let mut out: Vec<Rect> = Vec::new();
366        let ptr: *mut Vec<Rect> = &mut out;
367        // SAFETY: EnumDisplayMonitors invokes our callback synchronously
368        // for each monitor. `ptr` outlives the call.
369        unsafe {
370            EnumDisplayMonitors(
371                std::ptr::null_mut(),
372                std::ptr::null(),
373                Some(monitor_enum_proc),
374                ptr as isize,
375            );
376        }
377        out
378    }
379
380    unsafe extern "system" fn monitor_enum_proc(
381        hmon: HMONITOR,
382        _hdc: HDC,
383        _rect: *mut RECT,
384        lparam: LPARAM,
385    ) -> BOOL {
386        let mut info: MONITORINFO = std::mem::zeroed();
387        info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
388        if GetMonitorInfoW(hmon, &mut info) != 0 {
389            let r = info.rcMonitor;
390            let out = &mut *(lparam as *mut Vec<Rect>);
391            out.push(Rect {
392                x: r.left,
393                y: r.top,
394                w: r.right - r.left,
395                h: r.bottom - r.top,
396            });
397        }
398        TRUE
399    }
400
401    fn enumerate_visible_windows() -> Vec<Rect> {
402        let mut out: Vec<Rect> = Vec::new();
403        let ptr: *mut Vec<Rect> = &mut out;
404        // SAFETY: EnumWindows invokes our callback synchronously for
405        // each top-level window. `ptr` outlives the call.
406        unsafe {
407            EnumWindows(Some(enum_windows_proc), ptr as isize);
408        }
409        out
410    }
411
412    unsafe extern "system" fn enum_windows_proc(hwnd: HWND, lparam: LPARAM) -> BOOL {
413        if IsWindowVisible(hwnd) == 0 {
414            return TRUE;
415        }
416        let mut r: RECT = std::mem::zeroed();
417        if GetWindowRect(hwnd, &mut r) == 0 {
418            return TRUE;
419        }
420        let out = &mut *(lparam as *mut Vec<Rect>);
421        out.push(Rect {
422            x: r.left,
423            y: r.top,
424            w: r.right - r.left,
425            h: r.bottom - r.top,
426        });
427        TRUE
428    }
429}
430
431#[cfg(target_os = "linux")]
432mod linux {
433    use std::env;
434    use std::process::Command;
435    use std::sync::atomic::{AtomicBool, Ordering};
436    use std::sync::Arc;
437    use std::sync::OnceLock;
438    use std::thread;
439
440    use super::{pause_decision, WindowKnowledge};
441
442    // `/usr/bin/systemd-inhibit` is the consistent location on every
443    // systemd-based distro we ship to. Pinning the absolute path keeps
444    // a planted binary earlier in `$PATH` from intercepting the call.
445    pub(super) const SYSTEMD_INHIBIT_BIN: &str = "/usr/bin/systemd-inhibit";
446
447    // `xprop` is the standard X11 client-side property tool; included in
448    // x11-utils on Debian/Ubuntu/Arch and almost universally present on
449    // any X11 session. On Wayland there's no portable equivalent, so we
450    // degrade to assertion-only behaviour.
451    pub(super) const XPROP_BIN: &str = "/usr/bin/xprop";
452
453    pub fn spawn(active: Arc<AtomicBool>) {
454        log_wayland_degradation_once();
455        thread::spawn(move || loop {
456            active.store(check(), Ordering::Relaxed);
457            thread::sleep(super::POLL_INTERVAL);
458        });
459    }
460
461    fn check() -> bool {
462        let assertion = inhibitor_active();
463        if !assertion {
464            return false;
465        }
466        // On Wayland there's no portable way to enumerate windows from
467        // outside the compositor — signal `Unknowable` so `pause_decision`
468        // falls back to assertion-only behaviour.
469        let window = if is_wayland_session() {
470            WindowKnowledge::Unknowable
471        } else {
472            WindowKnowledge::Fullscreen(fullscreen_window_present())
473        };
474        pause_decision(assertion, window)
475    }
476
477    fn inhibitor_active() -> bool {
478        let Ok(output) = Command::new(SYSTEMD_INHIBIT_BIN)
479            .args(["--list", "--no-pager", "--no-legend"])
480            .output()
481        else {
482            return false;
483        };
484        if !output.status.success() {
485            return false;
486        }
487        let Ok(text) = std::str::from_utf8(&output.stdout) else {
488            return false;
489        };
490        parse_idle_inhibitor(text)
491    }
492
493    pub(super) fn parse_idle_inhibitor(text: &str) -> bool {
494        // The WHY column can contain spaces, so we can't reliably index by column.
495        // The WHAT column is a colon-separated set of inhibitor types; only "idle"
496        // matches a display-blocking video player (system-managed inhibitors like
497        // "handle-lid-switch" or "sleep" don't include the "idle" component).
498        for line in text.lines() {
499            for token in line.split_whitespace() {
500                if token.split(':').any(|w| w == "idle") {
501                    return true;
502                }
503            }
504        }
505        false
506    }
507
508    fn is_wayland_session() -> bool {
509        env::var("XDG_SESSION_TYPE")
510            .map(|s| s.eq_ignore_ascii_case("wayland"))
511            .unwrap_or(false)
512            || env::var("WAYLAND_DISPLAY").is_ok()
513    }
514
515    fn log_wayland_degradation_once() {
516        static LOGGED: OnceLock<()> = OnceLock::new();
517        if is_wayland_session() {
518            LOGGED.get_or_init(|| {
519                log::info!(
520                    "video: Wayland detected — falling back to assertion-only \
521                     (no portable way to enumerate fullscreen windows on Wayland)"
522                );
523            });
524        }
525    }
526
527    fn fullscreen_window_present() -> bool {
528        let Some(active_id) = xprop_active_window_id() else {
529            return false;
530        };
531        let Some(state) = xprop_window_state(&active_id) else {
532            return false;
533        };
534        parse_net_wm_state_fullscreen(&state)
535    }
536
537    fn xprop_active_window_id() -> Option<String> {
538        let out = Command::new(XPROP_BIN)
539            .args(["-root", "_NET_ACTIVE_WINDOW"])
540            .output()
541            .ok()?;
542        if !out.status.success() {
543            return None;
544        }
545        let text = std::str::from_utf8(&out.stdout).ok()?;
546        parse_active_window_id(text)
547    }
548
549    pub(super) fn parse_active_window_id(text: &str) -> Option<String> {
550        // Expected line: `_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00006`
551        // `0x0` is the "no active window" sentinel — reject it so we
552        // don't follow up with a useless xprop call.
553        let (_, after) = text.trim().rsplit_once('#')?;
554        let id = after.trim();
555        if !id.starts_with("0x") || id == "0x0" {
556            return None;
557        }
558        Some(id.to_string())
559    }
560
561    fn xprop_window_state(id: &str) -> Option<String> {
562        let out = Command::new(XPROP_BIN)
563            .args(["-id", id, "_NET_WM_STATE"])
564            .output()
565            .ok()?;
566        if !out.status.success() {
567            return None;
568        }
569        std::str::from_utf8(&out.stdout).ok().map(str::to_string)
570    }
571
572    /// True iff `_NET_WM_STATE_FULLSCREEN` appears in xprop output for
573    /// `_NET_WM_STATE`. Crucially, `_NET_WM_STATE_MAXIMIZED_*` must NOT
574    /// match — a maximised window with the taskbar visible is the exact
575    /// case we want to exclude.
576    pub(super) fn parse_net_wm_state_fullscreen(text: &str) -> bool {
577        text.contains("_NET_WM_STATE_FULLSCREEN")
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn rect_matches_exact() {
587        let r = Rect {
588            x: 0,
589            y: 0,
590            w: 1920,
591            h: 1080,
592        };
593        assert!(rect_matches(r, r));
594    }
595
596    #[test]
597    fn rect_matches_within_tolerance() {
598        let win = Rect {
599            x: 1,
600            y: 0,
601            w: 1919,
602            h: 1081,
603        };
604        let mon = Rect {
605            x: 0,
606            y: 0,
607            w: 1920,
608            h: 1080,
609        };
610        assert!(rect_matches(win, mon));
611    }
612
613    #[test]
614    fn rect_does_not_match_outside_tolerance() {
615        let win = Rect {
616            x: 0,
617            y: 0,
618            w: 1910,
619            h: 1080,
620        };
621        let mon = Rect {
622            x: 0,
623            y: 0,
624            w: 1920,
625            h: 1080,
626        };
627        assert!(!rect_matches(win, mon));
628    }
629
630    #[test]
631    fn any_window_is_fullscreen_finds_match_across_multiple_monitors() {
632        let windows = [
633            Rect {
634                x: 0,
635                y: 0,
636                w: 800,
637                h: 600,
638            }, // small
639            Rect {
640                x: 1920,
641                y: 0,
642                w: 2560,
643                h: 1440,
644            }, // matches monitor 2
645        ];
646        let monitors = [
647            Rect {
648                x: 0,
649                y: 0,
650                w: 1920,
651                h: 1080,
652            },
653            Rect {
654                x: 1920,
655                y: 0,
656                w: 2560,
657                h: 1440,
658            },
659        ];
660        assert!(any_window_is_fullscreen(&windows, &monitors));
661    }
662
663    #[test]
664    fn any_window_is_fullscreen_false_when_nothing_matches() {
665        let windows = [
666            Rect {
667                x: 100,
668                y: 100,
669                w: 800,
670                h: 600,
671            },
672            Rect {
673                x: 0,
674                y: 0,
675                w: 1280,
676                h: 720,
677            },
678        ];
679        let monitors = [Rect {
680            x: 0,
681            y: 0,
682            w: 1920,
683            h: 1080,
684        }];
685        assert!(!any_window_is_fullscreen(&windows, &monitors));
686    }
687
688    #[test]
689    fn any_window_is_fullscreen_false_for_maximised_but_not_fullscreen() {
690        // Typical Windows "maximised" window leaves room for the taskbar
691        // (~40px). Should NOT count as fullscreen.
692        let windows = [Rect {
693            x: 0,
694            y: 0,
695            w: 1920,
696            h: 1040,
697        }];
698        let monitors = [Rect {
699            x: 0,
700            y: 0,
701            w: 1920,
702            h: 1080,
703        }];
704        assert!(!any_window_is_fullscreen(&windows, &monitors));
705    }
706
707    // -- `pause_decision` truth-table regression guards. The whole
708    //    point of this PR is the `Fullscreen(false) → false` row;
709    //    every row below is a regression someone could re-introduce by
710    //    "simplifying" the combining logic.
711
712    #[test]
713    fn pause_decision_no_pause_without_assertion() {
714        assert!(!pause_decision(false, WindowKnowledge::Fullscreen(true)));
715        assert!(!pause_decision(false, WindowKnowledge::Fullscreen(false)));
716        assert!(!pause_decision(false, WindowKnowledge::Unknowable));
717    }
718
719    #[test]
720    fn pause_decision_pauses_when_assertion_and_fullscreen() {
721        assert!(pause_decision(true, WindowKnowledge::Fullscreen(true)));
722    }
723
724    #[test]
725    fn pause_decision_does_not_pause_for_small_window_video() {
726        // The original bug: assertion-only pause for a small-window
727        // video. Must stay false.
728        assert!(!pause_decision(true, WindowKnowledge::Fullscreen(false)));
729    }
730
731    #[test]
732    fn pause_decision_falls_back_to_assertion_only_when_window_unknowable() {
733        // Linux Wayland: we can't enumerate windows, so an active
734        // assertion is the strongest signal we have.
735        assert!(pause_decision(true, WindowKnowledge::Unknowable));
736    }
737}
738
739#[cfg(all(test, target_os = "macos"))]
740mod macos_tests {
741    use super::macos::{parse_display_sleep_blocked, PMSET_BIN};
742
743    #[test]
744    fn pmset_bin_is_absolute_and_non_empty() {
745        assert!(!PMSET_BIN.is_empty());
746        assert!(
747            PMSET_BIN.starts_with('/'),
748            "expected absolute path, got {PMSET_BIN}"
749        );
750    }
751
752    #[test]
753    fn no_assertions_means_inactive() {
754        let sample = "Assertion status system-wide:\n   PreventUserIdleDisplaySleep    0\n   UserIsActive                   1\n";
755        assert!(!parse_display_sleep_blocked(sample));
756    }
757
758    #[test]
759    fn nonzero_count_means_active() {
760        let sample = "Assertion status system-wide:\n   PreventUserIdleDisplaySleep    1\n   UserIsActive                   1\n";
761        assert!(parse_display_sleep_blocked(sample));
762    }
763
764    #[test]
765    fn higher_counts_still_active() {
766        let sample = "   PreventUserIdleDisplaySleep    3\n";
767        assert!(parse_display_sleep_blocked(sample));
768    }
769
770    #[test]
771    fn missing_key_means_inactive() {
772        let sample = "Assertion status system-wide:\n   UserIsActive                   1\n";
773        assert!(!parse_display_sleep_blocked(sample));
774    }
775
776    #[test]
777    fn garbled_count_means_inactive() {
778        let sample = "   PreventUserIdleDisplaySleep    NaN\n";
779        assert!(!parse_display_sleep_blocked(sample));
780    }
781}
782
783#[cfg(all(test, target_os = "windows"))]
784mod windows_tests {
785    use super::windows::{parse_display_request, POWERCFG_BIN};
786
787    #[test]
788    fn powercfg_bin_is_absolute_and_non_empty() {
789        assert!(!POWERCFG_BIN.is_empty());
790        // Windows absolute paths start with a drive letter + `:\`,
791        // not a leading slash.
792        assert!(
793            POWERCFG_BIN.contains(":\\"),
794            "expected absolute Windows path, got {POWERCFG_BIN}"
795        );
796    }
797
798    #[test]
799    fn all_none_means_inactive() {
800        let sample = "DISPLAY:\nNone.\n\nSYSTEM:\nNone.\n\nAWAYMODE:\nNone.\n";
801        assert!(!parse_display_request(sample));
802    }
803
804    #[test]
805    fn display_process_means_active() {
806        let sample =
807            "DISPLAY:\n[PROCESS] \\Device\\HarddiskVolume3\\firefox.exe\n\nSYSTEM:\nNone.\n";
808        assert!(parse_display_request(sample));
809    }
810
811    #[test]
812    fn only_system_request_is_inactive() {
813        let sample = "DISPLAY:\nNone.\n\nSYSTEM:\n[DRIVER] Realtek HD Audio\n";
814        assert!(!parse_display_request(sample));
815    }
816}
817
818#[cfg(all(test, target_os = "linux"))]
819mod linux_tests {
820    use super::linux::{parse_active_window_id, parse_idle_inhibitor, SYSTEMD_INHIBIT_BIN};
821
822    #[test]
823    fn systemd_inhibit_bin_is_absolute_and_non_empty() {
824        assert!(!SYSTEMD_INHIBIT_BIN.is_empty());
825        assert!(
826            SYSTEMD_INHIBIT_BIN.starts_with('/'),
827            "expected absolute path, got {SYSTEMD_INHIBIT_BIN}"
828        );
829    }
830
831    #[test]
832    fn empty_means_inactive() {
833        assert!(!parse_idle_inhibitor(""));
834    }
835
836    #[test]
837    fn idle_what_means_active() {
838        let sample = "user 1000 alice 12345 firefox idle Playing:video block\n";
839        assert!(parse_idle_inhibitor(sample));
840    }
841
842    #[test]
843    fn compound_what_with_idle_means_active() {
844        let sample = "user 1000 alice 12345 vlc sleep:idle Playing block\n";
845        assert!(parse_idle_inhibitor(sample));
846    }
847
848    #[test]
849    fn non_idle_inhibitor_is_inactive() {
850        let sample = "user 1000 alice 12345 systemd-logind handle-power-key:handle-suspend-key Lid closed block\n";
851        assert!(!parse_idle_inhibitor(sample));
852    }
853
854    #[test]
855    fn substring_idle_does_not_match() {
856        let sample = "user 1000 alice 12345 daemon sleep Process-is-idle-checker block\n";
857        assert!(!parse_idle_inhibitor(sample));
858    }
859
860    #[test]
861    fn parse_active_window_id_extracts_hex_id() {
862        let sample = "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00006\n";
863        assert_eq!(
864            parse_active_window_id(sample),
865            Some("0x3c00006".to_string())
866        );
867    }
868
869    #[test]
870    fn parse_active_window_id_rejects_non_hex() {
871        let sample = "_NET_ACTIVE_WINDOW(WINDOW): not set\n";
872        assert_eq!(parse_active_window_id(sample), None);
873    }
874
875    #[test]
876    fn parse_active_window_id_rejects_zero_sentinel() {
877        // `0x0` is what xprop returns when no window is focused (e.g.,
878        // the desktop has focus). We must not chain a second xprop
879        // call against that bogus id.
880        let sample = "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x0\n";
881        assert_eq!(parse_active_window_id(sample), None);
882    }
883
884    use super::linux::parse_net_wm_state_fullscreen;
885
886    #[test]
887    fn parse_net_wm_state_true_when_fullscreen_present() {
888        let sample = "_NET_WM_STATE(ATOM) = _NET_WM_STATE_FULLSCREEN\n";
889        assert!(parse_net_wm_state_fullscreen(sample));
890    }
891
892    #[test]
893    fn parse_net_wm_state_true_when_fullscreen_combined_with_other_states() {
894        // Common Picture-in-Picture / always-on-top combo from Firefox.
895        let sample = "_NET_WM_STATE(ATOM) = _NET_WM_STATE_FULLSCREEN, _NET_WM_STATE_ABOVE\n";
896        assert!(parse_net_wm_state_fullscreen(sample));
897    }
898
899    #[test]
900    fn parse_net_wm_state_false_for_maximised() {
901        // Maximised is NOT fullscreen — the taskbar / dock is still
902        // visible and the user isn't committed to a video. This is the
903        // exact regression we're guarding against.
904        let sample =
905            "_NET_WM_STATE(ATOM) = _NET_WM_STATE_MAXIMIZED_HORZ, _NET_WM_STATE_MAXIMIZED_VERT\n";
906        assert!(!parse_net_wm_state_fullscreen(sample));
907    }
908
909    #[test]
910    fn parse_net_wm_state_false_when_property_missing() {
911        // xprop's "no such property" output. Must not match.
912        let sample = "_NET_WM_STATE:  not found.\n";
913        assert!(!parse_net_wm_state_fullscreen(sample));
914    }
915
916    #[test]
917    fn parse_net_wm_state_false_for_empty_output() {
918        assert!(!parse_net_wm_state_fullscreen(""));
919    }
920}