1use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
5use std::sync::{Arc, Mutex, OnceLock};
6
7#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct PlatformDirectories {
10 pub data: PathBuf,
11 pub config: PathBuf,
12 pub cache: PathBuf,
13 pub documents: Option<PathBuf>,
14 pub temporary: PathBuf,
15 pub shared: Option<PathBuf>,
16}
17
18impl PlatformDirectories {
19 fn scoped(&self, application_id: &str) -> Self {
20 Self {
21 data: self.data.join(application_id),
22 config: self.config.join(application_id),
23 cache: self.cache.join(application_id),
24 documents: self
25 .documents
26 .as_ref()
27 .map(|path| path.join(application_id)),
28 temporary: self.temporary.join(application_id),
29 shared: self.shared.as_ref().map(|path| path.join(application_id)),
30 }
31 }
32}
33
34#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
35pub enum PlatformDirectoryError {
36 #[error("application id must be one non-empty path component")]
37 InvalidApplicationId,
38 #[error("no application id has been registered")]
39 NoApplicationId,
40 #[error("platform directories are unavailable")]
41 Unavailable,
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum LifecycleState {
47 Created,
48 Started,
49 Resumed,
50 Paused,
51 Stopped,
52 Destroyed,
53}
54
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub struct LifecycleEvent {
58 pub from: LifecycleState,
59 pub to: LifecycleState,
60}
61
62pub trait HostController: Send + Sync {
64 fn set_keep_screen_on(&self, enabled: bool);
66 fn platform_directories(&self) -> Option<PlatformDirectories>;
68 fn exit(&self);
70 fn background(&self);
72 fn durable_save_deadline(&self) -> std::time::Duration {
77 DEFAULT_DURABLE_SAVE_DEADLINE
78 }
79}
80
81pub const DEFAULT_DURABLE_SAVE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
83
84pub fn durable_save_deadline() -> std::time::Duration {
86 host_controller()
87 .map(|host| host.durable_save_deadline())
88 .unwrap_or(DEFAULT_DURABLE_SAVE_DEADLINE)
89}
90
91pub type HostControllerRef = Arc<dyn HostController>;
92
93fn controller() -> &'static Mutex<Option<HostControllerRef>> {
94 static SLOT: OnceLock<Mutex<Option<HostControllerRef>>> = OnceLock::new();
95 SLOT.get_or_init(|| Mutex::new(None))
96}
97
98pub fn set_host_controller(value: HostControllerRef) {
100 if let Ok(mut slot) = controller().lock() {
101 *slot = Some(value);
102 }
103 KEEP_SCREEN_ON.store(0, Ordering::Release);
104}
105pub fn clear_host_controller() {
107 if let Ok(mut slot) = controller().lock() {
108 *slot = None;
109 }
110 KEEP_SCREEN_ON.store(0, Ordering::Release);
111}
112pub fn host_controller() -> Option<HostControllerRef> {
114 controller().lock().ok().and_then(|slot| slot.clone())
115}
116pub fn set_keep_screen_on(enabled: bool) {
118 let value = if enabled { 2 } else { 1 };
119 if KEEP_SCREEN_ON.swap(value, Ordering::AcqRel) == value {
120 return;
121 }
122 if let Some(host) = host_controller() {
123 host.set_keep_screen_on(enabled);
124 }
125}
126fn valid_application_id(application_id: &str) -> bool {
127 !application_id.is_empty()
128 && Path::new(application_id).components().count() == 1
129 && application_id != "."
130 && application_id != ".."
131}
132
133fn desktop_platform_directories() -> Option<PlatformDirectories> {
134 let base = directories::BaseDirs::new()?;
135 let documents = directories::UserDirs::new()
136 .and_then(|directories| directories.document_dir().map(Path::to_path_buf));
137 Some(PlatformDirectories {
138 data: base.data_dir().to_path_buf(),
139 config: base.config_dir().to_path_buf(),
140 cache: base.cache_dir().to_path_buf(),
141 documents,
142 temporary: std::env::temp_dir(),
143 shared: None,
144 })
145}
146
147fn application_id_slot() -> &'static Mutex<Option<String>> {
148 static SLOT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
149 SLOT.get_or_init(|| Mutex::new(None))
150}
151
152pub fn set_application_id(application_id: &str) -> Result<(), PlatformDirectoryError> {
159 if !valid_application_id(application_id) {
160 return Err(PlatformDirectoryError::InvalidApplicationId);
161 }
162 if let Ok(mut slot) = application_id_slot().lock() {
163 *slot = Some(application_id.to_string());
164 }
165 Ok(())
166}
167
168pub fn clear_application_id() {
170 if let Ok(mut slot) = application_id_slot().lock() {
171 *slot = None;
172 }
173}
174
175pub fn application_id() -> Option<String> {
177 application_id_slot()
178 .lock()
179 .ok()
180 .and_then(|slot| slot.clone())
181}
182
183pub fn application_directories() -> Result<PlatformDirectories, PlatformDirectoryError> {
185 let application_id = application_id().ok_or(PlatformDirectoryError::NoApplicationId)?;
186 let roots = host_controller()
187 .and_then(|host| host.platform_directories())
188 .or_else(desktop_platform_directories)
189 .ok_or(PlatformDirectoryError::Unavailable)?;
190 Ok(roots.scoped(&application_id))
191}
192pub fn exit_app() {
194 if let Some(host) = host_controller() {
195 host.exit();
196 }
197}
198pub fn background_app() {
200 if let Some(host) = host_controller() {
201 host.background();
202 }
203}
204
205#[cfg(not(target_arch = "wasm32"))]
206type Observer = Arc<dyn Fn(LifecycleEvent) + Send + Sync>;
207#[cfg(target_arch = "wasm32")]
208type Observer = std::rc::Rc<dyn Fn(LifecycleEvent)>;
209
210#[cfg(not(target_arch = "wasm32"))]
211fn observers() -> &'static Mutex<Vec<(u64, Observer)>> {
212 static SLOT: OnceLock<Mutex<Vec<(u64, Observer)>>> = OnceLock::new();
213 SLOT.get_or_init(|| Mutex::new(Vec::new()))
214}
215#[cfg(target_arch = "wasm32")]
216thread_local! {
217 static OBSERVERS: std::cell::RefCell<Vec<(u64, Observer)>> = const { std::cell::RefCell::new(Vec::new()) };
218}
219static NEXT_ID: AtomicU64 = AtomicU64::new(1);
220static LIFECYCLE_STATE: AtomicU8 = AtomicU8::new(LifecycleState::Created as u8);
221static KEEP_SCREEN_ON: AtomicU8 = AtomicU8::new(0);
222
223pub struct LifecycleObserver {
225 id: u64,
226}
227impl Drop for LifecycleObserver {
228 fn drop(&mut self) {
229 #[cfg(not(target_arch = "wasm32"))]
230 if let Ok(mut list) = observers().lock() {
231 list.retain(|(id, _)| *id != self.id);
232 }
233 #[cfg(target_arch = "wasm32")]
234 OBSERVERS.with(|list| list.borrow_mut().retain(|(id, _)| *id != self.id));
235 }
236}
237#[cfg(not(target_arch = "wasm32"))]
239pub fn observe_lifecycle(
240 observer: impl Fn(LifecycleEvent) + Send + Sync + 'static,
241) -> LifecycleObserver {
242 let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
243 if let Ok(mut list) = observers().lock() {
244 list.push((id, Arc::new(observer)));
245 }
246 LifecycleObserver { id }
247}
248#[cfg(target_arch = "wasm32")]
250pub fn observe_lifecycle(observer: impl Fn(LifecycleEvent) + 'static) -> LifecycleObserver {
251 let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
252 OBSERVERS.with(|list| list.borrow_mut().push((id, std::rc::Rc::new(observer))));
253 LifecycleObserver { id }
254}
255pub fn dispatch_lifecycle(event: LifecycleEvent) {
257 LIFECYCLE_STATE.store(event.to as u8, Ordering::Release);
258 crate::media::on_lifecycle(event);
259 #[cfg(not(target_arch = "wasm32"))]
260 let callbacks = observers()
261 .lock()
262 .map(|list| {
263 list.iter()
264 .map(|(_, cb)| Arc::clone(cb))
265 .collect::<Vec<_>>()
266 })
267 .unwrap_or_default();
268 #[cfg(target_arch = "wasm32")]
269 let callbacks = OBSERVERS.with(|list| {
270 list.borrow()
271 .iter()
272 .map(|(_, callback)| std::rc::Rc::clone(callback))
273 .collect::<Vec<_>>()
274 });
275 for callback in callbacks {
276 callback(event);
277 }
278}
279
280pub fn current_lifecycle_state() -> LifecycleState {
282 match LIFECYCLE_STATE.load(Ordering::Acquire) {
283 0 => LifecycleState::Created,
284 1 => LifecycleState::Started,
285 2 => LifecycleState::Resumed,
286 3 => LifecycleState::Paused,
287 4 => LifecycleState::Stopped,
288 _ => LifecycleState::Destroyed,
289 }
290}
291
292pub fn dispatch_lifecycle_state(to: LifecycleState) {
299 let from = current_lifecycle_state();
300 if from == to {
301 return;
302 }
303 #[cfg(not(target_arch = "wasm32"))]
304 if matches!(to, LifecycleState::Paused) {
305 let outcome = run_durable_saves(durable_save_deadline());
306 if outcome == DurableSaveOutcome::TimedOut {
307 log::warn!("cranpose: durable saves overran the host deadline; they keep running");
308 }
309 }
310 dispatch_lifecycle(LifecycleEvent { from, to });
311}
312
313pub fn local_lifecycle_state() -> cranpose_core::CompositionLocal<LifecycleState> {
321 thread_local! {
322 static LOCAL: std::cell::RefCell<Option<cranpose_core::CompositionLocal<LifecycleState>>> =
323 const { std::cell::RefCell::new(None) };
324 }
325 LOCAL.with(|cell| {
326 cell.borrow_mut()
327 .get_or_insert_with(|| cranpose_core::compositionLocalOf(current_lifecycle_state))
328 .clone()
329 })
330}
331
332#[allow(non_snake_case)]
334pub fn rememberLifecycleState() -> cranpose_core::State<LifecycleState> {
335 let transitions = rememberLifecycleEvents();
336 let state = cranpose_core::collectAsState(
337 transitions,
338 (),
339 LifecycleEvent {
340 from: current_lifecycle_state(),
341 to: current_lifecycle_state(),
342 },
343 );
344 cranpose_core::derivedStateOf(move || state.get().to)
345}
346
347#[allow(non_snake_case)]
349pub fn rememberLifecycleEvents() -> cranpose_core::EventStream<LifecycleEvent> {
350 cranpose_core::rememberEventStream((), |sender| {
351 observe_lifecycle(move |event| sender.send(event))
352 })
353}
354
355#[allow(non_snake_case)]
360#[cranpose_macros::composable]
361pub fn ProvideLifecycle(content: impl FnOnce()) {
362 let state = rememberLifecycleState();
363 let local = local_lifecycle_state();
364 cranpose_core::CompositionLocalProvider(vec![local.provides(state.get())], move || {
365 content();
366 });
367}
368
369#[derive(Clone, Copy, Debug, Eq, PartialEq)]
373pub enum DurableSaveOutcome {
374 Nothing,
376 Completed,
378 TimedOut,
381}
382
383type SaveWork = Arc<dyn Fn() + Send + Sync>;
384
385fn durable_saves() -> &'static Mutex<Vec<(u64, SaveWork)>> {
386 static SLOT: OnceLock<Mutex<Vec<(u64, SaveWork)>>> = OnceLock::new();
387 SLOT.get_or_init(|| Mutex::new(Vec::new()))
388}
389
390pub struct DurableSaveRegistration {
392 id: u64,
393}
394
395impl Drop for DurableSaveRegistration {
396 fn drop(&mut self) {
397 if let Ok(mut saves) = durable_saves().lock() {
398 saves.retain(|(id, _)| *id != self.id);
399 }
400 }
401}
402
403pub fn register_durable_save(save: impl Fn() + Send + Sync + 'static) -> DurableSaveRegistration {
408 let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
409 if let Ok(mut saves) = durable_saves().lock() {
410 saves.push((id, Arc::new(save)));
411 }
412 DurableSaveRegistration { id }
413}
414
415#[allow(non_snake_case)]
421pub fn DurableSaveEffect<K: std::hash::Hash + 'static>(
422 keys: K,
423 save: impl Fn() + Send + Sync + 'static,
424) {
425 cranpose_core::__disposable_effect_impl(
426 cranpose_core::location_key(file!(), line!(), column!()),
427 keys,
428 move |scope| {
429 let registration = register_durable_save(save);
430 scope.on_dispose(move || drop(registration))
431 },
432 );
433}
434
435#[cfg(not(target_arch = "wasm32"))]
443pub fn run_durable_saves(deadline: std::time::Duration) -> DurableSaveOutcome {
444 let saves: Vec<SaveWork> = durable_saves()
445 .lock()
446 .map(|saves| saves.iter().map(|(_, save)| Arc::clone(save)).collect())
447 .unwrap_or_default();
448 if saves.is_empty() {
449 return DurableSaveOutcome::Nothing;
450 }
451
452 let outstanding = Arc::new(std::sync::atomic::AtomicUsize::new(saves.len()));
453 let finished = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
454 for save in saves {
455 let worker_outstanding = Arc::clone(&outstanding);
456 let worker_finished = Arc::clone(&finished);
457 let lease = crate::background::acquire_background_work();
458 let spawned = std::thread::Builder::new()
459 .name("cranpose-durable-save".to_string())
460 .spawn(move || {
461 save();
462 drop(lease);
463 if worker_outstanding.fetch_sub(1, Ordering::AcqRel) == 1 {
464 let (done, wake) = &*worker_finished;
465 if let Ok(mut done) = done.lock() {
466 *done = true;
467 }
468 wake.notify_all();
469 }
470 });
471 if spawned.is_err() {
472 log::warn!("cranpose: a durable save could not be started");
475 if outstanding.fetch_sub(1, Ordering::AcqRel) == 1 {
476 let (done, wake) = &*finished;
477 if let Ok(mut done) = done.lock() {
478 *done = true;
479 }
480 wake.notify_all();
481 }
482 }
483 }
484
485 let (done, wake) = &*finished;
486 let Ok(mut guard) = done.lock() else {
487 return DurableSaveOutcome::TimedOut;
488 };
489 let mut remaining = deadline;
490 let started = web_time::Instant::now();
491 while !*guard {
492 let Ok((next, timeout)) = wake.wait_timeout(guard, remaining) else {
493 return DurableSaveOutcome::TimedOut;
494 };
495 guard = next;
496 if timeout.timed_out() {
497 break;
498 }
499 remaining = deadline.saturating_sub(started.elapsed());
500 if remaining.is_zero() {
501 break;
502 }
503 }
504 if *guard {
505 DurableSaveOutcome::Completed
506 } else {
507 DurableSaveOutcome::TimedOut
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 fn test_lock() -> std::sync::MutexGuard<'static, ()> {
516 static LOCK: Mutex<()> = Mutex::new(());
517 LOCK.lock().unwrap_or_else(|error| error.into_inner())
518 }
519
520 #[test]
521 fn observer_is_removed_on_drop() {
522 let _guard = test_lock();
523 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
524 let seen = Arc::clone(&calls);
525 let handle = observe_lifecycle(move |_| {
526 seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
527 });
528 dispatch_lifecycle(LifecycleEvent {
529 from: LifecycleState::Created,
530 to: LifecycleState::Started,
531 });
532 assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
533 drop(handle);
534 dispatch_lifecycle(LifecycleEvent {
535 from: LifecycleState::Started,
536 to: LifecycleState::Resumed,
537 });
538 assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
539 }
540
541 #[test]
542 fn state_dispatch_derives_the_previous_state() {
543 let _guard = test_lock();
544 dispatch_lifecycle_state(LifecycleState::Paused);
545 assert_eq!(current_lifecycle_state(), LifecycleState::Paused);
546 dispatch_lifecycle_state(LifecycleState::Stopped);
547 assert_eq!(current_lifecycle_state(), LifecycleState::Stopped);
548 }
549
550 #[test]
551 fn repeated_keep_screen_value_reaches_the_host_once() {
552 let _guard = test_lock();
553 struct RecordingHost(Arc<std::sync::atomic::AtomicUsize>);
554 impl HostController for RecordingHost {
555 fn set_keep_screen_on(&self, _enabled: bool) {
556 self.0.fetch_add(1, Ordering::Relaxed);
557 }
558 fn platform_directories(&self) -> Option<PlatformDirectories> {
559 Some(PlatformDirectories {
560 data: PathBuf::from("data"),
561 config: PathBuf::from("config"),
562 cache: PathBuf::from("cache"),
563 documents: Some(PathBuf::from("documents")),
564 temporary: PathBuf::from("temporary"),
565 shared: Some(PathBuf::from("shared")),
566 })
567 }
568 fn exit(&self) {}
569 fn background(&self) {}
570 }
571 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
572 set_host_controller(Arc::new(RecordingHost(Arc::clone(&calls))));
573 set_keep_screen_on(true);
574 set_keep_screen_on(true);
575 assert_eq!(calls.load(Ordering::Relaxed), 1);
576 set_application_id("sample").expect("a plain id is valid");
577 assert_eq!(
578 application_directories().unwrap().data,
579 PathBuf::from("data/sample")
580 );
581 clear_application_id();
582 clear_host_controller();
583 }
584
585 #[test]
586 fn durable_saves_run_and_report_completion() {
587 let _services = crate::registry::test_service_guard();
591 let _guard = test_lock();
592 let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
593 let first = Arc::clone(&ran);
594 let second = Arc::clone(&ran);
595 let a = register_durable_save(move || {
596 first.fetch_add(1, Ordering::Relaxed);
597 });
598 let b = register_durable_save(move || {
599 second.fetch_add(1, Ordering::Relaxed);
600 });
601 assert_eq!(
602 run_durable_saves(std::time::Duration::from_secs(5)),
603 DurableSaveOutcome::Completed
604 );
605 assert_eq!(ran.load(Ordering::Relaxed), 2);
606 drop((a, b));
607 assert_eq!(
608 run_durable_saves(std::time::Duration::from_secs(1)),
609 DurableSaveOutcome::Nothing
610 );
611 }
612
613 #[test]
614 fn a_save_that_overruns_the_deadline_reports_a_timeout() {
615 let _services = crate::registry::test_service_guard();
616 let _guard = test_lock();
617 let registration = register_durable_save(|| {
618 std::thread::sleep(std::time::Duration::from_millis(400));
619 });
620 assert_eq!(
621 run_durable_saves(std::time::Duration::from_millis(30)),
622 DurableSaveOutcome::TimedOut
623 );
624 drop(registration);
625 }
626
627 #[test]
628 fn a_dropped_registration_is_no_longer_saved() {
629 let _services = crate::registry::test_service_guard();
630 let _guard = test_lock();
631 let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
632 let counted = Arc::clone(&ran);
633 let registration = register_durable_save(move || {
634 counted.fetch_add(1, Ordering::Relaxed);
635 });
636 drop(registration);
637 assert_eq!(
638 run_durable_saves(std::time::Duration::from_secs(1)),
639 DurableSaveOutcome::Nothing
640 );
641 assert_eq!(ran.load(Ordering::Relaxed), 0);
642 }
643
644 #[test]
645 fn application_id_must_be_one_component() {
646 let _guard = test_lock();
647 assert_eq!(
648 set_application_id("../sample"),
649 Err(PlatformDirectoryError::InvalidApplicationId)
650 );
651 assert_eq!(
652 set_application_id(""),
653 Err(PlatformDirectoryError::InvalidApplicationId)
654 );
655 clear_application_id();
656 assert_eq!(
657 application_directories(),
658 Err(PlatformDirectoryError::NoApplicationId)
659 );
660 }
661}