luminol_filesystem/
native.rs

1// Copyright (C) 2024 Melody Madeline Lyons
2//
3// This file is part of Luminol.
4//
5// Luminol is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// Luminol is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with Luminol.  If not, see <http://www.gnu.org/licenses/>.
17
18use color_eyre::eyre::WrapErr;
19use itertools::Itertools;
20
21use crate::{DirEntry, Metadata, OpenFlags, Result, StdIoErrorExt};
22use pin_project::pin_project;
23use std::{
24    io::ErrorKind::{InvalidInput, PermissionDenied},
25    pin::Pin,
26    task::Poll,
27};
28
29#[derive(Debug, Clone)]
30pub struct FileSystem {
31    root_path: camino::Utf8PathBuf,
32}
33
34#[derive(Debug)]
35#[pin_project]
36pub struct File {
37    file: Inner,
38    path: camino::Utf8PathBuf,
39    stripped_path: Option<camino::Utf8PathBuf>,
40    #[pin]
41    async_file: async_fs::File,
42}
43
44#[derive(Debug)]
45enum Inner {
46    StdFsFile(std::fs::File),
47    NamedTempFile(tempfile::NamedTempFile),
48}
49
50impl FileSystem {
51    pub fn new(root_path: impl AsRef<camino::Utf8Path>) -> Self {
52        Self {
53            root_path: root_path.as_ref().to_path_buf(),
54        }
55    }
56
57    pub fn root_path(&self) -> &camino::Utf8Path {
58        &self.root_path
59    }
60
61    pub async fn from_folder_picker() -> Result<Self> {
62        let c = "While picking a folder from the host filesystem";
63        if let Some(path) = rfd::AsyncFileDialog::default().pick_folder().await {
64            let path = camino::Utf8Path::from_path(path.path())
65                .ok_or(crate::Error::PathUtf8Error)
66                .wrap_err(c)?;
67            Ok(Self::new(path))
68        } else {
69            Err(crate::Error::CancelledLoading).wrap_err(c)
70        }
71    }
72
73    pub async fn from_file_picker() -> Result<Self> {
74        let c = "While picking a folder from the host filesystem";
75        if let Some(path) = rfd::AsyncFileDialog::default()
76            .add_filter("project file", &["rxproj", "rvproj", "rvproj2", "lumproj"])
77            .pick_file()
78            .await
79        {
80            let path = camino::Utf8Path::from_path(path.path())
81                .ok_or(crate::Error::PathUtf8Error)
82                .wrap_err(c)?
83                .parent()
84                .expect("path does not have parent");
85            Ok(Self::new(path))
86        } else {
87            Err(crate::Error::CancelledLoading).wrap_err(c)
88        }
89    }
90}
91
92impl crate::FileSystem for FileSystem {
93    type File = File;
94
95    fn open_file(
96        &self,
97        path: impl AsRef<camino::Utf8Path>,
98        flags: OpenFlags,
99    ) -> Result<Self::File> {
100        let stripped_path = path.as_ref();
101        let path = self.root_path.join(path.as_ref());
102        let c = format!("While opening file {path:?} in a host folder");
103        std::fs::OpenOptions::new()
104            .create(flags.contains(OpenFlags::Create))
105            .write(flags.contains(OpenFlags::Write))
106            .read(flags.contains(OpenFlags::Read))
107            .truncate(flags.contains(OpenFlags::Truncate))
108            .open(&path)
109            .map(|file| {
110                let clone = file.try_clone().wrap_err_with(|| c.clone())?;
111                Ok(File {
112                    file: Inner::StdFsFile(file),
113                    path,
114                    stripped_path: Some(stripped_path.to_owned()),
115                    async_file: clone.into(),
116                })
117            })
118            .wrap_err_with(|| c.clone())?
119    }
120
121    fn metadata(&self, path: impl AsRef<camino::Utf8Path>) -> Result<Metadata> {
122        let c = format!(
123            "While getting metadata for {:?} in a host folder",
124            path.as_ref()
125        );
126        let path = self.root_path.join(path);
127        let metadata = std::fs::metadata(path).wrap_err_with(|| c.clone())?;
128        Ok(Metadata {
129            is_file: metadata.is_file(),
130            size: metadata.len(),
131        })
132    }
133
134    fn rename(
135        &self,
136        from: impl AsRef<camino::Utf8Path>,
137        to: impl AsRef<camino::Utf8Path>,
138    ) -> Result<()> {
139        let c = format!(
140            "While renaming {:?} to {:?} in a host folder",
141            from.as_ref(),
142            to.as_ref()
143        );
144        let from = self.root_path.join(from);
145        let to = self.root_path.join(to);
146        std::fs::rename(from, to).wrap_err(c)
147    }
148
149    fn exists(&self, path: impl AsRef<camino::Utf8Path>) -> Result<bool> {
150        let c = format!(
151            "While checking if {:?} exists in a host folder",
152            path.as_ref()
153        );
154        let path = self.root_path.join(path);
155        path.try_exists().wrap_err(c)
156    }
157
158    fn create_dir(&self, path: impl AsRef<camino::Utf8Path>) -> Result<()> {
159        let c = format!(
160            "While creating a directory at {:?} in a host folder",
161            path.as_ref()
162        );
163        let path = self.root_path.join(path);
164        std::fs::create_dir_all(path).wrap_err(c)
165    }
166
167    fn remove_dir(&self, path: impl AsRef<camino::Utf8Path>) -> Result<()> {
168        let c = format!(
169            "While removing a directory at {:?} in a host folder",
170            path.as_ref()
171        );
172        let path = self.root_path.join(path);
173        std::fs::remove_dir_all(path).wrap_err(c)
174    }
175
176    fn remove_file(&self, path: impl AsRef<camino::Utf8Path>) -> Result<()> {
177        let c = format!(
178            "While removing a file at {:?} in a host folder",
179            path.as_ref()
180        );
181        let path = self.root_path.join(path);
182        std::fs::remove_file(path).wrap_err(c)
183    }
184
185    fn read_dir(&self, path: impl AsRef<camino::Utf8Path>) -> Result<Vec<DirEntry>> {
186        let c = format!(
187            "While reading the contents of the directory {:?} in a host folder",
188            path.as_ref()
189        );
190        let path = self.root_path.join(path);
191        path.read_dir_utf8()
192            .wrap_err_with(|| c.clone())?
193            .map_ok(|entry| {
194                let path = entry.into_path();
195                let path = path
196                    .strip_prefix(&self.root_path)
197                    .unwrap_or(&path)
198                    .to_path_buf();
199
200                // i hate windows.
201                #[cfg(windows)]
202                let path = path.into_string().replace('\\', "/").into();
203
204                let metadata = self.metadata(&path).wrap_err_with(|| c.clone())?;
205                Ok(DirEntry::new(path, metadata))
206            })
207            .flatten()
208            .try_collect()
209    }
210}
211
212impl File {
213    /// Creates a new empty temporary file with read-write permissions.
214    pub fn new() -> std::io::Result<Self> {
215        let c = "While creating a temporary file on the host filesystem";
216        let file = tempfile::NamedTempFile::new()?;
217        let path = file
218            .path()
219            .to_str()
220            .ok_or(std::io::Error::new(
221                InvalidInput,
222                "Tried to create a temporary file, but its path was not valid UTF-8",
223            ))
224            .wrap_io_err(c)?
225            .into();
226        let clone = file.as_file().try_clone().wrap_io_err(c)?;
227        Ok(Self {
228            file: Inner::NamedTempFile(file),
229            path,
230            stripped_path: None,
231            async_file: clone.into(),
232        })
233    }
234
235    /// Attempts to prompt the user to choose a file from their local machine.
236    /// Then creates a `File` allowing read-write access to that directory if they chose one
237    /// successfully, along with the name of the file including the extension.
238    ///
239    /// `extensions` should be a list of accepted file extensions for the file, without the leading
240    /// `.`
241    pub async fn from_file_picker(
242        filter_name: &str,
243        extensions: &[impl ToString],
244    ) -> Result<(Self, String)> {
245        let c = "While picking a file on the host filesystem";
246        if let Some(path) = rfd::AsyncFileDialog::default()
247            .add_filter(filter_name, extensions)
248            .pick_file()
249            .await
250        {
251            let file = std::fs::OpenOptions::new()
252                .read(true)
253                .open(path.path())
254                .map_err(crate::Error::IoError)
255                .wrap_err(c)?;
256            let path = path
257                .path()
258                .iter()
259                .last()
260                .unwrap()
261                .to_os_string()
262                .into_string()
263                .map_err(|_| crate::Error::PathUtf8Error)
264                .wrap_err(c)?;
265            let clone = file.try_clone().wrap_err(c)?;
266            Ok((
267                File {
268                    file: Inner::StdFsFile(file),
269                    path: path.clone().into(),
270                    stripped_path: Some(
271                        camino::Utf8Path::new(&path)
272                            .iter()
273                            .next_back()
274                            .unwrap()
275                            .into(),
276                    ),
277                    async_file: clone.into(),
278                },
279                path,
280            ))
281        } else {
282            Err(crate::Error::CancelledLoading).wrap_err(c)
283        }
284    }
285
286    /// Saves this file to a location of the user's choice.
287    ///
288    /// In native, this will open a file picker dialog, wait for the user to choose a location to
289    /// save a file, and then copy this file to the new location. This function will wait for the
290    /// user to finish picking a file location before returning.
291    ///
292    /// In web, this will use the browser's native file downloading method to save the file, which
293    /// may or may not open a file picker. Due to platform limitations, this function will return
294    /// immediately after making a download request and will not wait for the user to pick a file
295    /// location if a file picker is shown.
296    ///
297    /// You must flush the file yourself before saving. It will not be flushed for you.
298    ///
299    /// `filename` should be the default filename, with extension, to show in the file picker if
300    /// one is shown. `filter_name` should be the name of the file type shown in the part of the
301    /// file picker where the user selects a file extension. `filter_name` works only in native
302    /// builds; it is ignored in web builds.
303    pub async fn save(&self, filename: &str, filter_name: &str) -> Result<()> {
304        let stripped_path = self
305            .stripped_path
306            .as_ref()
307            .map(|p| p.as_str())
308            .unwrap_or("<temporary file>");
309        let c = format!(
310            "While saving the file {:?} in a host folder to disk",
311            stripped_path
312        );
313        let mut dialog = rfd::AsyncFileDialog::default().set_file_name(filename);
314        if let Some((_, extension)) = filename.rsplit_once('.') {
315            dialog = dialog.add_filter(filter_name, &[extension]);
316        }
317        let path = dialog
318            .save_file()
319            .await
320            .ok_or(crate::Error::CancelledLoading)
321            .wrap_err_with(|| c.clone())?;
322        std::fs::copy(&self.path, path.path()).wrap_err_with(|| c.clone())?;
323        Ok(())
324    }
325}
326
327impl crate::File for File {
328    fn metadata(&self) -> std::io::Result<Metadata> {
329        let stripped_path = self
330            .stripped_path
331            .as_ref()
332            .map(|p| p.as_str())
333            .unwrap_or("<temporary file>");
334        let c = format!(
335            "While getting metadata for file {:?} in a host folder",
336            stripped_path
337        );
338        let metdata = self.file.as_file().metadata().wrap_io_err(c)?;
339        Ok(Metadata {
340            is_file: metdata.is_file(),
341            size: metdata.len(),
342        })
343    }
344
345    fn set_len(&self, new_size: u64) -> std::io::Result<()> {
346        let stripped_path = self
347            .stripped_path
348            .as_ref()
349            .map(|p| p.as_str())
350            .unwrap_or("<temporary file>");
351        let c = format!(
352            "While setting length of file {:?} in a host folder",
353            stripped_path
354        );
355        self.file.as_file().set_len(new_size).wrap_io_err(c)
356    }
357}
358
359impl std::io::Read for File {
360    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
361        let stripped_path = self
362            .stripped_path
363            .as_ref()
364            .map(|p| p.as_str())
365            .unwrap_or("<temporary file>");
366        let c = format!(
367            "While reading from file {:?} in a host folder",
368            stripped_path
369        );
370        self.file.as_file().read(buf).wrap_io_err(c)
371    }
372
373    fn read_vectored(&mut self, bufs: &mut [std::io::IoSliceMut<'_>]) -> std::io::Result<usize> {
374        let stripped_path = self
375            .stripped_path
376            .as_ref()
377            .map(|p| p.as_str())
378            .unwrap_or("<temporary file>");
379        let c = format!(
380            "While reading (vectored) from file {:?} in a host folder",
381            stripped_path
382        );
383        self.file.as_file().read_vectored(bufs).wrap_io_err(c)
384    }
385}
386
387impl futures_lite::AsyncRead for File {
388    fn poll_read(
389        self: std::pin::Pin<&mut Self>,
390        cx: &mut std::task::Context<'_>,
391        buf: &mut [u8],
392    ) -> Poll<std::io::Result<usize>> {
393        let stripped_path = self
394            .stripped_path
395            .as_ref()
396            .map(|p| p.as_str())
397            .unwrap_or("<temporary file>");
398        let c = format!(
399            "While asynchronously reading from file {:?} in a host folder",
400            stripped_path
401        );
402        self.project()
403            .async_file
404            .poll_read(cx, buf)
405            .map(|p| p.wrap_io_err(c))
406    }
407
408    fn poll_read_vectored(
409        self: Pin<&mut Self>,
410        cx: &mut std::task::Context<'_>,
411        bufs: &mut [std::io::IoSliceMut<'_>],
412    ) -> Poll<std::io::Result<usize>> {
413        let stripped_path = self
414            .stripped_path
415            .as_ref()
416            .map(|p| p.as_str())
417            .unwrap_or("<temporary file>");
418        let c = format!(
419            "While asynchronously reading (vectored) from file {:?} in a host folder",
420            stripped_path
421        );
422        self.project()
423            .async_file
424            .poll_read_vectored(cx, bufs)
425            .map(|p| p.wrap_io_err(c))
426    }
427}
428
429impl std::io::Write for File {
430    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
431        let stripped_path = self
432            .stripped_path
433            .as_ref()
434            .map(|p| p.as_str())
435            .unwrap_or("<temporary file>");
436        let c = format!("While writing to file {:?} in a host folder", stripped_path);
437        self.file.as_file().write(buf).wrap_io_err(c)
438    }
439
440    fn flush(&mut self) -> std::io::Result<()> {
441        let stripped_path = self
442            .stripped_path
443            .as_ref()
444            .map(|p| p.as_str())
445            .unwrap_or("<temporary file>");
446        let c = format!("While flushing file {:?} in a host folder", stripped_path);
447        self.file.as_file().flush().wrap_io_err(c)
448    }
449
450    fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result<usize> {
451        let stripped_path = self
452            .stripped_path
453            .as_ref()
454            .map(|p| p.as_str())
455            .unwrap_or("<temporary file>");
456        let c = format!(
457            "While writing (vectored) to file {:?} in a host folder",
458            stripped_path
459        );
460        self.file.as_file().write_vectored(bufs).wrap_io_err(c)
461    }
462}
463
464impl futures_lite::AsyncWrite for File {
465    fn poll_write(
466        self: Pin<&mut Self>,
467        cx: &mut std::task::Context<'_>,
468        buf: &[u8],
469    ) -> Poll<std::io::Result<usize>> {
470        let stripped_path = self
471            .stripped_path
472            .as_ref()
473            .map(|p| p.as_str())
474            .unwrap_or("<temporary file>");
475        let c = format!(
476            "While asynchronously writing to file {:?} in a host folder",
477            stripped_path
478        );
479        self.project()
480            .async_file
481            .poll_write(cx, buf)
482            .map(|r| r.wrap_io_err(c))
483    }
484
485    fn poll_write_vectored(
486        self: Pin<&mut Self>,
487        cx: &mut std::task::Context<'_>,
488        bufs: &[std::io::IoSlice<'_>],
489    ) -> Poll<std::io::Result<usize>> {
490        let stripped_path = self
491            .stripped_path
492            .as_ref()
493            .map(|p| p.as_str())
494            .unwrap_or("<temporary file>");
495        let c = format!(
496            "While asynchronously writing (vectored) to file {:?} in a host folder",
497            stripped_path
498        );
499        self.project()
500            .async_file
501            .poll_write_vectored(cx, bufs)
502            .map(|r| r.wrap_io_err(c))
503    }
504
505    fn poll_flush(
506        self: Pin<&mut Self>,
507        cx: &mut std::task::Context<'_>,
508    ) -> Poll<std::io::Result<()>> {
509        let stripped_path = self
510            .stripped_path
511            .as_ref()
512            .map(|p| p.as_str())
513            .unwrap_or("<temporary file>");
514        let c = format!(
515            "While asynchronously flushing file {:?} in a host folder",
516            stripped_path
517        );
518        self.project()
519            .async_file
520            .poll_flush(cx)
521            .map(|r| r.wrap_io_err(c))
522    }
523
524    fn poll_close(
525        self: Pin<&mut Self>,
526        _cx: &mut std::task::Context<'_>,
527    ) -> Poll<std::io::Result<()>> {
528        let stripped_path = self
529            .stripped_path
530            .as_ref()
531            .map(|p| p.as_str())
532            .unwrap_or("<temporary file>");
533        let c = format!(
534            "While asynchronously closing file {:?} in a host folder",
535            stripped_path
536        );
537        Poll::Ready(Err(std::io::Error::new(PermissionDenied, "Attempted to asynchronously close a `luminol_filesystem::host::File`, which is not allowed")).wrap_io_err(c))
538    }
539}
540
541impl std::io::Seek for File {
542    fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
543        let stripped_path = self
544            .stripped_path
545            .as_ref()
546            .map(|p| p.as_str())
547            .unwrap_or("<temporary file>");
548        let c = format!("While seeking file {:?} in a host folder", stripped_path);
549        self.file.as_file().seek(pos).wrap_io_err(c)
550    }
551}
552
553impl futures_lite::AsyncSeek for File {
554    fn poll_seek(
555        self: Pin<&mut Self>,
556        cx: &mut std::task::Context<'_>,
557        pos: std::io::SeekFrom,
558    ) -> Poll<std::io::Result<u64>> {
559        let stripped_path = self
560            .stripped_path
561            .as_ref()
562            .map(|p| p.as_str())
563            .unwrap_or("<temporary file>");
564        let c = format!(
565            "While asynchronously seeking file {:?} in a host folder",
566            stripped_path
567        );
568        self.project()
569            .async_file
570            .poll_seek(cx, pos)
571            .map(|r| r.wrap_io_err(c))
572    }
573}
574
575impl Inner {
576    fn as_file(&self) -> &std::fs::File {
577        match self {
578            Inner::StdFsFile(file) => file,
579            Inner::NamedTempFile(file) => file.as_file(),
580        }
581    }
582}