1use std::{
4 path::{Path, PathBuf},
5 sync::{
6 Arc, Mutex, OnceLock,
7 atomic::{AtomicU8, AtomicU64, Ordering},
8 },
9};
10
11pub use crate::durable_save::{
12 DurableSaveEffect, DurableSaveError, DurableSaveOutcome, DurableSaveRegistration,
13 register_durable_save, register_preference_save, run_durable_saves,
14};
15
16#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct PlatformDirectories {
19 pub data: PathBuf,
20 pub config: PathBuf,
21 pub cache: PathBuf,
22 pub documents: Option<PathBuf>,
23 pub temporary: PathBuf,
24 pub shared: Option<PathBuf>,
25}
26
27impl PlatformDirectories {
28 fn scoped(&self, application_id: &str) -> Self {
29 Self {
30 data: self.data.join(application_id),
31 config: self.config.join(application_id),
32 cache: self.cache.join(application_id),
33 documents: self
34 .documents
35 .as_ref()
36 .map(|path| path.join(application_id)),
37 temporary: self.temporary.join(application_id),
38 shared: self.shared.as_ref().map(|path| path.join(application_id)),
39 }
40 }
41}
42
43#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
44pub enum PlatformDirectoryError {
45 #[error("application id must be one non-empty path component")]
46 InvalidApplicationId,
47 #[error("no application id has been registered")]
48 NoApplicationId,
49 #[error("platform directories are unavailable")]
50 Unavailable,
51}
52
53#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum LifecycleState {
56 Created,
57 Started,
58 Resumed,
59 Paused,
60 Stopped,
61 Destroyed,
62}
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub struct LifecycleEvent {
67 pub from: LifecycleState,
68 pub to: LifecycleState,
69}
70
71pub trait HostController: Send + Sync {
73 fn set_keep_screen_on(&self, enabled: bool);
75 fn platform_directories(&self) -> Option<PlatformDirectories>;
77 fn exit(&self);
79 fn background(&self);
81 #[cfg(feature = "localization")]
84 fn preferred_languages(&self) -> Vec<String> {
85 Vec::new()
86 }
87 fn durable_save_deadline(&self) -> std::time::Duration {
92 DEFAULT_DURABLE_SAVE_DEADLINE
93 }
94}
95
96pub const DEFAULT_DURABLE_SAVE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
98
99pub fn durable_save_deadline() -> std::time::Duration {
101 host_controller().map_or(DEFAULT_DURABLE_SAVE_DEADLINE, |host| {
102 host.durable_save_deadline()
103 })
104}
105
106pub type HostControllerRef = Arc<dyn HostController>;
107
108fn controller() -> &'static Mutex<Option<HostControllerRef>> {
109 static SLOT: OnceLock<Mutex<Option<HostControllerRef>>> = OnceLock::new();
110 SLOT.get_or_init(|| Mutex::new(None))
111}
112
113pub fn set_host_controller(value: HostControllerRef) {
115 if let Ok(mut slot) = controller().lock() {
116 *slot = Some(value);
117 }
118 KEEP_SCREEN_ON.store(0, Ordering::Release);
119}
120pub fn clear_host_controller() {
122 if let Ok(mut slot) = controller().lock() {
123 *slot = None;
124 }
125 KEEP_SCREEN_ON.store(0, Ordering::Release);
126}
127pub fn host_controller() -> Option<HostControllerRef> {
129 controller().lock().ok().and_then(|slot| slot.clone())
130}
131pub fn set_keep_screen_on(enabled: bool) {
133 let value = if enabled { 2 } else { 1 };
134 if KEEP_SCREEN_ON.swap(value, Ordering::AcqRel) == value {
135 return;
136 }
137 if let Some(host) = host_controller() {
138 host.set_keep_screen_on(enabled);
139 }
140}
141fn valid_application_id(application_id: &str) -> bool {
142 !application_id.is_empty()
143 && Path::new(application_id).components().count() == 1
144 && application_id != "."
145 && application_id != ".."
146}
147
148fn desktop_platform_directories() -> Option<PlatformDirectories> {
149 let base = directories::BaseDirs::new()?;
150 let documents = directories::UserDirs::new()
151 .and_then(|directories| directories.document_dir().map(Path::to_path_buf));
152 Some(PlatformDirectories {
153 data: base.data_dir().to_path_buf(),
154 config: base.config_dir().to_path_buf(),
155 cache: base.cache_dir().to_path_buf(),
156 documents,
157 temporary: std::env::temp_dir(),
158 shared: None,
159 })
160}
161
162fn application_id_slot() -> &'static Mutex<Option<String>> {
163 static SLOT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
164 SLOT.get_or_init(|| Mutex::new(None))
165}
166
167pub fn set_application_id(application_id: &str) -> Result<(), PlatformDirectoryError> {
174 if !valid_application_id(application_id) {
175 return Err(PlatformDirectoryError::InvalidApplicationId);
176 }
177 if let Ok(mut slot) = application_id_slot().lock() {
178 *slot = Some(application_id.to_string());
179 }
180 Ok(())
181}
182
183pub fn clear_application_id() {
185 if let Ok(mut slot) = application_id_slot().lock() {
186 *slot = None;
187 }
188}
189
190pub fn application_id() -> Option<String> {
192 application_id_slot()
193 .lock()
194 .ok()
195 .and_then(|slot| slot.clone())
196}
197
198pub fn application_directories() -> Result<PlatformDirectories, PlatformDirectoryError> {
200 let application_id = application_id().ok_or(PlatformDirectoryError::NoApplicationId)?;
201 let roots = host_controller()
202 .and_then(|host| host.platform_directories())
203 .or_else(desktop_platform_directories)
204 .ok_or(PlatformDirectoryError::Unavailable)?;
205 Ok(roots.scoped(&application_id))
206}
207pub fn exit_app() {
209 if let Some(host) = host_controller() {
210 host.exit();
211 }
212}
213pub fn background_app() {
215 if let Some(host) = host_controller() {
216 host.background();
217 }
218}
219
220#[cfg(not(target_arch = "wasm32"))]
221type Observer = Arc<dyn Fn(LifecycleEvent) + Send + Sync>;
222
223#[cfg(not(target_arch = "wasm32"))]
224fn observers() -> &'static Mutex<Vec<(u64, Observer)>> {
225 static SLOT: OnceLock<Mutex<Vec<(u64, Observer)>>> = OnceLock::new();
226 SLOT.get_or_init(|| Mutex::new(Vec::new()))
227}
228static NEXT_ID: AtomicU64 = AtomicU64::new(1);
229static LIFECYCLE_STATE: AtomicU8 = AtomicU8::new(LifecycleState::Created as u8);
230static KEEP_SCREEN_ON: AtomicU8 = AtomicU8::new(0);
231
232pub struct LifecycleObserver {
234 id: u64,
235}
236impl Drop for LifecycleObserver {
237 fn drop(&mut self) {
238 #[cfg(not(target_arch = "wasm32"))]
239 if let Ok(mut list) = observers().lock() {
240 list.retain(|(id, _)| *id != self.id);
241 }
242 #[cfg(target_arch = "wasm32")]
243 crate::thread_state::with(|state| state.lifecycle_observers.remove(self.id));
244 }
245}
246#[cfg(not(target_arch = "wasm32"))]
248pub fn observe_lifecycle(
249 observer: impl Fn(LifecycleEvent) + Send + Sync + 'static,
250) -> LifecycleObserver {
251 let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
252 if let Ok(mut list) = observers().lock() {
253 list.push((id, Arc::new(observer)));
254 }
255 LifecycleObserver { id }
256}
257#[cfg(target_arch = "wasm32")]
259pub fn observe_lifecycle(observer: impl Fn(LifecycleEvent) + 'static) -> LifecycleObserver {
260 let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
261 crate::thread_state::with(|state| {
262 state
263 .lifecycle_observers
264 .add(id, std::rc::Rc::new(observer));
265 });
266 LifecycleObserver { id }
267}
268pub fn dispatch_lifecycle(event: LifecycleEvent) {
270 LIFECYCLE_STATE.store(event.to as u8, Ordering::Release);
271 crate::media::on_lifecycle(event);
272 #[cfg(not(target_arch = "wasm32"))]
273 let callbacks = observers()
274 .lock()
275 .map(|list| {
276 list.iter()
277 .map(|(_, cb)| Arc::clone(cb))
278 .collect::<Vec<_>>()
279 })
280 .unwrap_or_default();
281 #[cfg(target_arch = "wasm32")]
282 let callbacks = crate::thread_state::with(|state| state.lifecycle_observers.all());
283 for callback in callbacks {
284 callback(event);
285 }
286}
287
288pub fn current_lifecycle_state() -> LifecycleState {
290 match LIFECYCLE_STATE.load(Ordering::Acquire) {
291 0 => LifecycleState::Created,
292 1 => LifecycleState::Started,
293 2 => LifecycleState::Resumed,
294 3 => LifecycleState::Paused,
295 4 => LifecycleState::Stopped,
296 _ => LifecycleState::Destroyed,
297 }
298}
299
300pub fn dispatch_lifecycle_state(to: LifecycleState) {
307 let from = current_lifecycle_state();
308 if from == to {
309 return;
310 }
311 if matches!(to, LifecycleState::Paused) {
312 let outcome = run_durable_saves(durable_save_deadline());
313 if matches!(
314 outcome,
315 DurableSaveOutcome::TimedOut | DurableSaveOutcome::Failed
316 ) {
317 log::warn!("cranpose: durable saves did not complete successfully: {outcome:?}");
318 }
319 }
320 dispatch_lifecycle(LifecycleEvent { from, to });
321}
322
323pub fn window_lifecycle_state(visible: bool, focused: bool) -> LifecycleState {
328 match (visible, focused) {
329 (false, _) => LifecycleState::Stopped,
330 (true, true) => LifecycleState::Resumed,
331 (true, false) => LifecycleState::Paused,
332 }
333}
334
335impl LifecycleState {
336 pub fn is_at_least(self, other: LifecycleState) -> bool {
339 lifecycle_level(self) >= lifecycle_level(other)
340 }
341}
342
343fn lifecycle_level(state: LifecycleState) -> u8 {
344 match state {
345 LifecycleState::Destroyed => 0,
346 LifecycleState::Created | LifecycleState::Stopped => 1,
347 LifecycleState::Started | LifecycleState::Paused => 2,
348 LifecycleState::Resumed => 3,
349 }
350}
351
352fn next_lifecycle_step(from: LifecycleState, to: LifecycleState) -> Option<LifecycleState> {
353 let rising = match lifecycle_level(from).cmp(&lifecycle_level(to)) {
354 std::cmp::Ordering::Equal => return None,
355 std::cmp::Ordering::Less => true,
356 std::cmp::Ordering::Greater => false,
357 };
358 match (from, rising) {
359 (LifecycleState::Destroyed, _) => None,
360 (LifecycleState::Created | LifecycleState::Stopped, true) => Some(LifecycleState::Started),
361 (LifecycleState::Started | LifecycleState::Paused, true) => Some(LifecycleState::Resumed),
362 (LifecycleState::Resumed, _) => Some(LifecycleState::Paused),
363 (LifecycleState::Started | LifecycleState::Paused, false) => Some(LifecycleState::Stopped),
364 (LifecycleState::Created | LifecycleState::Stopped, false) => {
365 Some(LifecycleState::Destroyed)
366 }
367 }
368}
369
370pub fn advance_lifecycle(target: LifecycleState) {
377 while let Some(next) = next_lifecycle_step(current_lifecycle_state(), target) {
378 dispatch_lifecycle_state(next);
379 }
380}
381
382pub fn local_lifecycle_state() -> cranpose_core::CompositionLocal<LifecycleState> {
388 crate::composition_locals::cached_local(
389 |locals| &locals.lifecycle,
390 || cranpose_core::compositionLocalOf(current_lifecycle_state),
391 )
392}
393
394#[expect(non_snake_case)]
396#[track_caller]
397pub fn rememberLifecycleState() -> cranpose_core::State<LifecycleState> {
398 let transitions = rememberLifecycleEvents();
399 let state = cranpose_core::collectAsState(
400 transitions,
401 (),
402 LifecycleEvent {
403 from: current_lifecycle_state(),
404 to: current_lifecycle_state(),
405 },
406 );
407 cranpose_core::derivedStateOf(move || state.get().to)
408}
409
410#[expect(non_snake_case)]
412#[track_caller]
413pub fn rememberLifecycleEvents() -> cranpose_core::EventStream<LifecycleEvent> {
414 cranpose_core::rememberEventStream((), |sender| {
415 observe_lifecycle(move |event| sender.send(event))
416 })
417}
418
419#[cranpose_macros::composable]
424pub fn ProvideLifecycle(content: impl FnOnce()) {
425 let state = rememberLifecycleState();
426 let local = local_lifecycle_state();
427 cranpose_core::CompositionLocalProvider([local.provides(state.get())], move || {
428 content();
429 });
430}
431
432#[cfg(test)]
433#[path = "tests/host_tests.rs"]
434mod tests;