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#[composable]
304pub fn ProvideFilePicker(content: impl FnOnce()) {
305    let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
306    let picker_local = local_file_picker();
307
308    CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
309        content();
310    });
311}
312
313mod builtin {
314
315    #[cfg(all(
316        not(target_arch = "wasm32"),
317        not(target_os = "android"),
318        not(target_os = "ios"),
319        feature = "file-picker-native"
320    ))]
321    pub(super) use super::desktop::{
322        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
323    };
324    #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
325    pub(super) use super::web::{
326        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
327    };
328
329    #[cfg(not(any(
330        all(
331            not(target_arch = "wasm32"),
332            not(target_os = "android"),
333            not(target_os = "ios"),
334            feature = "file-picker-native"
335        ),
336        all(target_arch = "wasm32", feature = "file-picker-web")
337    )))]
338    mod unsupported {
339        use crate::{
340            content::{ContentFolderRef, ContentHandle, ContentSinkRef},
341            file_picker::{FilePickerError, FilePickerOptions, PickerFuture, SaveDocumentRequest},
342        };
343
344        pub(in crate::file_picker) fn pick_file(
345            _options: FilePickerOptions,
346        ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
347            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
348        }
349
350        pub(in crate::file_picker) fn pick_files(
351            _options: FilePickerOptions,
352        ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
353            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
354        }
355
356        pub(in crate::file_picker) fn pick_folder(
357            _options: FilePickerOptions,
358        ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
359            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
360        }
361
362        pub(in crate::file_picker) fn save_document(
363            _request: SaveDocumentRequest,
364        ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
365            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
366        }
367
368        pub(in crate::file_picker) fn pick_writable_folder(
369            _options: FilePickerOptions,
370        ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
371            Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
372        }
373    }
374
375    #[cfg(not(any(
376        all(
377            not(target_arch = "wasm32"),
378            not(target_os = "android"),
379            not(target_os = "ios"),
380            feature = "file-picker-native"
381        ),
382        all(target_arch = "wasm32", feature = "file-picker-web")
383    )))]
384    pub(super) use unsupported::{
385        pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
386    };
387}
388
389#[cfg(all(
390    not(target_arch = "wasm32"),
391    not(target_os = "android"),
392    not(target_os = "ios"),
393    feature = "file-picker-native"
394))]
395mod desktop;
396
397#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
398mod web;
399
400#[cfg(test)]
401#[path = "tests/file_picker_tests.rs"]
402mod tests;