Skip to main content

inset_embedder/
file_dialogs.rs

1//! The system's file dialogs: open a file, open files, open a folder, save a file, each the
2//! system's own panel. Not a dart:ui file: Flutter reaches them through its `file_selector`
3//! package, whose functions these follow, one per kind (`openFile`, `openFiles`,
4//! `getDirectoryPath`, `getSaveLocation`), each with options of its own and answering what its
5//! kind means.
6//!
7//! A pick answers a path where the system gives one (macOS, iOS, Windows, Linux) and the
8//! system's own handle where it gives none, as Flutter's `XFile` carries a path or a blob: the
9//! web's file handle, and Android's document, open as a handle where it seeks and as a stream
10//! where it is a pipe ([`PickedFile`]). A pick grants reading only, unless the app asks to write
11//! ([`OpenOptions::writable`]); a save grants writing to the one file chosen, which may not exist
12//! yet. A folder pick answers a path where the system gives one, and the system's folder handle
13//! where it gives none, the web's directory handle and Android's document tree, which lists the
14//! folder and opens and, when writable, makes what is in it ([`PickedFolder`]).
15//!
16//! The platform answers `None` where its system has no file dialogs, and a dialog the system
17//! could not show is answered as a [`FileDialogError`]. Every answer comes through a future, once
18//! the user has chosen or cancelled: a cancelled dialog answers `None`, or no files.
19
20use std::fmt;
21use std::future::Future;
22use std::io;
23use std::path::PathBuf;
24use std::pin::Pin;
25use std::sync::{Arc, Mutex, PoisonError};
26use std::task::{Context, Poll, Waker};
27
28use crate::ViewId;
29
30/// The system's file dialogs. `parent` is the view whose window a dialog belongs to, which the
31/// system shows it on, as a sheet where it has them (macOS); without one it stands alone.
32pub trait FileDialogs {
33    /// Asks for one file to open; `None` if the user cancelled.
34    fn open_file(
35        &self,
36        parent: Option<ViewId>,
37        options: OpenOptions,
38    ) -> FileDialogFuture<Option<PickedFile>>;
39
40    /// Asks for files to open; none if the user cancelled.
41    fn open_files(
42        &self,
43        parent: Option<ViewId>,
44        options: OpenOptions,
45    ) -> FileDialogFuture<Vec<PickedFile>>;
46
47    /// Asks for a folder; `None` if the user cancelled.
48    fn open_folder(
49        &self,
50        parent: Option<ViewId>,
51        options: FolderOptions,
52    ) -> FileDialogFuture<Option<PickedFolder>>;
53
54    /// Asks where to save a file, which the pick may then write; `None` if the user cancelled.
55    fn save_file(
56        &self,
57        parent: Option<ViewId>,
58        options: SaveOptions,
59    ) -> FileDialogFuture<Option<PickedFile>>;
60}
61
62/// What a file dialog answers, once the user has chosen or cancelled.
63pub type FileDialogFuture<T> = Pin<Box<dyn Future<Output = Result<T, FileDialogError>>>>;
64
65/// Why a file dialog answered nothing the user chose.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub enum FileDialogError {
68    /// The system did not show the dialog, or failed to answer it.
69    Failed(String),
70}
71
72impl fmt::Display for FileDialogError {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        match self {
75            FileDialogError::Failed(reason) => write!(f, "the file dialog failed: {reason}"),
76        }
77    }
78}
79
80impl std::error::Error for FileDialogError {}
81
82/// The kinds of file a dialog offers, as `file_selector`'s `XTypeGroup`: a system takes the
83/// extensions or the MIME types it filters by, and shows the label where it shows one.
84#[derive(Clone, Debug, Default, PartialEq, Eq)]
85pub struct FileType {
86    pub label: String,
87    /// Without the dot: `txt`.
88    pub extensions: Vec<String>,
89    pub mime_types: Vec<String>,
90}
91
92/// How to ask for files to open.
93#[derive(Clone, Debug, Default, PartialEq, Eq)]
94pub struct OpenOptions {
95    /// The kinds the dialog offers; any file when there are none.
96    pub types: Vec<FileType>,
97    /// Where the dialog starts, where the system takes a folder.
98    pub initial_folder: Option<PathBuf>,
99    /// The confirming button's label, where the system takes one.
100    pub confirm_label: Option<String>,
101    /// Whether the app may write the files picked. Most systems grant writing to every pick, so
102    /// this only matters where a pick grants reading alone (Android, the web).
103    pub writable: bool,
104}
105
106/// How to ask for a folder.
107#[derive(Clone, Debug, Default, PartialEq, Eq)]
108pub struct FolderOptions {
109    pub initial_folder: Option<PathBuf>,
110    pub confirm_label: Option<String>,
111    /// Whether the app may write in the folder picked, where a pick grants reading alone.
112    pub writable: bool,
113}
114
115/// How to ask where to save a file.
116#[derive(Clone, Debug, Default, PartialEq, Eq)]
117pub struct SaveOptions {
118    pub types: Vec<FileType>,
119    pub initial_folder: Option<PathBuf>,
120    /// The file name the dialog suggests.
121    pub suggested_name: Option<String>,
122    pub confirm_label: Option<String>,
123}
124
125/// A file the user picked, as the system gives it.
126pub enum PickedFile {
127    /// macOS, iOS, Windows and Linux: a path the app opens as any file.
128    Path(PathBuf),
129    /// The web, and Android where the document seeks, as it always does when writable.
130    Handle(Box<dyn FileHandle>),
131    /// Android where the document is a pipe: read once, start to end. Read-only picks only.
132    Stream(Box<dyn FileStream>),
133}
134
135impl fmt::Debug for PickedFile {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        match self {
138            PickedFile::Path(path) => f.debug_tuple("Path").field(path).finish(),
139            PickedFile::Handle(handle) => f.debug_tuple("Handle").field(&handle.name()).finish(),
140            PickedFile::Stream(stream) => f.debug_tuple("Stream").field(&stream.name()).finish(),
141        }
142    }
143}
144
145/// A folder the user picked, as the system gives it.
146pub enum PickedFolder {
147    /// macOS, iOS, Windows and Linux: a path the app reads and writes in as any folder.
148    Path(PathBuf),
149    /// The web's directory handle, and Android's document tree.
150    Handle(Box<dyn FolderHandle>),
151}
152
153impl fmt::Debug for PickedFolder {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            PickedFolder::Path(path) => f.debug_tuple("Path").field(path).finish(),
157            PickedFolder::Handle(handle) => f.debug_tuple("Handle").field(&handle.name()).finish(),
158        }
159    }
160}
161
162/// A picked folder the system gives no path for: what is in it, opened by name, and, when the
163/// pick is writable, files and folders made in it. What it opens is writable when it is.
164pub trait FolderHandle {
165    /// The folder's name, which the user sees.
166    fn name(&self) -> &str;
167
168    /// What is in the folder, in no particular order.
169    fn entries(&self) -> FolderEntriesFuture;
170
171    /// The file `name` in the folder.
172    fn open_file(&self, name: &str) -> FileOpenFuture<PickedFile>;
173
174    /// The folder `name` in the folder.
175    fn open_folder(&self, name: &str) -> FileOpenFuture<PickedFolder>;
176
177    /// Makes an empty file `name` in the folder; `None` unless the pick is writable.
178    fn create_file(&self, name: &str) -> Option<FileOpenFuture<PickedFile>>;
179
180    /// Makes a folder `name` in the folder; `None` unless the pick is writable.
181    fn create_folder(&self, name: &str) -> Option<FileOpenFuture<PickedFolder>>;
182}
183
184/// One thing in a picked folder.
185#[derive(Clone, Debug, PartialEq, Eq)]
186pub struct FolderEntry {
187    pub name: String,
188    pub kind: EntryKind,
189}
190
191/// Whether a folder's entry is a file or a folder.
192#[derive(Clone, Copy, Debug, PartialEq, Eq)]
193pub enum EntryKind {
194    File,
195    Folder,
196}
197
198/// What a folder's listing answers.
199pub type FolderEntriesFuture = Pin<Box<dyn Future<Output = io::Result<Vec<FolderEntry>>>>>;
200
201/// A picked file the system gives no path for, read and written at any offset.
202pub trait FileHandle {
203    /// The file's name, which the user sees.
204    fn name(&self) -> &str;
205
206    /// The URL the system reads the file by (`PickedFile::url`).
207    fn url(&self) -> String;
208
209    fn size(&self) -> FileSizeFuture;
210
211    /// Reads into `buf` from `offset`; answers how many bytes it read, `0` at the end.
212    fn read_at<'a>(&'a self, offset: u64, buf: &'a mut [u8]) -> FileReadFuture<'a>;
213
214    /// Opens the file to write; `None` unless the pick is writable.
215    fn open_write(&self) -> Option<FileOpenFuture<Box<dyn FileWriter>>>;
216}
217
218/// Writing to a picked file.
219pub trait FileWriter {
220    fn write_at<'a>(&'a mut self, offset: u64, bytes: &'a [u8]) -> FileWriteFuture<'a>;
221
222    fn set_len(&mut self, len: u64) -> FileWriteFuture<'_>;
223
224    /// Ends the writing: on the web the file is replaced only now, and a writer dropped before
225    /// abandons it; on Android the writes landed in place as they were made.
226    fn finish(self: Box<Self>) -> FileFinishFuture;
227}
228
229/// A picked file that can only be read through, once, start to end.
230pub trait FileStream {
231    fn name(&self) -> &str;
232
233    /// The URL the system reads the file by (`PickedFile::url`).
234    fn url(&self) -> String;
235
236    /// Reads the next bytes into `buf`; answers how many, `0` at the end.
237    fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> FileReadFuture<'a>;
238}
239
240pub type FileSizeFuture = Pin<Box<dyn Future<Output = io::Result<u64>>>>;
241pub type FileReadFuture<'a> = Pin<Box<dyn Future<Output = io::Result<usize>> + 'a>>;
242pub type FileOpenFuture<T> = Pin<Box<dyn Future<Output = io::Result<T>>>>;
243pub type FileWriteFuture<'a> = Pin<Box<dyn Future<Output = io::Result<()>> + 'a>>;
244pub type FileFinishFuture = Pin<Box<dyn Future<Output = io::Result<()>>>>;
245
246/// How much a whole read asks for at a time.
247const READ_CHUNK: usize = 64 * 1024;
248
249impl PickedFile {
250    /// The URL a player, or any API that takes a URL, reads the file by, as every system's
251    /// player does: a `file:` URL for a path, absolute and percent-encoded; Android's
252    /// `content:` URI; the web's `blob:` URL, of the file as it was when picked. A `blob:` URL is
253    /// made once a pick and never revoked: a player may read it after the pick is let go, so the
254    /// page keeps the file until it unloads.
255    pub fn url(&self) -> String {
256        match self {
257            PickedFile::Path(path) => crate::opener::file_uri(path),
258            PickedFile::Handle(handle) => handle.url(),
259            PickedFile::Stream(stream) => stream.url(),
260        }
261    }
262
263    /// The whole file, as Flutter's `XFile.readAsBytes` reads it: a path is read on a thread of
264    /// its own, a handle from its start to its end, and a stream through, which uses it up.
265    pub fn read_all(&mut self) -> Pin<Box<dyn Future<Output = io::Result<Vec<u8>>> + '_>> {
266        match self {
267            PickedFile::Path(path) => Box::pin(read_path(path.clone())),
268            PickedFile::Handle(handle) => Box::pin(read_handle(&**handle)),
269            PickedFile::Stream(stream) => Box::pin(read_stream(&mut **stream)),
270        }
271    }
272}
273
274async fn read_path(path: PathBuf) -> io::Result<Vec<u8>> {
275    let (answer, read) = one_shot();
276    std::thread::Builder::new()
277        .name("inset-read-picked-file".to_owned())
278        .spawn(move || answer.give(std::fs::read(path)))?;
279    read.await
280}
281
282async fn read_handle(handle: &dyn FileHandle) -> io::Result<Vec<u8>> {
283    let mut bytes = Vec::with_capacity(usize::try_from(handle.size().await?).unwrap_or(0));
284    let mut chunk = vec![0; READ_CHUNK];
285    loop {
286        let read = handle.read_at(bytes.len() as u64, &mut chunk).await?;
287        if read == 0 {
288            return Ok(bytes);
289        }
290        bytes.extend_from_slice(&chunk[..read]);
291    }
292}
293
294async fn read_stream(stream: &mut dyn FileStream) -> io::Result<Vec<u8>> {
295    let mut bytes = Vec::new();
296    let mut chunk = vec![0; READ_CHUNK];
297    loop {
298        let read = stream.read(&mut chunk).await?;
299        if read == 0 {
300            return Ok(bytes);
301        }
302        bytes.extend_from_slice(&chunk[..read]);
303    }
304}
305
306/// An answer given once, from any thread, and the future that waits for it.
307fn one_shot<T>() -> (Answer<T>, Answered<T>) {
308    let shared = Arc::new(Mutex::new((None, None)));
309    (Answer(Arc::clone(&shared)), Answered(shared))
310}
311
312type Shared<T> = Arc<Mutex<(Option<io::Result<T>>, Option<Waker>)>>;
313
314struct Answer<T>(Shared<T>);
315
316impl<T> Answer<T> {
317    fn give(self, value: io::Result<T>) {
318        let waker = {
319            let mut shared = self.0.lock().unwrap_or_else(PoisonError::into_inner);
320            shared.0 = Some(value);
321            shared.1.take()
322        };
323        if let Some(waker) = waker {
324            waker.wake();
325        }
326    }
327}
328
329struct Answered<T>(Shared<T>);
330
331impl<T> Future for Answered<T> {
332    type Output = io::Result<T>;
333
334    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<T>> {
335        let mut shared = self.0.lock().unwrap_or_else(PoisonError::into_inner);
336        match shared.0.take() {
337            Some(value) => Poll::Ready(value),
338            None if Arc::strong_count(&self.0) == 1 => {
339                Poll::Ready(Err(io::Error::other("the read ended without an answer")))
340            }
341            None => {
342                shared.1 = Some(cx.waker().clone());
343                Poll::Pending
344            }
345        }
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use std::cell::RefCell;
352
353    use super::*;
354
355    /// A handle over bytes in memory, read a few at a time.
356    struct Bytes(RefCell<Vec<u8>>);
357
358    impl FileHandle for Bytes {
359        fn name(&self) -> &str {
360            "bytes"
361        }
362
363        fn url(&self) -> String {
364            "memory:bytes".to_owned()
365        }
366
367        fn size(&self) -> FileSizeFuture {
368            let size = self.0.borrow().len() as u64;
369            Box::pin(async move { Ok(size) })
370        }
371
372        fn read_at<'a>(&'a self, offset: u64, buf: &'a mut [u8]) -> FileReadFuture<'a> {
373            let bytes = self.0.borrow();
374            let start = (offset as usize).min(bytes.len());
375            let read = (bytes.len() - start).min(buf.len()).min(3);
376            buf[..read].copy_from_slice(&bytes[start..start + read]);
377            Box::pin(async move { Ok(read) })
378        }
379
380        fn open_write(&self) -> Option<FileOpenFuture<Box<dyn FileWriter>>> {
381            None
382        }
383    }
384
385    fn now<T>(future: impl Future<Output = T>) -> T {
386        let mut future = std::pin::pin!(future);
387        let mut context = Context::from_waker(Waker::noop());
388        loop {
389            if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
390                return output;
391            }
392            std::thread::yield_now();
393        }
394    }
395
396    #[test]
397    fn a_handle_answers_the_url_it_holds() {
398        let picked = PickedFile::Handle(Box::new(Bytes(RefCell::new(Vec::new()))));
399        assert_eq!(picked.url(), "memory:bytes");
400    }
401
402    #[test]
403    fn a_handle_is_read_whole_in_pieces() {
404        let mut picked = PickedFile::Handle(Box::new(Bytes(RefCell::new(b"hello, file".to_vec()))));
405        assert_eq!(now(picked.read_all()).unwrap(), b"hello, file");
406    }
407
408    #[test]
409    fn a_path_is_read_whole_on_a_thread() {
410        let path = std::env::temp_dir().join(format!("inset-read-all-{}", std::process::id()));
411        std::fs::write(&path, b"on disk").unwrap();
412        let mut picked = PickedFile::Path(path.clone());
413        assert_eq!(now(picked.read_all()).unwrap(), b"on disk");
414        std::fs::remove_file(path).unwrap();
415        let mut missing = PickedFile::Path(PathBuf::from("/no/such/inset/file"));
416        assert!(now(missing.read_all()).is_err());
417    }
418}