Skip to main content

cranpose_services/
host.rs

1//! Framework-owned application-host state and controls.
2
3use 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/// Platform directory roots for application-owned files.
17#[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/// Android-style lifecycle state exposed to composition observers.
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum LifecycleState {
56    Created,
57    Started,
58    Resumed,
59    Paused,
60    Stopped,
61    Destroyed,
62}
63
64/// A lifecycle transition delivered by the platform host.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub struct LifecycleEvent {
67    pub from: LifecycleState,
68    pub to: LifecycleState,
69}
70
71/// Host operations supplied by the framework platform backend.
72pub trait HostController: Send + Sync {
73    /// Keeps the host window awake while enabled.
74    fn set_keep_screen_on(&self, enabled: bool);
75    /// Returns platform directory roots inside the application's host sandbox.
76    fn platform_directories(&self) -> Option<PlatformDirectories>;
77    /// Finishes/backgrounds the host.
78    fn exit(&self);
79    /// Requests that the app move to the background.
80    fn background(&self);
81    /// Ordered BCP 47 language preferences supplied by an embedded or native host.
82    /// An empty list lets the application use the operating system's default query.
83    #[cfg(feature = "localization")]
84    fn preferred_languages(&self) -> Vec<String> {
85        Vec::new()
86    }
87    /// How long this host will wait for durable saves before it suspends the
88    /// app. Android's `onPause` and iOS's background transition each allow a
89    /// short, platform-defined budget; work that overruns it keeps running
90    /// under a background-work lease.
91    fn durable_save_deadline(&self) -> std::time::Duration {
92        DEFAULT_DURABLE_SAVE_DEADLINE
93    }
94}
95
96/// The budget used when a host does not state one of its own.
97pub const DEFAULT_DURABLE_SAVE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
98
99/// How long the installed host will wait for durable saves.
100pub 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
113/// Installs the framework host controller.
114pub 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}
120/// Removes the installed host controller.
121pub 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}
127/// Returns the installed framework host controller.
128pub fn host_controller() -> Option<HostControllerRef> {
129    controller().lock().ok().and_then(|slot| slot.clone())
130}
131/// Enables or disables the host's keep-screen-on flag.
132pub 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
167/// Registers the id every framework-owned storage path is scoped by.
168///
169/// Platform backends call this at startup with what the platform packaged —
170/// the Android package name, the iOS bundle identifier, the desktop
171/// application name — so applications never assemble a storage path
172/// themselves.
173pub 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
183/// Removes the registered application id (tests and teardown).
184pub fn clear_application_id() {
185    if let Ok(mut slot) = application_id_slot().lock() {
186        *slot = None;
187    }
188}
189
190/// The registered application id, if a host has published one.
191pub fn application_id() -> Option<String> {
192    application_id_slot()
193        .lock()
194        .ok()
195        .and_then(|slot| slot.clone())
196}
197
198/// Returns typed directories for the registered application.
199pub 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}
207/// Requests that the host finish the app.
208pub fn exit_app() {
209    if let Some(host) = host_controller() {
210        host.exit();
211    }
212}
213/// Requests that the host move the app to the background.
214pub 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#[cfg(target_arch = "wasm32")]
223type Observer = std::rc::Rc<dyn Fn(LifecycleEvent)>;
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}
230#[cfg(target_arch = "wasm32")]
231thread_local! {
232    static OBSERVERS: std::cell::RefCell<Vec<(u64, Observer)>> = const { std::cell::RefCell::new(Vec::new()) };
233}
234static NEXT_ID: AtomicU64 = AtomicU64::new(1);
235static LIFECYCLE_STATE: AtomicU8 = AtomicU8::new(LifecycleState::Created as u8);
236static KEEP_SCREEN_ON: AtomicU8 = AtomicU8::new(0);
237
238/// RAII lifecycle observer registration.
239pub struct LifecycleObserver {
240    id: u64,
241}
242impl Drop for LifecycleObserver {
243    fn drop(&mut self) {
244        #[cfg(not(target_arch = "wasm32"))]
245        if let Ok(mut list) = observers().lock() {
246            list.retain(|(id, _)| *id != self.id);
247        }
248        #[cfg(target_arch = "wasm32")]
249        OBSERVERS.with(|list| list.borrow_mut().retain(|(id, _)| *id != self.id));
250    }
251}
252/// Observes host lifecycle transitions until the returned handle is dropped.
253#[cfg(not(target_arch = "wasm32"))]
254pub fn observe_lifecycle(
255    observer: impl Fn(LifecycleEvent) + Send + Sync + 'static,
256) -> LifecycleObserver {
257    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
258    if let Ok(mut list) = observers().lock() {
259        list.push((id, Arc::new(observer)));
260    }
261    LifecycleObserver { id }
262}
263/// Observes host lifecycle transitions until the returned handle is dropped.
264#[cfg(target_arch = "wasm32")]
265pub fn observe_lifecycle(observer: impl Fn(LifecycleEvent) + 'static) -> LifecycleObserver {
266    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
267    OBSERVERS.with(|list| list.borrow_mut().push((id, std::rc::Rc::new(observer))));
268    LifecycleObserver { id }
269}
270/// Publishes a lifecycle transition from the platform host.
271pub 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 = OBSERVERS.with(|list| {
285        list.borrow()
286            .iter()
287            .map(|(_, callback)| std::rc::Rc::clone(callback))
288            .collect::<Vec<_>>()
289    });
290    for callback in callbacks {
291        callback(event);
292    }
293}
294
295/// Returns the latest lifecycle state published by the platform host.
296pub fn current_lifecycle_state() -> LifecycleState {
297    match LIFECYCLE_STATE.load(Ordering::Acquire) {
298        0 => LifecycleState::Created,
299        1 => LifecycleState::Started,
300        2 => LifecycleState::Resumed,
301        3 => LifecycleState::Paused,
302        4 => LifecycleState::Stopped,
303        _ => LifecycleState::Destroyed,
304    }
305}
306
307/// Publishes a platform lifecycle state and derives the transition source from
308/// the framework's previous state.
309///
310/// Leaving the foreground runs every registered durable save first, inside the
311/// budget the host allows, so an application never has to hook the transition
312/// itself to persist its data.
313pub fn dispatch_lifecycle_state(to: LifecycleState) {
314    let from = current_lifecycle_state();
315    if from == to {
316        return;
317    }
318    if matches!(to, LifecycleState::Paused) {
319        let outcome = run_durable_saves(durable_save_deadline());
320        if matches!(
321            outcome,
322            DurableSaveOutcome::TimedOut | DurableSaveOutcome::Failed
323        ) {
324            log::warn!("cranpose: durable saves did not complete successfully: {outcome:?}");
325        }
326    }
327    dispatch_lifecycle(LifecycleEvent { from, to });
328}
329
330/// The lifecycle state of a window or browser page that is `visible` and
331/// has keyboard `focused`, mapped the way Compose Multiplatform maps desktop
332/// windows: resumed while focused, paused while only visible, and stopped
333/// while minimized, hidden or in a background tab.
334pub fn window_lifecycle_state(visible: bool, focused: bool) -> LifecycleState {
335    match (visible, focused) {
336        (false, _) => LifecycleState::Stopped,
337        (true, true) => LifecycleState::Resumed,
338        (true, false) => LifecycleState::Paused,
339    }
340}
341
342impl LifecycleState {
343    /// Whether this state is at least as active as `other`, counting paused
344    /// as started and stopped as created — Android's `isAtLeast`.
345    pub fn is_at_least(self, other: LifecycleState) -> bool {
346        lifecycle_level(self) >= lifecycle_level(other)
347    }
348}
349
350fn lifecycle_level(state: LifecycleState) -> u8 {
351    match state {
352        LifecycleState::Destroyed => 0,
353        LifecycleState::Created | LifecycleState::Stopped => 1,
354        LifecycleState::Started | LifecycleState::Paused => 2,
355        LifecycleState::Resumed => 3,
356    }
357}
358
359fn next_lifecycle_step(from: LifecycleState, to: LifecycleState) -> Option<LifecycleState> {
360    let rising = match lifecycle_level(from).cmp(&lifecycle_level(to)) {
361        std::cmp::Ordering::Equal => return None,
362        std::cmp::Ordering::Less => true,
363        std::cmp::Ordering::Greater => false,
364    };
365    match (from, rising) {
366        (LifecycleState::Destroyed, _) => None,
367        (LifecycleState::Created | LifecycleState::Stopped, true) => Some(LifecycleState::Started),
368        (LifecycleState::Started | LifecycleState::Paused, true) => Some(LifecycleState::Resumed),
369        (LifecycleState::Resumed, _) => Some(LifecycleState::Paused),
370        (LifecycleState::Started | LifecycleState::Paused, false) => Some(LifecycleState::Stopped),
371        (LifecycleState::Created | LifecycleState::Stopped, false) => {
372            Some(LifecycleState::Destroyed)
373        }
374    }
375}
376
377/// Moves the host lifecycle to `target` through every state in between, in
378/// Android's order: a stopped app starts before it resumes, and a resumed one
379/// pauses before it stops, so leaving the foreground always runs the durable
380/// saves. Paused and started count as the same place, as do created and
381/// stopped. Hosts whose platform reports only visibility and focus use this
382/// instead of dispatching each transition themselves.
383pub fn advance_lifecycle(target: LifecycleState) {
384    while let Some(next) = next_lifecycle_step(current_lifecycle_state(), target) {
385        dispatch_lifecycle_state(next);
386    }
387}
388
389/// The `CompositionLocal` carrying the host's current lifecycle state.
390///
391/// [`ProvideLifecycle`] installs it; descendants read it and recompose on every
392/// transition, so a screen can pause its own work without registering an
393/// observer of its own.
394pub fn local_lifecycle_state() -> cranpose_core::CompositionLocal<LifecycleState> {
395    crate::composition_locals::cached_local(
396        |locals| &locals.lifecycle,
397        || cranpose_core::compositionLocalOf(current_lifecycle_state),
398    )
399}
400
401/// The host's lifecycle state as observable state.
402#[expect(non_snake_case)]
403#[track_caller]
404pub fn rememberLifecycleState() -> cranpose_core::State<LifecycleState> {
405    let transitions = rememberLifecycleEvents();
406    let state = cranpose_core::collectAsState(
407        transitions,
408        (),
409        LifecycleEvent {
410            from: current_lifecycle_state(),
411            to: current_lifecycle_state(),
412        },
413    );
414    cranpose_core::derivedStateOf(move || state.get().to)
415}
416
417/// Host lifecycle transitions as a composition-scoped stream.
418#[expect(non_snake_case)]
419#[track_caller]
420pub fn rememberLifecycleEvents() -> cranpose_core::EventStream<LifecycleEvent> {
421    cranpose_core::rememberEventStream((), |sender| {
422        observe_lifecycle(move |event| sender.send(event))
423    })
424}
425
426/// Provides the host's lifecycle state to descendant composables.
427///
428/// The application shell wraps its content in this once; screens then read
429/// [`local_lifecycle_state`].
430#[cranpose_macros::composable]
431pub fn ProvideLifecycle(content: impl FnOnce()) {
432    let state = rememberLifecycleState();
433    let local = local_lifecycle_state();
434    cranpose_core::CompositionLocalProvider([local.provides(state.get())], move || {
435        content();
436    });
437}
438
439#[cfg(test)]
440#[path = "tests/host_tests.rs"]
441mod tests;