Skip to main content

entracte_lib/scheduler/
idle.rs

1//! "Seconds since the last input" probing.
2//!
3//! `user_idle` wraps the windowing system's native idle counter —
4//! XScreenSaver on X11, IOKit `HIDIdleTime` on macOS, the Win32 last-input
5//! timer on Windows. On GNOME/Wayland none of those exist: Wayland
6//! deliberately doesn't expose a global idle counter, so
7//! `UserIdle::get_time()` returns `Status not OK` and every idle-driven
8//! feature (idle-reset, typing-defer, screen-time accounting) silently
9//! treats the user as permanently active (#190).
10//!
11//! GNOME's compositor publishes the same counter on the session bus as
12//! `org.gnome.Mutter.IdleMonitor.GetIdletime` (milliseconds since last
13//! input). When the primary `user_idle` probe fails we fall back to it.
14//! Like [`super::session_lock`], we shell out (`gdbus`) rather than link a
15//! D-Bus crate — one integer read isn't worth 30+ transitive dependencies
16//! — and reuse the shared 2 s probe timeout so a wedged session bus can't
17//! stall the scheduler tick.
18
19use user_idle::UserIdle;
20
21/// Seconds since the last keyboard/mouse input, or a display-error string
22/// if no idle source is available this call.
23///
24/// `user_idle` is the primary source on every platform. On GNOME/Wayland
25/// it fails, so we fall back to Mutter's `IdleMonitor`. The error surfaced
26/// on total failure is the primary one — that's the message callers have
27/// always logged, and the non-GNOME Wayland case (e.g. sway, where neither
28/// source works) is the one where the error matters.
29pub fn idle_secs() -> Result<u64, String> {
30    let primary = UserIdle::get_time()
31        .map(|idle| idle.as_seconds())
32        .map_err(|e| e.to_string());
33    combine_idle(primary, fallback_idle_secs)
34}
35
36/// Resolve the primary reading against the platform fallback: the primary
37/// value wins; on primary failure the injected `fallback` is consulted and,
38/// if it too has nothing, the primary error is surfaced. The fallback is a
39/// closure so this decision is pure and unit-testable on every OS without
40/// touching a windowing system — only the two FFI sources it's wired to in
41/// [`idle_secs`] stay platform-bound.
42fn combine_idle(
43    primary: Result<u64, String>,
44    fallback: impl FnOnce() -> Option<u64>,
45) -> Result<u64, String> {
46    match primary {
47        Ok(secs) => Ok(secs),
48        Err(primary_err) => fallback().ok_or(primary_err),
49    }
50}
51
52/// The platform fallback when `user_idle` can't read the counter. Only
53/// GNOME/Wayland has one (Mutter); everywhere else there's nothing more to
54/// try, so the primary error stands.
55#[cfg(target_os = "linux")]
56fn fallback_idle_secs() -> Option<u64> {
57    mutter::idle_secs()
58}
59
60#[cfg(not(target_os = "linux"))]
61fn fallback_idle_secs() -> Option<u64> {
62    None
63}
64
65/// Parse the `gdbus call … GetIdletime` reply — `(uint64 12345,)` — into
66/// idle milliseconds. Pure so the Wayland fallback's parsing is testable
67/// without a live session bus; the `gdbus` spawn stays in [`mutter`].
68#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
69fn parse_mutter_idletime_ms(text: &str) -> Option<u64> {
70    let inner = text.trim().strip_prefix('(')?.strip_suffix(')')?;
71    // The variant body is `uint64 12345,` — drop the trailing comma and
72    // take the last whitespace-separated token so the type tag is ignored.
73    let body = inner.trim().trim_end_matches(',').trim();
74    body.rsplit(char::is_whitespace).next()?.parse::<u64>().ok()
75}
76
77#[cfg(target_os = "linux")]
78mod mutter {
79    use std::process::Command;
80
81    use crate::proc::{CommandTimeoutExt, PROBE_TIMEOUT};
82
83    // Absolute path so a planted `gdbus` earlier in `$PATH` can't intercept
84    // the probe — same hardening as the DnD probe. If a session ships it
85    // elsewhere the call simply fails and idle stays unavailable.
86    const GDBUS_BIN: &str = "/usr/bin/gdbus";
87
88    /// Idle seconds from Mutter's `IdleMonitor`, or `None` if `gdbus` is
89    /// missing, the call fails (no GNOME shell on the bus), or the reply
90    /// can't be parsed.
91    pub(super) fn idle_secs() -> Option<u64> {
92        let output = Command::new(GDBUS_BIN)
93            .args([
94                "call",
95                "--session",
96                "--dest",
97                "org.gnome.Mutter.IdleMonitor",
98                "--object-path",
99                "/org/gnome/Mutter/IdleMonitor/Core",
100                "--method",
101                "org.gnome.Mutter.IdleMonitor.GetIdletime",
102            ])
103            .output_timeout(PROBE_TIMEOUT)
104            .ok()?;
105        if !output.status.success() {
106            return None;
107        }
108        let text = std::str::from_utf8(&output.stdout).ok()?;
109        super::parse_mutter_idletime_ms(text).map(|ms| ms / 1000)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn parses_a_normal_idletime_reply() {
119        assert_eq!(parse_mutter_idletime_ms("(uint64 12345,)\n"), Some(12345));
120    }
121
122    #[test]
123    fn parses_zero_idletime() {
124        assert_eq!(parse_mutter_idletime_ms("(uint64 0,)"), Some(0));
125    }
126
127    #[test]
128    fn tolerates_surrounding_whitespace() {
129        assert_eq!(parse_mutter_idletime_ms("  (uint64 42,)  \n"), Some(42));
130    }
131
132    #[test]
133    fn rejects_unparseable_replies() {
134        assert_eq!(parse_mutter_idletime_ms(""), None);
135        assert_eq!(parse_mutter_idletime_ms("()"), None);
136        assert_eq!(parse_mutter_idletime_ms("(uint64 ,)"), None);
137        assert_eq!(parse_mutter_idletime_ms("uint64 12345"), None);
138        assert_eq!(parse_mutter_idletime_ms("(uint64 notanumber,)"), None);
139    }
140
141    #[test]
142    fn ms_to_secs_conversion_truncates() {
143        // The probe divides by 1000 after parsing; confirm the parse keeps
144        // sub-second precision so callers can floor it themselves.
145        assert_eq!(parse_mutter_idletime_ms("(uint64 1999,)"), Some(1999));
146        assert_eq!(
147            parse_mutter_idletime_ms("(uint64 1999,)").map(|ms| ms / 1000),
148            Some(1)
149        );
150    }
151
152    #[test]
153    fn combine_idle_uses_primary_and_skips_fallback_on_success() {
154        let mut fallback_called = false;
155        let result = combine_idle(Ok(42), || {
156            fallback_called = true;
157            Some(7)
158        });
159        assert_eq!(result, Ok(42));
160        assert!(!fallback_called, "fallback must not run when primary works");
161    }
162
163    #[test]
164    fn combine_idle_falls_back_when_primary_fails() {
165        let result = combine_idle(Err("Status not OK".into()), || Some(7));
166        assert_eq!(result, Ok(7));
167    }
168
169    #[test]
170    fn combine_idle_surfaces_primary_error_when_fallback_empty() {
171        let result = combine_idle(Err("Status not OK".into()), || None);
172        assert_eq!(result, Err("Status not OK".to_string()));
173    }
174}