Skip to main content

cranpose_services/
file_picker.rs

1//! Native, cross-platform file, folder and document choosers.
2//!
3//! Every chooser resolves to the streaming content model: a file becomes a
4//! [`ContentHandle`], a folder becomes a [`ContentFolderRef`], and a save
5//! destination becomes a [`ContentSinkRef`]. None of them is a filesystem path.
6//! That is deliberate — on Android (Storage Access Framework `content://`
7//! trees), iOS (`UIDocumentPicker` security-scoped URLs) and the web (File
8//! System Access handles) the user can choose locations served by *system*
9//! document providers, such as a mounted WebDAV share or cloud storage, which
10//! have no local path.
11//!
12//! Applications do not call this trait. They compose the launchers in
13//! [`crate::launcher`], which own the request across host recreation and hand
14//! the result back through a callback.
15
16use crate::content::{ContentError, ContentFolderRef, ContentHandle, ContentSinkRef};
17use cranpose_core::compositionLocalOfWithPolicy;
18use cranpose_core::CompositionLocal;
19use cranpose_core::CompositionLocalProvider;
20use cranpose_macros::composable;
21use std::cell::RefCell;
22use std::future::Future;
23use std::pin::Pin;
24use std::rc::Rc;
25
26/// Errors produced while presenting a chooser.
27#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
28pub enum FilePickerError {
29    /// Presenting the chooser failed.
30    #[error("file picker failed: {0}")]
31    Failed(String),
32    /// Reading or writing the chosen content failed.
33    #[error(transparent)]
34    Content(#[from] ContentError),
35    /// The chooser requires a cranpose-services feature that is not enabled.
36    #[error("{operation} requires cranpose-services feature `{feature}`")]
37    UnsupportedFeature {
38        /// The attempted operation.
39        operation: &'static str,
40        /// The feature that enables it.
41        feature: &'static str,
42    },
43    /// No chooser is available on this platform/build.
44    #[error("file picking is not available on this platform")]
45    UnsupportedPlatform,
46}
47
48/// A `'static` future returned by chooser operations, polled on the UI thread.
49pub type PickerFuture<T> = Pin<Box<dyn Future<Output = T>>>;
50
51/// A named filter limiting the file types a chooser offers.
52#[derive(Clone, Debug, Default, PartialEq, Eq)]
53pub struct FileFilter {
54    /// Human-readable group name, for example `"Audio"`.
55    pub label: String,
56    /// Accepted extensions without the leading dot, for example `["mp3", "flac"]`.
57    pub extensions: Vec<String>,
58    /// Accepted MIME types. Android's Storage Access Framework and the web
59    /// filter by MIME rather than extension; backends that only understand
60    /// extensions ignore this.
61    pub mime_types: Vec<String>,
62}
63
64impl FileFilter {
65    /// Creates a filter from a label and a set of extensions.
66    pub fn new(label: impl Into<String>, extensions: &[&str]) -> Self {
67        Self {
68            label: label.into(),
69            extensions: extensions.iter().map(|ext| (*ext).to_string()).collect(),
70            mime_types: Vec::new(),
71        }
72    }
73
74    /// Adds the MIME types this filter accepts.
75    pub fn with_mime_types(mut self, mime_types: &[&str]) -> Self {
76        self.mime_types = mime_types.iter().map(|mime| (*mime).to_string()).collect();
77        self
78    }
79}
80
81/// Options controlling a chooser request.
82#[derive(Clone, Debug, Default, PartialEq, Eq)]
83pub struct FilePickerOptions {
84    /// Dialog title.
85    pub title: Option<String>,
86    /// File-type filters (ignored by folder choosers and some platforms).
87    pub filters: Vec<FileFilter>,
88}
89
90impl FilePickerOptions {
91    /// Sets the dialog title.
92    pub fn with_title(mut self, title: impl Into<String>) -> Self {
93        self.title = Some(title.into());
94        self
95    }
96
97    /// Adds a file-type filter.
98    pub fn with_filter(mut self, filter: FileFilter) -> Self {
99        self.filters.push(filter);
100        self
101    }
102
103    /// Every MIME type across the filters, for backends that filter by MIME.
104    pub fn mime_types(&self) -> Vec<String> {
105        self.filters
106            .iter()
107            .flat_map(|filter| filter.mime_types.iter().cloned())
108            .collect()
109    }
110}
111
112/// A request for a user-named destination to stream a document into.
113#[derive(Clone, Debug, Default, PartialEq, Eq)]
114pub struct SaveDocumentRequest {
115    /// Suggested file name (including extension).
116    pub file_name: String,
117    /// MIME type, used by backends that need one (Android's
118    /// `ACTION_CREATE_DOCUMENT`, the web download).
119    pub mime_type: String,
120    /// Dialog title.
121    pub title: Option<String>,
122}
123
124impl SaveDocumentRequest {
125    /// Creates a request for `file_name` of `mime_type`.
126    pub fn new(file_name: impl Into<String>, mime_type: impl Into<String>) -> Self {
127        Self {
128            file_name: file_name.into(),
129            mime_type: mime_type.into(),
130            title: None,
131        }
132    }
133
134    /// Sets the dialog title.
135    pub fn with_title(mut self, title: impl Into<String>) -> Self {
136        self.title = Some(title.into());
137        self
138    }
139}
140
141/// A chooser result the host recovered after the composition that requested it
142/// was destroyed.
143///
144/// Android can destroy and recreate the activity — and with it the native app —
145/// while the system chooser is in front. The platform backend records the
146/// granted selection and the framework's launchers redeliver it. This type is
147/// the framework's own transport; applications never construct or drain it.
148#[doc(hidden)]
149pub enum RecoveredPick {
150    /// A single recovered file.
151    File(ContentHandle),
152    /// Recovered files from a multi-selection.
153    Files(Vec<ContentHandle>),
154    /// A recovered folder grant.
155    Folder(ContentFolderRef),
156    /// A recovered persistent writable-folder grant, as its durable handle.
157    WritableFolder(String),
158}
159
160/// Presents the system's file, folder and document choosers.
161///
162/// Implemented by the platform backends and consumed by [`crate::launcher`].
163pub trait FilePicker {
164    /// Presents a single-file chooser. Resolves to `None` if cancelled.
165    fn pick_file(
166        &self,
167        options: FilePickerOptions,
168    ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>>;
169
170    /// Presents a multi-file chooser. Resolves to an empty vector if cancelled.
171    ///
172    /// The default presents the single-file chooser, for backends whose system
173    /// chooser has no multi-selection mode.
174    fn pick_files(
175        &self,
176        options: FilePickerOptions,
177    ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
178        let single = self.pick_file(options);
179        Box::pin(async move { Ok(single.await?.into_iter().collect()) })
180    }
181
182    /// Presents a folder chooser. Resolves to `None` if cancelled.
183    fn pick_folder(
184        &self,
185        options: FilePickerOptions,
186    ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>>;
187
188    /// Presents a save-destination chooser and opens a sink on the chosen
189    /// document. Resolves to `None` if cancelled.
190    fn save_document(
191        &self,
192        request: SaveDocumentRequest,
193    ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
194        let _ = request;
195        Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
196    }
197
198    /// Presents a chooser for a folder the app may keep writing to across runs,
199    /// resolving to the durable handle accepted by
200    /// [`crate::writable_folder::open_writable_folder`].
201    fn pick_writable_folder(
202        &self,
203        options: FilePickerOptions,
204    ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
205        let _ = options;
206        Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
207    }
208
209    /// Hands back a selection the host recovered after the requesting
210    /// composition was destroyed. Framework-internal; the launchers call it.
211    /// Backends that never lose a result keep the default.
212    #[doc(hidden)]
213    fn take_recovered_pick(&self) -> Option<RecoveredPick> {
214        None
215    }
216}
217
218/// Shared handle to a [`FilePicker`].
219pub type FilePickerRef = Rc<dyn FilePicker>;
220
221thread_local! {
222    static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
223}
224
225/// Registers the platform-provided chooser (Android SAF / iOS UIDocumentPicker).
226///
227/// The cranpose crate's Android and iOS backends call this during startup, when
228/// they have access to the Activity / root view controller. Once registered it
229/// takes precedence over the built-in desktop/web choosers.
230pub fn set_platform_file_picker(picker: FilePickerRef) {
231    PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
232}
233
234/// Removes any registered platform chooser (used in tests and teardown).
235pub fn clear_platform_file_picker() {
236    PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
237}
238
239fn registered_platform_file_picker() -> Option<FilePickerRef> {
240    PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
241}
242
243/// The chooser installed by [`ProvideFilePicker`]: a registered platform
244/// chooser if present, otherwise the built-in backend for this target.
245struct PlatformFilePicker;
246
247impl FilePicker for PlatformFilePicker {
248    fn pick_file(
249        &self,
250        options: FilePickerOptions,
251    ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
252        match registered_platform_file_picker() {
253            Some(picker) => picker.pick_file(options),
254            None => builtin::pick_file(options),
255        }
256    }
257
258    fn pick_files(
259        &self,
260        options: FilePickerOptions,
261    ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
262        match registered_platform_file_picker() {
263            Some(picker) => picker.pick_files(options),
264            None => builtin::pick_files(options),
265        }
266    }
267
268    fn pick_folder(
269        &self,
270        options: FilePickerOptions,
271    ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
272        match registered_platform_file_picker() {
273            Some(picker) => picker.pick_folder(options),
274            None => builtin::pick_folder(options),
275        }
276    }
277
278    fn save_document(
279        &self,
280        request: SaveDocumentRequest,
281    ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
282        match registered_platform_file_picker() {
283            Some(picker) => picker.save_document(request),
284            None => builtin::save_document(request),
285        }
286    }
287
288    fn pick_writable_folder(
289        &self,
290        options: FilePickerOptions,
291    ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
292        match registered_platform_file_picker() {
293            Some(picker) => picker.pick_writable_folder(options),
294            None => builtin::pick_writable_folder(options),
295        }
296    }
297
298    fn take_recovered_pick(&self) -> Option<RecoveredPick> {
299        registered_platform_file_picker().and_then(|picker| picker.take_recovered_pick())
300    }
301}
302
303/// The default chooser (the platform backend).
304pub fn default_file_picker() -> FilePickerRef {
305    Rc::new(PlatformFilePicker)
306}
307
308/// The [`CompositionLocal`] carrying the active [`FilePicker`].
309pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
310    thread_local! {
311        static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
312    }
313
314    LOCAL_FILE_PICKER.with(|cell| {
315        cell.borrow_mut()
316            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
317            .clone()
318    })
319}
320
321/// Provides the default [`FilePicker`] to descendant composables.
322#[allow(non_snake_case)]
323#[composable]
324pub fn ProvideFilePicker(content: impl FnOnce()) {
325    let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
326    let picker_local = local_file_picker();
327
328    CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
329        content();
330    });
331}
332
333mod builtin {
334
335    #[cfg(all(
336        not(target_arch = "wasm32"),
337        not(target_os = "android"),
338        not(target_os = "ios"),
339        feature = "file-picker-native"
340    ))]
341    pub(super) use super::desktop::{
342        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
343    };
344
345    #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
346    pub(super) use super::web::{
347        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
348    };
349
350    #[cfg(not(any(
351        all(
352            not(target_arch = "wasm32"),
353            not(target_os = "android"),
354            not(target_os = "ios"),
355            feature = "file-picker-native"
356        ),
357        all(target_arch = "wasm32", feature = "file-picker-web")
358    )))]
359    mod unsupported {
360        use crate::content::{ContentFolderRef, ContentHandle, ContentSinkRef};
361        use crate::file_picker::{
362            FilePickerError, FilePickerOptions, PickerFuture, SaveDocumentRequest,
363        };
364
365        pub(in crate::file_picker) fn pick_file(
366            _options: FilePickerOptions,
367        ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
368            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
369        }
370
371        pub(in crate::file_picker) fn pick_files(
372            _options: FilePickerOptions,
373        ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
374            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
375        }
376
377        pub(in crate::file_picker) fn pick_folder(
378            _options: FilePickerOptions,
379        ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
380            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
381        }
382
383        pub(in crate::file_picker) fn save_document(
384            _request: SaveDocumentRequest,
385        ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
386            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
387        }
388
389        pub(in crate::file_picker) fn pick_writable_folder(
390            _options: FilePickerOptions,
391        ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
392            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
393        }
394    }
395
396    #[cfg(not(any(
397        all(
398            not(target_arch = "wasm32"),
399            not(target_os = "android"),
400            not(target_os = "ios"),
401            feature = "file-picker-native"
402        ),
403        all(target_arch = "wasm32", feature = "file-picker-web")
404    )))]
405    pub(super) use unsupported::{
406        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
407    };
408}
409
410#[cfg(all(
411    not(target_arch = "wasm32"),
412    not(target_os = "android"),
413    not(target_os = "ios"),
414    feature = "file-picker-native"
415))]
416mod desktop;
417
418#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
419mod web;
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn options_builder_sets_title_and_filters() {
427        let options = FilePickerOptions::default()
428            .with_title("Pick audio")
429            .with_filter(FileFilter::new("Audio", &["mp3", "flac"]).with_mime_types(&["audio/*"]));
430        assert_eq!(options.title.as_deref(), Some("Pick audio"));
431        assert_eq!(options.filters.len(), 1);
432        assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
433        assert_eq!(options.mime_types(), vec!["audio/*"]);
434    }
435
436    #[test]
437    fn default_picker_is_created() {
438        let picker = default_file_picker();
439        assert_eq!(Rc::strong_count(&picker), 1);
440    }
441
442    struct Marker;
443
444    impl FilePicker for Marker {
445        fn pick_file(
446            &self,
447            _options: FilePickerOptions,
448        ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
449            Box::pin(async {
450                Ok(Some(
451                    crate::content::BytesContent::named("marker.txt", b"marker".to_vec()).handle(),
452                ))
453            })
454        }
455
456        fn pick_folder(
457            &self,
458            _options: FilePickerOptions,
459        ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
460            Box::pin(async { Ok(None) })
461        }
462    }
463
464    #[test]
465    fn registered_platform_picker_takes_precedence() {
466        clear_platform_file_picker();
467        assert!(registered_platform_file_picker().is_none());
468        set_platform_file_picker(Rc::new(Marker));
469        assert!(registered_platform_file_picker().is_some());
470
471        let picked =
472            pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()))
473                .expect("the marker picker resolves")
474                .expect("the marker picker picks a file");
475        assert_eq!(picked.metadata().name, "marker.txt");
476        clear_platform_file_picker();
477    }
478
479    #[test]
480    fn multi_selection_falls_back_to_the_single_chooser() {
481        clear_platform_file_picker();
482        set_platform_file_picker(Rc::new(Marker));
483        let picked =
484            pollster::block_on(default_file_picker().pick_files(FilePickerOptions::default()))
485                .expect("the marker picker resolves");
486        assert_eq!(picked.len(), 1);
487        clear_platform_file_picker();
488    }
489
490    #[test]
491    fn unsupported_operations_report_the_platform_gap() {
492        clear_platform_file_picker();
493        set_platform_file_picker(Rc::new(Marker));
494        let saved = pollster::block_on(
495            default_file_picker().save_document(SaveDocumentRequest::new("a.txt", "text/plain")),
496        );
497        let Err(error) = saved else {
498            panic!("the marker picker offers no save destination");
499        };
500        assert_eq!(error, FilePickerError::UnsupportedPlatform);
501        clear_platform_file_picker();
502    }
503}