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