Skip to main content

cranpose_services/
launcher.rs

1//! Composition-owned launchers for the system choosers.
2//!
3//! A launcher is remembered under a stable **request key**, presents a system
4//! chooser when the application calls `launch`, and delivers the result to the
5//! composition through a callback. The framework — not the application — owns
6//! the awkward part: Android may destroy the activity, and with it the whole
7//! composition, while the chooser is in front. The launcher records which
8//! request is in flight, the platform backend records the granted selection,
9//! and the launcher that recomposes under the same key receives the result.
10//! Applications never poll an inbox, never drain a resume queue, and never
11//! write a marker file.
12//!
13//! ```rust,no_run
14//! use cranpose_macros::composable;
15//! use cranpose_services::{FilePickerOptions, launcher::rememberOpenFileLauncher};
16//!
17//! #[composable]
18//! fn ImportButton() {
19//!     let launcher = rememberOpenFileLauncher("import.document", |picked| {
20//!         if let Ok(Some(content)) = picked {
21//!             log::info!("picked {}", content.metadata().name);
22//!         }
23//!     });
24//!     launcher.launch(FilePickerOptions::default().with_title("Import"));
25//! }
26//! ```
27
28use std::{
29    cell::{Cell, RefCell},
30    collections::HashMap,
31    future::Future,
32    rc::Rc,
33};
34
35use cranpose_core::{RuntimeHandle, current_runtime_handle};
36use cranpose_macros::composable;
37
38use crate::{
39    content::{ContentFolderRef, ContentHandle, ContentSinkRef},
40    file_picker::{
41        FilePickerError, FilePickerOptions, FilePickerRef, RecoveredPick, SaveDocumentRequest,
42        local_file_picker,
43    },
44    preferences::preferences,
45};
46
47/// What a launcher hands back once the chooser resolves.
48///
49/// Cancellation is `Ok` with an empty selection, not an error: the user
50/// declining is an outcome, not a failure.
51pub type LauncherResult<T> = Result<T, FilePickerError>;
52
53thread_local! {
54    /// Results the host recovered after their requesting composition was
55    /// destroyed, keyed by the request key that asked for them.
56    static RECOVERED: RefCell<HashMap<String, RecoveredPick>> = RefCell::new(HashMap::new());
57    /// The request key of the chooser currently in flight, if any. At most one
58    /// system chooser can be in front, so one slot is enough.
59    static IN_FLIGHT: RefCell<Option<String>> = const { RefCell::new(None) };
60    /// Request keys with a live launcher, so duplicates are caught in debug.
61    static REGISTERED_KEYS: RefCell<HashMap<String, usize>> = RefCell::new(HashMap::new());
62}
63
64/// Where the in-flight request key is kept so it outlives the process.
65///
66/// The in-memory slot covers a composition being torn down. It does not cover
67/// the process being killed, which is the case Android actually presents: the
68/// system may destroy an application outright while its chooser is in front,
69/// and the grant then arrives to a process that has forgotten it asked. An
70/// application that wanted to survive that had to write its own marker file
71/// beside its data and read it back on the next start — which is the framework
72/// doing half a job and leaving the awkward half to every application.
73const IN_FLIGHT_PREFERENCE: &str = "cranpose.launcher.in-flight";
74
75/// Records that `request_key` launched a chooser. Called before presenting so a
76/// host recreation — or a restart after the process was killed — can attribute
77/// the recovered result.
78fn begin_request(request_key: &str) {
79    IN_FLIGHT.with(|slot| *slot.borrow_mut() = Some(request_key.to_string()));
80    // Written before the chooser is presented, so a process killed while it is
81    // in front still leaves the record behind.
82    let _ = preferences().set(IN_FLIGHT_PREFERENCE, request_key);
83}
84
85/// Clears the in-flight record once the chooser resolves in this process.
86fn finish_request(request_key: &str) {
87    IN_FLIGHT.with(|slot| {
88        let mut slot = slot.borrow_mut();
89        if slot.as_deref() == Some(request_key) {
90            *slot = None;
91        }
92    });
93    if preferences().get(IN_FLIGHT_PREFERENCE).as_deref() == Some(request_key) {
94        let _ = preferences().remove(IN_FLIGHT_PREFERENCE);
95    }
96}
97
98/// The request that was in flight, from this process or the one before it.
99fn in_flight_request() -> Option<String> {
100    IN_FLIGHT
101        .with(|slot| slot.borrow().clone())
102        .or_else(|| preferences().get(IN_FLIGHT_PREFERENCE))
103}
104
105/// Moves anything the platform recovered into the keyed inbox. Called whenever a
106/// launcher composes, which is the first moment a recreated composition can take
107/// delivery.
108fn drain_recovered(picker: &FilePickerRef) {
109    while let Some(pick) = picker.take_recovered_pick() {
110        let Some(key) = in_flight_request() else {
111            log::warn!("cranpose: a recovered pick arrived with no request in flight; dropping it");
112            continue;
113        };
114        RECOVERED.with(|inbox| inbox.borrow_mut().insert(key, pick));
115    }
116}
117
118/// Takes the recovered result addressed to `request_key`, if any.
119fn take_recovered(request_key: &str) -> Option<RecoveredPick> {
120    let recovered = RECOVERED.with(|inbox| inbox.borrow_mut().remove(request_key));
121    if recovered.is_some() {
122        finish_request(request_key);
123    }
124    recovered
125}
126
127/// Discards every framework-owned launcher record. Used by tests and by host
128/// teardown so one composition's in-flight request never leaks into the next.
129pub fn clear_launcher_state() {
130    RECOVERED.with(|inbox| inbox.borrow_mut().clear());
131    IN_FLIGHT.with(|slot| *slot.borrow_mut() = None);
132    REGISTERED_KEYS.with(|keys| keys.borrow_mut().clear());
133}
134
135/// Shared state behind every launcher: the picker, the runtime that runs the
136/// chooser future, and whether a request is outstanding.
137struct LauncherCore {
138    request_key: String,
139    picker: FilePickerRef,
140    runtime: Option<RuntimeHandle>,
141    in_flight: Cell<bool>,
142}
143
144impl LauncherCore {
145    fn spawn(self: &Rc<Self>, future: impl Future<Output = ()> + 'static) {
146        let Some(runtime) = self.runtime.clone() else {
147            log::warn!(
148                "cranpose: launcher `{}` has no runtime; the chooser was not presented",
149                self.request_key
150            );
151            self.in_flight.set(false);
152            finish_request(&self.request_key);
153            return;
154        };
155        if runtime.spawn_ui(future).is_none() {
156            log::warn!(
157                "cranpose: launcher `{}` outlived its runtime; the chooser was not presented",
158                self.request_key
159            );
160            self.in_flight.set(false);
161            finish_request(&self.request_key);
162        }
163    }
164
165    /// Marks a request as starting, refusing to present a second chooser while
166    /// one is already in front.
167    fn begin(self: &Rc<Self>) -> bool {
168        if self.in_flight.get() {
169            return false;
170        }
171        self.in_flight.set(true);
172        begin_request(&self.request_key);
173        true
174    }
175
176    fn end(self: &Rc<Self>) {
177        self.in_flight.set(false);
178        finish_request(&self.request_key);
179    }
180}
181
182/// Registration guard: keeps the duplicate-key check honest across recomposition
183/// and removes the key when the launcher leaves the composition.
184struct KeyRegistration {
185    request_key: String,
186}
187
188impl KeyRegistration {
189    fn new(request_key: &str) -> Self {
190        REGISTERED_KEYS.with(|keys| {
191            let mut keys = keys.borrow_mut();
192            let count = keys.entry(request_key.to_string()).or_insert(0);
193            *count += 1;
194            debug_assert!(
195                *count == 1,
196                "launcher request key `{request_key}` is registered {count} times; \
197                 keys must be unique so a recovered result reaches the right launcher"
198            );
199        });
200        Self {
201            request_key: request_key.to_string(),
202        }
203    }
204}
205
206impl Drop for KeyRegistration {
207    fn drop(&mut self) {
208        REGISTERED_KEYS.with(|keys| {
209            let mut keys = keys.borrow_mut();
210            if let Some(count) = keys.get_mut(&self.request_key) {
211                *count -= 1;
212                if *count == 0 {
213                    keys.remove(&self.request_key);
214                }
215            }
216        });
217    }
218}
219
220/// Everything a remembered launcher owns for one request key.
221struct LauncherSlot {
222    core: Rc<LauncherCore>,
223    _registration: KeyRegistration,
224}
225
226/// Remembers a launcher core for `request_key`, draining anything the host
227/// recovered for it and handing it to `deliver`.
228#[track_caller]
229fn remember_core(
230    request_key: &'static str,
231    deliver: impl FnOnce(RecoveredPick) + 'static,
232) -> Rc<LauncherCore> {
233    let picker = local_file_picker().current();
234    let slot = cranpose_core::remember({
235        let picker = picker.clone();
236        let request_key = request_key.to_string();
237        move || LauncherSlot {
238            core: Rc::new(LauncherCore {
239                request_key: request_key.clone(),
240                picker,
241                runtime: current_runtime_handle(),
242                in_flight: Cell::new(false),
243            }),
244            _registration: KeyRegistration::new(&request_key),
245        }
246    });
247    let core = slot.with(|slot| Rc::clone(&slot.core));
248
249    drain_recovered(&picker);
250    if let Some(recovered) = take_recovered(request_key) {
251        let core = Rc::clone(&core);
252        cranpose_core::SideEffect(move || {
253            core.end();
254            deliver(recovered);
255        });
256    }
257    core
258}
259
260/// Presents the single-file chooser and hands the picked content back.
261#[derive(Clone)]
262pub struct OpenFileLauncher {
263    core: Rc<LauncherCore>,
264    on_result: Rc<dyn Fn(LauncherResult<Option<ContentHandle>>)>,
265}
266
267impl OpenFileLauncher {
268    /// Presents the chooser. A second call while one is in front is ignored.
269    pub fn launch(&self, options: FilePickerOptions) {
270        if !self.core.begin() {
271            return;
272        }
273        let core = Rc::clone(&self.core);
274        let done = Rc::clone(&self.core);
275        let on_result = Rc::clone(&self.on_result);
276        let future = core.picker.pick_file(options);
277        core.spawn(async move {
278            let result = future.await;
279            done.end();
280            on_result(result);
281        });
282    }
283
284    /// Whether a chooser presented by this launcher is still in front.
285    pub fn is_in_flight(&self) -> bool {
286        self.core.in_flight.get()
287    }
288}
289
290/// Remembers a single-file launcher under `request_key`.
291#[allow(non_snake_case)]
292#[composable]
293#[track_caller]
294pub fn rememberOpenFileLauncher(
295    request_key: &'static str,
296    on_result: impl Fn(LauncherResult<Option<ContentHandle>>) + 'static,
297) -> OpenFileLauncher {
298    let on_result: Rc<dyn Fn(LauncherResult<Option<ContentHandle>>)> = Rc::new(on_result);
299    let core = remember_core(request_key, {
300        let on_result = Rc::clone(&on_result);
301        move |recovered| match recovered {
302            RecoveredPick::File(content) => on_result(Ok(Some(content))),
303            RecoveredPick::Files(mut files) => on_result(Ok(files.drain(..).next())),
304            _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
305        }
306    });
307    OpenFileLauncher { core, on_result }
308}
309
310/// Presents the multi-file chooser and hands every picked item back.
311#[derive(Clone)]
312pub struct OpenFilesLauncher {
313    core: Rc<LauncherCore>,
314    on_result: Rc<dyn Fn(LauncherResult<Vec<ContentHandle>>)>,
315}
316
317impl OpenFilesLauncher {
318    /// Presents the chooser. A second call while one is in front is ignored.
319    pub fn launch(&self, options: FilePickerOptions) {
320        if !self.core.begin() {
321            return;
322        }
323        let core = Rc::clone(&self.core);
324        let done = Rc::clone(&self.core);
325        let on_result = Rc::clone(&self.on_result);
326        let future = core.picker.pick_files(options);
327        core.spawn(async move {
328            let result = future.await;
329            done.end();
330            on_result(result);
331        });
332    }
333
334    /// Whether a chooser presented by this launcher is still in front.
335    pub fn is_in_flight(&self) -> bool {
336        self.core.in_flight.get()
337    }
338}
339
340/// Remembers a multi-file launcher under `request_key`.
341#[allow(non_snake_case)]
342#[composable]
343#[track_caller]
344pub fn rememberOpenFilesLauncher(
345    request_key: &'static str,
346    on_result: impl Fn(LauncherResult<Vec<ContentHandle>>) + 'static,
347) -> OpenFilesLauncher {
348    let on_result: Rc<dyn Fn(LauncherResult<Vec<ContentHandle>>)> = Rc::new(on_result);
349    let core = remember_core(request_key, {
350        let on_result = Rc::clone(&on_result);
351        move |recovered| match recovered {
352            RecoveredPick::Files(files) => on_result(Ok(files)),
353            RecoveredPick::File(content) => on_result(Ok(vec![content])),
354            _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
355        }
356    });
357    OpenFilesLauncher { core, on_result }
358}
359
360/// Presents the folder chooser and hands the granted folder back.
361#[derive(Clone)]
362pub struct OpenFolderLauncher {
363    core: Rc<LauncherCore>,
364    on_result: Rc<dyn Fn(LauncherResult<Option<ContentFolderRef>>)>,
365}
366
367impl OpenFolderLauncher {
368    /// Presents the chooser. A second call while one is in front is ignored.
369    pub fn launch(&self, options: FilePickerOptions) {
370        if !self.core.begin() {
371            return;
372        }
373        let core = Rc::clone(&self.core);
374        let done = Rc::clone(&self.core);
375        let on_result = Rc::clone(&self.on_result);
376        let future = core.picker.pick_folder(options);
377        core.spawn(async move {
378            let result = future.await;
379            done.end();
380            on_result(result);
381        });
382    }
383
384    /// Whether a chooser presented by this launcher is still in front.
385    pub fn is_in_flight(&self) -> bool {
386        self.core.in_flight.get()
387    }
388}
389
390/// Remembers a folder launcher under `request_key`.
391#[allow(non_snake_case)]
392#[composable]
393#[track_caller]
394pub fn rememberOpenFolderLauncher(
395    request_key: &'static str,
396    on_result: impl Fn(LauncherResult<Option<ContentFolderRef>>) + 'static,
397) -> OpenFolderLauncher {
398    let on_result: Rc<dyn Fn(LauncherResult<Option<ContentFolderRef>>)> = Rc::new(on_result);
399    let core = remember_core(request_key, {
400        let on_result = Rc::clone(&on_result);
401        move |recovered| match recovered {
402            RecoveredPick::Folder(folder) => on_result(Ok(Some(folder))),
403            _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
404        }
405    });
406    OpenFolderLauncher { core, on_result }
407}
408
409/// Presents the save-document chooser and hands the opened sink back.
410#[derive(Clone)]
411pub struct SaveDocumentLauncher {
412    core: Rc<LauncherCore>,
413    on_result: Rc<dyn Fn(LauncherResult<Option<ContentSinkRef>>)>,
414}
415
416impl SaveDocumentLauncher {
417    /// Presents the chooser. A second call while one is in front is ignored.
418    pub fn launch(&self, request: SaveDocumentRequest) {
419        if !self.core.begin() {
420            return;
421        }
422        let core = Rc::clone(&self.core);
423        let done = Rc::clone(&self.core);
424        let on_result = Rc::clone(&self.on_result);
425        let future = core.picker.save_document(request);
426        core.spawn(async move {
427            let result = future.await;
428            done.end();
429            on_result(result);
430        });
431    }
432
433    /// Whether a chooser presented by this launcher is still in front.
434    pub fn is_in_flight(&self) -> bool {
435        self.core.in_flight.get()
436    }
437}
438
439/// Remembers a save-document launcher under `request_key`.
440#[allow(non_snake_case)]
441#[composable]
442#[track_caller]
443pub fn rememberSaveDocumentLauncher(
444    request_key: &'static str,
445    on_result: impl Fn(LauncherResult<Option<ContentSinkRef>>) + 'static,
446) -> SaveDocumentLauncher {
447    let on_result: Rc<dyn Fn(LauncherResult<Option<ContentSinkRef>>)> = Rc::new(on_result);
448    let core = remember_core(request_key, move |_recovered| {
449        log::warn!("cranpose: `{request_key}` recovered a pick of another kind");
450    });
451    SaveDocumentLauncher { core, on_result }
452}
453
454/// Presents the persistent writable-folder chooser and hands the durable handle
455/// back. Store the handle and reopen it with
456/// [`crate::writable_folder::open_writable_folder`].
457#[derive(Clone)]
458pub struct WritableFolderLauncher {
459    core: Rc<LauncherCore>,
460    on_result: Rc<dyn Fn(LauncherResult<Option<String>>)>,
461}
462
463impl WritableFolderLauncher {
464    /// Presents the chooser. A second call while one is in front is ignored.
465    pub fn launch(&self, options: FilePickerOptions) {
466        if !self.core.begin() {
467            return;
468        }
469        let core = Rc::clone(&self.core);
470        let done = Rc::clone(&self.core);
471        let on_result = Rc::clone(&self.on_result);
472        let future = core.picker.pick_writable_folder(options);
473        core.spawn(async move {
474            let result = future.await;
475            done.end();
476            on_result(result);
477        });
478    }
479
480    /// Whether a chooser presented by this launcher is still in front.
481    pub fn is_in_flight(&self) -> bool {
482        self.core.in_flight.get()
483    }
484}
485
486/// Remembers a writable-folder launcher under `request_key`.
487#[allow(non_snake_case)]
488#[composable]
489#[track_caller]
490pub fn rememberWritableFolderLauncher(
491    request_key: &'static str,
492    on_result: impl Fn(LauncherResult<Option<String>>) + 'static,
493) -> WritableFolderLauncher {
494    let on_result: Rc<dyn Fn(LauncherResult<Option<String>>)> = Rc::new(on_result);
495    let core = remember_core(request_key, {
496        let on_result = Rc::clone(&on_result);
497        move |recovered| match recovered {
498            RecoveredPick::WritableFolder(handle) => on_result(Ok(Some(handle))),
499            _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
500        }
501    });
502    WritableFolderLauncher { core, on_result }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    /// The persistence contract, in one test.
510    ///
511    /// Two tests would share the process-wide preferences store *and* the one
512    /// key the in-flight record lives under, and the harness runs them at the
513    /// same time - so each would clear the other's record and the failure would
514    /// look like the contract being wrong. One outstanding request is also the
515    /// real invariant: a system chooser is modal, so there is one to remember.
516    #[test]
517    fn an_in_flight_request_outlives_the_process_that_started_it() {
518        // An in-memory store, because a unit test has no business writing to
519        // the user's config directory - which is where the default file-backed
520        // store puts it.
521        crate::preferences::set_platform_preferences(std::sync::Arc::new(
522            crate::preferences::MemoryPreferences::new(),
523        ));
524
525        // The in-memory slot is what a torn-down composition leaves behind. A
526        // killed process leaves nothing, so the record has to be somewhere that
527        // outlives it - otherwise a grant returns to an application that has
528        // forgotten it asked, and every application writes its own marker file.
529        begin_request("test.pick");
530        assert_eq!(in_flight_request().as_deref(), Some("test.pick"));
531
532        // The process dies: the thread-local is gone, the record is not.
533        IN_FLIGHT.with(|slot| *slot.borrow_mut() = None);
534        assert_eq!(
535            in_flight_request().as_deref(),
536            Some("test.pick"),
537            "a restarted process must still know which request was outstanding"
538        );
539
540        // A different launcher resolving must not clear this one's record;
541        // `drain_recovered` routes a recovered grant by it.
542        finish_request("test.other");
543        assert_eq!(
544            in_flight_request().as_deref(),
545            Some("test.pick"),
546            "one launcher resolving must not clear a different launcher's record"
547        );
548
549        finish_request("test.pick");
550        assert_eq!(
551            in_flight_request(),
552            None,
553            "a resolved request is not still in flight"
554        );
555
556        crate::preferences::clear_platform_preferences();
557    }
558}