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}