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#[doc(hidden)]
139pub enum RecoveredPick {
140    File(ContentHandle),
141    Files(Vec<ContentHandle>),
142    Folder(ContentFolderRef),
143    WritableFolder(String),
144}
145
146/// Presents the system's file, folder and document choosers.
147///
148/// Implemented by the platform backends and consumed by [`crate::launcher`].
149pub trait FilePicker {
150    /// Presents a single-file chooser. Resolves to `None` if cancelled.
151    fn pick_file(
152        &self,
153        options: FilePickerOptions,
154    ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>>;
155
156    /// Presents a multi-file chooser. Resolves to an empty vector if cancelled.
157    ///
158    /// The default presents the single-file chooser, for backends whose system
159    /// chooser has no multi-selection mode.
160    fn pick_files(
161        &self,
162        options: FilePickerOptions,
163    ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
164        let single = self.pick_file(options);
165        Box::pin(async move { Ok(single.await?.into_iter().collect()) })
166    }
167
168    /// Presents a folder chooser. Resolves to `None` if cancelled.
169    fn pick_folder(
170        &self,
171        options: FilePickerOptions,
172    ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>>;
173
174    /// Presents a save-destination chooser and opens a sink on the chosen
175    /// document. Resolves to `None` if cancelled.
176    fn save_document(
177        &self,
178        request: SaveDocumentRequest,
179    ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
180        let _ = request;
181        Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
182    }
183
184    /// Presents a chooser for a folder the app may keep writing to across runs,
185    /// resolving to the durable handle accepted by
186    /// [`crate::writable_folder::open_writable_folder`].
187    fn pick_writable_folder(
188        &self,
189        options: FilePickerOptions,
190    ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
191        let _ = options;
192        Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
193    }
194
195    #[doc(hidden)]
196    fn take_recovered_pick(&self) -> Option<RecoveredPick> {
197        None
198    }
199}
200
201/// Shared handle to a [`FilePicker`].
202pub type FilePickerRef = Rc<dyn FilePicker>;
203
204thread_local! {
205    static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
206}
207
208/// Registers the platform-provided chooser (Android SAF / iOS UIDocumentPicker).
209///
210/// The cranpose crate's Android and iOS backends call this during startup, when
211/// they have access to the Activity / root view controller. Once registered it
212/// takes precedence over the built-in desktop/web choosers.
213pub fn set_platform_file_picker(picker: FilePickerRef) {
214    PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
215}
216
217/// Removes any registered platform chooser (used in tests and teardown).
218pub fn clear_platform_file_picker() {
219    PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
220}
221
222fn registered_platform_file_picker() -> Option<FilePickerRef> {
223    PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
224}
225
226struct PlatformFilePicker;
227
228impl FilePicker for PlatformFilePicker {
229    fn pick_file(
230        &self,
231        options: FilePickerOptions,
232    ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
233        match registered_platform_file_picker() {
234            Some(picker) => picker.pick_file(options),
235            None => builtin::pick_file(options),
236        }
237    }
238
239    fn pick_files(
240        &self,
241        options: FilePickerOptions,
242    ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
243        match registered_platform_file_picker() {
244            Some(picker) => picker.pick_files(options),
245            None => builtin::pick_files(options),
246        }
247    }
248
249    fn pick_folder(
250        &self,
251        options: FilePickerOptions,
252    ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
253        match registered_platform_file_picker() {
254            Some(picker) => picker.pick_folder(options),
255            None => builtin::pick_folder(options),
256        }
257    }
258
259    fn save_document(
260        &self,
261        request: SaveDocumentRequest,
262    ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
263        match registered_platform_file_picker() {
264            Some(picker) => picker.save_document(request),
265            None => builtin::save_document(request),
266        }
267    }
268
269    fn pick_writable_folder(
270        &self,
271        options: FilePickerOptions,
272    ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
273        match registered_platform_file_picker() {
274            Some(picker) => picker.pick_writable_folder(options),
275            None => builtin::pick_writable_folder(options),
276        }
277    }
278
279    fn take_recovered_pick(&self) -> Option<RecoveredPick> {
280        registered_platform_file_picker().and_then(|picker| picker.take_recovered_pick())
281    }
282}
283
284/// The default chooser (the platform backend).
285pub fn default_file_picker() -> FilePickerRef {
286    Rc::new(PlatformFilePicker)
287}
288
289/// The [`CompositionLocal`] carrying the active [`FilePicker`].
290pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
291    thread_local! {
292        static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
293    }
294
295    LOCAL_FILE_PICKER.with(|cell| {
296        cell.borrow_mut()
297            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
298            .clone()
299    })
300}
301
302/// Provides the default [`FilePicker`] to descendant composables.
303#[allow(non_snake_case)]
304#[composable]
305pub fn ProvideFilePicker(content: impl FnOnce()) {
306    let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
307    let picker_local = local_file_picker();
308
309    CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
310        content();
311    });
312}
313
314mod builtin {
315
316    #[cfg(all(
317        not(target_arch = "wasm32"),
318        not(target_os = "android"),
319        not(target_os = "ios"),
320        feature = "file-picker-native"
321    ))]
322    pub(super) use super::desktop::{
323        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
324    };
325    #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
326    pub(super) use super::web::{
327        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
328    };
329
330    #[cfg(not(any(
331        all(
332            not(target_arch = "wasm32"),
333            not(target_os = "android"),
334            not(target_os = "ios"),
335            feature = "file-picker-native"
336        ),
337        all(target_arch = "wasm32", feature = "file-picker-web")
338    )))]
339    mod unsupported {
340        use crate::{
341            content::{ContentFolderRef, ContentHandle, ContentSinkRef},
342            file_picker::{FilePickerError, FilePickerOptions, PickerFuture, SaveDocumentRequest},
343        };
344
345        pub(in crate::file_picker) fn pick_file(
346            _options: FilePickerOptions,
347        ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
348            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
349        }
350
351        pub(in crate::file_picker) fn pick_files(
352            _options: FilePickerOptions,
353        ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
354            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
355        }
356
357        pub(in crate::file_picker) fn pick_folder(
358            _options: FilePickerOptions,
359        ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
360            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
361        }
362
363        pub(in crate::file_picker) fn save_document(
364            _request: SaveDocumentRequest,
365        ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
366            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
367        }
368
369        pub(in crate::file_picker) fn pick_writable_folder(
370            _options: FilePickerOptions,
371        ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
372            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
373        }
374    }
375
376    #[cfg(not(any(
377        all(
378            not(target_arch = "wasm32"),
379            not(target_os = "android"),
380            not(target_os = "ios"),
381            feature = "file-picker-native"
382        ),
383        all(target_arch = "wasm32", feature = "file-picker-web")
384    )))]
385    pub(super) use unsupported::{
386        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
387    };
388}
389
390#[cfg(all(
391    not(target_arch = "wasm32"),
392    not(target_os = "android"),
393    not(target_os = "ios"),
394    feature = "file-picker-native"
395))]
396mod desktop;
397
398#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
399mod web;
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn options_builder_sets_title_and_filters() {
407        let options = FilePickerOptions::default()
408            .with_title("Pick audio")
409            .with_filter(FileFilter::new("Audio", &["mp3", "flac"]).with_mime_types(&["audio/*"]));
410        assert_eq!(options.title.as_deref(), Some("Pick audio"));
411        assert_eq!(options.filters.len(), 1);
412        assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
413        assert_eq!(options.mime_types(), vec!["audio/*"]);
414    }
415
416    #[test]
417    fn default_picker_is_created() {
418        let picker = default_file_picker();
419        assert_eq!(Rc::strong_count(&picker), 1);
420    }
421
422    struct Marker;
423
424    impl FilePicker for Marker {
425        fn pick_file(
426            &self,
427            _options: FilePickerOptions,
428        ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
429            Box::pin(async {
430                Ok(Some(
431                    crate::content::BytesContent::named("marker.txt", b"marker".to_vec()).handle(),
432                ))
433            })
434        }
435
436        fn pick_folder(
437            &self,
438            _options: FilePickerOptions,
439        ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
440            Box::pin(async { Ok(None) })
441        }
442    }
443
444    #[test]
445    fn registered_platform_picker_takes_precedence() {
446        clear_platform_file_picker();
447        assert!(registered_platform_file_picker().is_none());
448        set_platform_file_picker(Rc::new(Marker));
449        assert!(registered_platform_file_picker().is_some());
450
451        let picked =
452            pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()))
453                .expect("the marker picker resolves")
454                .expect("the marker picker picks a file");
455        assert_eq!(picked.metadata().name, "marker.txt");
456        clear_platform_file_picker();
457    }
458
459    #[test]
460    fn multi_selection_falls_back_to_the_single_chooser() {
461        clear_platform_file_picker();
462        set_platform_file_picker(Rc::new(Marker));
463        let picked =
464            pollster::block_on(default_file_picker().pick_files(FilePickerOptions::default()))
465                .expect("the marker picker resolves");
466        assert_eq!(picked.len(), 1);
467        clear_platform_file_picker();
468    }
469
470    #[test]
471    fn unsupported_operations_report_the_platform_gap() {
472        clear_platform_file_picker();
473        set_platform_file_picker(Rc::new(Marker));
474        let saved = pollster::block_on(
475            default_file_picker().save_document(SaveDocumentRequest::new("a.txt", "text/plain")),
476        );
477        let Err(error) = saved else {
478            panic!("the marker picker offers no save destination");
479        };
480        assert_eq!(error, FilePickerError::UnsupportedPlatform);
481        clear_platform_file_picker();
482    }
483}