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