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::{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 save-destination chooser for the local file at `path` and
185    /// puts a copy of it there. Resolves to `false` if cancelled.
186    ///
187    /// The default streams the file into [`save_document`](Self::save_document)
188    /// with the reads and writes off the UI thread; a backend whose chooser
189    /// takes the file itself overrides it and skips that copy.
190    fn export_file(
191        &self,
192        path: std::path::PathBuf,
193        request: SaveDocumentRequest,
194    ) -> PickerFuture<Result<bool, FilePickerError>> {
195        #[cfg(not(target_arch = "wasm32"))]
196        {
197            let saving = self.save_document(request);
198            Box::pin(async move {
199                let Some(sink) = saving.await? else {
200                    return Ok(false);
201                };
202                let file = crate::content::file_content(path);
203                crate::content::copy_content(&*file, &sink, |_| {}).await?;
204                Ok(true)
205            })
206        }
207        #[cfg(target_arch = "wasm32")]
208        {
209            let _ = (path, request);
210            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
211        }
212    }
213
214    /// Presents a chooser for a folder the app may keep writing to across runs,
215    /// resolving to the durable handle accepted by
216    /// [`crate::writable_folder::open_writable_folder`].
217    fn pick_writable_folder(
218        &self,
219        options: FilePickerOptions,
220    ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
221        let _ = options;
222        Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
223    }
224
225    #[doc(hidden)]
226    fn take_recovered_pick(&self) -> Option<RecoveredPick> {
227        None
228    }
229}
230
231/// Shared handle to a [`FilePicker`].
232pub type FilePickerRef = Rc<dyn FilePicker>;
233
234/// Registers the platform-provided chooser (Android SAF / iOS UIDocumentPicker).
235///
236/// The cranpose crate's Android and iOS backends call this during startup, when
237/// they have access to the Activity / root view controller. Once registered it
238/// takes precedence over the built-in desktop/web choosers.
239pub fn set_platform_file_picker(picker: FilePickerRef) {
240    crate::thread_state::with(|state| *state.file_picker.borrow_mut() = Some(picker));
241}
242
243/// Removes any registered platform chooser (used in tests and teardown).
244pub fn clear_platform_file_picker() {
245    crate::thread_state::with(|state| *state.file_picker.borrow_mut() = None);
246}
247
248fn registered_platform_file_picker() -> Option<FilePickerRef> {
249    crate::thread_state::with(|state| state.file_picker.borrow().clone())
250}
251
252struct PlatformFilePicker;
253
254impl FilePicker for PlatformFilePicker {
255    fn pick_file(
256        &self,
257        options: FilePickerOptions,
258    ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
259        match registered_platform_file_picker() {
260            Some(picker) => picker.pick_file(options),
261            None => builtin::pick_file(options),
262        }
263    }
264
265    fn pick_files(
266        &self,
267        options: FilePickerOptions,
268    ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
269        match registered_platform_file_picker() {
270            Some(picker) => picker.pick_files(options),
271            None => builtin::pick_files(options),
272        }
273    }
274
275    fn pick_folder(
276        &self,
277        options: FilePickerOptions,
278    ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
279        match registered_platform_file_picker() {
280            Some(picker) => picker.pick_folder(options),
281            None => builtin::pick_folder(options),
282        }
283    }
284
285    fn save_document(
286        &self,
287        request: SaveDocumentRequest,
288    ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
289        match registered_platform_file_picker() {
290            Some(picker) => picker.save_document(request),
291            None => builtin::save_document(request),
292        }
293    }
294
295    fn pick_writable_folder(
296        &self,
297        options: FilePickerOptions,
298    ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
299        match registered_platform_file_picker() {
300            Some(picker) => picker.pick_writable_folder(options),
301            None => builtin::pick_writable_folder(options),
302        }
303    }
304
305    fn take_recovered_pick(&self) -> Option<RecoveredPick> {
306        registered_platform_file_picker().and_then(|picker| picker.take_recovered_pick())
307    }
308}
309
310/// The default chooser (the platform backend).
311pub fn default_file_picker() -> FilePickerRef {
312    Rc::new(PlatformFilePicker)
313}
314
315/// The [`CompositionLocal`] carrying the active [`FilePicker`].
316pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
317    crate::composition_locals::cached_local(
318        |locals| &locals.file_picker,
319        || compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq),
320    )
321}
322
323/// Provides the default [`FilePicker`] to descendant composables.
324#[composable]
325pub fn ProvideFilePicker(content: impl FnOnce()) {
326    let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
327    let picker_local = local_file_picker();
328
329    CompositionLocalProvider([picker_local.provides(picker)], move || {
330        content();
331    });
332}
333
334mod builtin {
335
336    #[cfg(all(
337        not(target_arch = "wasm32"),
338        not(target_os = "android"),
339        not(target_os = "ios"),
340        feature = "file-picker-native"
341    ))]
342    pub(super) use super::desktop::{
343        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
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::{
361            content::{ContentFolderRef, ContentHandle, ContentSinkRef},
362            file_picker::{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)]
422#[path = "tests/file_picker_tests.rs"]
423mod tests;