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