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,
23 pub config: PathBuf,
25 pub cache: PathBuf,
27 pub documents: Option<PathBuf>,
29 pub temporary: PathBuf,
31 pub shared: Option<PathBuf>,
34}
35
36impl PlatformDirectories {
37 fn scoped(&self, application_id: &str) -> Self {
38 Self {
39 data: self.data.join(application_id),
40 config: self.config.join(application_id),
41 cache: self.cache.join(application_id),
42 documents: self
43 .documents
44 .as_ref()
45 .map(|path| path.join(application_id)),
46 temporary: self.temporary.join(application_id),
47 shared: self.shared.as_ref().map(|path| path.join(application_id)),
48 }
49 }
50}
51
52#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
54pub enum PlatformDirectoryError {
55 #[error("application id must be one non-empty path component")]
57 InvalidApplicationId,
58 #[error("no application id has been registered")]
60 NoApplicationId,
61 #[error("platform directories are unavailable")]
63 Unavailable,
64}
65
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum LifecycleState {
69 Created,
71 Started,
73 Resumed,
75 Paused,
77 Stopped,
79 Destroyed,
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub struct LifecycleEvent {
86 pub from: LifecycleState,
88 pub to: LifecycleState,
90}
91
92pub trait HostController: Send + Sync {
94 fn set_keep_screen_on(&self, enabled: bool);
96 fn set_system_bar_icons(&self, _icons: crate::SystemBarIcons) {}
99 fn platform_directories(&self) -> Option<PlatformDirectories>;
101 fn exit(&self);
103 fn background(&self);
105 #[cfg(feature = "localization")]
108 fn preferred_languages(&self) -> Vec<String> {
109 Vec::new()
110 }
111 fn durable_save_deadline(&self) -> std::time::Duration {
116 DEFAULT_DURABLE_SAVE_DEADLINE
117 }
118}
119
120pub const DEFAULT_DURABLE_SAVE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
122
123pub fn durable_save_deadline() -> std::time::Duration {
125 host_controller().map_or(DEFAULT_DURABLE_SAVE_DEADLINE, |host| {
126 host.durable_save_deadline()
127 })
128}
129
130pub type HostControllerRef = Arc<dyn HostController>;
132
133fn controller() -> &'static Mutex<Option<HostControllerRef>> {
134 static SLOT: OnceLock<Mutex<Option<HostControllerRef>>> = OnceLock::new();
135 SLOT.get_or_init(|| Mutex::new(None))
136}
137
138pub fn set_host_controller(value: HostControllerRef) {
140 if let Ok(mut slot) = controller().lock() {
141 *slot = Some(value);
142 }
143 KEEP_SCREEN_ON.store(0, Ordering::Release);
144 crate::system_bars::show_current_system_bar_icons();
147}
148pub fn clear_host_controller() {
150 if let Ok(mut slot) = controller().lock() {
151 *slot = None;
152 }
153 KEEP_SCREEN_ON.store(0, Ordering::Release);
154}
155pub fn host_controller() -> Option<HostControllerRef> {
157 controller().lock().ok().and_then(|slot| slot.clone())
158}
159pub fn set_keep_screen_on(enabled: bool) {
161 let value = if enabled { 2 } else { 1 };
162 if KEEP_SCREEN_ON.swap(value, Ordering::AcqRel) == value {
163 return;
164 }
165 if let Some(host) = host_controller() {
166 host.set_keep_screen_on(enabled);
167 }
168}
169fn valid_application_id(application_id: &str) -> bool {
170 !application_id.is_empty()
171 && Path::new(application_id).components().count() == 1
172 && application_id != "."
173 && application_id != ".."
174}
175
176fn desktop_platform_directories() -> Option<PlatformDirectories> {
177 let base = directories::BaseDirs::new()?;
178 let documents = directories::UserDirs::new()
179 .and_then(|directories| directories.document_dir().map(Path::to_path_buf));
180 Some(PlatformDirectories {
181 data: base.data_dir().to_path_buf(),
182 config: base.config_dir().to_path_buf(),
183 cache: base.cache_dir().to_path_buf(),
184 documents,
185 temporary: std::env::temp_dir(),
186 shared: None,
187 })
188}
189
190fn application_id_slot() -> &'static Mutex<Option<String>> {
191 static SLOT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
192 SLOT.get_or_init(|| Mutex::new(None))
193}
194
195pub fn set_application_id(application_id: &str) -> Result<(), PlatformDirectoryError> {
202 if !valid_application_id(application_id) {
203 return Err(PlatformDirectoryError::InvalidApplicationId);
204 }
205 if let Ok(mut slot) = application_id_slot().lock() {
206 *slot = Some(application_id.to_string());
207 }
208 Ok(())
209}
210
211pub fn clear_application_id() {
213 if let Ok(mut slot) = application_id_slot().lock() {
214 *slot = None;
215 }
216}
217
218pub fn application_id() -> Option<String> {
220 application_id_slot()
221 .lock()
222 .ok()
223 .and_then(|slot| slot.clone())
224}
225
226pub fn application_directories() -> Result<PlatformDirectories, PlatformDirectoryError> {
228 let application_id = application_id().ok_or(PlatformDirectoryError::NoApplicationId)?;
229 let roots = host_controller()
230 .and_then(|host| host.platform_directories())
231 .or_else(desktop_platform_directories)
232 .ok_or(PlatformDirectoryError::Unavailable)?;
233 Ok(roots.scoped(&application_id))
234}
235pub fn exit_app() {
237 if let Some(host) = host_controller() {
238 host.exit();
239 }
240}
241pub fn background_app() {
243 if let Some(host) = host_controller() {
244 host.background();
245 }
246}
247
248#[cfg(not(target_arch = "wasm32"))]
249type Observer = Arc<dyn Fn(LifecycleEvent) + Send + Sync>;
250
251#[cfg(not(target_arch = "wasm32"))]
252fn observers() -> &'static Mutex<Vec<(u64, Observer)>> {
253 static SLOT: OnceLock<Mutex<Vec<(u64, Observer)>>> = OnceLock::new();
254 SLOT.get_or_init(|| Mutex::new(Vec::new()))
255}
256static NEXT_ID: AtomicU64 = AtomicU64::new(1);
257static LIFECYCLE_STATE: AtomicU8 = AtomicU8::new(LifecycleState::Created as u8);
258static KEEP_SCREEN_ON: AtomicU8 = AtomicU8::new(0);
259
260pub struct LifecycleObserver {
262 id: u64,
263}
264impl Drop for LifecycleObserver {
265 fn drop(&mut self) {
266 #[cfg(not(target_arch = "wasm32"))]
267 if let Ok(mut list) = observers().lock() {
268 list.retain(|(id, _)| *id != self.id);
269 }
270 #[cfg(target_arch = "wasm32")]
271 crate::thread_state::with(|state| state.lifecycle_observers.remove(self.id));
272 }
273}
274#[cfg(not(target_arch = "wasm32"))]
276pub fn observe_lifecycle(
277 observer: impl Fn(LifecycleEvent) + Send + Sync + 'static,
278) -> LifecycleObserver {
279 let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
280 if let Ok(mut list) = observers().lock() {
281 list.push((id, Arc::new(observer)));
282 }
283 LifecycleObserver { id }
284}
285#[cfg(target_arch = "wasm32")]
287pub fn observe_lifecycle(observer: impl Fn(LifecycleEvent) + 'static) -> LifecycleObserver {
288 let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
289 crate::thread_state::with(|state| {
290 state
291 .lifecycle_observers
292 .add(id, std::rc::Rc::new(observer));
293 });
294 LifecycleObserver { id }
295}
296pub fn dispatch_lifecycle(event: LifecycleEvent) {
298 LIFECYCLE_STATE.store(event.to as u8, Ordering::Release);
299 crate::media::on_lifecycle(event);
300 #[cfg(not(target_arch = "wasm32"))]
301 let callbacks = observers()
302 .lock()
303 .map(|list| {
304 list.iter()
305 .map(|(_, cb)| Arc::clone(cb))
306 .collect::<Vec<_>>()
307 })
308 .unwrap_or_default();
309 #[cfg(target_arch = "wasm32")]
310 let callbacks = crate::thread_state::with(|state| state.lifecycle_observers.all());
311 for callback in callbacks {
312 callback(event);
313 }
314}
315
316pub fn current_lifecycle_state() -> LifecycleState {
318 match LIFECYCLE_STATE.load(Ordering::Acquire) {
319 0 => LifecycleState::Created,
320 1 => LifecycleState::Started,
321 2 => LifecycleState::Resumed,
322 3 => LifecycleState::Paused,
323 4 => LifecycleState::Stopped,
324 _ => LifecycleState::Destroyed,
325 }
326}
327
328pub fn dispatch_lifecycle_state(to: LifecycleState) {
335 let from = current_lifecycle_state();
336 if from == to {
337 return;
338 }
339 if matches!(to, LifecycleState::Paused) {
340 let outcome = run_durable_saves(durable_save_deadline());
341 if matches!(
342 outcome,
343 DurableSaveOutcome::TimedOut | DurableSaveOutcome::Failed
344 ) {
345 log::warn!("cranpose: durable saves did not complete successfully: {outcome:?}");
346 }
347 }
348 dispatch_lifecycle(LifecycleEvent { from, to });
349}
350
351pub fn window_lifecycle_state(visible: bool, focused: bool) -> LifecycleState {
356 match (visible, focused) {
357 (false, _) => LifecycleState::Stopped,
358 (true, true) => LifecycleState::Resumed,
359 (true, false) => LifecycleState::Paused,
360 }
361}
362
363impl LifecycleState {
364 pub fn is_at_least(self, other: Self) -> bool {
367 lifecycle_level(self) >= lifecycle_level(other)
368 }
369}
370
371fn lifecycle_level(state: LifecycleState) -> u8 {
372 match state {
373 LifecycleState::Destroyed => 0,
374 LifecycleState::Created | LifecycleState::Stopped => 1,
375 LifecycleState::Started | LifecycleState::Paused => 2,
376 LifecycleState::Resumed => 3,
377 }
378}
379
380fn next_lifecycle_step(from: LifecycleState, to: LifecycleState) -> Option<LifecycleState> {
381 let rising = match lifecycle_level(from).cmp(&lifecycle_level(to)) {
382 std::cmp::Ordering::Equal => return None,
383 std::cmp::Ordering::Less => true,
384 std::cmp::Ordering::Greater => false,
385 };
386 match (from, rising) {
387 (LifecycleState::Destroyed, _) => None,
388 (LifecycleState::Created | LifecycleState::Stopped, true) => Some(LifecycleState::Started),
389 (LifecycleState::Started | LifecycleState::Paused, true) => Some(LifecycleState::Resumed),
390 (LifecycleState::Resumed, _) => Some(LifecycleState::Paused),
391 (LifecycleState::Started | LifecycleState::Paused, false) => Some(LifecycleState::Stopped),
392 (LifecycleState::Created | LifecycleState::Stopped, false) => {
393 Some(LifecycleState::Destroyed)
394 }
395 }
396}
397
398pub fn advance_lifecycle(target: LifecycleState) {
405 while let Some(next) = next_lifecycle_step(current_lifecycle_state(), target) {
406 dispatch_lifecycle_state(next);
407 }
408}
409
410pub fn local_lifecycle_state() -> cranpose_core::CompositionLocal<LifecycleState> {
416 crate::composition_locals::cached_local(
417 |locals| &locals.lifecycle,
418 || cranpose_core::compositionLocalOf(current_lifecycle_state),
419 )
420}
421
422#[expect(non_snake_case, reason = "names follow Compose's casing")]
424#[track_caller]
425pub fn rememberLifecycleState() -> cranpose_core::State<LifecycleState> {
426 let transitions = rememberLifecycleEvents();
427 let state = cranpose_core::collectAsState(
428 transitions,
429 (),
430 LifecycleEvent {
431 from: current_lifecycle_state(),
432 to: current_lifecycle_state(),
433 },
434 );
435 cranpose_core::derivedStateOf(move || state.get().to)
436}
437
438#[expect(non_snake_case, reason = "names follow Compose's casing")]
440#[track_caller]
441pub fn rememberLifecycleEvents() -> cranpose_core::EventStream<LifecycleEvent> {
442 cranpose_core::rememberEventStream((), |sender| {
443 observe_lifecycle(move |event| sender.send(event))
444 })
445}
446
447#[cranpose_macros::composable]
452pub fn ProvideLifecycle(content: impl FnOnce()) {
453 let state = rememberLifecycleState();
454 let local = local_lifecycle_state();
455 cranpose_core::CompositionLocalProvider([local.provides(state.get())], move || {
456 content();
457 });
458}
459
460#[cfg(test)]
461#[path = "tests/host_tests.rs"]
462pub(crate) mod tests;