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    static RECOVERED: RefCell<HashMap<String, RecoveredPick>> = RefCell::new(HashMap::new());
55    static IN_FLIGHT: RefCell<Option<String>> = const { RefCell::new(None) };
56    static REGISTERED_KEYS: RefCell<HashMap<String, usize>> = RefCell::new(HashMap::new());
57}
58
59const IN_FLIGHT_PREFERENCE: &str = "cranpose.launcher.in-flight";
60
61fn begin_request(request_key: &str) {
62    IN_FLIGHT.with(|slot| *slot.borrow_mut() = Some(request_key.to_string()));
63    let _ = preferences().set(IN_FLIGHT_PREFERENCE, request_key);
64}
65
66fn finish_request(request_key: &str) {
67    IN_FLIGHT.with(|slot| {
68        let mut slot = slot.borrow_mut();
69        if slot.as_deref() == Some(request_key) {
70            *slot = None;
71        }
72    });
73    if preferences().get(IN_FLIGHT_PREFERENCE).as_deref() == Some(request_key) {
74        let _ = preferences().remove(IN_FLIGHT_PREFERENCE);
75    }
76}
77
78fn in_flight_request() -> Option<String> {
79    IN_FLIGHT
80        .with(|slot| slot.borrow().clone())
81        .or_else(|| preferences().get(IN_FLIGHT_PREFERENCE))
82}
83
84fn drain_recovered(picker: &FilePickerRef) {
85    while let Some(pick) = picker.take_recovered_pick() {
86        let Some(key) = in_flight_request() else {
87            log::warn!("cranpose: a recovered pick arrived with no request in flight; dropping it");
88            continue;
89        };
90        RECOVERED.with(|inbox| inbox.borrow_mut().insert(key, pick));
91    }
92}
93
94fn take_recovered(request_key: &str) -> Option<RecoveredPick> {
95    let recovered = RECOVERED.with(|inbox| inbox.borrow_mut().remove(request_key));
96    if recovered.is_some() {
97        finish_request(request_key);
98    }
99    recovered
100}
101
102/// Discards every framework-owned launcher record. Used by tests and by host
103/// teardown so one composition's in-flight request never leaks into the next.
104pub fn clear_launcher_state() {
105    RECOVERED.with(|inbox| inbox.borrow_mut().clear());
106    IN_FLIGHT.with(|slot| *slot.borrow_mut() = None);
107    REGISTERED_KEYS.with(|keys| keys.borrow_mut().clear());
108}
109
110struct LauncherCore {
111    request_key: String,
112    picker: FilePickerRef,
113    runtime: Option<RuntimeHandle>,
114    in_flight: Cell<bool>,
115}
116
117impl LauncherCore {
118    fn spawn(self: &Rc<Self>, future: impl Future<Output = ()> + 'static) {
119        let Some(runtime) = self.runtime.clone() else {
120            log::warn!(
121                "cranpose: launcher `{}` has no runtime; the chooser was not presented",
122                self.request_key
123            );
124            self.in_flight.set(false);
125            finish_request(&self.request_key);
126            return;
127        };
128        if runtime.spawn_ui(future).is_none() {
129            log::warn!(
130                "cranpose: launcher `{}` outlived its runtime; the chooser was not presented",
131                self.request_key
132            );
133            self.in_flight.set(false);
134            finish_request(&self.request_key);
135        }
136    }
137
138    fn begin(self: &Rc<Self>) -> bool {
139        if self.in_flight.get() {
140            return false;
141        }
142        self.in_flight.set(true);
143        begin_request(&self.request_key);
144        true
145    }
146
147    fn end(self: &Rc<Self>) {
148        self.in_flight.set(false);
149        finish_request(&self.request_key);
150    }
151}
152
153struct KeyRegistration {
154    request_key: String,
155}
156
157impl KeyRegistration {
158    fn new(request_key: &str) -> Self {
159        REGISTERED_KEYS.with(|keys| {
160            let mut keys = keys.borrow_mut();
161            let count = keys.entry(request_key.to_string()).or_insert(0);
162            *count += 1;
163            debug_assert!(
164                *count == 1,
165                "launcher request key `{request_key}` is registered {count} times; \
166                 keys must be unique so a recovered result reaches the right launcher"
167            );
168        });
169        Self {
170            request_key: request_key.to_string(),
171        }
172    }
173}
174
175impl Drop for KeyRegistration {
176    fn drop(&mut self) {
177        REGISTERED_KEYS.with(|keys| {
178            let mut keys = keys.borrow_mut();
179            if let Some(count) = keys.get_mut(&self.request_key) {
180                *count -= 1;
181                if *count == 0 {
182                    keys.remove(&self.request_key);
183                }
184            }
185        });
186    }
187}
188
189struct LauncherSlot {
190    core: Rc<LauncherCore>,
191    _registration: KeyRegistration,
192}
193
194#[track_caller]
195fn remember_core(
196    request_key: &'static str,
197    deliver: impl FnOnce(RecoveredPick) + 'static,
198) -> Rc<LauncherCore> {
199    let picker = local_file_picker().current();
200    let slot = cranpose_core::remember({
201        let picker = picker.clone();
202        let request_key = request_key.to_string();
203        move || LauncherSlot {
204            core: Rc::new(LauncherCore {
205                request_key: request_key.clone(),
206                picker,
207                runtime: current_runtime_handle(),
208                in_flight: Cell::new(false),
209            }),
210            _registration: KeyRegistration::new(&request_key),
211        }
212    });
213    let core = slot.with(|slot| Rc::clone(&slot.core));
214
215    drain_recovered(&picker);
216    if let Some(recovered) = take_recovered(request_key) {
217        let core = Rc::clone(&core);
218        cranpose_core::SideEffect(move || {
219            core.end();
220            deliver(recovered);
221        });
222    }
223    core
224}
225
226/// Presents the single-file chooser and hands the picked content back.
227#[derive(Clone)]
228pub struct OpenFileLauncher {
229    core: Rc<LauncherCore>,
230    on_result: Rc<dyn Fn(LauncherResult<Option<ContentHandle>>)>,
231}
232
233impl OpenFileLauncher {
234    /// Presents the chooser. A second call while one is in front is ignored.
235    pub fn launch(&self, options: FilePickerOptions) {
236        if !self.core.begin() {
237            return;
238        }
239        let core = Rc::clone(&self.core);
240        let done = Rc::clone(&self.core);
241        let on_result = Rc::clone(&self.on_result);
242        let future = core.picker.pick_file(options);
243        core.spawn(async move {
244            let result = future.await;
245            done.end();
246            on_result(result);
247        });
248    }
249
250    /// Whether a chooser presented by this launcher is still in front.
251    pub fn is_in_flight(&self) -> bool {
252        self.core.in_flight.get()
253    }
254}
255
256/// Remembers a single-file launcher under `request_key`.
257#[allow(non_snake_case)]
258#[composable]
259#[track_caller]
260pub fn rememberOpenFileLauncher(
261    request_key: &'static str,
262    on_result: impl Fn(LauncherResult<Option<ContentHandle>>) + 'static,
263) -> OpenFileLauncher {
264    let on_result: Rc<dyn Fn(LauncherResult<Option<ContentHandle>>)> = Rc::new(on_result);
265    let core = remember_core(request_key, {
266        let on_result = Rc::clone(&on_result);
267        move |recovered| match recovered {
268            RecoveredPick::File(content) => on_result(Ok(Some(content))),
269            RecoveredPick::Files(mut files) => on_result(Ok(files.drain(..).next())),
270            _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
271        }
272    });
273    OpenFileLauncher { core, on_result }
274}
275
276/// Presents the multi-file chooser and hands every picked item back.
277#[derive(Clone)]
278pub struct OpenFilesLauncher {
279    core: Rc<LauncherCore>,
280    on_result: Rc<dyn Fn(LauncherResult<Vec<ContentHandle>>)>,
281}
282
283impl OpenFilesLauncher {
284    /// Presents the chooser. A second call while one is in front is ignored.
285    pub fn launch(&self, options: FilePickerOptions) {
286        if !self.core.begin() {
287            return;
288        }
289        let core = Rc::clone(&self.core);
290        let done = Rc::clone(&self.core);
291        let on_result = Rc::clone(&self.on_result);
292        let future = core.picker.pick_files(options);
293        core.spawn(async move {
294            let result = future.await;
295            done.end();
296            on_result(result);
297        });
298    }
299
300    /// Whether a chooser presented by this launcher is still in front.
301    pub fn is_in_flight(&self) -> bool {
302        self.core.in_flight.get()
303    }
304}
305
306/// Remembers a multi-file launcher under `request_key`.
307#[allow(non_snake_case)]
308#[composable]
309#[track_caller]
310pub fn rememberOpenFilesLauncher(
311    request_key: &'static str,
312    on_result: impl Fn(LauncherResult<Vec<ContentHandle>>) + 'static,
313) -> OpenFilesLauncher {
314    let on_result: Rc<dyn Fn(LauncherResult<Vec<ContentHandle>>)> = Rc::new(on_result);
315    let core = remember_core(request_key, {
316        let on_result = Rc::clone(&on_result);
317        move |recovered| match recovered {
318            RecoveredPick::Files(files) => on_result(Ok(files)),
319            RecoveredPick::File(content) => on_result(Ok(vec![content])),
320            _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
321        }
322    });
323    OpenFilesLauncher { core, on_result }
324}
325
326/// Presents the folder chooser and hands the granted folder back.
327#[derive(Clone)]
328pub struct OpenFolderLauncher {
329    core: Rc<LauncherCore>,
330    on_result: Rc<dyn Fn(LauncherResult<Option<ContentFolderRef>>)>,
331}
332
333impl OpenFolderLauncher {
334    /// Presents the chooser. A second call while one is in front is ignored.
335    pub fn launch(&self, options: FilePickerOptions) {
336        if !self.core.begin() {
337            return;
338        }
339        let core = Rc::clone(&self.core);
340        let done = Rc::clone(&self.core);
341        let on_result = Rc::clone(&self.on_result);
342        let future = core.picker.pick_folder(options);
343        core.spawn(async move {
344            let result = future.await;
345            done.end();
346            on_result(result);
347        });
348    }
349
350    /// Whether a chooser presented by this launcher is still in front.
351    pub fn is_in_flight(&self) -> bool {
352        self.core.in_flight.get()
353    }
354}
355
356/// Remembers a folder launcher under `request_key`.
357#[allow(non_snake_case)]
358#[composable]
359#[track_caller]
360pub fn rememberOpenFolderLauncher(
361    request_key: &'static str,
362    on_result: impl Fn(LauncherResult<Option<ContentFolderRef>>) + 'static,
363) -> OpenFolderLauncher {
364    let on_result: Rc<dyn Fn(LauncherResult<Option<ContentFolderRef>>)> = Rc::new(on_result);
365    let core = remember_core(request_key, {
366        let on_result = Rc::clone(&on_result);
367        move |recovered| match recovered {
368            RecoveredPick::Folder(folder) => on_result(Ok(Some(folder))),
369            _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
370        }
371    });
372    OpenFolderLauncher { core, on_result }
373}
374
375/// Presents the save-document chooser and hands the opened sink back.
376#[derive(Clone)]
377pub struct SaveDocumentLauncher {
378    core: Rc<LauncherCore>,
379    on_result: Rc<dyn Fn(LauncherResult<Option<ContentSinkRef>>)>,
380}
381
382impl SaveDocumentLauncher {
383    /// Presents the chooser. A second call while one is in front is ignored.
384    pub fn launch(&self, request: SaveDocumentRequest) {
385        if !self.core.begin() {
386            return;
387        }
388        let core = Rc::clone(&self.core);
389        let done = Rc::clone(&self.core);
390        let on_result = Rc::clone(&self.on_result);
391        let future = core.picker.save_document(request);
392        core.spawn(async move {
393            let result = future.await;
394            done.end();
395            on_result(result);
396        });
397    }
398
399    /// Whether a chooser presented by this launcher is still in front.
400    pub fn is_in_flight(&self) -> bool {
401        self.core.in_flight.get()
402    }
403}
404
405/// Remembers a save-document launcher under `request_key`.
406#[allow(non_snake_case)]
407#[composable]
408#[track_caller]
409pub fn rememberSaveDocumentLauncher(
410    request_key: &'static str,
411    on_result: impl Fn(LauncherResult<Option<ContentSinkRef>>) + 'static,
412) -> SaveDocumentLauncher {
413    let on_result: Rc<dyn Fn(LauncherResult<Option<ContentSinkRef>>)> = Rc::new(on_result);
414    let core = remember_core(request_key, move |_recovered| {
415        log::warn!("cranpose: `{request_key}` recovered a pick of another kind");
416    });
417    SaveDocumentLauncher { core, on_result }
418}
419
420/// Presents the persistent writable-folder chooser and hands the durable handle
421/// back. Store the handle and reopen it with
422/// [`crate::writable_folder::open_writable_folder`].
423#[derive(Clone)]
424pub struct WritableFolderLauncher {
425    core: Rc<LauncherCore>,
426    on_result: Rc<dyn Fn(LauncherResult<Option<String>>)>,
427}
428
429impl WritableFolderLauncher {
430    /// Presents the chooser. A second call while one is in front is ignored.
431    pub fn launch(&self, options: FilePickerOptions) {
432        if !self.core.begin() {
433            return;
434        }
435        let core = Rc::clone(&self.core);
436        let done = Rc::clone(&self.core);
437        let on_result = Rc::clone(&self.on_result);
438        let future = core.picker.pick_writable_folder(options);
439        core.spawn(async move {
440            let result = future.await;
441            done.end();
442            on_result(result);
443        });
444    }
445
446    /// Whether a chooser presented by this launcher is still in front.
447    pub fn is_in_flight(&self) -> bool {
448        self.core.in_flight.get()
449    }
450}
451
452/// Remembers a writable-folder launcher under `request_key`.
453#[allow(non_snake_case)]
454#[composable]
455#[track_caller]
456pub fn rememberWritableFolderLauncher(
457    request_key: &'static str,
458    on_result: impl Fn(LauncherResult<Option<String>>) + 'static,
459) -> WritableFolderLauncher {
460    let on_result: Rc<dyn Fn(LauncherResult<Option<String>>)> = Rc::new(on_result);
461    let core = remember_core(request_key, {
462        let on_result = Rc::clone(&on_result);
463        move |recovered| match recovered {
464            RecoveredPick::WritableFolder(handle) => on_result(Ok(Some(handle))),
465            _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
466        }
467    });
468    WritableFolderLauncher { core, on_result }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn an_in_flight_request_outlives_the_process_that_started_it() {
477        crate::preferences::set_platform_preferences(std::sync::Arc::new(
478            crate::preferences::MemoryPreferences::new(),
479        ));
480
481        begin_request("test.pick");
482        assert_eq!(in_flight_request().as_deref(), Some("test.pick"));
483
484        IN_FLIGHT.with(|slot| *slot.borrow_mut() = None);
485        assert_eq!(
486            in_flight_request().as_deref(),
487            Some("test.pick"),
488            "a restarted process must still know which request was outstanding"
489        );
490
491        finish_request("test.other");
492        assert_eq!(
493            in_flight_request().as_deref(),
494            Some("test.pick"),
495            "one launcher resolving must not clear a different launcher's record"
496        );
497
498        finish_request("test.pick");
499        assert_eq!(
500            in_flight_request(),
501            None,
502            "a resolved request is not still in flight"
503        );
504
505        crate::preferences::clear_platform_preferences();
506    }
507}