Skip to main content

entracte_lib/scheduler/
session_lock.rs

1//! Cross-platform "is the user session locked?" probe.
2//!
3//! `HIDIdleTime` (and its X11/Windows analogues, which `user_idle`
4//! wraps) only knows about input events. A stray `caffeinate -u`, a
5//! Zoom meeting holding `UserIsActive`, a debugger posting synthetic
6//! `CGEventPost` clicks, or a window-jiggler utility all keep the HID
7//! idle clock at zero while the human is gone. The lock screen is a
8//! stronger signal — short of unlocking it, the user is by definition
9//! away — so the scheduler layers this check on top of HID idle.
10//!
11//! Each backend returns `Option<bool>`:
12//! - `Some(true)` — confidently locked
13//! - `Some(false)` — confidently unlocked
14//! - `None` — couldn't determine (no GUI session, API unavailable,
15//!   helper binary missing)
16//!
17//! Callers treat `None` as "trust HID idle alone"; the scheduler only
18//! promotes idleness when we get a definitive `Some(true)`.
19
20pub fn screen_locked() -> Option<bool> {
21    inner::screen_locked()
22}
23
24#[cfg(target_os = "macos")]
25mod inner {
26    use core_foundation::base::{CFType, TCFType};
27    use core_foundation::boolean::CFBoolean;
28    use core_foundation::dictionary::{CFDictionary, CFDictionaryRef};
29    use core_foundation::string::CFString;
30
31    extern "C" {
32        // Documented-but-private CoreGraphics API; stable since 10.6.
33        // Returns a retained CFDictionary describing the current console
34        // session, or NULL if the caller has no GUI session attached
35        // (SSH, launchd background context, etc.).
36        fn CGSessionCopyCurrentDictionary() -> CFDictionaryRef;
37    }
38
39    pub fn screen_locked() -> Option<bool> {
40        // SAFETY: `CGSessionCopyCurrentDictionary` either returns NULL
41        // (handled below) or a +1-retained CFDictionary that we adopt
42        // via `wrap_under_create_rule`. The wrapper releases it on drop.
43        unsafe {
44            let raw = CGSessionCopyCurrentDictionary();
45            if raw.is_null() {
46                return None;
47            }
48            let dict: CFDictionary<CFString, CFType> = CFDictionary::wrap_under_create_rule(raw);
49            let key = CFString::from_static_string("CGSSessionScreenIsLocked");
50            // Apple populates the key with `kCFBooleanTrue` while the
51            // screen is locked, and omits it on a clean unlocked
52            // session. A present-but-non-boolean value is an Apple
53            // contract change — return `None` so the scheduler falls
54            // back to HID idle rather than silently misclassify.
55            match dict.find(&key) {
56                Some(value_ref) => value_ref.downcast::<CFBoolean>().map(bool::from),
57                None => Some(false),
58            }
59        }
60    }
61
62    #[cfg(test)]
63    mod tests {
64        use super::*;
65
66        #[test]
67        fn does_not_panic_on_host() {
68            let _ = screen_locked();
69        }
70    }
71}
72
73#[cfg(target_os = "windows")]
74mod inner {
75    use windows_sys::Win32::System::StationsAndDesktops::{
76        CloseDesktop, GetUserObjectInformationW, OpenInputDesktop, DESKTOP_READOBJECTS, UOI_NAME,
77    };
78
79    pub fn screen_locked() -> Option<bool> {
80        // SAFETY: `OpenInputDesktop` returns either NULL or an HDESK we
81        // must close exactly once; both branches handle that. The
82        // wide-string buffer is owned on the stack and only inspected up
83        // to the returned `needed` length.
84        unsafe {
85            let desktop = OpenInputDesktop(0, 0, DESKTOP_READOBJECTS);
86            if desktop.is_null() {
87                // When the workstation is locked, Winlogon owns the
88                // input desktop and our user-session process can't open
89                // it. ACCESS_DENIED here is the canonical lock signal.
90                return Some(true);
91            }
92
93            let mut buf = [0u16; 256];
94            let mut needed = 0u32;
95            let ok = GetUserObjectInformationW(
96                desktop as _,
97                UOI_NAME,
98                buf.as_mut_ptr() as _,
99                (buf.len() * std::mem::size_of::<u16>()) as u32,
100                &mut needed,
101            );
102            let _ = CloseDesktop(desktop);
103            if ok == 0 {
104                return None;
105            }
106            let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
107            let name = String::from_utf16_lossy(&buf[..len]);
108            Some(parse_desktop_name(&name))
109        }
110    }
111
112    // Pulled out for unit testing — the FFI side can only be exercised
113    // on a real Windows host with a logged-in interactive session.
114    pub(super) fn parse_desktop_name(name: &str) -> bool {
115        // The interactive desktop is always literally "Default". A
116        // locked workstation switches the focused desktop to
117        // "Winlogon"; an active screensaver to "Screen-saver". Anything
118        // that isn't "Default" means our process is no longer the one
119        // receiving the user's input.
120        !name.eq_ignore_ascii_case("Default")
121    }
122
123    #[cfg(test)]
124    mod tests {
125        use super::*;
126
127        #[test]
128        fn default_desktop_is_unlocked() {
129            assert!(!parse_desktop_name("Default"));
130            assert!(!parse_desktop_name("default"));
131        }
132
133        #[test]
134        fn winlogon_desktop_is_locked() {
135            assert!(parse_desktop_name("Winlogon"));
136        }
137
138        #[test]
139        fn screensaver_desktop_is_locked() {
140            assert!(parse_desktop_name("Screen-saver"));
141        }
142
143        #[test]
144        fn empty_desktop_name_is_locked() {
145            // Defensive: a zero-length name shouldn't be silently
146            // treated as the interactive desktop.
147            assert!(parse_desktop_name(""));
148        }
149    }
150}
151
152#[cfg(target_os = "linux")]
153mod inner {
154    use std::process::Command;
155    use std::sync::Mutex;
156    use std::time::{Duration, Instant};
157
158    use crate::proc::{CommandTimeoutExt, PROBE_TIMEOUT};
159
160    // `loginctl show-session` spawns a child process. While it's
161    // answering we re-probe at `HEALTHY_TTL` so a lock/unlock is noticed
162    // promptly without forking once per 1 Hz tick.
163    const HEALTHY_TTL: Duration = Duration::from_secs(5);
164
165    // Once `loginctl` is *persistently* failing (no systemd session,
166    // missing binary, container), keep retrying — a session could still
167    // appear — but back the interval off so we stop forking every few
168    // seconds, settling at this cap. The matching warning is logged once,
169    // on the transition into the failed state, not on every probe.
170    const FAILED_BACKOFF_MAX: Duration = Duration::from_secs(300);
171
172    static STATE: Mutex<ProbeState> = Mutex::new(ProbeState {
173        next_probe_at: None,
174        last_value: None,
175        consecutive_failures: 0,
176        disabled_logged: false,
177    });
178
179    struct ProbeState {
180        /// Earliest instant we're allowed to spawn `loginctl` again.
181        next_probe_at: Option<Instant>,
182        /// Last determined value, served while inside the interval.
183        last_value: Option<bool>,
184        /// Failures since the last healthy probe, driving the back-off.
185        consecutive_failures: u32,
186        /// Whether we've already logged the current failed streak, so the
187        /// "disabled" warning fires once rather than on every probe.
188        disabled_logged: bool,
189    }
190
191    /// What a single `loginctl` probe told us.
192    #[derive(Debug, PartialEq, Eq)]
193    pub(super) enum ProbeOutcome {
194        /// Definite lock state (`LockedHint` = yes/no).
195        Determined(bool),
196        /// `loginctl` ran but the hint was unset/unparseable — not a
197        /// failure, just "can't say this time".
198        Unknown,
199        /// `loginctl` couldn't be spawned or exited non-zero. Carries the
200        /// detail for the one-time log line.
201        Failed(String),
202    }
203
204    /// A probe-health transition worth logging exactly once.
205    #[derive(Debug, PartialEq, Eq)]
206    pub(super) enum HealthLog {
207        Disabled,
208        Restored,
209    }
210
211    pub fn screen_locked() -> Option<bool> {
212        let now = Instant::now();
213        {
214            let st = lock_state();
215            if let Some(at) = st.next_probe_at {
216                if now < at {
217                    return st.last_value;
218                }
219            }
220        }
221        let outcome = probe();
222        let prev = {
223            let st = lock_state();
224            (st.consecutive_failures, st.disabled_logged)
225        };
226        let (next, health) = plan(prev, &outcome);
227        match health {
228            Some(HealthLog::Disabled) => {
229                let detail = match &outcome {
230                    ProbeOutcome::Failed(d) => d.as_str(),
231                    _ => "unknown",
232                };
233                log::warn!(
234                    "session_lock: loginctl probe failed ({detail}); lock detection disabled \
235                     (retrying quietly until it recovers)"
236                );
237            }
238            Some(HealthLog::Restored) => {
239                log::info!("session_lock: loginctl probe recovered; lock detection re-enabled")
240            }
241            None => {}
242        }
243        let mut st = lock_state();
244        st.consecutive_failures = next.consecutive_failures;
245        st.disabled_logged = next.disabled_logged;
246        st.last_value = next.value;
247        st.next_probe_at = Some(now + next.ttl);
248        st.last_value
249    }
250
251    fn lock_state() -> std::sync::MutexGuard<'static, ProbeState> {
252        STATE.lock().unwrap_or_else(|e| e.into_inner())
253    }
254
255    /// The state changes a probe outcome implies. Returned by [`plan`].
256    pub(super) struct Plan {
257        pub consecutive_failures: u32,
258        pub disabled_logged: bool,
259        pub ttl: Duration,
260        pub value: Option<bool>,
261    }
262
263    /// Pure decision: from the prior `(failure streak, already-logged)`
264    /// and a fresh probe outcome, compute the next state, the re-probe
265    /// interval, and whether to emit a one-time health-transition log.
266    /// Split out so the back-off growth and log-once behaviour are
267    /// unit-testable without a real `loginctl`.
268    pub(super) fn plan(
269        (prev_failures, prev_disabled_logged): (u32, bool),
270        outcome: &ProbeOutcome,
271    ) -> (Plan, Option<HealthLog>) {
272        match outcome {
273            ProbeOutcome::Failed(_) => {
274                let consecutive_failures = prev_failures.saturating_add(1);
275                let health = if prev_disabled_logged {
276                    None
277                } else {
278                    Some(HealthLog::Disabled)
279                };
280                (
281                    Plan {
282                        consecutive_failures,
283                        disabled_logged: true,
284                        ttl: probe_backoff_ttl(consecutive_failures),
285                        value: None,
286                    },
287                    health,
288                )
289            }
290            // `loginctl` answered (even if "unknown"), so the probe is
291            // healthy again: reset the streak and, if we'd announced it
292            // disabled, announce recovery once.
293            ProbeOutcome::Determined(b) => (
294                healthy_plan(Some(*b)),
295                restored_if_was_disabled(prev_disabled_logged),
296            ),
297            ProbeOutcome::Unknown => (
298                healthy_plan(None),
299                restored_if_was_disabled(prev_disabled_logged),
300            ),
301        }
302    }
303
304    fn healthy_plan(value: Option<bool>) -> Plan {
305        Plan {
306            consecutive_failures: 0,
307            disabled_logged: false,
308            ttl: HEALTHY_TTL,
309            value,
310        }
311    }
312
313    fn restored_if_was_disabled(prev_disabled_logged: bool) -> Option<HealthLog> {
314        if prev_disabled_logged {
315            Some(HealthLog::Restored)
316        } else {
317            None
318        }
319    }
320
321    /// Re-probe interval after `consecutive_failures` failures. `0` (a
322    /// healthy probe) uses `HEALTHY_TTL`; failures grow it exponentially
323    /// from 30 s, capped at `FAILED_BACKOFF_MAX`, so a permanently-broken
324    /// probe settles at one attempt every five minutes instead of every
325    /// five seconds.
326    pub(super) fn probe_backoff_ttl(consecutive_failures: u32) -> Duration {
327        if consecutive_failures == 0 {
328            return HEALTHY_TTL;
329        }
330        let shift = (consecutive_failures - 1).min(u32::BITS - 1);
331        let secs = 30u64.saturating_mul(1u64 << shift);
332        Duration::from_secs(secs).min(FAILED_BACKOFF_MAX)
333    }
334
335    fn probe() -> ProbeOutcome {
336        // systemd-logind exposes `LockedHint` on every session,
337        // regardless of compositor or desktop environment (GNOME, KDE,
338        // sway, X11, Wayland). Shelling out to `loginctl` keeps the
339        // dependency surface tiny — pulling in zbus/dbus would add 30+
340        // crates for one boolean read. Run under the shared probe timeout
341        // so a wedged session bus can't stall the lock check (#191).
342        let candidates = session_candidates(std::env::var("XDG_SESSION_ID").ok().as_deref());
343        let mut last_err = String::from("loginctl produced no session candidate");
344        for session in &candidates {
345            let out = match Command::new("loginctl")
346                .args([
347                    "show-session",
348                    session.as_str(),
349                    "-p",
350                    "LockedHint",
351                    "--value",
352                ])
353                .output_timeout(PROBE_TIMEOUT)
354            {
355                Ok(o) => o,
356                // A spawn/timeout failure won't change between candidates
357                // (missing binary, hung bus), so stop trying and report it.
358                Err(e) => return ProbeOutcome::Failed(format!("spawn failed: {e}")),
359            };
360            match classify_loginctl(
361                session,
362                out.status.success(),
363                out.status.code(),
364                &String::from_utf8_lossy(&out.stdout),
365                &String::from_utf8_lossy(&out.stderr),
366            ) {
367                Ok(outcome) => return outcome,
368                Err(detail) => last_err = detail,
369            }
370        }
371        ProbeOutcome::Failed(last_err)
372    }
373
374    /// Ordered `loginctl show-session` identifiers to try. The caller's own
375    /// `XDG_SESSION_ID` is the most specific, but it's frequently unset for
376    /// GUI apps launched detached from the logind session (D-Bus
377    /// activation, some autostart paths) — and there the old literal `self`
378    /// fallback resolved to nothing, so `loginctl` exited non-zero and lock
379    /// detection disabled itself (#191). `auto` is logind's lenient
380    /// resolver — the caller's session if it has one, otherwise the user's
381    /// display session — so it succeeds where `self` can't. Pure so the
382    /// fallback order is testable without a logind session.
383    pub(super) fn session_candidates(env_session_id: Option<&str>) -> Vec<String> {
384        let mut candidates = Vec::new();
385        if let Some(id) = env_session_id.map(str::trim).filter(|s| !s.is_empty()) {
386            candidates.push(id.to_string());
387        }
388        candidates.push("auto".to_string());
389        candidates
390    }
391
392    /// Classify one `loginctl` invocation. `Ok(outcome)` ends the probe;
393    /// `Err(detail)` means "this candidate failed, try the next" and
394    /// carries the failure detail — including `loginctl`'s stderr, which
395    /// the old code dropped, so a bug report now pins the exact cause
396    /// (#191). Pure over the raw output pieces so the success classification
397    /// and stderr capture are testable without spawning `loginctl`.
398    pub(super) fn classify_loginctl(
399        session: &str,
400        success: bool,
401        code: Option<i32>,
402        stdout: &str,
403        stderr: &str,
404    ) -> Result<ProbeOutcome, String> {
405        if success {
406            return Ok(match parse_locked_hint(stdout) {
407                Some(b) => ProbeOutcome::Determined(b),
408                None => ProbeOutcome::Unknown,
409            });
410        }
411        let stderr = stderr.trim();
412        if stderr.is_empty() {
413            Err(format!("loginctl show-session {session} exited {code:?}"))
414        } else {
415            Err(format!(
416                "loginctl show-session {session} exited {code:?}: {stderr}"
417            ))
418        }
419    }
420
421    pub(super) fn parse_locked_hint(text: &str) -> Option<bool> {
422        match text.trim() {
423            v if v.eq_ignore_ascii_case("yes") => Some(true),
424            v if v.eq_ignore_ascii_case("no") => Some(false),
425            _ => None,
426        }
427    }
428
429    #[cfg(test)]
430    mod tests {
431        use super::*;
432
433        #[test]
434        fn parse_yes_means_locked() {
435            assert_eq!(parse_locked_hint("yes\n"), Some(true));
436            assert_eq!(parse_locked_hint("YES"), Some(true));
437            assert_eq!(parse_locked_hint("  yes  "), Some(true));
438        }
439
440        #[test]
441        fn parse_no_means_unlocked() {
442            assert_eq!(parse_locked_hint("no\n"), Some(false));
443            assert_eq!(parse_locked_hint("No"), Some(false));
444        }
445
446        #[test]
447        fn parse_unknown_returns_none() {
448            // `loginctl` prints an empty string when the property
449            // exists but is unset, and a non-zero exit when the session
450            // is missing — but the *parser* alone should also reject
451            // anything it can't classify rather than guessing.
452            assert_eq!(parse_locked_hint(""), None);
453            assert_eq!(parse_locked_hint("maybe"), None);
454            assert_eq!(parse_locked_hint("1"), None);
455        }
456
457        #[test]
458        fn session_candidates_prefers_env_id_then_auto() {
459            assert_eq!(session_candidates(Some("3")), vec!["3", "auto"]);
460        }
461
462        #[test]
463        fn session_candidates_falls_back_to_auto_when_unset_or_blank() {
464            assert_eq!(session_candidates(None), vec!["auto"]);
465            assert_eq!(session_candidates(Some("")), vec!["auto"]);
466            assert_eq!(session_candidates(Some("   ")), vec!["auto"]);
467        }
468
469        #[test]
470        fn session_candidates_trims_a_padded_env_id() {
471            assert_eq!(session_candidates(Some("  7  ")), vec!["7", "auto"]);
472        }
473
474        #[test]
475        fn classify_success_yes_is_determined_locked() {
476            assert_eq!(
477                classify_loginctl("3", true, Some(0), "yes\n", ""),
478                Ok(ProbeOutcome::Determined(true))
479            );
480        }
481
482        #[test]
483        fn classify_success_unparseable_hint_is_unknown() {
484            // loginctl answered but `LockedHint` was unset/empty: healthy,
485            // just no value this time.
486            assert_eq!(
487                classify_loginctl("3", true, Some(0), "\n", ""),
488                Ok(ProbeOutcome::Unknown)
489            );
490        }
491
492        #[test]
493        fn classify_failure_captures_stderr_for_diagnostics() {
494            let detail = classify_loginctl(
495                "auto",
496                false,
497                Some(1),
498                "",
499                "Failed to get session: No such file or directory\n",
500            )
501            .unwrap_err();
502            assert!(detail.contains("auto"));
503            assert!(detail.contains("Some(1)"));
504            assert!(detail.contains("No such file or directory"));
505        }
506
507        #[test]
508        fn classify_failure_without_stderr_still_reports_the_exit() {
509            let detail = classify_loginctl("self", false, Some(1), "", "  \n").unwrap_err();
510            assert!(detail.contains("self"));
511            assert!(detail.contains("Some(1)"));
512            // No trailing ": " when there's nothing to append.
513            assert!(!detail.trim_end().ends_with(':'));
514        }
515
516        #[test]
517        fn backoff_ttl_is_healthy_interval_when_not_failing() {
518            assert_eq!(probe_backoff_ttl(0), HEALTHY_TTL);
519        }
520
521        #[test]
522        fn backoff_ttl_grows_then_caps() {
523            assert_eq!(probe_backoff_ttl(1), Duration::from_secs(30));
524            assert_eq!(probe_backoff_ttl(2), Duration::from_secs(60));
525            assert_eq!(probe_backoff_ttl(3), Duration::from_secs(120));
526            assert_eq!(probe_backoff_ttl(4), Duration::from_secs(240));
527            // 30 * 2^4 = 480 > cap → clamped.
528            assert_eq!(probe_backoff_ttl(5), FAILED_BACKOFF_MAX);
529            // Extreme streak must not overflow the shift or multiply.
530            assert_eq!(probe_backoff_ttl(u32::MAX), FAILED_BACKOFF_MAX);
531        }
532
533        #[test]
534        fn plan_first_failure_logs_disabled_once_and_backs_off() {
535            let (plan, log) = plan(
536                (0, false),
537                &ProbeOutcome::Failed("loginctl exited Some(1)".into()),
538            );
539            assert_eq!(plan.consecutive_failures, 1);
540            assert!(plan.disabled_logged);
541            assert_eq!(plan.ttl, Duration::from_secs(30));
542            assert_eq!(plan.value, None);
543            assert_eq!(log, Some(HealthLog::Disabled));
544        }
545
546        #[test]
547        fn plan_repeated_failure_is_silent_and_grows_backoff() {
548            // Already announced disabled: don't log again, keep backing off.
549            let (plan, log) = plan((1, true), &ProbeOutcome::Failed("spawn failed".into()));
550            assert_eq!(plan.consecutive_failures, 2);
551            assert!(plan.disabled_logged);
552            assert_eq!(plan.ttl, Duration::from_secs(60));
553            assert_eq!(log, None);
554        }
555
556        #[test]
557        fn plan_recovery_logs_restored_once() {
558            // Determined after a logged-disabled streak: reset, log once.
559            let (plan, log) = plan((4, true), &ProbeOutcome::Determined(true));
560            assert_eq!(plan.consecutive_failures, 0);
561            assert!(!plan.disabled_logged);
562            assert_eq!(plan.ttl, HEALTHY_TTL);
563            assert_eq!(plan.value, Some(true));
564            assert_eq!(log, Some(HealthLog::Restored));
565        }
566
567        #[test]
568        fn plan_healthy_determined_is_silent() {
569            let (plan, log) = plan((0, false), &ProbeOutcome::Determined(false));
570            assert_eq!(plan.value, Some(false));
571            assert_eq!(plan.ttl, HEALTHY_TTL);
572            assert_eq!(log, None);
573        }
574
575        #[test]
576        fn plan_unknown_is_healthy_with_no_value() {
577            // loginctl answered but the hint was unset: not a failure, no
578            // value, healthy interval, and it clears a prior disabled log.
579            let (after_disabled, log) = plan((3, true), &ProbeOutcome::Unknown);
580            assert_eq!(after_disabled.consecutive_failures, 0);
581            assert_eq!(after_disabled.value, None);
582            assert_eq!(after_disabled.ttl, HEALTHY_TTL);
583            assert_eq!(log, Some(HealthLog::Restored));
584
585            let (healthy, log) = plan((0, false), &ProbeOutcome::Unknown);
586            assert_eq!(healthy.value, None);
587            assert_eq!(log, None);
588        }
589
590        #[test]
591        fn does_not_panic_on_host() {
592            // End-to-end smoke: exercise the state + probe + loginctl
593            // round-trip without asserting the result. On CI the calling
594            // process has no logind session, so loginctl returns non-zero
595            // and we fall back to None — the value of the test is purely
596            // that we don't crash on the FFI path.
597            let _ = screen_locked();
598            // Call again to hit the within-interval (cached) branch.
599            let _ = screen_locked();
600        }
601    }
602}
603
604#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
605mod inner {
606    pub fn screen_locked() -> Option<bool> {
607        None
608    }
609}