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 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
204/// Registers the platform-provided chooser (Android SAF / iOS UIDocumentPicker).
205///
206/// The cranpose crate's Android and iOS backends call this during startup, when
207/// they have access to the Activity / root view controller. Once registered it
208/// takes precedence over the built-in desktop/web choosers.
209pub fn set_platform_file_picker(picker: FilePickerRef) {
210    crate::thread_state::with(|state| *state.file_picker.borrow_mut() = Some(picker));
211}
212
213/// Removes any registered platform chooser (used in tests and teardown).
214pub fn clear_platform_file_picker() {
215    crate::thread_state::with(|state| *state.file_picker.borrow_mut() = None);
216}
217
218fn registered_platform_file_picker() -> Option<FilePickerRef> {
219    crate::thread_state::with(|state| state.file_picker.borrow().clone())
220}
221
222struct PlatformFilePicker;
223
224impl FilePicker for PlatformFilePicker {
225    fn pick_file(
226        &self,
227        options: FilePickerOptions,
228    ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
229        match registered_platform_file_picker() {
230            Some(picker) => picker.pick_file(options),
231            None => builtin::pick_file(options),
232        }
233    }
234
235    fn pick_files(
236        &self,
237        options: FilePickerOptions,
238    ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
239        match registered_platform_file_picker() {
240            Some(picker) => picker.pick_files(options),
241            None => builtin::pick_files(options),
242        }
243    }
244
245    fn pick_folder(
246        &self,
247        options: FilePickerOptions,
248    ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
249        match registered_platform_file_picker() {
250            Some(picker) => picker.pick_folder(options),
251            None => builtin::pick_folder(options),
252        }
253    }
254
255    fn save_document(
256        &self,
257        request: SaveDocumentRequest,
258    ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
259        match registered_platform_file_picker() {
260            Some(picker) => picker.save_document(request),
261            None => builtin::save_document(request),
262        }
263    }
264
265    fn pick_writable_folder(
266        &self,
267        options: FilePickerOptions,
268    ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
269        match registered_platform_file_picker() {
270            Some(picker) => picker.pick_writable_folder(options),
271            None => builtin::pick_writable_folder(options),
272        }
273    }
274
275    fn take_recovered_pick(&self) -> Option<RecoveredPick> {
276        registered_platform_file_picker().and_then(|picker| picker.take_recovered_pick())
277    }
278}
279
280/// The default chooser (the platform backend).
281pub fn default_file_picker() -> FilePickerRef {
282    Rc::new(PlatformFilePicker)
283}
284
285/// The [`CompositionLocal`] carrying the active [`FilePicker`].
286pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
287    crate::composition_locals::cached_local(
288        |locals| &locals.file_picker,
289        || compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq),
290    )
291}
292
293/// Provides the default [`FilePicker`] to descendant composables.
294#[composable]
295pub fn ProvideFilePicker(content: impl FnOnce()) {
296    let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
297    let picker_local = local_file_picker();
298
299    CompositionLocalProvider([picker_local.provides(picker)], move || {
300        content();
301    });
302}
303
304mod builtin {
305
306    #[cfg(all(
307        not(target_arch = "wasm32"),
308        not(target_os = "android"),
309        not(target_os = "ios"),
310        feature = "file-picker-native"
311    ))]
312    pub(super) use super::desktop::{
313        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
314    };
315    #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
316    pub(super) use super::web::{
317        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
318    };
319
320    #[cfg(not(any(
321        all(
322            not(target_arch = "wasm32"),
323            not(target_os = "android"),
324            not(target_os = "ios"),
325            feature = "file-picker-native"
326        ),
327        all(target_arch = "wasm32", feature = "file-picker-web")
328    )))]
329    mod unsupported {
330        use crate::{
331            content::{ContentFolderRef, ContentHandle, ContentSinkRef},
332            file_picker::{FilePickerError, FilePickerOptions, PickerFuture, SaveDocumentRequest},
333        };
334
335        pub(in crate::file_picker) fn pick_file(
336            _options: FilePickerOptions,
337        ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
338            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
339        }
340
341        pub(in crate::file_picker) fn pick_files(
342            _options: FilePickerOptions,
343        ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
344            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
345        }
346
347        pub(in crate::file_picker) fn pick_folder(
348            _options: FilePickerOptions,
349        ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
350            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
351        }
352
353        pub(in crate::file_picker) fn save_document(
354            _request: SaveDocumentRequest,
355        ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
356            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
357        }
358
359        pub(in crate::file_picker) fn pick_writable_folder(
360            _options: FilePickerOptions,
361        ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
362            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
363        }
364    }
365
366    #[cfg(not(any(
367        all(
368            not(target_arch = "wasm32"),
369            not(target_os = "android"),
370            not(target_os = "ios"),
371            feature = "file-picker-native"
372        ),
373        all(target_arch = "wasm32", feature = "file-picker-web")
374    )))]
375    pub(super) use unsupported::{
376        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
377    };
378}
379
380#[cfg(all(
381    not(target_arch = "wasm32"),
382    not(target_os = "android"),
383    not(target_os = "ios"),
384    feature = "file-picker-native"
385))]
386mod desktop;
387
388#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
389mod web;
390
391#[cfg(test)]
392#[path = "tests/file_picker_tests.rs"]
393mod tests;