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