Skip to main content

tauri_plugin_fs/
lib.rs

1// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5//! Access the file system.
6//!
7//! ## Cargo features
8//!
9//! - **watch**: Enables the `watch` command backed by [`notify`](http://crates.io/crates/notify).
10
11// TODO(v3): consider redesign the API to implement automatic stopAccessingSecurityScopedResource on iOS
12// this likely requires returning a handle to a resource so we can impl Drop for it
13
14#![doc(
15    html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png",
16    html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
17)]
18
19use std::io::Read;
20#[cfg(target_os = "ios")]
21use std::sync::Mutex;
22
23use serde::Deserialize;
24use tauri::{
25    ipc::ScopeObject,
26    plugin::{Builder as PluginBuilder, TauriPlugin},
27    utils::{acl::Value, config::FsScope},
28    AppHandle, DragDropEvent, Manager, RunEvent, Runtime, WindowEvent,
29};
30
31#[cfg(target_os = "android")]
32mod android;
33mod commands;
34mod config;
35#[cfg(desktop)]
36mod desktop;
37mod error;
38mod file_path;
39#[cfg(target_os = "ios")]
40mod ios;
41#[cfg(target_os = "android")]
42mod models;
43mod scope;
44#[cfg(feature = "watch")]
45mod watcher;
46
47#[cfg(target_os = "android")]
48pub use android::Fs;
49#[cfg(desktop)]
50pub use desktop::Fs;
51#[cfg(target_os = "ios")]
52pub use ios::Fs;
53
54pub use error::Error;
55
56pub use file_path::FilePath;
57pub use file_path::SafeFilePath;
58
59type Result<T> = std::result::Result<T, Error>;
60
61#[derive(Debug, Default, Clone, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct OpenOptions {
64    #[serde(default = "default_true")]
65    read: bool,
66    #[serde(default)]
67    write: bool,
68    #[serde(default)]
69    append: bool,
70    #[serde(default)]
71    truncate: bool,
72    #[serde(default)]
73    create: bool,
74    #[serde(default)]
75    create_new: bool,
76    #[serde(default)]
77    #[allow(unused)]
78    mode: Option<u32>,
79    #[serde(default)]
80    #[allow(unused)]
81    custom_flags: Option<i32>,
82}
83
84fn default_true() -> bool {
85    true
86}
87
88impl From<OpenOptions> for std::fs::OpenOptions {
89    fn from(open_options: OpenOptions) -> Self {
90        let mut opts = std::fs::OpenOptions::new();
91
92        #[cfg(unix)]
93        {
94            use std::os::unix::fs::OpenOptionsExt;
95            if let Some(mode) = open_options.mode {
96                opts.mode(mode);
97            }
98            if let Some(flags) = open_options.custom_flags {
99                opts.custom_flags(flags);
100            }
101        }
102
103        opts.read(open_options.read)
104            .write(open_options.write)
105            .create(open_options.create)
106            .append(open_options.append)
107            .truncate(open_options.truncate)
108            .create_new(open_options.create_new);
109
110        opts
111    }
112}
113
114impl OpenOptions {
115    /// Creates a blank new set of options ready for configuration.
116    ///
117    /// All options are initially set to `false`.
118    ///
119    /// # Examples
120    ///
121    /// ```no_run
122    /// use tauri_plugin_fs::OpenOptions;
123    ///
124    /// let mut options = OpenOptions::new();
125    /// let file = options.read(true).open("foo.txt");
126    /// ```
127    #[must_use]
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Sets the option for read access.
133    ///
134    /// This option, when true, will indicate that the file should be
135    /// `read`-able if opened.
136    ///
137    /// # Examples
138    ///
139    /// ```no_run
140    /// use tauri_plugin_fs::OpenOptions;
141    ///
142    /// let file = OpenOptions::new().read(true).open("foo.txt");
143    /// ```
144    pub fn read(&mut self, read: bool) -> &mut Self {
145        self.read = read;
146        self
147    }
148
149    /// Sets the option for write access.
150    ///
151    /// This option, when true, will indicate that the file should be
152    /// `write`-able if opened.
153    ///
154    /// If the file already exists, any write calls on it will overwrite its
155    /// contents, without truncating it.
156    ///
157    /// # Examples
158    ///
159    /// ```no_run
160    /// use tauri_plugin_fs::OpenOptions;
161    ///
162    /// let file = OpenOptions::new().write(true).open("foo.txt");
163    /// ```
164    pub fn write(&mut self, write: bool) -> &mut Self {
165        self.write = write;
166        self
167    }
168
169    /// Sets the option for the append mode.
170    ///
171    /// This option, when true, means that writes will append to a file instead
172    /// of overwriting previous contents.
173    /// Note that setting `.write(true).append(true)` has the same effect as
174    /// setting only `.append(true)`.
175    ///
176    /// Append mode guarantees that writes will be positioned at the current end of file,
177    /// even when there are other processes or threads appending to the same file. This is
178    /// unlike <code>[seek]\([SeekFrom]::[End]\(0))</code> followed by `write()`, which
179    /// has a race between seeking and writing during which another writer can write, with
180    /// our `write()` overwriting their data.
181    ///
182    /// Keep in mind that this does not necessarily guarantee that data appended by
183    /// different processes or threads does not interleave. The amount of data accepted a
184    /// single `write()` call depends on the operating system and file system. A
185    /// successful `write()` is allowed to write only part of the given data, so even if
186    /// you're careful to provide the whole message in a single call to `write()`, there
187    /// is no guarantee that it will be written out in full. If you rely on the filesystem
188    /// accepting the message in a single write, make sure that all data that belongs
189    /// together is written in one operation. This can be done by concatenating strings
190    /// before passing them to [`write()`].
191    ///
192    /// If a file is opened with both read and append access, beware that after
193    /// opening, and after every write, the position for reading may be set at the
194    /// end of the file. So, before writing, save the current position (using
195    /// <code>[Seek]::[stream_position]</code>), and restore it before the next read.
196    ///
197    /// ## Note
198    ///
199    /// This function doesn't create the file if it doesn't exist. Use the
200    /// [`OpenOptions::create`] method to do so.
201    ///
202    /// [`write()`]: Write::write "io::Write::write"
203    /// [`flush()`]: Write::flush "io::Write::flush"
204    /// [stream_position]: Seek::stream_position "io::Seek::stream_position"
205    /// [seek]: Seek::seek "io::Seek::seek"
206    /// [Current]: SeekFrom::Current "io::SeekFrom::Current"
207    /// [End]: SeekFrom::End "io::SeekFrom::End"
208    ///
209    /// # Examples
210    ///
211    /// ```no_run
212    /// use tauri_plugin_fs::OpenOptions;
213    ///
214    /// let file = OpenOptions::new().append(true).open("foo.txt");
215    /// ```
216    pub fn append(&mut self, append: bool) -> &mut Self {
217        self.append = append;
218        self
219    }
220
221    /// Sets the option for truncating a previous file.
222    ///
223    /// If a file is successfully opened with this option set it will truncate
224    /// the file to 0 length if it already exists.
225    ///
226    /// The file must be opened with write access for truncate to work.
227    ///
228    /// # Examples
229    ///
230    /// ```no_run
231    /// use tauri_plugin_fs::OpenOptions;
232    ///
233    /// let file = OpenOptions::new().write(true).truncate(true).open("foo.txt");
234    /// ```
235    pub fn truncate(&mut self, truncate: bool) -> &mut Self {
236        self.truncate = truncate;
237        self
238    }
239
240    /// Sets the option to create a new file, or open it if it already exists.
241    ///
242    /// In order for the file to be created, [`OpenOptions::write`] or
243    /// [`OpenOptions::append`] access must be used.
244    ///
245    ///
246    /// # Examples
247    ///
248    /// ```no_run
249    /// use tauri_plugin_fs::OpenOptions;
250    ///
251    /// let file = OpenOptions::new().write(true).create(true).open("foo.txt");
252    /// ```
253    pub fn create(&mut self, create: bool) -> &mut Self {
254        self.create = create;
255        self
256    }
257
258    /// Sets the option to create a new file, failing if it already exists.
259    ///
260    /// No file is allowed to exist at the target location, also no (dangling) symlink. In this
261    /// way, if the call succeeds, the file returned is guaranteed to be new.
262    /// If a file exists at the target location, creating a new file will fail with [`AlreadyExists`]
263    /// or another error based on the situation. See [`OpenOptions::open`] for a
264    /// non-exhaustive list of likely errors.
265    ///
266    /// This option is useful because it is atomic. Otherwise between checking
267    /// whether a file exists and creating a new one, the file may have been
268    /// created by another process (a TOCTOU race condition / attack).
269    ///
270    /// If `.create_new(true)` is set, [`.create()`] and [`.truncate()`] are
271    /// ignored.
272    ///
273    /// The file must be opened with write or append access in order to create
274    /// a new file.
275    ///
276    /// [`.create()`]: OpenOptions::create
277    /// [`.truncate()`]: OpenOptions::truncate
278    /// [`AlreadyExists`]: io::ErrorKind::AlreadyExists
279    ///
280    /// # Examples
281    ///
282    /// ```no_run
283    /// use tauri_plugin_fs::OpenOptions;
284    ///
285    /// let file = OpenOptions::new().write(true)
286    ///                              .create_new(true)
287    ///                              .open("foo.txt");
288    /// ```
289    pub fn create_new(&mut self, create_new: bool) -> &mut Self {
290        self.create_new = create_new;
291        self
292    }
293}
294
295#[cfg(unix)]
296impl std::os::unix::fs::OpenOptionsExt for OpenOptions {
297    fn custom_flags(&mut self, flags: i32) -> &mut Self {
298        self.custom_flags.replace(flags);
299        self
300    }
301
302    fn mode(&mut self, mode: u32) -> &mut Self {
303        self.mode.replace(mode);
304        self
305    }
306}
307
308impl OpenOptions {
309    #[cfg(target_os = "android")]
310    fn android_mode(&self) -> String {
311        let mut mode = String::new();
312
313        if self.read {
314            mode.push('r');
315        }
316        if self.write {
317            mode.push('w');
318        }
319        if self.truncate {
320            mode.push('t');
321        }
322        if self.append {
323            mode.push('a');
324        }
325
326        mode
327    }
328}
329
330impl<R: Runtime> Fs<R> {
331    pub fn read_to_string<P: Into<FilePath>>(&self, path: P) -> std::io::Result<String> {
332        let mut s = String::new();
333        self.open(
334            path,
335            OpenOptions {
336                read: true,
337                ..Default::default()
338            },
339        )?
340        .read_to_string(&mut s)?;
341        Ok(s)
342    }
343
344    pub fn read<P: Into<FilePath>>(&self, path: P) -> std::io::Result<Vec<u8>> {
345        let mut buf = Vec::new();
346        self.open(
347            path,
348            OpenOptions {
349                read: true,
350                ..Default::default()
351            },
352        )?
353        .read_to_end(&mut buf)?;
354        Ok(buf)
355    }
356}
357
358// implement ScopeObject here instead of in the scope module because it is also used on the build script
359// and we don't want to add tauri as a build dependency
360impl ScopeObject for scope::Entry {
361    type Error = Error;
362    fn deserialize<R: Runtime>(
363        app: &AppHandle<R>,
364        raw: Value,
365    ) -> std::result::Result<Self, Self::Error> {
366        let path = serde_json::from_value(raw.into()).map(|raw| match raw {
367            scope::EntryRaw::Value(path) => path,
368            scope::EntryRaw::Object { path } => path,
369        })?;
370
371        match app.path().parse(path) {
372            Ok(path) => Ok(Self { path: Some(path) }),
373            #[cfg(not(target_os = "android"))]
374            Err(tauri::Error::UnknownPath) => Ok(Self { path: None }),
375            Err(err) => Err(err.into()),
376        }
377    }
378}
379
380pub(crate) struct Scope {
381    pub(crate) scope: tauri::fs::Scope,
382    pub(crate) require_literal_leading_dot: Option<bool>,
383}
384
385/// Tracks which paths have active security-scoped resource access on iOS.
386#[cfg(target_os = "ios")]
387pub(crate) struct SecurityScopedResources {
388    /// Set of file URLs that are currently accessing security-scoped resources.
389    /// The key is the URL string representation.
390    pub(crate) active_urls: Mutex<std::collections::HashSet<String>>,
391}
392
393#[cfg(target_os = "ios")]
394impl SecurityScopedResources {
395    pub(crate) fn new() -> Self {
396        Self {
397            active_urls: Mutex::new(std::collections::HashSet::new()),
398        }
399    }
400
401    pub(crate) fn is_tracked_manually(&self, url: &str) -> bool {
402        self.active_urls.lock().unwrap().contains(url)
403    }
404
405    pub(crate) fn track_manually(&self, url: String) {
406        self.active_urls.lock().unwrap().insert(url);
407    }
408
409    pub(crate) fn remove(&self, url: &str) {
410        self.active_urls.lock().unwrap().remove(url);
411    }
412}
413
414#[cfg(not(target_os = "ios"))]
415pub(crate) struct SecurityScopedResources;
416
417#[cfg(not(target_os = "ios"))]
418impl SecurityScopedResources {
419    pub(crate) fn new() -> Self {
420        Self
421    }
422
423    #[allow(dead_code)] // Used on iOS, but not on other platforms
424    pub(crate) fn is_tracked_manually(&self, _url: &str) -> bool {
425        false
426    }
427
428    #[allow(dead_code)] // Used on iOS, but not on other platforms
429    pub(crate) fn track_manually(&self, _url: String) {}
430
431    #[allow(dead_code)] // Used on iOS, but not on other platforms
432    pub(crate) fn remove(&self, _url: &str) {}
433}
434
435pub trait FsExt<R: Runtime> {
436    fn fs_scope(&self) -> tauri::fs::Scope;
437    fn try_fs_scope(&self) -> Option<tauri::fs::Scope>;
438
439    /// Cross platform file system APIs that also support manipulating Android files.
440    fn fs(&self) -> &Fs<R>;
441}
442
443impl<R: Runtime, T: Manager<R>> FsExt<R> for T {
444    fn fs_scope(&self) -> tauri::fs::Scope {
445        self.state::<Scope>().scope.clone()
446    }
447
448    fn try_fs_scope(&self) -> Option<tauri::fs::Scope> {
449        self.try_state::<Scope>().map(|s| s.scope.clone())
450    }
451
452    fn fs(&self) -> &Fs<R> {
453        self.state::<Fs<R>>().inner()
454    }
455}
456
457pub fn init<R: Runtime>() -> TauriPlugin<R, Option<config::Config>> {
458    PluginBuilder::<R, Option<config::Config>>::new("fs")
459        .invoke_handler(tauri::generate_handler![
460            commands::create,
461            commands::open,
462            commands::copy_file,
463            commands::mkdir,
464            commands::read_dir,
465            commands::read,
466            commands::read_file,
467            commands::read_text_file,
468            commands::read_text_file_lines,
469            commands::read_text_file_lines_next,
470            commands::remove,
471            commands::rename,
472            commands::seek,
473            commands::stat,
474            commands::lstat,
475            commands::fstat,
476            commands::truncate,
477            commands::ftruncate,
478            commands::write,
479            commands::write_file,
480            commands::write_text_file,
481            commands::exists,
482            commands::size,
483            commands::start_accessing_security_scoped_resource,
484            commands::stop_accessing_security_scoped_resource,
485            #[cfg(feature = "watch")]
486            watcher::watch,
487        ])
488        .setup(|app, api| {
489            let scope = Scope {
490                require_literal_leading_dot: api
491                    .config()
492                    .as_ref()
493                    .and_then(|c| c.require_literal_leading_dot),
494                scope: tauri::fs::Scope::new(app, &FsScope::default())?,
495            };
496
497            #[cfg(target_os = "android")]
498            {
499                let fs = android::init(app, api)?;
500                app.manage(fs);
501            }
502            #[cfg(target_os = "ios")]
503            {
504                let fs = ios::init(app, api)?;
505                app.manage(fs);
506            }
507            #[cfg(desktop)]
508            app.manage(Fs(app.clone()));
509
510            app.manage(scope);
511            app.manage(SecurityScopedResources::new());
512            Ok(())
513        })
514        .on_event(|app, event| {
515            if let RunEvent::WindowEvent {
516                label: _,
517                event: WindowEvent::DragDrop(DragDropEvent::Drop { paths, position: _ }),
518                ..
519            } = event
520            {
521                let scope = app.fs_scope();
522                for path in paths {
523                    if path.is_file() {
524                        let _ = scope.allow_file(path);
525                    } else {
526                        let _ = scope.allow_directory(path, true);
527                    }
528                }
529            }
530        })
531        .build()
532}