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}