Skip to main content

teksilo_platform/
session.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Whether anything on screen can be seen at all: on macOS, the displays
5//! asleep, the screen locked, or the user's session switched out.
6//!
7//! AppKit reports a window not visible when another window covers it, when
8//! it is minimised, and when it was never shown. A window already showing
9//! stays visible to it while the displays sleep or the screen is locked, so
10//! wgpu goes on acquiring its frames and the window goes on drawing them for
11//! nobody. A [`SessionWatch`] hears those changes from the system instead:
12//! `NSWorkspace`'s screens-did-sleep and session-did-resign-active
13//! notifications and their opposites, and the distributed
14//! `com.apple.screenIsLocked` and `com.apple.screenIsUnlocked`, which Apple
15//! does not document but macOS has posted for many releases.
16//!
17//! Each of the three reasons is set and cleared by its own notifications. A
18//! clearing notification that never came would leave every window drawing
19//! nothing, so [`SessionWatch::recheck`] reads what the system reports and
20//! clears a reason it no longer does. It never sets one: a reading that is
21//! wrong can only mean frames drawn for nobody, which is what happened
22//! before there was a watch. For the same reason the watch starts with no
23//! reason set, even in a session already locked when it starts.
24//!
25//! There is no watch elsewhere. A Wayland compositor stops asking a surface
26//! it does not show for frames, which already holds a window's redraws.
27
28#[cfg(target_os = "macos")]
29mod macos;
30
31use std::sync::Arc;
32use std::sync::atomic::{AtomicU8, Ordering};
33
34/// One reason nothing can be seen.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
37enum Reason {
38    /// The displays sleep.
39    ScreensAsleep,
40    /// Another user's session has the console.
41    SessionInactive,
42    /// The screen is locked.
43    ScreenLocked,
44}
45
46impl Reason {
47    fn bit(self) -> u8 {
48        match self {
49            Reason::ScreensAsleep => 1,
50            Reason::SessionInactive => 1 << 1,
51            Reason::ScreenLocked => 1 << 2,
52        }
53    }
54}
55
56/// What the system reports now, each `None` where it cannot say.
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
59struct Reading {
60    screens_asleep: Option<bool>,
61    on_console: Option<bool>,
62    locked: Option<bool>,
63}
64
65/// The reasons set now, written from whatever thread the system posts its
66/// notifications on.
67#[derive(Debug, Default)]
68struct Reasons(AtomicU8);
69
70#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
71impl Reasons {
72    /// Set or clear `reason`; whether that changed [`away`](Self::away).
73    fn set(&self, reason: Reason, on: bool) -> bool {
74        let bit = reason.bit();
75        let before = if on {
76            self.0.fetch_or(bit, Ordering::AcqRel)
77        } else {
78            self.0.fetch_and(!bit, Ordering::AcqRel)
79        };
80        let after = if on { before | bit } else { before & !bit };
81        (before != 0) != (after != 0)
82    }
83
84    fn away(&self) -> bool {
85        self.0.load(Ordering::Acquire) != 0
86    }
87
88    /// Clear each reason `reading` says no longer holds; whether that
89    /// changed [`away`](Self::away).
90    fn recheck(&self, reading: Reading) -> bool {
91        let mut changed = false;
92        if reading.screens_asleep == Some(false) {
93            changed |= self.set(Reason::ScreensAsleep, false);
94        }
95        if reading.on_console == Some(true) {
96            changed |= self.set(Reason::SessionInactive, false);
97        }
98        if reading.locked == Some(false) {
99            changed |= self.set(Reason::ScreenLocked, false);
100        }
101        changed
102    }
103}
104
105/// Watches whether anything on screen can be seen; see the
106/// [module](self). Dropping it stops the watch.
107pub struct SessionWatch {
108    reasons: Arc<Reasons>,
109    #[cfg(target_os = "macos")]
110    _observer: macos::Observer,
111}
112
113impl std::fmt::Debug for SessionWatch {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("SessionWatch")
116            .field("away", &self.away())
117            .finish()
118    }
119}
120
121impl SessionWatch {
122    /// Start watching, on the main thread. `changed` runs each time
123    /// [`away`](Self::away) changes on a notification, on whatever thread
124    /// the system posts it from. `None` on every platform but macOS, and off
125    /// the main thread.
126    pub fn new(changed: impl Fn() + Send + Sync + 'static) -> Option<Self> {
127        #[cfg(target_os = "macos")]
128        {
129            let reasons = Arc::new(Reasons::default());
130            let observer = macos::Observer::new(Arc::clone(&reasons), Box::new(changed))?;
131            Some(Self {
132                reasons,
133                _observer: observer,
134            })
135        }
136        #[cfg(not(target_os = "macos"))]
137        {
138            let _ = changed;
139            None
140        }
141    }
142
143    /// Whether nothing on screen can be seen now: the displays sleep, the
144    /// screen is locked, or another session has the console.
145    pub fn away(&self) -> bool {
146        self.reasons.away()
147    }
148
149    /// Clear each reason the system no longer reports, should the
150    /// notification that clears it not have come; whether
151    /// [`away`](Self::away) changed. For while it is away, on the main
152    /// thread.
153    pub fn recheck(&self) -> bool {
154        #[cfg(target_os = "macos")]
155        {
156            self.reasons.recheck(macos::reading())
157        }
158        #[cfg(not(target_os = "macos"))]
159        {
160            false
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::{Reading, Reason, Reasons};
168
169    /// Away while any reason holds; only the first reason set and the last
170    /// one cleared change it.
171    #[test]
172    fn away_while_any_reason_holds() {
173        let reasons = Reasons::default();
174        assert!(!reasons.away());
175        assert!(reasons.set(Reason::ScreensAsleep, true));
176        assert!(!reasons.set(Reason::ScreenLocked, true), "already away");
177        assert!(!reasons.set(Reason::ScreensAsleep, true), "set twice");
178        assert!(!reasons.set(Reason::ScreensAsleep, false), "still locked");
179        assert!(reasons.away());
180        assert!(reasons.set(Reason::ScreenLocked, false));
181        assert!(!reasons.away());
182        assert!(!reasons.set(Reason::SessionInactive, false), "never set");
183    }
184
185    /// A reading clears only the reasons it says no longer hold, and sets
186    /// none.
187    #[test]
188    fn a_reading_clears_and_never_sets() {
189        let reasons = Reasons::default();
190        let everything = Reading {
191            screens_asleep: Some(true),
192            on_console: Some(false),
193            locked: Some(true),
194        };
195        assert!(!reasons.recheck(everything));
196        assert!(!reasons.away(), "a reading sets nothing");
197
198        reasons.set(Reason::ScreensAsleep, true);
199        reasons.set(Reason::ScreenLocked, true);
200        assert!(!reasons.recheck(Reading::default()), "it cannot say");
201        let awake = Reading {
202            screens_asleep: Some(false),
203            ..Reading::default()
204        };
205        assert!(!reasons.recheck(awake), "the screen is still locked");
206        assert!(reasons.away());
207        let unlocked = Reading {
208            locked: Some(false),
209            ..Reading::default()
210        };
211        assert!(reasons.recheck(unlocked));
212        assert!(!reasons.away());
213
214        reasons.set(Reason::SessionInactive, true);
215        let console = Reading {
216            on_console: Some(true),
217            ..Reading::default()
218        };
219        assert!(reasons.recheck(console));
220        assert!(!reasons.away());
221    }
222}