1pub 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 fn CGSessionCopyCurrentDictionary() -> CFDictionaryRef;
37 }
38
39 pub fn screen_locked() -> Option<bool> {
40 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 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 unsafe {
85 let desktop = OpenInputDesktop(0, 0, DESKTOP_READOBJECTS);
86 if desktop.is_null() {
87 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 pub(super) fn parse_desktop_name(name: &str) -> bool {
115 !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 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 const HEALTHY_TTL: Duration = Duration::from_secs(5);
164
165 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 next_probe_at: Option<Instant>,
182 last_value: Option<bool>,
184 consecutive_failures: u32,
186 disabled_logged: bool,
189 }
190
191 #[derive(Debug, PartialEq, Eq)]
193 pub(super) enum ProbeOutcome {
194 Determined(bool),
196 Unknown,
199 Failed(String),
202 }
203
204 #[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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(probe_backoff_ttl(5), FAILED_BACKOFF_MAX);
529 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 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 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 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 let _ = screen_locked();
598 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}