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    pub data: PathBuf,
22    pub config: PathBuf,
23    pub cache: PathBuf,
24    pub documents: Option<PathBuf>,
25    pub temporary: PathBuf,
26    pub shared: Option<PathBuf>,
27}
28
29impl PlatformDirectories {
30    fn scoped(&self, application_id: &str) -> Self {
31        Self {
32            data: self.data.join(application_id),
33            config: self.config.join(application_id),
34            cache: self.cache.join(application_id),
35            documents: self
36                .documents
37                .as_ref()
38                .map(|path| path.join(application_id)),
39            temporary: self.temporary.join(application_id),
40            shared: self.shared.as_ref().map(|path| path.join(application_id)),
41        }
42    }
43}
44
45#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
46pub enum PlatformDirectoryError {
47    #[error("application id must be one non-empty path component")]
48    InvalidApplicationId,
49    #[error("no application id has been registered")]
50    NoApplicationId,
51    #[error("platform directories are unavailable")]
52    Unavailable,
53}
54
55/// Android-style lifecycle state exposed to composition observers.
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum LifecycleState {
58    Created,
59    Started,
60    Resumed,
61    Paused,
62    Stopped,
63    Destroyed,
64}
65
66/// A lifecycle transition delivered by the platform host.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub struct LifecycleEvent {
69    pub from: LifecycleState,
70    pub to: LifecycleState,
71}
72
73/// Host operations supplied by the framework platform backend.
74pub trait HostController: Send + Sync {
75    /// Keeps the host window awake while enabled.
76    fn set_keep_screen_on(&self, enabled: bool);
77    /// Returns platform directory roots inside the application's host sandbox.
78    fn platform_directories(&self) -> Option<PlatformDirectories>;
79    /// Finishes/backgrounds the host.
80    fn exit(&self);
81    /// Requests that the app move to the background.
82    fn background(&self);
83    /// Ordered BCP 47 language preferences supplied by an embedded or native host.
84    /// An empty list lets the application use the operating system's default query.
85    #[cfg(feature = "localization")]
86    fn preferred_languages(&self) -> Vec<String> {
87        Vec::new()
88    }
89    /// How long this host will wait for durable saves before it suspends the
90    /// app. Android's `onPause` and iOS's background transition each allow a
91    /// short, platform-defined budget; work that overruns it keeps running
92    /// under a background-work lease.
93    fn durable_save_deadline(&self) -> std::time::Duration {
94        DEFAULT_DURABLE_SAVE_DEADLINE
95    }
96}
97
98/// The budget used when a host does not state one of its own.
99pub const DEFAULT_DURABLE_SAVE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
100
101/// How long the installed host will wait for durable saves.
102pub fn durable_save_deadline() -> std::time::Duration {
103    host_controller().map_or(DEFAULT_DURABLE_SAVE_DEADLINE, |host| {
104        host.durable_save_deadline()
105    })
106}
107
108pub type HostControllerRef = Arc<dyn HostController>;
109
110fn controller() -> &'static Mutex<Option<HostControllerRef>> {
111    static SLOT: OnceLock<Mutex<Option<HostControllerRef>>> = OnceLock::new();
112    SLOT.get_or_init(|| Mutex::new(None))
113}
114
115/// Installs the framework host controller.
116pub fn set_host_controller(value: HostControllerRef) {
117    if let Ok(mut slot) = controller().lock() {
118        *slot = Some(value);
119    }
120    KEEP_SCREEN_ON.store(0, Ordering::Release);
121}
122/// Removes the installed host controller.
123pub fn clear_host_controller() {
124    if let Ok(mut slot) = controller().lock() {
125        *slot = None;
126    }
127    KEEP_SCREEN_ON.store(0, Ordering::Release);
128}
129/// Returns the installed framework host controller.
130pub fn host_controller() -> Option<HostControllerRef> {
131    controller().lock().ok().and_then(|slot| slot.clone())
132}
133/// Enables or disables the host's keep-screen-on flag.
134pub fn set_keep_screen_on(enabled: bool) {
135    let value = if enabled { 2 } else { 1 };
136    if KEEP_SCREEN_ON.swap(value, Ordering::AcqRel) == value {
137        return;
138    }
139    if let Some(host) = host_controller() {
140        host.set_keep_screen_on(enabled);
141    }
142}
143fn valid_application_id(application_id: &str) -> bool {
144    !application_id.is_empty()
145        && Path::new(application_id).components().count() == 1
146        && application_id != "."
147        && application_id != ".."
148}
149
150fn desktop_platform_directories() -> Option<PlatformDirectories> {
151    let base = directories::BaseDirs::new()?;
152    let documents = directories::UserDirs::new()
153        .and_then(|directories| directories.document_dir().map(Path::to_path_buf));
154    Some(PlatformDirectories {
155        data: base.data_dir().to_path_buf(),
156        config: base.config_dir().to_path_buf(),
157        cache: base.cache_dir().to_path_buf(),
158        documents,
159        temporary: std::env::temp_dir(),
160        shared: None,
161    })
162}
163
164fn application_id_slot() -> &'static Mutex<Option<String>> {
165    static SLOT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
166    SLOT.get_or_init(|| Mutex::new(None))
167}
168
169/// Registers the id every framework-owned storage path is scoped by.
170///
171/// Platform backends call this at startup with what the platform packaged —
172/// the Android package name, the iOS bundle identifier, the desktop
173/// application name — so applications never assemble a storage path
174/// themselves.
175pub fn set_application_id(application_id: &str) -> Result<(), PlatformDirectoryError> {
176    if !valid_application_id(application_id) {
177        return Err(PlatformDirectoryError::InvalidApplicationId);
178    }
179    if let Ok(mut slot) = application_id_slot().lock() {
180        *slot = Some(application_id.to_string());
181    }
182    Ok(())
183}
184
185/// Removes the registered application id (tests and teardown).
186pub fn clear_application_id() {
187    if let Ok(mut slot) = application_id_slot().lock() {
188        *slot = None;
189    }
190}
191
192/// The registered application id, if a host has published one.
193pub fn application_id() -> Option<String> {
194    application_id_slot()
195        .lock()
196        .ok()
197        .and_then(|slot| slot.clone())
198}
199
200/// Returns typed directories for the registered application.
201pub fn application_directories() -> Result<PlatformDirectories, PlatformDirectoryError> {
202    let application_id = application_id().ok_or(PlatformDirectoryError::NoApplicationId)?;
203    let roots = host_controller()
204        .and_then(|host| host.platform_directories())
205        .or_else(desktop_platform_directories)
206        .ok_or(PlatformDirectoryError::Unavailable)?;
207    Ok(roots.scoped(&application_id))
208}
209/// Requests that the host finish the app.
210pub fn exit_app() {
211    if let Some(host) = host_controller() {
212        host.exit();
213    }
214}
215/// Requests that the host move the app to the background.
216pub fn background_app() {
217    if let Some(host) = host_controller() {
218        host.background();
219    }
220}
221
222#[cfg(not(target_arch = "wasm32"))]
223type Observer = Arc<dyn Fn(LifecycleEvent) + Send + Sync>;
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}
230static NEXT_ID: AtomicU64 = AtomicU64::new(1);
231static LIFECYCLE_STATE: AtomicU8 = AtomicU8::new(LifecycleState::Created as u8);
232static KEEP_SCREEN_ON: AtomicU8 = AtomicU8::new(0);
233
234/// RAII lifecycle observer registration.
235pub struct LifecycleObserver {
236    id: u64,
237}
238impl Drop for LifecycleObserver {
239    fn drop(&mut self) {
240        #[cfg(not(target_arch = "wasm32"))]
241        if let Ok(mut list) = observers().lock() {
242            list.retain(|(id, _)| *id != self.id);
243        }
244        #[cfg(target_arch = "wasm32")]
245        crate::thread_state::with(|state| state.lifecycle_observers.remove(self.id));
246    }
247}
248/// Observes host lifecycle transitions until the returned handle is dropped.
249#[cfg(not(target_arch = "wasm32"))]
250pub fn observe_lifecycle(
251    observer: impl Fn(LifecycleEvent) + Send + Sync + 'static,
252) -> LifecycleObserver {
253    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
254    if let Ok(mut list) = observers().lock() {
255        list.push((id, Arc::new(observer)));
256    }
257    LifecycleObserver { id }
258}
259/// Observes host lifecycle transitions until the returned handle is dropped.
260#[cfg(target_arch = "wasm32")]
261pub fn observe_lifecycle(observer: impl Fn(LifecycleEvent) + 'static) -> LifecycleObserver {
262    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
263    crate::thread_state::with(|state| {
264        state
265            .lifecycle_observers
266            .add(id, std::rc::Rc::new(observer));
267    });
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 = crate::thread_state::with(|state| state.lifecycle_observers.all());
285    for callback in callbacks {
286        callback(event);
287    }
288}
289
290/// Returns the latest lifecycle state published by the platform host.
291pub fn current_lifecycle_state() -> LifecycleState {
292    match LIFECYCLE_STATE.load(Ordering::Acquire) {
293        0 => LifecycleState::Created,
294        1 => LifecycleState::Started,
295        2 => LifecycleState::Resumed,
296        3 => LifecycleState::Paused,
297        4 => LifecycleState::Stopped,
298        _ => LifecycleState::Destroyed,
299    }
300}
301
302/// Publishes a platform lifecycle state and derives the transition source from
303/// the framework's previous state.
304///
305/// Leaving the foreground runs every registered durable save first, inside the
306/// budget the host allows, so an application never has to hook the transition
307/// itself to persist its data.
308pub fn dispatch_lifecycle_state(to: LifecycleState) {
309    let from = current_lifecycle_state();
310    if from == to {
311        return;
312    }
313    if matches!(to, LifecycleState::Paused) {
314        let outcome = run_durable_saves(durable_save_deadline());
315        if matches!(
316            outcome,
317            DurableSaveOutcome::TimedOut | DurableSaveOutcome::Failed
318        ) {
319            log::warn!("cranpose: durable saves did not complete successfully: {outcome:?}");
320        }
321    }
322    dispatch_lifecycle(LifecycleEvent { from, to });
323}
324
325/// The lifecycle state of a window or browser page that is `visible` and
326/// has keyboard `focused`, mapped the way Compose Multiplatform maps desktop
327/// windows: resumed while focused, paused while only visible, and stopped
328/// while minimized, hidden or in a background tab.
329pub fn window_lifecycle_state(visible: bool, focused: bool) -> LifecycleState {
330    match (visible, focused) {
331        (false, _) => LifecycleState::Stopped,
332        (true, true) => LifecycleState::Resumed,
333        (true, false) => LifecycleState::Paused,
334    }
335}
336
337impl LifecycleState {
338    /// Whether this state is at least as active as `other`, counting paused
339    /// as started and stopped as created — Android's `isAtLeast`.
340    pub fn is_at_least(self, other: LifecycleState) -> bool {
341        lifecycle_level(self) >= lifecycle_level(other)
342    }
343}
344
345fn lifecycle_level(state: LifecycleState) -> u8 {
346    match state {
347        LifecycleState::Destroyed => 0,
348        LifecycleState::Created | LifecycleState::Stopped => 1,
349        LifecycleState::Started | LifecycleState::Paused => 2,
350        LifecycleState::Resumed => 3,
351    }
352}
353
354fn next_lifecycle_step(from: LifecycleState, to: LifecycleState) -> Option<LifecycleState> {
355    let rising = match lifecycle_level(from).cmp(&lifecycle_level(to)) {
356        std::cmp::Ordering::Equal => return None,
357        std::cmp::Ordering::Less => true,
358        std::cmp::Ordering::Greater => false,
359    };
360    match (from, rising) {
361        (LifecycleState::Destroyed, _) => None,
362        (LifecycleState::Created | LifecycleState::Stopped, true) => Some(LifecycleState::Started),
363        (LifecycleState::Started | LifecycleState::Paused, true) => Some(LifecycleState::Resumed),
364        (LifecycleState::Resumed, _) => Some(LifecycleState::Paused),
365        (LifecycleState::Started | LifecycleState::Paused, false) => Some(LifecycleState::Stopped),
366        (LifecycleState::Created | LifecycleState::Stopped, false) => {
367            Some(LifecycleState::Destroyed)
368        }
369    }
370}
371
372/// Moves the host lifecycle to `target` through every state in between, in
373/// Android's order: a stopped app starts before it resumes, and a resumed one
374/// pauses before it stops, so leaving the foreground always runs the durable
375/// saves. Paused and started count as the same place, as do created and
376/// stopped. Hosts whose platform reports only visibility and focus use this
377/// instead of dispatching each transition themselves.
378pub fn advance_lifecycle(target: LifecycleState) {
379    while let Some(next) = next_lifecycle_step(current_lifecycle_state(), target) {
380        dispatch_lifecycle_state(next);
381    }
382}
383
384/// The `CompositionLocal` carrying the host's current lifecycle state.
385///
386/// [`ProvideLifecycle`] installs it; descendants read it and recompose on every
387/// transition, so a screen can pause its own work without registering an
388/// observer of its own.
389pub fn local_lifecycle_state() -> cranpose_core::CompositionLocal<LifecycleState> {
390    crate::composition_locals::cached_local(
391        |locals| &locals.lifecycle,
392        || cranpose_core::compositionLocalOf(current_lifecycle_state),
393    )
394}
395
396/// The host's lifecycle state as observable state.
397#[expect(non_snake_case)]
398#[track_caller]
399pub fn rememberLifecycleState() -> cranpose_core::State<LifecycleState> {
400    let transitions = rememberLifecycleEvents();
401    let state = cranpose_core::collectAsState(
402        transitions,
403        (),
404        LifecycleEvent {
405            from: current_lifecycle_state(),
406            to: current_lifecycle_state(),
407        },
408    );
409    cranpose_core::derivedStateOf(move || state.get().to)
410}
411
412/// Host lifecycle transitions as a composition-scoped stream.
413#[expect(non_snake_case)]
414#[track_caller]
415pub fn rememberLifecycleEvents() -> cranpose_core::EventStream<LifecycleEvent> {
416    cranpose_core::rememberEventStream((), |sender| {
417        observe_lifecycle(move |event| sender.send(event))
418    })
419}
420
421/// Provides the host's lifecycle state to descendant composables.
422///
423/// The application shell wraps its content in this once; screens then read
424/// [`local_lifecycle_state`].
425#[cranpose_macros::composable]
426pub fn ProvideLifecycle(content: impl FnOnce()) {
427    let state = rememberLifecycleState();
428    let local = local_lifecycle_state();
429    cranpose_core::CompositionLocalProvider([local.provides(state.get())], move || {
430        content();
431    });
432}
433
434#[cfg(test)]
435#[path = "tests/host_tests.rs"]
436mod tests;