Skip to main content

ravenrs_platform_native/
files.rs

1//! Files and the desktop: asking where to save, and opening with the system's program.
2
3use std::io;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7/// The system's dialog for choosing where to save a file. It asks before
8/// replacing a file that exists, so the path it returns can be written.
9///
10/// It blocks until answered and has to run on the UI thread, which is where
11/// event handlers such as `on_click` run.
12///
13/// ```no_run
14/// use ravenrs_platform_native::SaveDialog;
15///
16/// let chosen = SaveDialog::new("Contas.pdf").title("Exportar").filter("PDF", &["pdf"]).ask();
17/// if let Some(path) = chosen {
18///     std::fs::write(path, b"...")?;
19/// }
20/// # Ok::<(), std::io::Error>(())
21/// ```
22#[derive(Debug)]
23pub struct SaveDialog {
24    dialog: rfd::FileDialog,
25}
26
27impl SaveDialog {
28    /// A dialog suggesting this file name.
29    pub fn new(file_name: &str) -> Self {
30        Self { dialog: rfd::FileDialog::new().set_file_name(file_name) }
31    }
32
33    /// Sets the dialog's title.
34    pub fn title(mut self, title: &str) -> Self {
35        self.dialog = self.dialog.set_title(title);
36        self
37    }
38
39    /// Offers a kind of file, such as `("PDF", &["pdf"])`.
40    pub fn filter(mut self, name: &str, extensions: &[&str]) -> Self {
41        self.dialog = self.dialog.add_filter(name, extensions);
42        self
43    }
44
45    /// Opens in this folder, when the system allows it.
46    pub fn folder(mut self, folder: &Path) -> Self {
47        self.dialog = self.dialog.set_directory(folder);
48        self
49    }
50
51    /// Shows the dialog; `None` when the user cancels.
52    pub fn ask(self) -> Option<PathBuf> {
53        self.dialog.save_file()
54    }
55}
56
57/// The system's dialog for choosing a file to open.
58///
59/// It blocks until answered and has to run on the UI thread, which is where
60/// event handlers such as `on_click` run.
61///
62/// ```no_run
63/// use ravenrs_platform_native::OpenDialog;
64///
65/// let chosen = OpenDialog::new().title("Abrir").filter("Texto", &["txt"]).ask();
66/// if let Some(path) = chosen {
67///     let text = std::fs::read_to_string(path)?;
68/// }
69/// # Ok::<(), std::io::Error>(())
70/// ```
71#[derive(Debug, Default)]
72pub struct OpenDialog {
73    dialog: rfd::FileDialog,
74}
75
76impl OpenDialog {
77    /// A dialog that shows every file, until a [`filter`](Self::filter) narrows it.
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Sets the dialog's title.
83    pub fn title(mut self, title: &str) -> Self {
84        self.dialog = self.dialog.set_title(title);
85        self
86    }
87
88    /// Offers a kind of file, such as `("PDF", &["pdf"])`.
89    pub fn filter(mut self, name: &str, extensions: &[&str]) -> Self {
90        self.dialog = self.dialog.add_filter(name, extensions);
91        self
92    }
93
94    /// Opens in this folder, when the system allows it.
95    pub fn folder(mut self, folder: &Path) -> Self {
96        self.dialog = self.dialog.set_directory(folder);
97        self
98    }
99
100    /// Shows the dialog; `None` when the user cancels.
101    pub fn ask(self) -> Option<PathBuf> {
102        self.dialog.pick_file()
103    }
104}
105
106/// The largest file a drop reads into memory: 1 GiB.
107pub(crate) const MOST_DROPPED_BYTES: u64 = 1024 * 1024 * 1024;
108
109/// Reads what the system's dialogs and drops bring. A file above `most_bytes` keeps its
110/// name and size, with no bytes, so the place that asked can say why it refused it.
111pub(crate) fn read_picked(path: &Path, most_bytes: Option<u64>) -> Option<ravenrs_widgets::PickedFile> {
112    let name = path.file_name()?.to_string_lossy().into_owned();
113    let size = std::fs::metadata(path).ok()?.len();
114    if most_bytes.is_some_and(|most| size > most) {
115        return Some(ravenrs_widgets::PickedFile { name, size, bytes: Vec::new().into() });
116    }
117    match std::fs::read(path) {
118        Ok(bytes) => Some(ravenrs_widgets::PickedFile::new(name, bytes)),
119        Err(e) => {
120            tracing::warn!(error = %e, "could not read the chosen file");
121            None
122        }
123    }
124}
125
126/// The system's dialog, as what views ask for files through.
127#[derive(Debug, Default)]
128pub(crate) struct SystemFilePicker;
129
130impl ravenrs_widgets::FilePickerProvider for SystemFilePicker {
131    fn pick(
132        &self,
133        request: &ravenrs_widgets::FileRequest,
134        done: Box<dyn FnOnce(Vec<ravenrs_widgets::PickedFile>)>,
135    ) {
136        let mut dialog = rfd::FileDialog::new();
137        if !request.extensions.is_empty() {
138            let kinds: Vec<&str> = request.extensions.iter().map(String::as_str).collect();
139            dialog = dialog.add_filter(kinds.join(", "), &kinds);
140        }
141        let paths = if request.multiple {
142            dialog.pick_files().unwrap_or_default()
143        } else {
144            dialog.pick_file().into_iter().collect()
145        };
146        done(paths.iter().filter_map(|p| read_picked(p, request.most_bytes)).collect());
147    }
148
149    fn save(&self, name: &str, bytes: &[u8]) {
150        let Some(path) = SaveDialog::new(name).ask() else { return };
151        if let Err(e) = std::fs::write(path, bytes) {
152            tracing::warn!(error = %e, "could not save the file");
153        }
154    }
155}
156
157/// Opens a file with the program the system uses for it: a PDF in its viewer, a
158/// web page in the browser. The program runs on its own, so this does not wait for it.
159pub fn open_with_system(path: &Path) -> io::Result<()> {
160    let mut command = if cfg!(target_os = "macos") {
161        Command::new("open")
162    } else if cfg!(target_os = "windows") {
163        // The empty argument is the window title `start` takes first, when a path has quotes.
164        let mut start = Command::new("cmd");
165        start.args(["/C", "start", ""]);
166        start
167    } else {
168        Command::new("xdg-open")
169    };
170    command.arg(path).spawn().map(|_| ())
171}
172
173/// Opens a web page in the browser. Only `http` and `https` are accepted, so a
174/// bad URL cannot end up running a program or opening a file instead.
175///
176/// On Windows this does not go through `cmd`, as [`open_with_system`] does: `cmd`
177/// would take the `&` between a query's parameters as the start of a new command.
178pub fn open_url(url: &str) -> io::Result<()> {
179    if !(url.starts_with("https://") || url.starts_with("http://")) {
180        return Err(io::Error::new(io::ErrorKind::InvalidInput, "not a web address"));
181    }
182    if cfg!(target_os = "windows") {
183        Command::new("rundll32").args(["url.dll,FileProtocolHandler", url]).spawn().map(|_| ())
184    } else {
185        open_with_system(Path::new(url))
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn only_web_addresses_are_opened() {
195        for url in ["file:///etc/passwd", "/bin/sh", "--help", "javascript:alert(1)", ""] {
196            assert_eq!(open_url(url).map_err(|e| e.kind()), Err(io::ErrorKind::InvalidInput), "{url}");
197        }
198    }
199}