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// --- Per-OS assertion / window parsers ---------------------------------
129//
130// These are pure string parsers, one per platform's CLI output. They live
131// at module level (not inside the `#[cfg(target_os)]` mods) so every CI
132// runner compiles and unit-tests all of them — otherwise the macOS runner
133// would never see the Windows/Linux parsers, which is exactly the
134// cross-platform blind spot these tests close. Each `check()` reads its
135// own OS's command output and calls the matching parser via `super::`.
136// The per-symbol `allow(dead_code)` mirrors `Rect`/`rect_matches` above:
137// in a non-test build only one OS's parser is actually called.
138
139/// macOS `pmset -g assertions`: true if `PreventUserIdleDisplaySleep` has
140/// a non-zero count (something is keeping the display awake).
141#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
142pub(crate) fn parse_display_sleep_blocked(text: &str) -> bool {
143    for line in text.lines() {
144        let trimmed = line.trim_start();
145        let Some(rest) = trimmed.strip_prefix("PreventUserIdleDisplaySleep") else {
146            continue;
147        };
148        let count: u32 = rest.trim().parse().unwrap_or(0);
149        return count > 0;
150    }
151    false
152}
153
154/// Windows `powercfg /requests`: true if the `DISPLAY:` section lists any
155/// request other than `None.` (something is requesting the display stay on).
156#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
157pub(crate) fn parse_display_request(text: &str) -> bool {
158    let mut in_display = false;
159    for line in text.lines() {
160        let trimmed = line.trim();
161        if trimmed.eq_ignore_ascii_case("DISPLAY:") {
162            in_display = true;
163            continue;
164        }
165        if trimmed.ends_with(':') && !trimmed.eq_ignore_ascii_case("DISPLAY:") {
166            in_display = false;
167            continue;
168        }
169        if in_display && !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("None.") {
170            return true;
171        }
172    }
173    false
174}
175
176/// Linux `systemd-inhibit --list`: true if any inhibitor's WHAT column
177/// (colon-separated types) includes `idle`. System-managed inhibitors like
178/// `handle-lid-switch` / `sleep` don't carry the `idle` component.
179#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
180pub(crate) fn parse_idle_inhibitor(text: &str) -> bool {
181    for line in text.lines() {
182        for token in line.split_whitespace() {
183            if token.split(':').any(|w| w == "idle") {
184                return true;
185            }
186        }
187    }
188    false
189}
190
191/// Linux `xprop -root _NET_ACTIVE_WINDOW`: extract the `0x…` window id,
192/// rejecting the `0x0` "no active window" sentinel so we don't chain a
193/// useless follow-up xprop call.
194#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
195pub(crate) fn parse_active_window_id(text: &str) -> Option<String> {
196    let (_, after) = text.trim().rsplit_once('#')?;
197    let id = after.trim();
198    if !id.starts_with("0x") || id == "0x0" {
199        return None;
200    }
201    Some(id.to_string())
202}
203
204/// Linux `xprop -id <id> _NET_WM_STATE`: true iff `_NET_WM_STATE_FULLSCREEN`
205/// is present. Crucially `_NET_WM_STATE_MAXIMIZED_*` must NOT match — a
206/// maximised window with the taskbar visible is the case we exclude.
207#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
208pub(crate) fn parse_net_wm_state_fullscreen(text: &str) -> bool {
209    text.contains("_NET_WM_STATE_FULLSCREEN")
210}
211
212#[cfg(target_os = "macos")]
213mod macos {
214    use std::process::Command;
215    use std::sync::atomic::{AtomicBool, Ordering};
216    use std::sync::Arc;
217    use std::thread;
218
219    use crate::proc::CommandTimeoutExt;
220
221    use core_foundation::array::{CFArray, CFArrayRef};
222    use core_foundation::base::{TCFType, ToVoid};
223    use core_foundation::dictionary::CFDictionary;
224    use core_foundation::number::CFNumber;
225    use core_foundation::string::CFString;
226    use core_graphics::display::CGDisplay;
227    use core_graphics::window::{
228        kCGNullWindowID, kCGWindowListExcludeDesktopElements, kCGWindowListOptionOnScreenOnly,
229        CGWindowListCopyWindowInfo,
230    };
231
232    use super::{any_window_is_fullscreen, pause_decision, Rect, WindowKnowledge};
233
234    // Pin to the absolute path so `$PATH` shenanigans can't swap in a
235    // shim. `/usr/bin/pmset` is the OS-shipped location on every
236    // supported macOS release.
237    pub(super) const PMSET_BIN: &str = "/usr/bin/pmset";
238
239    pub fn spawn(active: Arc<AtomicBool>) {
240        thread::spawn(move || loop {
241            active.store(check(), Ordering::Relaxed);
242            thread::sleep(super::POLL_INTERVAL);
243        });
244    }
245
246    fn check() -> bool {
247        let assertion = display_assertion_active();
248        // Fast path: skip the (more expensive) window enumeration when
249        // nothing is even claiming to play media.
250        if !assertion {
251            return false;
252        }
253        pause_decision(
254            assertion,
255            WindowKnowledge::Fullscreen(fullscreen_window_present()),
256        )
257    }
258
259    pub(super) fn display_assertion_active() -> bool {
260        let Ok(output) = Command::new(PMSET_BIN)
261            .args(["-g", "assertions"])
262            .output_timeout(crate::proc::PROBE_TIMEOUT)
263        else {
264            return false;
265        };
266        if !output.status.success() {
267            return false;
268        }
269        let Ok(text) = std::str::from_utf8(&output.stdout) else {
270            return false;
271        };
272        super::parse_display_sleep_blocked(text)
273    }
274
275    fn fullscreen_window_present() -> bool {
276        let Some(monitors) = active_display_bounds() else {
277            return false;
278        };
279        let windows = onscreen_app_window_bounds();
280        any_window_is_fullscreen(&windows, &monitors)
281    }
282
283    fn active_display_bounds() -> Option<Vec<Rect>> {
284        let ids = CGDisplay::active_displays().ok()?;
285        let mut out = Vec::with_capacity(ids.len());
286        for id in ids {
287            let b = CGDisplay::new(id).bounds();
288            out.push(Rect {
289                x: b.origin.x as i32,
290                y: b.origin.y as i32,
291                w: b.size.width as i32,
292                h: b.size.height as i32,
293            });
294        }
295        Some(out)
296    }
297
298    fn onscreen_app_window_bounds() -> Vec<Rect> {
299        // SAFETY: CGWindowListCopyWindowInfo returns a +1-refcount CFArray
300        // (or NULL on failure). We wrap with `CFArray::wrap_under_create_rule`
301        // which takes ownership and releases on drop.
302        let array_ref: CFArrayRef = unsafe {
303            CGWindowListCopyWindowInfo(
304                kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
305                kCGNullWindowID,
306            )
307        };
308        if array_ref.is_null() {
309            return Vec::new();
310        }
311        let array: CFArray<CFDictionary> = unsafe { CFArray::wrap_under_create_rule(array_ref) };
312
313        let mut out = Vec::new();
314        for dict in array.iter() {
315            let dict: &CFDictionary = &dict;
316            if !is_normal_app_window(dict) {
317                continue;
318            }
319            if let Some(rect) = window_bounds(dict) {
320                out.push(rect);
321            }
322        }
323        out
324    }
325
326    fn is_normal_app_window(dict: &CFDictionary) -> bool {
327        // Layer 0 = normal application window. Status-bar items,
328        // wallpaper, the dock etc. live on higher / lower layers and
329        // would otherwise be false-positives (the wallpaper's bounds
330        // match the screen exactly).
331        let key = CFString::from_static_string("kCGWindowLayer");
332        let raw = match dict.find(key.to_void()) {
333            Some(v) => v,
334            None => return false,
335        };
336        let num = unsafe { CFNumber::wrap_under_get_rule(*raw as _) };
337        num.to_i32() == Some(0)
338    }
339
340    fn window_bounds(dict: &CFDictionary) -> Option<Rect> {
341        let key = CFString::from_static_string("kCGWindowBounds");
342        let raw = dict.find(key.to_void())?;
343        let bounds_dict: CFDictionary = unsafe { CFDictionary::wrap_under_get_rule(*raw as _) };
344        Some(Rect {
345            x: dict_f64(&bounds_dict, "X")? as i32,
346            y: dict_f64(&bounds_dict, "Y")? as i32,
347            w: dict_f64(&bounds_dict, "Width")? as i32,
348            h: dict_f64(&bounds_dict, "Height")? as i32,
349        })
350    }
351
352    fn dict_f64(dict: &CFDictionary, key: &str) -> Option<f64> {
353        let key = CFString::new(key);
354        let raw = dict.find(key.to_void())?;
355        let num = unsafe { CFNumber::wrap_under_get_rule(*raw as _) };
356        num.to_f64()
357    }
358}
359
360#[cfg(target_os = "windows")]
361mod windows {
362    use std::process::Command;
363    use std::sync::atomic::{AtomicBool, Ordering};
364
365    use crate::proc::CommandTimeoutExt;
366    use std::sync::Arc;
367    use std::thread;
368
369    use windows_sys::Win32::Foundation::{BOOL, HWND, LPARAM, RECT, TRUE};
370    use windows_sys::Win32::Graphics::Gdi::{
371        EnumDisplayMonitors, GetMonitorInfoW, HDC, HMONITOR, MONITORINFO,
372    };
373    use windows_sys::Win32::UI::WindowsAndMessaging::{
374        EnumWindows, GetWindowRect, IsWindowVisible,
375    };
376
377    use super::{any_window_is_fullscreen, pause_decision, Rect, WindowKnowledge};
378
379    // Absolute `System32` path so a planted `powercfg.exe` earlier in
380    // `%PATH%` can't intercept the call. Raw string keeps the
381    // backslashes literal without escaping noise.
382    pub(super) const POWERCFG_BIN: &str = r"C:\Windows\System32\powercfg.exe";
383
384    pub fn spawn(active: Arc<AtomicBool>) {
385        thread::spawn(move || loop {
386            active.store(check(), Ordering::Relaxed);
387            thread::sleep(super::POLL_INTERVAL);
388        });
389    }
390
391    fn check() -> bool {
392        let assertion = display_request_active();
393        if !assertion {
394            return false;
395        }
396        pause_decision(
397            assertion,
398            WindowKnowledge::Fullscreen(fullscreen_window_present()),
399        )
400    }
401
402    pub(super) fn display_request_active() -> bool {
403        let Ok(output) = Command::new(POWERCFG_BIN)
404            .arg("/requests")
405            .output_timeout(crate::proc::PROBE_TIMEOUT)
406        else {
407            return false;
408        };
409        if !output.status.success() {
410            return false;
411        }
412        let Ok(text) = std::str::from_utf8(&output.stdout) else {
413            return false;
414        };
415        super::parse_display_request(text)
416    }
417
418    fn fullscreen_window_present() -> bool {
419        let monitors = enumerate_monitors();
420        if monitors.is_empty() {
421            return false;
422        }
423        let windows = enumerate_visible_windows();
424        any_window_is_fullscreen(&windows, &monitors)
425    }
426
427    fn enumerate_monitors() -> Vec<Rect> {
428        let mut out: Vec<Rect> = Vec::new();
429        let ptr: *mut Vec<Rect> = &mut out;
430        // SAFETY: EnumDisplayMonitors invokes our callback synchronously
431        // for each monitor. `ptr` outlives the call.
432        unsafe {
433            EnumDisplayMonitors(
434                std::ptr::null_mut(),
435                std::ptr::null(),
436                Some(monitor_enum_proc),
437                ptr as isize,
438            );
439        }
440        out
441    }
442
443    unsafe extern "system" fn monitor_enum_proc(
444        hmon: HMONITOR,
445        _hdc: HDC,
446        _rect: *mut RECT,
447        lparam: LPARAM,
448    ) -> BOOL {
449        let mut info: MONITORINFO = std::mem::zeroed();
450        info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
451        if GetMonitorInfoW(hmon, &mut info) != 0 {
452            let r = info.rcMonitor;
453            let out = &mut *(lparam as *mut Vec<Rect>);
454            out.push(Rect {
455                x: r.left,
456                y: r.top,
457                w: r.right - r.left,
458                h: r.bottom - r.top,
459            });
460        }
461        TRUE
462    }
463
464    fn enumerate_visible_windows() -> Vec<Rect> {
465        let mut out: Vec<Rect> = Vec::new();
466        let ptr: *mut Vec<Rect> = &mut out;
467        // SAFETY: EnumWindows invokes our callback synchronously for
468        // each top-level window. `ptr` outlives the call.
469        unsafe {
470            EnumWindows(Some(enum_windows_proc), ptr as isize);
471        }
472        out
473    }
474
475    unsafe extern "system" fn enum_windows_proc(hwnd: HWND, lparam: LPARAM) -> BOOL {
476        if IsWindowVisible(hwnd) == 0 {
477            return TRUE;
478        }
479        let mut r: RECT = std::mem::zeroed();
480        if GetWindowRect(hwnd, &mut r) == 0 {
481            return TRUE;
482        }
483        let out = &mut *(lparam as *mut Vec<Rect>);
484        out.push(Rect {
485            x: r.left,
486            y: r.top,
487            w: r.right - r.left,
488            h: r.bottom - r.top,
489        });
490        TRUE
491    }
492}
493
494#[cfg(target_os = "linux")]
495mod linux {
496    use std::env;
497    use std::process::Command;
498
499    use crate::proc::CommandTimeoutExt;
500    use std::sync::atomic::{AtomicBool, Ordering};
501    use std::sync::Arc;
502    use std::sync::OnceLock;
503    use std::thread;
504
505    use super::{pause_decision, WindowKnowledge};
506
507    // `/usr/bin/systemd-inhibit` is the consistent location on every
508    // systemd-based distro we ship to. Pinning the absolute path keeps
509    // a planted binary earlier in `$PATH` from intercepting the call.
510    pub(super) const SYSTEMD_INHIBIT_BIN: &str = "/usr/bin/systemd-inhibit";
511
512    // `xprop` is the standard X11 client-side property tool; included in
513    // x11-utils on Debian/Ubuntu/Arch and almost universally present on
514    // any X11 session. On Wayland there's no portable equivalent, so we
515    // degrade to assertion-only behaviour.
516    pub(super) const XPROP_BIN: &str = "/usr/bin/xprop";
517
518    pub fn spawn(active: Arc<AtomicBool>) {
519        log_wayland_degradation_once();
520        thread::spawn(move || loop {
521            active.store(check(), Ordering::Relaxed);
522            thread::sleep(super::POLL_INTERVAL);
523        });
524    }
525
526    fn check() -> bool {
527        let assertion = inhibitor_active();
528        if !assertion {
529            return false;
530        }
531        // On Wayland there's no portable way to enumerate windows from
532        // outside the compositor — signal `Unknowable` so `pause_decision`
533        // falls back to assertion-only behaviour.
534        let window = if is_wayland_session() {
535            WindowKnowledge::Unknowable
536        } else {
537            WindowKnowledge::Fullscreen(fullscreen_window_present())
538        };
539        pause_decision(assertion, window)
540    }
541
542    fn inhibitor_active() -> bool {
543        let Ok(output) = Command::new(SYSTEMD_INHIBIT_BIN)
544            .args(["--list", "--no-pager", "--no-legend"])
545            .output_timeout(crate::proc::PROBE_TIMEOUT)
546        else {
547            return false;
548        };
549        if !output.status.success() {
550            return false;
551        }
552        let Ok(text) = std::str::from_utf8(&output.stdout) else {
553            return false;
554        };
555        super::parse_idle_inhibitor(text)
556    }
557
558    fn is_wayland_session() -> bool {
559        env::var("XDG_SESSION_TYPE")
560            .map(|s| s.eq_ignore_ascii_case("wayland"))
561            .unwrap_or(false)
562            || env::var("WAYLAND_DISPLAY").is_ok()
563    }
564
565    fn log_wayland_degradation_once() {
566        static LOGGED: OnceLock<()> = OnceLock::new();
567        if is_wayland_session() {
568            LOGGED.get_or_init(|| {
569                log::info!(
570                    "video: Wayland detected — falling back to assertion-only \
571                     (no portable way to enumerate fullscreen windows on Wayland)"
572                );
573            });
574        }
575    }
576
577    fn fullscreen_window_present() -> bool {
578        let Some(active_id) = xprop_active_window_id() else {
579            return false;
580        };
581        let Some(state) = xprop_window_state(&active_id) else {
582            return false;
583        };
584        super::parse_net_wm_state_fullscreen(&state)
585    }
586
587    fn xprop_active_window_id() -> Option<String> {
588        let out = Command::new(XPROP_BIN)
589            .args(["-root", "_NET_ACTIVE_WINDOW"])
590            .output_timeout(crate::proc::PROBE_TIMEOUT)
591            .ok()?;
592        if !out.status.success() {
593            return None;
594        }
595        let text = std::str::from_utf8(&out.stdout).ok()?;
596        super::parse_active_window_id(text)
597    }
598
599    fn xprop_window_state(id: &str) -> Option<String> {
600        let out = Command::new(XPROP_BIN)
601            .args(["-id", id, "_NET_WM_STATE"])
602            .output_timeout(crate::proc::PROBE_TIMEOUT)
603            .ok()?;
604        if !out.status.success() {
605            return None;
606        }
607        std::str::from_utf8(&out.stdout).ok().map(str::to_string)
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn rect_matches_exact() {
617        let r = Rect {
618            x: 0,
619            y: 0,
620            w: 1920,
621            h: 1080,
622        };
623        assert!(rect_matches(r, r));
624    }
625
626    #[test]
627    fn rect_matches_within_tolerance() {
628        let win = Rect {
629            x: 1,
630            y: 0,
631            w: 1919,
632            h: 1081,
633        };
634        let mon = Rect {
635            x: 0,
636            y: 0,
637            w: 1920,
638            h: 1080,
639        };
640        assert!(rect_matches(win, mon));
641    }
642
643    #[test]
644    fn rect_does_not_match_outside_tolerance() {
645        let win = Rect {
646            x: 0,
647            y: 0,
648            w: 1910,
649            h: 1080,
650        };
651        let mon = Rect {
652            x: 0,
653            y: 0,
654            w: 1920,
655            h: 1080,
656        };
657        assert!(!rect_matches(win, mon));
658    }
659
660    #[test]
661    fn any_window_is_fullscreen_finds_match_across_multiple_monitors() {
662        let windows = [
663            Rect {
664                x: 0,
665                y: 0,
666                w: 800,
667                h: 600,
668            }, // small
669            Rect {
670                x: 1920,
671                y: 0,
672                w: 2560,
673                h: 1440,
674            }, // matches monitor 2
675        ];
676        let monitors = [
677            Rect {
678                x: 0,
679                y: 0,
680                w: 1920,
681                h: 1080,
682            },
683            Rect {
684                x: 1920,
685                y: 0,
686                w: 2560,
687                h: 1440,
688            },
689        ];
690        assert!(any_window_is_fullscreen(&windows, &monitors));
691    }
692
693    #[test]
694    fn any_window_is_fullscreen_false_when_nothing_matches() {
695        let windows = [
696            Rect {
697                x: 100,
698                y: 100,
699                w: 800,
700                h: 600,
701            },
702            Rect {
703                x: 0,
704                y: 0,
705                w: 1280,
706                h: 720,
707            },
708        ];
709        let monitors = [Rect {
710            x: 0,
711            y: 0,
712            w: 1920,
713            h: 1080,
714        }];
715        assert!(!any_window_is_fullscreen(&windows, &monitors));
716    }
717
718    #[test]
719    fn any_window_is_fullscreen_false_for_maximised_but_not_fullscreen() {
720        // Typical Windows "maximised" window leaves room for the taskbar
721        // (~40px). Should NOT count as fullscreen.
722        let windows = [Rect {
723            x: 0,
724            y: 0,
725            w: 1920,
726            h: 1040,
727        }];
728        let monitors = [Rect {
729            x: 0,
730            y: 0,
731            w: 1920,
732            h: 1080,
733        }];
734        assert!(!any_window_is_fullscreen(&windows, &monitors));
735    }
736
737    // -- `pause_decision` truth-table regression guards. The whole
738    //    point of this PR is the `Fullscreen(false) → false` row;
739    //    every row below is a regression someone could re-introduce by
740    //    "simplifying" the combining logic.
741
742    #[test]
743    fn pause_decision_no_pause_without_assertion() {
744        assert!(!pause_decision(false, WindowKnowledge::Fullscreen(true)));
745        assert!(!pause_decision(false, WindowKnowledge::Fullscreen(false)));
746        assert!(!pause_decision(false, WindowKnowledge::Unknowable));
747    }
748
749    #[test]
750    fn pause_decision_pauses_when_assertion_and_fullscreen() {
751        assert!(pause_decision(true, WindowKnowledge::Fullscreen(true)));
752    }
753
754    #[test]
755    fn pause_decision_does_not_pause_for_small_window_video() {
756        // The original bug: assertion-only pause for a small-window
757        // video. Must stay false.
758        assert!(!pause_decision(true, WindowKnowledge::Fullscreen(false)));
759    }
760
761    #[test]
762    fn pause_decision_falls_back_to_assertion_only_when_window_unknowable() {
763        // Linux Wayland: we can't enumerate windows, so an active
764        // assertion is the strongest signal we have.
765        assert!(pause_decision(true, WindowKnowledge::Unknowable));
766    }
767
768    // --- macOS pmset parser (runs on every OS) ---
769
770    #[test]
771    fn pmset_no_assertions_means_inactive() {
772        let sample = "Assertion status system-wide:\n   PreventUserIdleDisplaySleep    0\n   UserIsActive                   1\n";
773        assert!(!parse_display_sleep_blocked(sample));
774    }
775
776    #[test]
777    fn pmset_nonzero_count_means_active() {
778        let sample = "Assertion status system-wide:\n   PreventUserIdleDisplaySleep    1\n   UserIsActive                   1\n";
779        assert!(parse_display_sleep_blocked(sample));
780    }
781
782    #[test]
783    fn pmset_higher_counts_still_active() {
784        let sample = "   PreventUserIdleDisplaySleep    3\n";
785        assert!(parse_display_sleep_blocked(sample));
786    }
787
788    #[test]
789    fn pmset_missing_key_means_inactive() {
790        let sample = "Assertion status system-wide:\n   UserIsActive                   1\n";
791        assert!(!parse_display_sleep_blocked(sample));
792    }
793
794    #[test]
795    fn pmset_garbled_count_means_inactive() {
796        let sample = "   PreventUserIdleDisplaySleep    NaN\n";
797        assert!(!parse_display_sleep_blocked(sample));
798    }
799
800    // --- Windows powercfg parser (runs on every OS) ---
801
802    #[test]
803    fn powercfg_all_none_means_inactive() {
804        let sample = "DISPLAY:\nNone.\n\nSYSTEM:\nNone.\n\nAWAYMODE:\nNone.\n";
805        assert!(!parse_display_request(sample));
806    }
807
808    #[test]
809    fn powercfg_display_process_means_active() {
810        let sample =
811            "DISPLAY:\n[PROCESS] \\Device\\HarddiskVolume3\\firefox.exe\n\nSYSTEM:\nNone.\n";
812        assert!(parse_display_request(sample));
813    }
814
815    #[test]
816    fn powercfg_only_system_request_is_inactive() {
817        let sample = "DISPLAY:\nNone.\n\nSYSTEM:\n[DRIVER] Realtek HD Audio\n";
818        assert!(!parse_display_request(sample));
819    }
820
821    // --- Linux systemd-inhibit parser (runs on every OS) ---
822
823    #[test]
824    fn inhibit_empty_means_inactive() {
825        assert!(!parse_idle_inhibitor(""));
826    }
827
828    #[test]
829    fn inhibit_idle_what_means_active() {
830        let sample = "user 1000 alice 12345 firefox idle Playing:video block\n";
831        assert!(parse_idle_inhibitor(sample));
832    }
833
834    #[test]
835    fn inhibit_compound_what_with_idle_means_active() {
836        let sample = "user 1000 alice 12345 vlc sleep:idle Playing block\n";
837        assert!(parse_idle_inhibitor(sample));
838    }
839
840    #[test]
841    fn inhibit_non_idle_inhibitor_is_inactive() {
842        let sample = "user 1000 alice 12345 systemd-logind handle-power-key:handle-suspend-key Lid closed block\n";
843        assert!(!parse_idle_inhibitor(sample));
844    }
845
846    #[test]
847    fn inhibit_substring_idle_does_not_match() {
848        let sample = "user 1000 alice 12345 daemon sleep Process-is-idle-checker block\n";
849        assert!(!parse_idle_inhibitor(sample));
850    }
851
852    // --- Linux xprop _NET_ACTIVE_WINDOW parser (runs on every OS) ---
853
854    #[test]
855    fn active_window_id_extracts_hex_id() {
856        let sample = "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00006\n";
857        assert_eq!(
858            parse_active_window_id(sample),
859            Some("0x3c00006".to_string())
860        );
861    }
862
863    #[test]
864    fn active_window_id_rejects_non_hex() {
865        let sample = "_NET_ACTIVE_WINDOW(WINDOW): not set\n";
866        assert_eq!(parse_active_window_id(sample), None);
867    }
868
869    #[test]
870    fn active_window_id_rejects_zero_sentinel() {
871        // `0x0` is what xprop returns when no window is focused (e.g.,
872        // the desktop has focus). We must not chain a second xprop
873        // call against that bogus id.
874        let sample = "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x0\n";
875        assert_eq!(parse_active_window_id(sample), None);
876    }
877
878    // --- Linux xprop _NET_WM_STATE parser (runs on every OS) ---
879
880    #[test]
881    fn net_wm_state_true_when_fullscreen_present() {
882        let sample = "_NET_WM_STATE(ATOM) = _NET_WM_STATE_FULLSCREEN\n";
883        assert!(parse_net_wm_state_fullscreen(sample));
884    }
885
886    #[test]
887    fn net_wm_state_true_when_fullscreen_combined_with_other_states() {
888        // Common Picture-in-Picture / always-on-top combo from Firefox.
889        let sample = "_NET_WM_STATE(ATOM) = _NET_WM_STATE_FULLSCREEN, _NET_WM_STATE_ABOVE\n";
890        assert!(parse_net_wm_state_fullscreen(sample));
891    }
892
893    #[test]
894    fn net_wm_state_false_for_maximised() {
895        // Maximised is NOT fullscreen — the taskbar / dock is still
896        // visible and the user isn't committed to a video. This is the
897        // exact regression we're guarding against.
898        let sample =
899            "_NET_WM_STATE(ATOM) = _NET_WM_STATE_MAXIMIZED_HORZ, _NET_WM_STATE_MAXIMIZED_VERT\n";
900        assert!(!parse_net_wm_state_fullscreen(sample));
901    }
902
903    #[test]
904    fn net_wm_state_false_when_property_missing() {
905        // xprop's "no such property" output. Must not match.
906        let sample = "_NET_WM_STATE:  not found.\n";
907        assert!(!parse_net_wm_state_fullscreen(sample));
908    }
909
910    #[test]
911    fn net_wm_state_false_for_empty_output() {
912        assert!(!parse_net_wm_state_fullscreen(""));
913    }
914}
915
916// Absolute-path sanity for the per-OS command binaries. These stay
917// OS-gated because each const only exists on its own platform.
918#[cfg(all(test, target_os = "macos"))]
919mod macos_bin_tests {
920    use super::macos::PMSET_BIN;
921
922    #[test]
923    fn pmset_bin_is_absolute_and_non_empty() {
924        assert!(!PMSET_BIN.is_empty());
925        assert!(
926            PMSET_BIN.starts_with('/'),
927            "expected absolute path, got {PMSET_BIN}"
928        );
929    }
930}
931
932#[cfg(all(test, target_os = "windows"))]
933mod windows_bin_tests {
934    use super::windows::POWERCFG_BIN;
935
936    #[test]
937    fn powercfg_bin_is_absolute_and_non_empty() {
938        assert!(!POWERCFG_BIN.is_empty());
939        // Windows absolute paths start with a drive letter + `:\`,
940        // not a leading slash.
941        assert!(
942            POWERCFG_BIN.contains(":\\"),
943            "expected absolute Windows path, got {POWERCFG_BIN}"
944        );
945    }
946}
947
948#[cfg(all(test, target_os = "linux"))]
949mod linux_bin_tests {
950    use super::linux::SYSTEMD_INHIBIT_BIN;
951
952    #[test]
953    fn systemd_inhibit_bin_is_absolute_and_non_empty() {
954        assert!(!SYSTEMD_INHIBIT_BIN.is_empty());
955        assert!(
956            SYSTEMD_INHIBIT_BIN.starts_with('/'),
957            "expected absolute path, got {SYSTEMD_INHIBIT_BIN}"
958        );
959    }
960}