1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
//! Device power and thermal state: observable, capability-aware, and explicit
//! about what this platform cannot answer.
//!
//! A snapshot query cannot tell "the battery is at 100%" from "this platform
//! has no battery API", and an application that guesses gets the decision
//! wrong on the platform it was not written for. Every reading here is either a
//! value or [`PowerReading::Unsupported`], and the whole state is observable so
//! a screen reacts to thermal pressure instead of sampling it.
use std::sync::{Arc, Mutex, OnceLock};
use cranpose_core::{ProcessIds, State, rememberEventStream};
use crate::registry::ServiceRegistry;
/// The operating system's thermal-pressure level.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum ThermalState {
/// No thermal pressure.
#[default]
Normal,
/// Light pressure; ordinary work may continue.
Light,
/// Sustained work should be reduced.
Moderate,
/// Expensive work should pause.
Severe,
/// The device is close to forced shutdown.
Critical,
/// Emergency pressure reported by the platform.
Emergency,
/// The platform is shutting down because of heat.
Shutdown,
}
impl ThermalState {
/// Whether sustained, expensive work should stop at this level.
pub fn should_pause_work(self) -> bool {
self >= Self::Severe
}
}
/// Current battery level and charging state.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BatteryStatus {
/// Charge from zero through one hundred percent.
pub percent: u8,
/// Whether external power is charging or has fully charged the battery.
pub charging: bool,
}
/// One power reading, or the reason there is no value.
///
/// The distinction matters: a desktop with no battery is not a device at 100%,
/// and an application that treats it as one refuses to run its own work on a
/// machine that is plugged into the wall.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PowerReading<T> {
/// The platform reported this value.
Known(T),
/// This platform has no API for it.
Unsupported,
/// The platform has the API but has not reported a value yet.
Unknown,
}
impl<T> PowerReading<T> {
/// The value, if the platform reported one.
pub fn known(self) -> Option<T> {
match self {
Self::Known(value) => Some(value),
_ => None,
}
}
/// Whether this platform can answer at all.
pub fn is_supported(&self) -> bool {
!matches!(self, Self::Unsupported)
}
/// The value, or `fallback` when there is none.
pub fn unwrap_or(self, fallback: T) -> T {
self.known().unwrap_or(fallback)
}
}
/// What a power backend can answer on this platform.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PowerCapabilities {
/// Whether [`PowerMonitor::thermal_state`] reports real readings.
pub thermal: bool,
/// Whether [`PowerMonitor::battery_status`] reports real readings.
pub battery: bool,
/// Whether the OS has a background-execution restriction to report.
pub background_restriction: bool,
}
/// Everything the platform currently says about power.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PowerState {
/// Current thermal pressure.
pub thermal: PowerReading<ThermalState>,
/// Current battery state.
pub battery: PowerReading<BatteryStatus>,
/// Whether the OS permits unrestricted background execution.
pub unrestricted_background_work: PowerReading<bool>,
}
impl PowerState {
/// The state a platform with no power APIs reports.
pub const fn unsupported() -> Self {
Self {
thermal: PowerReading::Unsupported,
battery: PowerReading::Unsupported,
unrestricted_background_work: PowerReading::Unsupported,
}
}
/// Whether sustained work should stop. A platform that cannot measure heat
/// never says stop, because guessing would starve every desktop build.
pub fn should_pause_work(&self) -> bool {
matches!(self.thermal, PowerReading::Known(level) if level.should_pause_work())
}
}
/// Platform power policy.
pub trait PowerMonitor: Send + Sync {
/// What this backend can answer.
fn capabilities(&self) -> PowerCapabilities {
PowerCapabilities::default()
}
/// Current thermal pressure.
fn thermal_state(&self) -> PowerReading<ThermalState> {
PowerReading::Unsupported
}
/// Current battery state.
fn battery_status(&self) -> PowerReading<BatteryStatus> {
PowerReading::Unsupported
}
/// Whether the OS permits unrestricted background execution for this app.
fn unrestricted_background_work(&self) -> PowerReading<bool> {
PowerReading::Unsupported
}
/// Opens the platform UI where the user can allow background execution.
fn request_unrestricted_background_work(&self) {}
}
/// Shared power-monitor service.
pub type PowerMonitorRef = Arc<dyn PowerMonitor>;
struct DefaultPowerMonitor;
impl PowerMonitor for DefaultPowerMonitor {}
static PLATFORM_POWER_MONITOR: ServiceRegistry<dyn PowerMonitor> = ServiceRegistry::new();
/// Installs the platform power monitor.
pub fn set_platform_power_monitor(monitor: PowerMonitorRef) {
PLATFORM_POWER_MONITOR.set(monitor);
publish_power_state(power_state());
}
/// Removes the platform power monitor.
pub fn clear_platform_power_monitor() {
PLATFORM_POWER_MONITOR.clear();
if let Ok(mut observers) = power_observers().lock() {
observers.clear();
}
}
/// Returns the platform monitor, or one that answers "unsupported".
pub fn power_monitor() -> PowerMonitorRef {
PLATFORM_POWER_MONITOR
.get()
.unwrap_or_else(|| Arc::new(DefaultPowerMonitor))
}
/// The current power state, read from the installed backend.
pub fn power_state() -> PowerState {
let monitor = power_monitor();
PowerState {
thermal: monitor.thermal_state(),
battery: monitor.battery_status(),
unrestricted_background_work: monitor.unrestricted_background_work(),
}
}
/// What the installed backend can answer on this platform.
pub fn power_capabilities() -> PowerCapabilities {
power_monitor().capabilities()
}
type PowerObserver = Arc<dyn Fn(PowerState) + Send + Sync>;
fn power_observers() -> &'static Mutex<Vec<(u64, PowerObserver)>> {
static SLOT: OnceLock<Mutex<Vec<(u64, PowerObserver)>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(Vec::new()))
}
static NEXT_OBSERVER: ProcessIds = ProcessIds::starting_at(1);
/// Keeps a power observer registered until it is dropped.
pub struct PowerObserverRegistration {
id: u64,
}
impl Drop for PowerObserverRegistration {
fn drop(&mut self) {
if let Ok(mut observers) = power_observers().lock() {
observers.retain(|(id, _)| *id != self.id);
}
}
}
/// Registers `observer` for power-state changes. Applications collect
/// [`rememberPowerState`] instead of calling this.
pub fn observe_power_state(
observer: impl Fn(PowerState) + Send + Sync + 'static,
) -> PowerObserverRegistration {
let id = NEXT_OBSERVER.next();
if let Ok(mut observers) = power_observers().lock() {
observers.push((id, Arc::new(observer)));
}
PowerObserverRegistration { id }
}
/// Publishes a new power state. Platform backends call this whenever the OS
/// reports a thermal or battery change, from whatever thread delivered it.
pub fn publish_power_state(state: PowerState) {
let observers = power_observers()
.lock()
.map(|observers| {
observers
.iter()
.map(|(_, observer)| Arc::clone(observer))
.collect::<Vec<_>>()
})
.unwrap_or_default();
for observer in observers {
observer(state);
}
}
/// The device's power state, observed for as long as this call stays in the
/// composition.
#[expect(non_snake_case, reason = "names follow Compose's casing")]
#[track_caller]
pub fn rememberPowerState() -> State<PowerState> {
let updates = rememberEventStream((), |sender| {
observe_power_state(move |state| sender.send(state))
});
cranpose_core::collectAsState(updates, (), power_state())
}
#[cfg(test)]
#[path = "tests/power_tests.rs"]
mod tests;