Skip to main content

cranpose_services/
background.rs

1//! Background execution: ask the OS to keep running after the app leaves the
2//! screen, so work in flight (a download, a save, playback) can finish instead
3//! of being suspended at once.
4//!
5//! Each piece of work holds a [`BackgroundWorkLease`] with a title and, where
6//! it knows one, its progress. The platform shows the latest lease where the
7//! person can see it: Android in the notification of its foreground service,
8//! which opens the app when tapped. iOS shows nothing for this kind of work.
9//!
10//! The platform backend installs an implementation via
11//! [`set_platform_background_activity`] (iOS `beginBackgroundTask`, Android a
12//! foreground service). No default: [`background_activity`] returns `None` where
13//! unsupported, and the app simply runs only while foregrounded.
14
15use std::sync::{
16    Arc, Mutex, MutexGuard, OnceLock, PoisonError,
17    atomic::{AtomicUsize, Ordering},
18};
19
20/// What the person is shown about work that runs off screen.
21#[derive(Clone, Debug, PartialEq)]
22pub struct BackgroundWork {
23    /// What the work does, as the person reads it: "Downloading the models".
24    pub title: String,
25    /// How much of it is done, from 0 to 1, when the work knows.
26    pub progress: Option<f32>,
27}
28
29/// Keeps the app running while work is active, and shows that work.
30/// Implementations are `Send + Sync` (leases change from worker threads).
31pub trait BackgroundActivity: Send + Sync {
32    /// The work to show, or `None` once every lease has ended. Called when the
33    /// first lease starts, whenever what is shown changes, and when the last
34    /// lease ends; implementations must tolerate repeats.
35    fn show(&self, work: Option<&BackgroundWork>);
36}
37
38pub type BackgroundActivityRef = Arc<dyn BackgroundActivity>;
39
40fn slot() -> &'static Mutex<Option<BackgroundActivityRef>> {
41    static SLOT: OnceLock<Mutex<Option<BackgroundActivityRef>>> = OnceLock::new();
42    SLOT.get_or_init(|| Mutex::new(None))
43}
44
45/// Installs the platform background-activity handler, replacing any previous one.
46pub fn set_platform_background_activity(activity: BackgroundActivityRef) {
47    if let Some(work) = leases().shown() {
48        activity.show(Some(&work));
49    }
50    if let Ok(mut s) = slot().lock() {
51        *s = Some(activity);
52    }
53}
54
55/// Removes any registered handler (tests/teardown).
56pub fn clear_platform_background_activity() {
57    if let Ok(mut s) = slot().lock() {
58        *s = None;
59    }
60}
61
62/// The registered background-activity handler, or `None` where unsupported.
63pub fn background_activity() -> Option<BackgroundActivityRef> {
64    slot().lock().ok().and_then(|s| s.clone())
65}
66
67#[derive(Default)]
68struct Leases {
69    next: u64,
70    /// Active leases, oldest first; the last one is shown.
71    active: Vec<(u64, BackgroundWork)>,
72}
73
74impl Leases {
75    fn shown(&self) -> Option<BackgroundWork> {
76        self.active.last().map(|(_, work)| work.clone())
77    }
78}
79
80static ACTIVE_LEASES: AtomicUsize = AtomicUsize::new(0);
81
82fn leases() -> MutexGuard<'static, Leases> {
83    static LEASES: OnceLock<Mutex<Leases>> = OnceLock::new();
84    LEASES
85        .get_or_init(Mutex::default)
86        .lock()
87        .unwrap_or_else(PoisonError::into_inner)
88}
89
90/// Changes the leases with `change` and tells the platform when what it shows
91/// changed. The platform is called outside the lock.
92fn update(change: impl FnOnce(&mut Leases)) {
93    let (before, after) = {
94        let mut leases = leases();
95        let before = leases.shown().map(|work| Shown::of(&work));
96        change(&mut leases);
97        ACTIVE_LEASES.store(leases.active.len(), Ordering::Release);
98        (before, leases.shown())
99    };
100    if before != after.as_ref().map(Shown::of)
101        && let Some(activity) = background_activity()
102    {
103        activity.show(after.as_ref());
104    }
105}
106
107/// What a platform redraws for: the title and whole percents, so work that
108/// reports every chunk does not redraw a notification for each.
109#[derive(PartialEq)]
110struct Shown {
111    title: String,
112    percent: Option<u8>,
113}
114
115impl Shown {
116    fn of(work: &BackgroundWork) -> Self {
117        Self {
118            title: work.title.clone(),
119            percent: work
120                .progress
121                .map(|fraction| (fraction.clamp(0.0, 1.0) * 100.0) as u8),
122        }
123    }
124}
125
126/// A claim that important work needs the host's background execution allowance.
127/// The allowance remains active until every outstanding lease is dropped.
128pub struct BackgroundWorkLease {
129    id: u64,
130}
131
132impl BackgroundWorkLease {
133    /// Shows how much of the work is done, from 0 to 1, or `None` when it is
134    /// not known.
135    pub fn set_progress(&self, progress: Option<f32>) {
136        self.change(|work| work.progress = progress);
137    }
138
139    /// Changes what the work is called.
140    pub fn set_title(&self, title: impl Into<String>) {
141        let title = title.into();
142        self.change(|work| work.title = title);
143    }
144
145    fn change(&self, change: impl FnOnce(&mut BackgroundWork)) {
146        update(|leases| {
147            if let Some((_, work)) = leases.active.iter_mut().find(|(id, _)| *id == self.id) {
148                change(work);
149            }
150        });
151    }
152}
153
154impl Drop for BackgroundWorkLease {
155    fn drop(&mut self) {
156        update(|leases| leases.active.retain(|(id, _)| *id != self.id));
157    }
158}
159
160/// Acquires background execution for one independent operation, shown to the
161/// person as `title` where the platform shows such work.
162pub fn acquire_background_work(title: impl Into<String>) -> BackgroundWorkLease {
163    let title = title.into();
164    let mut id = 0;
165    update(|leases| {
166        id = leases.next;
167        leases.next += 1;
168        leases.active.push((
169            id,
170            BackgroundWork {
171                title,
172                progress: None,
173            },
174        ));
175    });
176    BackgroundWorkLease { id }
177}
178
179/// Whether at least one background-work lease is active.
180///
181/// A platform backend reads this to decide whether the runtime keeps turning
182/// while the app is off screen. Both mobile backends drive composition from the
183/// frame path, and both stop that path when the surface is gone: Android drops
184/// its GPU resources on `TerminateWindow`, iOS stops receiving redraws. Anything
185/// posted to the UI dispatcher then waits until the user comes back, which is
186/// wrong for an app that told the OS it has work to finish.
187pub fn background_active() -> bool {
188    ACTIVE_LEASES.load(Ordering::Acquire) != 0
189}
190
191#[cfg(test)]
192#[path = "tests/background_tests.rs"]
193mod tests;