Skip to main content

ashlar_native/
power.rs

1//! Keep the system awake, and keep the app at full speed in the background, while long work runs
2//! (a render, an export, playback). An RAII guard: the request lasts while the [`KeepAwake`]
3//! lives.
4//!
5//! | | macOS | Windows | Linux |
6//! |---|---|---|---|
7//! | [`Awake::Busy`] | no App Nap (user-initiated activity, idle sleep allowed) | nothing to do | nothing to do |
8//! | [`Awake::System`] | + no idle system sleep | `ES_SYSTEM_REQUIRED` | logind `idle:sleep` inhibitor |
9//! | [`Awake::Display`] | + no display sleep | + `ES_DISPLAY_REQUIRED` | logind `idle:sleep` inhibitor |
10//!
11//! The guard is not `Send`: on Windows the request belongs to the thread that made it, so create
12//! and drop it on the same thread (a job's worker thread is fine).
13
14use crate::Result;
15
16/// How awake to stay.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub enum Awake {
20    /// Keep the process at full speed when it is hidden (macOS App Nap off). The machine may
21    /// still sleep when idle.
22    Busy,
23    /// Also keep the machine from sleeping when idle (renders, exports, downloads).
24    System,
25    /// Also keep the display on (playback, presentations).
26    Display,
27}
28
29/// Holds the request until dropped.
30pub struct KeepAwake {
31    _inner: imp::Guard,
32    // Not Send / Sync: the Windows request is per thread, the macOS token is an Objective-C object.
33    _not_send: std::marker::PhantomData<*const ()>,
34}
35
36impl KeepAwake {
37    /// Ask the OS to stay awake. `reason` is shown by the OS (`pmset -g assertions`, logind) and
38    /// clipped to one line.
39    pub fn new(kind: Awake, reason: &str) -> Result<Self> {
40        let reason: String = reason.chars().filter(|c| !c.is_control()).take(128).collect();
41        let reason = if reason.trim().is_empty() { "working".to_string() } else { reason };
42        Ok(Self { _inner: imp::Guard::new(kind, &reason)?, _not_send: std::marker::PhantomData })
43    }
44}
45
46impl std::fmt::Debug for KeepAwake {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.write_str("KeepAwake")
49    }
50}
51
52#[cfg(target_os = "macos")]
53#[allow(unsafe_code)]
54mod imp {
55    use objc2::rc::Retained;
56    use objc2::runtime::{NSObjectProtocol, ProtocolObject};
57    use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString};
58
59    use super::*;
60
61    pub struct Guard(Retained<ProtocolObject<dyn NSObjectProtocol>>);
62
63    impl Guard {
64        pub fn new(kind: Awake, reason: &str) -> Result<Self> {
65            let options = match kind {
66                Awake::Busy => NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical,
67                Awake::System => NSActivityOptions::UserInitiated,
68                Awake::Display => NSActivityOptions::UserInitiated | NSActivityOptions::IdleDisplaySleepDisabled,
69            };
70            let token = NSProcessInfo::processInfo().beginActivityWithOptions_reason(options, &NSString::from_str(reason));
71            Ok(Self(token))
72        }
73    }
74
75    impl Drop for Guard {
76        fn drop(&mut self) {
77            // SAFETY: the token is exactly the object `beginActivityWithOptions:reason:` returned
78            // for this activity, and it is ended once (here).
79            unsafe { NSProcessInfo::processInfo().endActivity(&self.0) };
80        }
81    }
82}
83
84#[cfg(target_os = "windows")]
85#[allow(unsafe_code)]
86mod imp {
87    use std::cell::RefCell;
88
89    use crate::NativeError;
90    use windows::Win32::System::Power::{ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, EXECUTION_STATE, SetThreadExecutionState};
91
92    use super::*;
93
94    thread_local! {
95        /// The requests alive on this thread: (id, flags). The thread's state is their union.
96        static ACTIVE: RefCell<(u64, Vec<(u64, EXECUTION_STATE)>)> = const { RefCell::new((0, Vec::new())) };
97    }
98
99    fn apply(active: &[(u64, EXECUTION_STATE)]) -> bool {
100        let flags = active.iter().fold(ES_CONTINUOUS, |acc, (_, f)| acc | *f);
101        // SAFETY: SetThreadExecutionState takes flags by value and only changes this thread's
102        // power request; it has no pointers or other preconditions.
103        let previous = unsafe { SetThreadExecutionState(flags) };
104        previous.0 != 0
105    }
106
107    pub struct Guard(Option<u64>);
108
109    impl Guard {
110        pub fn new(kind: Awake, _reason: &str) -> Result<Self> {
111            let flags = match kind {
112                Awake::Busy => return Ok(Self(None)),
113                Awake::System => ES_SYSTEM_REQUIRED,
114                Awake::Display => ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED,
115            };
116            ACTIVE.with(|a| {
117                let mut a = a.borrow_mut();
118                a.0 = a.0.wrapping_add(1);
119                let id = a.0;
120                a.1.push((id, flags));
121                if apply(&a.1) {
122                    Ok(Self(Some(id)))
123                } else {
124                    a.1.retain(|(i, _)| *i != id);
125                    Err(NativeError::Os("Windows refused the power request".into()))
126                }
127            })
128        }
129    }
130
131    impl Drop for Guard {
132        fn drop(&mut self) {
133            let Some(id) = self.0 else { return };
134            let _ = ACTIVE.try_with(|a| {
135                if let Ok(mut a) = a.try_borrow_mut() {
136                    a.1.retain(|(i, _)| *i != id);
137                    apply(&a.1);
138                }
139            });
140        }
141    }
142}
143
144#[cfg(target_os = "linux")]
145mod imp {
146    use super::*;
147    use crate::NativeError;
148
149    /// The logind inhibitor's file descriptor; closing it ends the inhibition.
150    pub struct Guard {
151        _fd: Option<zbus::zvariant::OwnedFd>,
152    }
153
154    impl Guard {
155        pub fn new(kind: Awake, reason: &str) -> Result<Self> {
156            if kind == Awake::Busy {
157                return Ok(Self { _fd: None });
158            }
159            let os = |e: zbus::Error| NativeError::Os(format!("logind refused the inhibitor ({})", crate::os_text(e)));
160            let conn = zbus::blocking::Connection::system().map_err(os)?;
161            let who =
162                std::env::current_exe().ok().and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())).unwrap_or_else(|| "app".into());
163            let reply = conn
164                .call_method(
165                    Some("org.freedesktop.login1"),
166                    "/org/freedesktop/login1",
167                    Some("org.freedesktop.login1.Manager"),
168                    "Inhibit",
169                    &("idle:sleep", who.as_str(), reason, "block"),
170                )
171                .map_err(os)?;
172            let fd: zbus::zvariant::OwnedFd = reply.body().deserialize().map_err(os)?;
173            Ok(Self { _fd: Some(fd) })
174        }
175    }
176}
177
178#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
179mod imp {
180    use super::*;
181    use crate::NativeError;
182
183    pub struct Guard;
184
185    impl Guard {
186        pub fn new(_kind: Awake, _reason: &str) -> Result<Self> {
187            Err(NativeError::Unsupported)
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::NativeError;
196
197    #[test]
198    fn busy_is_held_and_released_without_side_effects() {
199        // App Nap off (macOS) or nothing to do (Windows, Linux): always succeeds there.
200        let r = KeepAwake::new(Awake::Busy, "ashlar test\nwith a newline");
201        if cfg!(any(target_os = "macos", target_os = "windows", target_os = "linux")) {
202            let g = r.unwrap();
203            drop(g);
204        } else {
205            assert_eq!(r.err(), Some(NativeError::Unsupported));
206        }
207    }
208
209    #[cfg(target_os = "macos")]
210    #[test]
211    fn the_system_assertion_shows_up_while_the_guard_lives() {
212        let reason = format!("ashlar-native test {}", std::process::id());
213        let assertions = || {
214            String::from_utf8_lossy(&std::process::Command::new("pmset").args(["-g", "assertions"]).output().map(|o| o.stdout).unwrap_or_default())
215                .into_owned()
216        };
217        let g = KeepAwake::new(Awake::System, &reason).unwrap();
218        let during = assertions();
219        drop(g);
220        let after = assertions();
221        if during.is_empty() {
222            return; // pmset unavailable (sandbox): nothing to check
223        }
224        assert!(during.contains(&reason), "{during}");
225        assert!(!after.contains(&reason), "{after}");
226    }
227}