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, 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/// Platform directory roots for application-owned files.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct PlatformDirectories {
21    /// Folder for the persistent data of the app.
22    pub data: PathBuf,
23    /// Folder for settings files.
24    pub config: PathBuf,
25    /// Folder for files that the app can recreate. The system may delete them.
26    pub cache: PathBuf,
27    /// Folder for documents that the person can see. `None` where the platform has no such folder.
28    pub documents: Option<PathBuf>,
29    /// Folder for short-lived files.
30    pub temporary: PathBuf,
31    /// Folder that the person and other apps can reach, such as the external files folder on
32    /// Android. `None` where the platform has no such folder.
33    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/// Why the application directories could not be resolved.
53#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
54pub enum PlatformDirectoryError {
55    /// The application id is empty or has more than one path component.
56    #[error("application id must be one non-empty path component")]
57    InvalidApplicationId,
58    /// No host has registered an application id.
59    #[error("no application id has been registered")]
60    NoApplicationId,
61    /// The platform reports no directories.
62    #[error("platform directories are unavailable")]
63    Unavailable,
64}
65
66/// Android-style lifecycle state exposed to composition observers.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum LifecycleState {
69    /// The host exists and has not become visible yet.
70    Created,
71    /// The host is visible.
72    Started,
73    /// The host is in the foreground and receives input.
74    Resumed,
75    /// The host lost the foreground and may still be visible.
76    Paused,
77    /// The host is not visible.
78    Stopped,
79    /// The host is being torn down.
80    Destroyed,
81}
82
83/// A lifecycle transition delivered by the platform host.
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub struct LifecycleEvent {
86    /// The state before the transition.
87    pub from: LifecycleState,
88    /// The state after the transition.
89    pub to: LifecycleState,
90}
91
92/// Host operations supplied by the framework platform backend.
93pub trait HostController: Send + Sync {
94    /// Keeps the host window awake while enabled.
95    fn set_keep_screen_on(&self, enabled: bool);
96    /// Shows `icons` in the system bars drawn over the app. A host without
97    /// system bars over the app keeps this default, which does nothing.
98    fn set_system_bar_icons(&self, _icons: crate::SystemBarIcons) {}
99    /// Returns platform directory roots inside the application's host sandbox.
100    fn platform_directories(&self) -> Option<PlatformDirectories>;
101    /// Finishes/backgrounds the host.
102    fn exit(&self);
103    /// Requests that the app move to the background.
104    fn background(&self);
105    /// Ordered BCP 47 language preferences supplied by an embedded or native host.
106    /// An empty list lets the application use the operating system's default query.
107    #[cfg(feature = "localization")]
108    fn preferred_languages(&self) -> Vec<String> {
109        Vec::new()
110    }
111    /// How long this host will wait for durable saves before it suspends the
112    /// app. Android's `onPause` and iOS's background transition each allow a
113    /// short, platform-defined budget; work that overruns it keeps running
114    /// under a background-work lease.
115    fn durable_save_deadline(&self) -> std::time::Duration {
116        DEFAULT_DURABLE_SAVE_DEADLINE
117    }
118}
119
120/// The budget used when a host does not state one of its own.
121pub const DEFAULT_DURABLE_SAVE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
122
123/// How long the installed host will wait for durable saves.
124pub 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
130/// Shared handle to a [`HostController`].
131pub 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
138/// Installs the framework host controller.
139pub 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    // A new host, such as a recreated Android activity, starts with the
145    // platform's bars.
146    crate::system_bars::show_current_system_bar_icons();
147}
148/// Removes the installed host controller.
149pub 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}
155/// Returns the installed framework host controller.
156pub fn host_controller() -> Option<HostControllerRef> {
157    controller().lock().ok().and_then(|slot| slot.clone())
158}
159/// Enables or disables the host's keep-screen-on flag.
160pub 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
195/// Registers the id every framework-owned storage path is scoped by.
196///
197/// Platform backends call this at startup with what the platform packaged —
198/// the Android package name, the iOS bundle identifier, the desktop
199/// application name — so applications never assemble a storage path
200/// themselves.
201pub 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
211/// Removes the registered application id (tests and teardown).
212pub fn clear_application_id() {
213    if let Ok(mut slot) = application_id_slot().lock() {
214        *slot = None;
215    }
216}
217
218/// The registered application id, if a host has published one.
219pub fn application_id() -> Option<String> {
220    application_id_slot()
221        .lock()
222        .ok()
223        .and_then(|slot| slot.clone())
224}
225
226/// Returns typed directories for the registered application.
227pub 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}
235/// Requests that the host finish the app.
236pub fn exit_app() {
237    if let Some(host) = host_controller() {
238        host.exit();
239    }
240}
241/// Requests that the host move the app to the background.
242pub 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
260/// RAII lifecycle observer registration.
261pub 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/// Observes host lifecycle transitions until the returned handle is dropped.
275#[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/// Observes host lifecycle transitions until the returned handle is dropped.
286#[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}
296/// Publishes a lifecycle transition from the platform host.
297pub 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
316/// Returns the latest lifecycle state published by the platform host.
317pub 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
328/// Publishes a platform lifecycle state and derives the transition source from
329/// the framework's previous state.
330///
331/// Leaving the foreground runs every registered durable save first, inside the
332/// budget the host allows, so an application never has to hook the transition
333/// itself to persist its data.
334pub 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
351/// The lifecycle state of a window or browser page that is `visible` and
352/// has keyboard `focused`, mapped the way Compose Multiplatform maps desktop
353/// windows: resumed while focused, paused while only visible, and stopped
354/// while minimized, hidden or in a background tab.
355pub 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    /// Whether this state is at least as active as `other`, counting paused
365    /// as started and stopped as created — Android's `isAtLeast`.
366    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
398/// Moves the host lifecycle to `target` through every state in between, in
399/// Android's order: a stopped app starts before it resumes, and a resumed one
400/// pauses before it stops, so leaving the foreground always runs the durable
401/// saves. Paused and started count as the same place, as do created and
402/// stopped. Hosts whose platform reports only visibility and focus use this
403/// instead of dispatching each transition themselves.
404pub 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
410/// The `CompositionLocal` carrying the host's current lifecycle state.
411///
412/// [`ProvideLifecycle`] installs it; descendants read it and recompose on every
413/// transition, so a screen can pause its own work without registering an
414/// observer of its own.
415pub 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/// The host's lifecycle state as observable state.
423#[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/// Host lifecycle transitions as a composition-scoped stream.
439#[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/// Provides the host's lifecycle state to descendant composables.
448///
449/// The application shell wraps its content in this once; screens then read
450/// [`local_lifecycle_state`].
451#[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;