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