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
38/// Shared handle to a [`BackgroundActivity`].
39pub type BackgroundActivityRef = Arc<dyn BackgroundActivity>;
40
41fn slot() -> &'static Mutex<Option<BackgroundActivityRef>> {
42    static SLOT: OnceLock<Mutex<Option<BackgroundActivityRef>>> = OnceLock::new();
43    SLOT.get_or_init(|| Mutex::new(None))
44}
45
46/// Installs the platform background-activity handler, replacing any previous one.
47pub fn set_platform_background_activity(activity: BackgroundActivityRef) {
48    if let Some(work) = leases().shown() {
49        activity.show(Some(&work));
50    }
51    if let Ok(mut s) = slot().lock() {
52        *s = Some(activity);
53    }
54}
55
56/// Removes any registered handler (tests/teardown).
57pub fn clear_platform_background_activity() {
58    if let Ok(mut s) = slot().lock() {
59        *s = None;
60    }
61}
62
63/// The registered background-activity handler, or `None` where unsupported.
64pub fn background_activity() -> Option<BackgroundActivityRef> {
65    slot().lock().ok().and_then(|s| s.clone())
66}
67
68#[derive(Default)]
69struct Leases {
70    next: u64,
71    /// Active leases, oldest first; the last one is shown.
72    active: Vec<(u64, BackgroundWork)>,
73}
74
75impl Leases {
76    fn shown(&self) -> Option<BackgroundWork> {
77        self.active.last().map(|(_, work)| work.clone())
78    }
79}
80
81static ACTIVE_LEASES: AtomicUsize = AtomicUsize::new(0);
82
83fn leases() -> MutexGuard<'static, Leases> {
84    static LEASES: OnceLock<Mutex<Leases>> = OnceLock::new();
85    LEASES
86        .get_or_init(Mutex::default)
87        .lock()
88        .unwrap_or_else(PoisonError::into_inner)
89}
90
91/// Changes the leases with `change` and tells the platform when what it shows
92/// changed. The platform is called outside the lock.
93fn update(change: impl FnOnce(&mut Leases)) {
94    let (before, after) = {
95        let mut leases = leases();
96        let before = leases.shown().map(|work| Shown::of(&work));
97        change(&mut leases);
98        ACTIVE_LEASES.store(leases.active.len(), Ordering::Release);
99        (before, leases.shown())
100    };
101    if before != after.as_ref().map(Shown::of)
102        && let Some(activity) = background_activity()
103    {
104        activity.show(after.as_ref());
105    }
106}
107
108/// What a platform redraws for: the title and whole percents, so work that
109/// reports every chunk does not redraw a notification for each.
110#[derive(PartialEq)]
111struct Shown {
112    title: String,
113    percent: Option<u8>,
114}
115
116impl Shown {
117    fn of(work: &BackgroundWork) -> Self {
118        Self {
119            title: work.title.clone(),
120            percent: work
121                .progress
122                .map(|fraction| (fraction.clamp(0.0, 1.0) * 100.0) as u8),
123        }
124    }
125}
126
127/// A claim that important work needs the host's background execution allowance.
128/// The allowance remains active until every outstanding lease is dropped.
129pub struct BackgroundWorkLease {
130    id: u64,
131}
132
133impl BackgroundWorkLease {
134    /// Shows how much of the work is done, from 0 to 1, or `None` when it is
135    /// not known.
136    pub fn set_progress(&self, progress: Option<f32>) {
137        self.change(|work| work.progress = progress);
138    }
139
140    /// Changes what the work is called.
141    pub fn set_title(&self, title: impl Into<String>) {
142        let title = title.into();
143        self.change(|work| work.title = title);
144    }
145
146    fn change(&self, change: impl FnOnce(&mut BackgroundWork)) {
147        update(|leases| {
148            if let Some((_, work)) = leases.active.iter_mut().find(|(id, _)| *id == self.id) {
149                change(work);
150            }
151        });
152    }
153}
154
155impl Drop for BackgroundWorkLease {
156    fn drop(&mut self) {
157        update(|leases| leases.active.retain(|(id, _)| *id != self.id));
158    }
159}
160
161/// Acquires background execution for one independent operation, shown to the
162/// person as `title` where the platform shows such work.
163pub fn acquire_background_work(title: impl Into<String>) -> BackgroundWorkLease {
164    let title = title.into();
165    let mut id = 0;
166    update(|leases| {
167        id = leases.next;
168        leases.next += 1;
169        leases.active.push((
170            id,
171            BackgroundWork {
172                title,
173                progress: None,
174            },
175        ));
176    });
177    BackgroundWorkLease { id }
178}
179
180/// Whether at least one background-work lease is active.
181///
182/// A platform backend reads this to decide whether the runtime keeps turning
183/// while the app is off screen. Both mobile backends drive composition from the
184/// frame path, and both stop that path when the surface is gone: Android drops
185/// its GPU resources on `TerminateWindow`, iOS stops receiving redraws. Anything
186/// posted to the UI dispatcher then waits until the user comes back, which is
187/// wrong for an app that told the OS it has work to finish.
188pub fn background_active() -> bool {
189    ACTIVE_LEASES.load(Ordering::Acquire) != 0
190}
191
192#[cfg(test)]
193#[path = "tests/background_tests.rs"]
194mod tests;