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