Skip to main content

cranpose_services/
host.rs

1//! Framework-owned application-host state and controls.
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
5use std::sync::{Arc, Mutex, OnceLock};
6
7/// Platform directory roots for application-owned files.
8#[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/// Android-style lifecycle state exposed to composition observers.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum LifecycleState {
47    Created,
48    Started,
49    Resumed,
50    Paused,
51    Stopped,
52    Destroyed,
53}
54
55/// A lifecycle transition delivered by the platform host.
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub struct LifecycleEvent {
58    pub from: LifecycleState,
59    pub to: LifecycleState,
60}
61
62/// Host operations supplied by the framework platform backend.
63pub trait HostController: Send + Sync {
64    /// Keeps the host window awake while enabled.
65    fn set_keep_screen_on(&self, enabled: bool);
66    /// Returns platform directory roots inside the application's host sandbox.
67    fn platform_directories(&self) -> Option<PlatformDirectories>;
68    /// Finishes/backgrounds the host.
69    fn exit(&self);
70    /// Requests that the app move to the background.
71    fn background(&self);
72    /// How long this host will wait for durable saves before it suspends the
73    /// app. Android's `onPause` and iOS's background transition each allow a
74    /// short, platform-defined budget; work that overruns it keeps running
75    /// under a background-work lease.
76    fn durable_save_deadline(&self) -> std::time::Duration {
77        DEFAULT_DURABLE_SAVE_DEADLINE
78    }
79}
80
81/// The budget used when a host does not state one of its own.
82pub const DEFAULT_DURABLE_SAVE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
83
84/// How long the installed host will wait for durable saves.
85pub 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
98/// Installs the framework host controller.
99pub 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}
105/// Removes the installed host controller.
106pub 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}
112/// Returns the installed framework host controller.
113pub fn host_controller() -> Option<HostControllerRef> {
114    controller().lock().ok().and_then(|slot| slot.clone())
115}
116/// Enables or disables the host's keep-screen-on flag.
117pub 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
152/// Registers the id every framework-owned storage path is scoped by.
153///
154/// Platform backends call this at startup with what the platform packaged —
155/// the Android package name, the iOS bundle identifier, the desktop
156/// application name — so applications never assemble a storage path
157/// themselves.
158pub 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
168/// Removes the registered application id (tests and teardown).
169pub fn clear_application_id() {
170    if let Ok(mut slot) = application_id_slot().lock() {
171        *slot = None;
172    }
173}
174
175/// The registered application id, if a host has published one.
176pub fn application_id() -> Option<String> {
177    application_id_slot()
178        .lock()
179        .ok()
180        .and_then(|slot| slot.clone())
181}
182
183/// Returns typed directories for the registered application.
184pub 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}
192/// Requests that the host finish the app.
193pub fn exit_app() {
194    if let Some(host) = host_controller() {
195        host.exit();
196    }
197}
198/// Requests that the host move the app to the background.
199pub 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
223/// RAII lifecycle observer registration.
224pub 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/// Observes host lifecycle transitions until the returned handle is dropped.
238#[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/// Observes host lifecycle transitions until the returned handle is dropped.
249#[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}
255/// Publishes a lifecycle transition from the platform host.
256pub 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
280/// Returns the latest lifecycle state published by the platform host.
281pub 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
292/// Publishes a platform lifecycle state and derives the transition source from
293/// the framework's previous state.
294///
295/// Leaving the foreground runs every registered durable save first, inside the
296/// budget the host allows, so an application never has to hook the transition
297/// itself to persist its data.
298pub 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
313// ---- Observable lifecycle ------------------------------------------------
314
315/// The [`CompositionLocal`] carrying the host's current lifecycle state.
316///
317/// [`ProvideLifecycle`] installs it; descendants read it and recompose on every
318/// transition, so a screen can pause its own work without registering an
319/// observer of its own.
320pub 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/// The host's lifecycle state as observable state.
333#[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/// Host lifecycle transitions as a composition-scoped stream.
348#[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/// Provides the host's lifecycle state to descendant composables.
356///
357/// The application shell wraps its content in this once; screens then read
358/// [`local_lifecycle_state`].
359#[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// ---- Durable saves -------------------------------------------------------
370
371/// What became of the durable saves the host asked for.
372#[derive(Clone, Copy, Debug, Eq, PartialEq)]
373pub enum DurableSaveOutcome {
374    /// Nothing was registered.
375    Nothing,
376    /// Every registered save finished inside the deadline.
377    Completed,
378    /// The deadline expired with saves still running. They keep running under
379    /// a background-work lease, but the host is free to suspend.
380    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
390/// Keeps a durable save registered until it is dropped.
391pub 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
403/// Registers work that must reach durable storage before the host suspends.
404///
405/// Applications use [`DurableSaveEffect`] so the registration is scoped to the
406/// composition that owns the data.
407pub 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/// Registers `save` for as long as this call stays in the composition.
416///
417/// The host runs it when the app is about to be suspended, off the UI thread
418/// and under a background-work lease, so a slow write does not stall the
419/// lifecycle callback the platform is waiting on.
420#[allow(non_snake_case)]
421pub fn DurableSaveEffect<K: PartialEq + 'static>(keys: K, save: impl Fn() + Send + Sync + 'static) {
422    cranpose_core::__disposable_effect_impl(
423        cranpose_core::location_key(file!(), line!(), column!()),
424        keys,
425        move |scope| {
426            let registration = register_durable_save(save);
427            scope.on_dispose(move || drop(registration))
428        },
429    );
430}
431
432/// Runs every registered durable save, waiting up to `deadline`.
433///
434/// Platform hosts call this from the lifecycle callback the OS gives them —
435/// Android's `onPause`, iOS's `applicationDidEnterBackground` — passing the
436/// budget that platform allows. Saves run on worker threads under a
437/// background-work lease, so work that overruns the deadline still finishes
438/// while the OS keeps the process alive.
439#[cfg(not(target_arch = "wasm32"))]
440pub fn run_durable_saves(deadline: std::time::Duration) -> DurableSaveOutcome {
441    let saves: Vec<SaveWork> = durable_saves()
442        .lock()
443        .map(|saves| saves.iter().map(|(_, save)| Arc::clone(save)).collect())
444        .unwrap_or_default();
445    if saves.is_empty() {
446        return DurableSaveOutcome::Nothing;
447    }
448
449    let outstanding = Arc::new(std::sync::atomic::AtomicUsize::new(saves.len()));
450    let finished = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
451    for save in saves {
452        let worker_outstanding = Arc::clone(&outstanding);
453        let worker_finished = Arc::clone(&finished);
454        let lease = crate::background::acquire_background_work();
455        let spawned = std::thread::Builder::new()
456            .name("cranpose-durable-save".to_string())
457            .spawn(move || {
458                save();
459                drop(lease);
460                if worker_outstanding.fetch_sub(1, Ordering::AcqRel) == 1 {
461                    let (done, wake) = &*worker_finished;
462                    if let Ok(mut done) = done.lock() {
463                        *done = true;
464                    }
465                    wake.notify_all();
466                }
467            });
468        if spawned.is_err() {
469            // A host that cannot spawn threads is a host under real pressure;
470            // counting the save down keeps the wait from hanging on it.
471            log::warn!("cranpose: a durable save could not be started");
472            if outstanding.fetch_sub(1, Ordering::AcqRel) == 1 {
473                let (done, wake) = &*finished;
474                if let Ok(mut done) = done.lock() {
475                    *done = true;
476                }
477                wake.notify_all();
478            }
479        }
480    }
481
482    let (done, wake) = &*finished;
483    let Ok(mut guard) = done.lock() else {
484        return DurableSaveOutcome::TimedOut;
485    };
486    let mut remaining = deadline;
487    let started = web_time::Instant::now();
488    while !*guard {
489        let Ok((next, timeout)) = wake.wait_timeout(guard, remaining) else {
490            return DurableSaveOutcome::TimedOut;
491        };
492        guard = next;
493        if timeout.timed_out() {
494            break;
495        }
496        remaining = deadline.saturating_sub(started.elapsed());
497        if remaining.is_zero() {
498            break;
499        }
500    }
501    if *guard {
502        DurableSaveOutcome::Completed
503    } else {
504        DurableSaveOutcome::TimedOut
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    fn test_lock() -> std::sync::MutexGuard<'static, ()> {
513        static LOCK: Mutex<()> = Mutex::new(());
514        LOCK.lock().unwrap_or_else(|error| error.into_inner())
515    }
516
517    #[test]
518    fn observer_is_removed_on_drop() {
519        let _guard = test_lock();
520        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
521        let seen = Arc::clone(&calls);
522        let handle = observe_lifecycle(move |_| {
523            seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
524        });
525        dispatch_lifecycle(LifecycleEvent {
526            from: LifecycleState::Created,
527            to: LifecycleState::Started,
528        });
529        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
530        drop(handle);
531        dispatch_lifecycle(LifecycleEvent {
532            from: LifecycleState::Started,
533            to: LifecycleState::Resumed,
534        });
535        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
536    }
537
538    #[test]
539    fn state_dispatch_derives_the_previous_state() {
540        let _guard = test_lock();
541        dispatch_lifecycle_state(LifecycleState::Paused);
542        assert_eq!(current_lifecycle_state(), LifecycleState::Paused);
543        dispatch_lifecycle_state(LifecycleState::Stopped);
544        assert_eq!(current_lifecycle_state(), LifecycleState::Stopped);
545    }
546
547    #[test]
548    fn repeated_keep_screen_value_reaches_the_host_once() {
549        let _guard = test_lock();
550        struct RecordingHost(Arc<std::sync::atomic::AtomicUsize>);
551        impl HostController for RecordingHost {
552            fn set_keep_screen_on(&self, _enabled: bool) {
553                self.0.fetch_add(1, Ordering::Relaxed);
554            }
555            fn platform_directories(&self) -> Option<PlatformDirectories> {
556                Some(PlatformDirectories {
557                    data: PathBuf::from("data"),
558                    config: PathBuf::from("config"),
559                    cache: PathBuf::from("cache"),
560                    documents: Some(PathBuf::from("documents")),
561                    temporary: PathBuf::from("temporary"),
562                    shared: Some(PathBuf::from("shared")),
563                })
564            }
565            fn exit(&self) {}
566            fn background(&self) {}
567        }
568        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
569        set_host_controller(Arc::new(RecordingHost(Arc::clone(&calls))));
570        set_keep_screen_on(true);
571        set_keep_screen_on(true);
572        assert_eq!(calls.load(Ordering::Relaxed), 1);
573        set_application_id("sample").expect("a plain id is valid");
574        assert_eq!(
575            application_directories().unwrap().data,
576            PathBuf::from("data/sample")
577        );
578        clear_application_id();
579        clear_host_controller();
580    }
581
582    #[test]
583    fn durable_saves_run_and_report_completion() {
584        // A durable save holds a background-work lease while it runs, and that
585        // count is one number for the process, so tests that take or read it
586        // take turns.
587        let _services = crate::registry::test_service_guard();
588        let _guard = test_lock();
589        let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
590        let first = Arc::clone(&ran);
591        let second = Arc::clone(&ran);
592        let a = register_durable_save(move || {
593            first.fetch_add(1, Ordering::Relaxed);
594        });
595        let b = register_durable_save(move || {
596            second.fetch_add(1, Ordering::Relaxed);
597        });
598        assert_eq!(
599            run_durable_saves(std::time::Duration::from_secs(5)),
600            DurableSaveOutcome::Completed
601        );
602        assert_eq!(ran.load(Ordering::Relaxed), 2);
603        drop((a, b));
604        assert_eq!(
605            run_durable_saves(std::time::Duration::from_secs(1)),
606            DurableSaveOutcome::Nothing
607        );
608    }
609
610    #[test]
611    fn a_save_that_overruns_the_deadline_reports_a_timeout() {
612        let _services = crate::registry::test_service_guard();
613        let _guard = test_lock();
614        let registration = register_durable_save(|| {
615            std::thread::sleep(std::time::Duration::from_millis(400));
616        });
617        assert_eq!(
618            run_durable_saves(std::time::Duration::from_millis(30)),
619            DurableSaveOutcome::TimedOut
620        );
621        drop(registration);
622    }
623
624    #[test]
625    fn a_dropped_registration_is_no_longer_saved() {
626        let _services = crate::registry::test_service_guard();
627        let _guard = test_lock();
628        let ran = Arc::new(std::sync::atomic::AtomicUsize::new(0));
629        let counted = Arc::clone(&ran);
630        let registration = register_durable_save(move || {
631            counted.fetch_add(1, Ordering::Relaxed);
632        });
633        drop(registration);
634        assert_eq!(
635            run_durable_saves(std::time::Duration::from_secs(1)),
636            DurableSaveOutcome::Nothing
637        );
638        assert_eq!(ran.load(Ordering::Relaxed), 0);
639    }
640
641    #[test]
642    fn application_id_must_be_one_component() {
643        let _guard = test_lock();
644        assert_eq!(
645            set_application_id("../sample"),
646            Err(PlatformDirectoryError::InvalidApplicationId)
647        );
648        assert_eq!(
649            set_application_id(""),
650            Err(PlatformDirectoryError::InvalidApplicationId)
651        );
652        clear_application_id();
653        assert_eq!(
654            application_directories(),
655            Err(PlatformDirectoryError::NoApplicationId)
656        );
657    }
658}