From 6895ec5b3a230c5bee88dbe6f11aca41a31a8f23 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Sat, 15 Apr 2023 12:36:59 -0300 Subject: [PATCH] feat(fs): add plugin --- Cargo.lock | 24 +- Cargo.toml | 4 +- plugins/fs-extra/guest-js/index.ts | 130 ---- plugins/fs-extra/src/lib.rs | 132 ---- plugins/{fs-extra => fs}/.gitignore | 0 plugins/{fs-extra => fs}/Cargo.toml | 7 +- plugins/{fs-extra => fs}/LICENSE.spdx | 0 plugins/{fs-extra => fs}/LICENSE_APACHE-2.0 | 0 plugins/{fs-extra => fs}/LICENSE_MIT | 0 plugins/{fs-extra => fs}/README.md | 16 +- plugins/{fs-extra => fs}/banner.png | Bin plugins/fs/guest-js/index.ts | 691 ++++++++++++++++++++ plugins/{fs-extra => fs}/package.json | 4 +- plugins/{fs-extra => fs}/rollup.config.mjs | 0 plugins/fs/src/commands.rs | 390 +++++++++++ plugins/fs/src/error.rs | 22 + plugins/fs/src/lib.rs | 33 + plugins/{fs-extra => fs}/tsconfig.json | 0 pnpm-lock.yaml | 2 +- 19 files changed, 1162 insertions(+), 293 deletions(-) delete mode 100644 plugins/fs-extra/guest-js/index.ts delete mode 100644 plugins/fs-extra/src/lib.rs rename plugins/{fs-extra => fs}/.gitignore (100%) rename plugins/{fs-extra => fs}/Cargo.toml (71%) rename plugins/{fs-extra => fs}/LICENSE.spdx (100%) rename plugins/{fs-extra => fs}/LICENSE_APACHE-2.0 (100%) rename plugins/{fs-extra => fs}/LICENSE_MIT (100%) rename plugins/{fs-extra => fs}/README.md (76%) rename plugins/{fs-extra => fs}/banner.png (100%) create mode 100644 plugins/fs/guest-js/index.ts rename plugins/{fs-extra => fs}/package.json (84%) rename plugins/{fs-extra => fs}/rollup.config.mjs (100%) create mode 100644 plugins/fs/src/commands.rs create mode 100644 plugins/fs/src/error.rs create mode 100644 plugins/fs/src/lib.rs rename plugins/{fs-extra => fs}/tsconfig.json (100%) diff --git a/Cargo.lock b/Cargo.lock index 38e4f0a4..d9b83e63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4400,8 +4400,7 @@ dependencies = [ [[package]] name = "tauri" version = "2.0.0-alpha.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25eefe4ca0a396a73fd0309f778eeb22a19953a3169bf316b893abadc2118fb" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/move-fs#5659ca10eb68b8c0a2439ff6c10585b4f38d8cc2" dependencies = [ "anyhow", "bytes 1.4.0", @@ -4452,8 +4451,7 @@ dependencies = [ [[package]] name = "tauri-build" version = "2.0.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c492211c72b95f8866e5c1fbc0a915080a5ebb9f03f9b250a1c936534b680a76" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/move-fs#5659ca10eb68b8c0a2439ff6c10585b4f38d8cc2" dependencies = [ "anyhow", "cargo_toml", @@ -4472,8 +4470,7 @@ dependencies = [ [[package]] name = "tauri-codegen" version = "2.0.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818c570932ebc2ff6d498be89d93494b89ff142131937a7e56d7cfb9c8ef0ad0" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/move-fs#5659ca10eb68b8c0a2439ff6c10585b4f38d8cc2" dependencies = [ "base64 0.21.0", "brotli", @@ -4498,8 +4495,7 @@ dependencies = [ [[package]] name = "tauri-macros" version = "2.0.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3b596485d89003d2d7869469b2830e9a846de9ac2eecd69bc7c24890234aefc" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/move-fs#5659ca10eb68b8c0a2439ff6c10585b4f38d8cc2" dependencies = [ "heck", "proc-macro2", @@ -4553,9 +4549,10 @@ dependencies = [ ] [[package]] -name = "tauri-plugin-fs-extra" +name = "tauri-plugin-fs" version = "0.1.0" dependencies = [ + "anyhow", "log", "serde", "serde_json", @@ -4734,8 +4731,7 @@ dependencies = [ [[package]] name = "tauri-runtime" version = "0.13.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404367cd32a5a8d33368448aab7da54bb2187b6a632526f1019de3fd13591cc2" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/move-fs#5659ca10eb68b8c0a2439ff6c10585b4f38d8cc2" dependencies = [ "gtk", "http", @@ -4756,8 +4752,7 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" version = "0.13.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "203764d673b440877dea87b972772be4091ee0ab25141748008646ca774a20dc" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/move-fs#5659ca10eb68b8c0a2439ff6c10585b4f38d8cc2" dependencies = [ "cocoa", "gtk", @@ -4777,8 +4772,7 @@ dependencies = [ [[package]] name = "tauri-utils" version = "2.0.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49fa79bc56f04ece491268a64273de945f65627bcda30d9e8ecc8708b89bca26" +source = "git+https://github.com/tauri-apps/tauri?branch=refactor/move-fs#5659ca10eb68b8c0a2439ff6c10585b4f38d8cc2" dependencies = [ "brotli", "ctor", diff --git a/Cargo.toml b/Cargo.toml index 5f7de541..c72946a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,8 @@ resolver = "2" [workspace.dependencies] serde = { version = "1", features = ["derive"] } log = "0.4" -tauri = "2.0.0-alpha.8" -tauri-build = "2.0.0-alpha.4" +tauri = { git = "https://github.com/tauri-apps/tauri", branch = "refactor/move-fs" } +tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "refactor/move-fs" } serde_json = "1" thiserror = "1" diff --git a/plugins/fs-extra/guest-js/index.ts b/plugins/fs-extra/guest-js/index.ts deleted file mode 100644 index 3236f645..00000000 --- a/plugins/fs-extra/guest-js/index.ts +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright 2019-2021 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -import { invoke } from "@tauri-apps/api/tauri"; - -export interface Permissions { - /** - * `true` if these permissions describe a readonly (unwritable) file. - */ - readonly: boolean; - /** - * The underlying raw `st_mode` bits that contain the standard Unix permissions for this file. - */ - mode: number | undefined; -} - -/** - * Metadata information about a file. - * This structure is returned from the `metadata` function or method - * and represents known metadata about a file such as its permissions, size, modification times, etc. - */ -export interface Metadata { - /** - * The last access time of this metadata. - */ - accessedAt: Date; - /** - * The creation time listed in this metadata. - */ - createdAt: Date; - /** - * The last modification time listed in this metadata. - */ - modifiedAt: Date; - /** - * `true` if this metadata is for a directory. - */ - isDir: boolean; - /** - * `true` if this metadata is for a regular file. - */ - isFile: boolean; - /** - * `true` if this metadata is for a symbolic link. - */ - isSymlink: boolean; - /** - * The size of the file, in bytes, this metadata is for. - */ - size: number; - /** - * The permissions of the file this metadata is for. - */ - permissions: Permissions; - /** - * The ID of the device containing the file. Only available on Unix. - */ - dev: number | undefined; - /** - * The inode number. Only available on Unix. - */ - ino: number | undefined; - /** - * The rights applied to this file. Only available on Unix. - */ - mode: number | undefined; - /** - * The number of hard links pointing to this file. Only available on Unix. - */ - nlink: number | undefined; - /** - * The user ID of the owner of this file. Only available on Unix. - */ - uid: number | undefined; - /** - * The group ID of the owner of this file. Only available on Unix. - */ - gid: number | undefined; - /** - * The device ID of this file (if it is a special one). Only available on Unix. - */ - rdev: number | undefined; - /** - * The block size for filesystem I/O. Only available on Unix. - */ - blksize: number | undefined; - /** - * The number of blocks allocated to the file, in 512-byte units. Only available on Unix. - */ - blocks: number | undefined; -} - -interface BackendMetadata { - accessedAtMs: number; - createdAtMs: number; - modifiedAtMs: number; - isDir: boolean; - isFile: boolean; - isSymlink: boolean; - size: number; - permissions: Permissions; - dev: number | undefined; - ino: number | undefined; - mode: number | undefined; - nlink: number | undefined; - uid: number | undefined; - gid: number | undefined; - rdev: number | undefined; - blksize: number | undefined; - blocks: number | undefined; -} - -export async function metadata(path: string): Promise { - return await invoke("plugin:fs-extra|metadata", { - path, - }).then((metadata) => { - const { accessedAtMs, createdAtMs, modifiedAtMs, ...data } = metadata; - return { - accessedAt: new Date(accessedAtMs), - createdAt: new Date(createdAtMs), - modifiedAt: new Date(modifiedAtMs), - ...data, - }; - }); -} - -export async function exists(path: string): Promise { - return await invoke("plugin:fs-extra|exists", { path }); -} diff --git a/plugins/fs-extra/src/lib.rs b/plugins/fs-extra/src/lib.rs deleted file mode 100644 index 621a28bd..00000000 --- a/plugins/fs-extra/src/lib.rs +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2019-2021 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -use serde::{ser::Serializer, Serialize}; -use tauri::{ - command, - plugin::{Builder as PluginBuilder, TauriPlugin}, - Runtime, -}; - -use std::{ - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, -}; - -#[cfg(unix)] -use std::os::unix::fs::{MetadataExt, PermissionsExt}; -#[cfg(windows)] -use std::os::windows::fs::MetadataExt; - -type Result = std::result::Result; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error(transparent)] - Io(#[from] std::io::Error), -} - -impl Serialize for Error { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - serializer.serialize_str(self.to_string().as_ref()) - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct Permissions { - readonly: bool, - #[cfg(unix)] - mode: u32, -} - -#[cfg(unix)] -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct UnixMetadata { - dev: u64, - ino: u64, - mode: u32, - nlink: u64, - uid: u32, - gid: u32, - rdev: u64, - blksize: u64, - blocks: u64, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct Metadata { - accessed_at_ms: u64, - created_at_ms: u64, - modified_at_ms: u64, - is_dir: bool, - is_file: bool, - is_symlink: bool, - size: u64, - permissions: Permissions, - #[cfg(unix)] - #[serde(flatten)] - unix: UnixMetadata, - #[cfg(windows)] - file_attributes: u32, -} - -fn system_time_to_ms(time: std::io::Result) -> u64 { - time.map(|t| { - let duration_since_epoch = t.duration_since(UNIX_EPOCH).unwrap(); - duration_since_epoch.as_millis() as u64 - }) - .unwrap_or_default() -} - -#[command] -async fn metadata(path: PathBuf) -> Result { - let metadata = std::fs::metadata(path)?; - let file_type = metadata.file_type(); - let permissions = metadata.permissions(); - Ok(Metadata { - accessed_at_ms: system_time_to_ms(metadata.accessed()), - created_at_ms: system_time_to_ms(metadata.created()), - modified_at_ms: system_time_to_ms(metadata.modified()), - is_dir: file_type.is_dir(), - is_file: file_type.is_file(), - is_symlink: file_type.is_symlink(), - size: metadata.len(), - permissions: Permissions { - readonly: permissions.readonly(), - #[cfg(unix)] - mode: permissions.mode(), - }, - #[cfg(unix)] - unix: UnixMetadata { - dev: metadata.dev(), - ino: metadata.ino(), - mode: metadata.mode(), - nlink: metadata.nlink(), - uid: metadata.uid(), - gid: metadata.gid(), - rdev: metadata.rdev(), - blksize: metadata.blksize(), - blocks: metadata.blocks(), - }, - #[cfg(windows)] - file_attributes: metadata.file_attributes(), - }) -} - -#[command] -async fn exists(path: PathBuf) -> bool { - path.exists() -} - -pub fn init() -> TauriPlugin { - PluginBuilder::new("fs-extra") - .invoke_handler(tauri::generate_handler![exists, metadata]) - .build() -} diff --git a/plugins/fs-extra/.gitignore b/plugins/fs/.gitignore similarity index 100% rename from plugins/fs-extra/.gitignore rename to plugins/fs/.gitignore diff --git a/plugins/fs-extra/Cargo.toml b/plugins/fs/Cargo.toml similarity index 71% rename from plugins/fs-extra/Cargo.toml rename to plugins/fs/Cargo.toml index 79b0e7ce..69527bbb 100644 --- a/plugins/fs-extra/Cargo.toml +++ b/plugins/fs/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "tauri-plugin-fs-extra" +name = "tauri-plugin-fs" version = "0.1.0" -description = "Additional file system methods not included in the core API." +description = "Access the file system." authors.workspace = true license.workspace = true edition.workspace = true @@ -14,4 +14,5 @@ serde.workspace = true serde_json.workspace = true tauri.workspace = true log.workspace = true -thiserror.workspace = true \ No newline at end of file +thiserror.workspace = true +anyhow = "1" diff --git a/plugins/fs-extra/LICENSE.spdx b/plugins/fs/LICENSE.spdx similarity index 100% rename from plugins/fs-extra/LICENSE.spdx rename to plugins/fs/LICENSE.spdx diff --git a/plugins/fs-extra/LICENSE_APACHE-2.0 b/plugins/fs/LICENSE_APACHE-2.0 similarity index 100% rename from plugins/fs-extra/LICENSE_APACHE-2.0 rename to plugins/fs/LICENSE_APACHE-2.0 diff --git a/plugins/fs-extra/LICENSE_MIT b/plugins/fs/LICENSE_MIT similarity index 100% rename from plugins/fs-extra/LICENSE_MIT rename to plugins/fs/LICENSE_MIT diff --git a/plugins/fs-extra/README.md b/plugins/fs/README.md similarity index 76% rename from plugins/fs-extra/README.md rename to plugins/fs/README.md index 98d311fe..4d5a2cd6 100644 --- a/plugins/fs-extra/README.md +++ b/plugins/fs/README.md @@ -1,6 +1,6 @@ -![tauri-plugin-fs-extra](banner.png) +![tauri-plugin-fs](banner.png) -Additional file system methods not included in the core API. +Access the file system. ## Install @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-fs = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: @@ -26,11 +26,11 @@ You can install the JavaScript Guest bindings using your preferred JavaScript pa > Note: Since most JavaScript package managers are unable to install packages from git monorepos we provide read-only mirrors of each plugin. This makes installation option 2 more ergonomic to use. ```sh -pnpm add https://github.com/tauri-apps/tauri-plugin-fs-extra +pnpm add https://github.com/tauri-apps/tauri-plugin-fs # or -npm add https://github.com/tauri-apps/tauri-plugin-fs-extra +npm add https://github.com/tauri-apps/tauri-plugin-fs # or -yarn add https://github.com/tauri-apps/tauri-plugin-fs-extra +yarn add https://github.com/tauri-apps/tauri-plugin-fs ``` ## Usage @@ -42,7 +42,7 @@ First you need to register the core plugin with Tauri: ```rust fn main() { tauri::Builder::default() - .plugin(tauri_plugin_fs_extra::init()) + .plugin(tauri_plugin_fs::init()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } @@ -51,7 +51,7 @@ fn main() { Afterwards all the plugin's APIs are available through the JavaScript guest bindings: ```javascript -import { metadata } from "tauri-plugin-fs-extra-api"; +import { metadata } from "tauri-plugin-fs-api"; await metadata("/path/to/file"); ``` diff --git a/plugins/fs-extra/banner.png b/plugins/fs/banner.png similarity index 100% rename from plugins/fs-extra/banner.png rename to plugins/fs/banner.png diff --git a/plugins/fs/guest-js/index.ts b/plugins/fs/guest-js/index.ts new file mode 100644 index 00000000..9fa5f24c --- /dev/null +++ b/plugins/fs/guest-js/index.ts @@ -0,0 +1,691 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/** + * Access the file system. + * + * This package is also accessible with `window.__TAURI__.fs` when [`build.withGlobalTauri`](https://tauri.app/v1/api/config/#buildconfig.withglobaltauri) in `tauri.conf.json` is set to `true`. + * + * The APIs must be added to [`tauri.allowlist.fs`](https://tauri.app/v1/api/config/#allowlistconfig.fs) in `tauri.conf.json`: + * ```json + * { + * "tauri": { + * "allowlist": { + * "fs": { + * "all": true, // enable all FS APIs + * "readFile": true, + * "writeFile": true, + * "readDir": true, + * "copyFile": true, + * "createDir": true, + * "removeDir": true, + * "removeFile": true, + * "renameFile": true, + * "metadata": true, + * "exists": true + * } + * } + * } + * } + * ``` + * It is recommended to allowlist only the APIs you use for optimal bundle size and security. + * + * ## Security + * + * This module prevents path traversal, not allowing absolute paths or parent dir components + * (i.e. "/usr/path/to/file" or "../path/to/file" paths are not allowed). + * Paths accessed with this API must be relative to one of the {@link BaseDirectory | base directories} + * so if you need access to arbitrary filesystem paths, you must write such logic on the core layer instead. + * + * The API has a scope configuration that forces you to restrict the paths that can be accessed using glob patterns. + * + * The scope configuration is an array of glob patterns describing folder paths that are allowed. + * For instance, this scope configuration only allows accessing files on the + * *databases* folder of the {@link path.appDataDir | $APPDATA directory}: + * ```json + * { + * "tauri": { + * "allowlist": { + * "fs": { + * "scope": ["$APPDATA/databases/*"] + * } + * } + * } + * } + * ``` + * + * Notice the use of the `$APPDATA` variable. The value is injected at runtime, resolving to the {@link path.appDataDir | app data directory}. + * The available variables are: + * {@link path.appConfigDir | `$APPCONFIG`}, {@link path.appDataDir | `$APPDATA`}, {@link path.appLocalDataDir | `$APPLOCALDATA`}, + * {@link path.appCacheDir | `$APPCACHE`}, {@link path.appLogDir | `$APPLOG`}, + * {@link path.audioDir | `$AUDIO`}, {@link path.cacheDir | `$CACHE`}, {@link path.configDir | `$CONFIG`}, {@link path.dataDir | `$DATA`}, + * {@link path.localDataDir | `$LOCALDATA`}, {@link path.desktopDir | `$DESKTOP`}, {@link path.documentDir | `$DOCUMENT`}, + * {@link path.downloadDir | `$DOWNLOAD`}, {@link path.executableDir | `$EXE`}, {@link path.fontDir | `$FONT`}, {@link path.homeDir | `$HOME`}, + * {@link path.pictureDir | `$PICTURE`}, {@link path.publicDir | `$PUBLIC`}, {@link path.runtimeDir | `$RUNTIME`}, + * {@link path.templateDir | `$TEMPLATE`}, {@link path.videoDir | `$VIDEO`}, {@link path.resourceDir | `$RESOURCE`}, + * {@link os.tempdir | `$TEMP`}. + * + * Trying to execute any API with a URL not configured on the scope results in a promise rejection due to denied access. + * + * Note that this scope applies to **all** APIs on this module. + * + * @module + */ + +import { invoke } from "@tauri-apps/api/tauri"; + +interface Permissions { + /** + * `true` if these permissions describe a readonly (unwritable) file. + */ + readonly: boolean; + /** + * The underlying raw `st_mode` bits that contain the standard Unix permissions for this file. + */ + mode: number | undefined; +} + +/** + * Metadata information about a file. + * This structure is returned from the `metadata` function or method + * and represents known metadata about a file such as its permissions, size, modification times, etc. + */ +interface Metadata { + /** + * The last access time of this metadata. + */ + accessedAt: Date; + /** + * The creation time listed in this metadata. + */ + createdAt: Date; + /** + * The last modification time listed in this metadata. + */ + modifiedAt: Date; + /** + * `true` if this metadata is for a directory. + */ + isDir: boolean; + /** + * `true` if this metadata is for a regular file. + */ + isFile: boolean; + /** + * `true` if this metadata is for a symbolic link. + */ + isSymlink: boolean; + /** + * The size of the file, in bytes, this metadata is for. + */ + size: number; + /** + * The permissions of the file this metadata is for. + */ + permissions: Permissions; + /** + * The ID of the device containing the file. Only available on Unix. + */ + dev: number | undefined; + /** + * The inode number. Only available on Unix. + */ + ino: number | undefined; + /** + * The rights applied to this file. Only available on Unix. + */ + mode: number | undefined; + /** + * The number of hard links pointing to this file. Only available on Unix. + */ + nlink: number | undefined; + /** + * The user ID of the owner of this file. Only available on Unix. + */ + uid: number | undefined; + /** + * The group ID of the owner of this file. Only available on Unix. + */ + gid: number | undefined; + /** + * The device ID of this file (if it is a special one). Only available on Unix. + */ + rdev: number | undefined; + /** + * The block size for filesystem I/O. Only available on Unix. + */ + blksize: number | undefined; + /** + * The number of blocks allocated to the file, in 512-byte units. Only available on Unix. + */ + blocks: number | undefined; +} + +interface BackendMetadata { + accessedAtMs: number; + createdAtMs: number; + modifiedAtMs: number; + isDir: boolean; + isFile: boolean; + isSymlink: boolean; + size: number; + permissions: Permissions; + dev: number | undefined; + ino: number | undefined; + mode: number | undefined; + nlink: number | undefined; + uid: number | undefined; + gid: number | undefined; + rdev: number | undefined; + blksize: number | undefined; + blocks: number | undefined; +} + +// TODO: pull BaseDirectory from @tauri-apps/api/path +/** + * @since 1.0.0 + */ +enum BaseDirectory { + Audio = 1, + Cache, + Config, + Data, + LocalData, + Document, + Download, + Picture, + Public, + Video, + Resource, + Temp, + AppConfig, + AppData, + AppLocalData, + AppCache, + AppLog, + + Desktop, + Executable, + Font, + Home, + Runtime, + Template +} + +/** + * @since 1.0.0 + */ +interface FsOptions { + dir?: BaseDirectory + // note that adding fields here needs a change in the writeBinaryFile check +} + +/** + * @since 1.0.0 + */ +interface FsDirOptions { + dir?: BaseDirectory + recursive?: boolean +} + +/** + * Options object used to write a UTF-8 string to a file. + * + * @since 1.0.0 + */ +interface FsTextFileOption { + /** Path to the file to write. */ + path: string + /** The UTF-8 string to write to the file. */ + contents: string +} + +type BinaryFileContents = Iterable | ArrayLike | ArrayBuffer + +/** + * Options object used to write a binary data to a file. + * + * @since 1.0.0 + */ +interface FsBinaryFileOption { + /** Path to the file to write. */ + path: string + /** The byte array contents. */ + contents: BinaryFileContents +} + +/** + * @since 1.0.0 + */ +interface FileEntry { + path: string + /** + * Name of the directory/file + * can be null if the path terminates with `..` + */ + name?: string + /** Children of this entry if it's a directory; null otherwise */ + children?: FileEntry[] +} + +/** + * Reads a file as an UTF-8 encoded string. + * @example + * ```typescript + * import { readTextFile, BaseDirectory } from '@tauri-apps/api/fs'; + * // Read the text file in the `$APPCONFIG/app.conf` path + * const contents = await readTextFile('app.conf', { dir: BaseDirectory.AppConfig }); + * ``` + * + * @since 1.0.0 + */ +async function readTextFile( + filePath: string, + options: FsOptions = {} +): Promise { + return await invoke("plugin:fs|read_text_file", { + path: filePath, + options + }) +} + +/** + * Reads a file as byte array. + * @example + * ```typescript + * import { readBinaryFile, BaseDirectory } from '@tauri-apps/api/fs'; + * // Read the image file in the `$RESOURCEDIR/avatar.png` path + * const contents = await readBinaryFile('avatar.png', { dir: BaseDirectory.Resource }); + * ``` + * + * @since 1.0.0 + */ +async function readBinaryFile( + filePath: string, + options: FsOptions = {} +): Promise { + const arr = await invoke("plugin:fs|read_file", { + path: filePath, + options + }) + + return Uint8Array.from(arr) +} + +/** + * Writes a UTF-8 text file. + * @example + * ```typescript + * import { writeTextFile, BaseDirectory } from '@tauri-apps/api/fs'; + * // Write a text file to the `$APPCONFIG/app.conf` path + * await writeTextFile('app.conf', 'file contents', { dir: BaseDirectory.AppConfig }); + * ``` + * + * @since 1.0.0 + */ +async function writeTextFile( + path: string, + contents: string, + options?: FsOptions +): Promise + +/** + * Writes a UTF-8 text file. + * @example + * ```typescript + * import { writeTextFile, BaseDirectory } from '@tauri-apps/api/fs'; + * // Write a text file to the `$APPCONFIG/app.conf` path + * await writeTextFile({ path: 'app.conf', contents: 'file contents' }, { dir: BaseDirectory.AppConfig }); + * ``` + * @returns A promise indicating the success or failure of the operation. + * + * @since 1.0.0 + */ +async function writeTextFile( + file: FsTextFileOption, + options?: FsOptions +): Promise + +/** + * Writes a UTF-8 text file. + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 1.0.0 + */ +async function writeTextFile( + path: string | FsTextFileOption, + contents?: string | FsOptions, + options?: FsOptions +): Promise { + if (typeof options === 'object') { + Object.freeze(options) + } + if (typeof path === 'object') { + Object.freeze(path) + } + + const file: FsTextFileOption = { path: '', contents: '' } + let fileOptions: FsOptions | undefined = options + if (typeof path === 'string') { + file.path = path + } else { + file.path = path.path + file.contents = path.contents + } + + if (typeof contents === 'string') { + file.contents = contents ?? '' + } else { + fileOptions = contents + } + + return await invoke("plugin:fs|write_file", { + path: file.path, + contents: Array.from(new TextEncoder().encode(file.contents)), + options: fileOptions + }) +} + +/** + * Writes a byte array content to a file. + * @example + * ```typescript + * import { writeBinaryFile, BaseDirectory } from '@tauri-apps/api/fs'; + * // Write a binary file to the `$APPDATA/avatar.png` path + * await writeBinaryFile('avatar.png', new Uint8Array([]), { dir: BaseDirectory.AppData }); + * ``` + * + * @param options Configuration object. + * @returns A promise indicating the success or failure of the operation. + * + * @since 1.0.0 + */ +async function writeBinaryFile( + path: string, + contents: BinaryFileContents, + options?: FsOptions +): Promise + +/** + * Writes a byte array content to a file. + * @example + * ```typescript + * import { writeBinaryFile, BaseDirectory } from '@tauri-apps/api/fs'; + * // Write a binary file to the `$APPDATA/avatar.png` path + * await writeBinaryFile({ path: 'avatar.png', contents: new Uint8Array([]) }, { dir: BaseDirectory.AppData }); + * ``` + * + * @param file The object containing the file path and contents. + * @param options Configuration object. + * @returns A promise indicating the success or failure of the operation. + * + * @since 1.0.0 + */ +async function writeBinaryFile( + file: FsBinaryFileOption, + options?: FsOptions +): Promise + +/** + * Writes a byte array content to a file. + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 1.0.0 + */ +async function writeBinaryFile( + path: string | FsBinaryFileOption, + contents?: BinaryFileContents | FsOptions, + options?: FsOptions +): Promise { + if (typeof options === 'object') { + Object.freeze(options) + } + if (typeof path === 'object') { + Object.freeze(path) + } + + const file: FsBinaryFileOption = { path: '', contents: [] } + let fileOptions: FsOptions | undefined = options + if (typeof path === 'string') { + file.path = path + } else { + file.path = path.path + file.contents = path.contents + } + + if (contents && 'dir' in contents) { + fileOptions = contents + } else if (typeof path === 'string') { + // @ts-expect-error in this case `contents` is always a BinaryFileContents + file.contents = contents ?? [] + } + + return await invoke("plugin:fs|write_binary_file", { + path: file.path, + contents: Array.from( + file.contents instanceof ArrayBuffer + ? new Uint8Array(file.contents) + : file.contents + ), + options: fileOptions + }) +} + +/** + * List directory files. + * @example + * ```typescript + * import { readDir, BaseDirectory } from '@tauri-apps/api/fs'; + * // Reads the `$APPDATA/users` directory recursively + * const entries = await readDir('users', { dir: BaseDirectory.AppData, recursive: true }); + * + * function processEntries(entries) { + * for (const entry of entries) { + * console.log(`Entry: ${entry.path}`); + * if (entry.children) { + * processEntries(entry.children) + * } + * } + * } + * ``` + * + * @since 1.0.0 + */ +async function readDir( + dir: string, + options: FsDirOptions = {} +): Promise { + return await invoke("plugin:fs|read_dir", { + path: dir, + options + }) +} + +/** + * Creates a directory. + * If one of the path's parent components doesn't exist + * and the `recursive` option isn't set to true, the promise will be rejected. + * @example + * ```typescript + * import { createDir, BaseDirectory } from '@tauri-apps/api/fs'; + * // Create the `$APPDATA/users` directory + * await createDir('users', { dir: BaseDirectory.AppData, recursive: true }); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 1.0.0 + */ +async function createDir( + dir: string, + options: FsDirOptions = {} +): Promise { + return await invoke("plugin:fs|create_dir", { + path: dir, + options + }) +} + +/** + * Removes a directory. + * If the directory is not empty and the `recursive` option isn't set to true, the promise will be rejected. + * @example + * ```typescript + * import { removeDir, BaseDirectory } from '@tauri-apps/api/fs'; + * // Remove the directory `$APPDATA/users` + * await removeDir('users', { dir: BaseDirectory.AppData }); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 1.0.0 + */ +async function removeDir( + dir: string, + options: FsDirOptions = {} +): Promise { + return await invoke("plugin:fs|remove_dir", { + path: dir, + options + }) +} + +/** + * Copies a file to a destination. + * @example + * ```typescript + * import { copyFile, BaseDirectory } from '@tauri-apps/api/fs'; + * // Copy the `$APPCONFIG/app.conf` file to `$APPCONFIG/app.conf.bk` + * await copyFile('app.conf', 'app.conf.bk', { dir: BaseDirectory.AppConfig }); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 1.0.0 + */ +async function copyFile( + source: string, + destination: string, + options: FsOptions = {} +): Promise { + return await invoke("plugin:fs|copy_file", { + source, + destination, + options + }) +} + +/** + * Removes a file. + * @example + * ```typescript + * import { removeFile, BaseDirectory } from '@tauri-apps/api/fs'; + * // Remove the `$APPConfig/app.conf` file + * await removeFile('app.conf', { dir: BaseDirectory.AppConfig }); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 1.0.0 + */ +async function removeFile( + file: string, + options: FsOptions = {} +): Promise { + return await invoke("plugin:fs|remove_file", { + path: file, + options + }) +} + +/** + * Renames a file. + * @example + * ```typescript + * import { renameFile, BaseDirectory } from '@tauri-apps/api/fs'; + * // Rename the `$APPDATA/avatar.png` file + * await renameFile('avatar.png', 'deleted.png', { dir: BaseDirectory.AppData }); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 1.0.0 + */ +async function renameFile( + oldPath: string, + newPath: string, + options: FsOptions = {} +): Promise { + return await invoke("plugin:fs|rename_file", { + oldPath, + newPath, + options + + }) +} + +/** + * Check if a path exists. + * @example + * ```typescript + * import { exists, BaseDirectory } from '@tauri-apps/api/fs'; + * // Check if the `$APPDATA/avatar.png` file exists + * await exists('avatar.png', { dir: BaseDirectory.AppData }); + * ``` + * + * @since 1.0.0 + */ +async function exists(path: string): Promise { + return await invoke("plugin:fs|exists", { path }); +} + +/** + * Returns the metadata for the given path. + * + * @since 1.0.0 + */ +async function metadata(path: string): Promise { + return await invoke("plugin:fs|metadata", { + path, + }).then((metadata) => { + const { accessedAtMs, createdAtMs, modifiedAtMs, ...data } = metadata; + return { + accessedAt: new Date(accessedAtMs), + createdAt: new Date(createdAtMs), + modifiedAt: new Date(modifiedAtMs), + ...data, + }; + }); +} + +export type { + FsOptions, + FsDirOptions, + FsTextFileOption, + BinaryFileContents, + FsBinaryFileOption, + FileEntry, + Permissions, + Metadata, +} + +export { + BaseDirectory, + BaseDirectory as Dir, + readTextFile, + readBinaryFile, + writeTextFile, + writeTextFile as writeFile, + writeBinaryFile, + readDir, + createDir, + removeDir, + copyFile, + removeFile, + renameFile, + exists, + metadata +} \ No newline at end of file diff --git a/plugins/fs-extra/package.json b/plugins/fs/package.json similarity index 84% rename from plugins/fs-extra/package.json rename to plugins/fs/package.json index d6a32f42..0f4348e6 100644 --- a/plugins/fs-extra/package.json +++ b/plugins/fs/package.json @@ -1,7 +1,7 @@ { - "name": "tauri-plugin-fs-extra-api", + "name": "tauri-plugin-fs-api", "version": "0.0.0", - "description": "Additional file system methods not included in the core API.", + "description": "Access the file system.", "license": "MIT or APACHE-2.0", "authors": [ "Tauri Programme within The Commons Conservancy" diff --git a/plugins/fs-extra/rollup.config.mjs b/plugins/fs/rollup.config.mjs similarity index 100% rename from plugins/fs-extra/rollup.config.mjs rename to plugins/fs/rollup.config.mjs diff --git a/plugins/fs/src/commands.rs b/plugins/fs/src/commands.rs new file mode 100644 index 00000000..aa471c69 --- /dev/null +++ b/plugins/fs/src/commands.rs @@ -0,0 +1,390 @@ +use anyhow::Context; +use serde::{Deserialize, Serialize, Serializer}; +use tauri::{ + path::{BaseDirectory, SafePathBuf}, + FsScope, Manager, Runtime, Window, +}; + +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; +use std::{ + fs::{self, symlink_metadata, File}, + io::Write, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use crate::{Error, Result}; + +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error(transparent)] + Anyhow(#[from] anyhow::Error), + #[error(transparent)] + Plugin(#[from] Error), +} + +impl Serialize for CommandError { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +type CommandResult = std::result::Result; + +/// The options for the directory functions on the file system API. +#[derive(Debug, Clone, Deserialize)] +pub struct DirOperationOptions { + /// Whether the API should recursively perform the operation on the directory. + #[serde(default)] + pub recursive: bool, + /// The base directory of the operation. + /// The directory path of the BaseDirectory will be the prefix of the defined directory path. + pub dir: Option, +} + +/// The options for the file functions on the file system API. +#[derive(Debug, Clone, Deserialize)] +pub struct FileOperationOptions { + /// The base directory of the operation. + /// The directory path of the BaseDirectory will be the prefix of the defined file path. + pub dir: Option, +} + +fn resolve_path( + window: &Window, + path: SafePathBuf, + dir: Option, +) -> Result { + let path = if let Some(dir) = dir { + window + .path() + .resolve(&path, dir) + .map_err(Error::CannotResolvePath)? + } else { + path.as_ref().to_path_buf() + }; + if window.fs_scope().is_allowed(&path) { + Ok( + // safety: the path is resolved by Tauri so it is safe + unsafe { SafePathBuf::new_unchecked(path) }, + ) + } else { + Err(Error::PathForbidden(path)) + } +} + +#[tauri::command] +pub fn read_file( + window: Window, + path: SafePathBuf, + options: Option, +) -> CommandResult> { + let resolved_path = resolve_path(&window, path, options.and_then(|o| o.dir))?; + fs::read(&resolved_path) + .with_context(|| format!("path: {}", resolved_path.display())) + .map_err(Into::into) +} + +#[tauri::command] +pub fn read_text_file( + window: Window, + path: SafePathBuf, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path(&window, path, options.and_then(|o| o.dir))?; + fs::read_to_string(&resolved_path) + .with_context(|| format!("path: {}", resolved_path.display())) + .map_err(Into::into) +} + +#[tauri::command] +pub fn write_file( + window: Window, + path: SafePathBuf, + contents: Vec, + options: Option, +) -> CommandResult<()> { + let resolved_path = resolve_path(&window, path, options.and_then(|o| o.dir))?; + File::create(&resolved_path) + .with_context(|| format!("path: {}", resolved_path.display())) + .map_err(Into::into) + .and_then(|mut f| { + f.write_all(&contents) + .map_err(|err| anyhow::anyhow!("{}", err)) + .map_err(Into::into) + }) +} + +#[derive(Clone, Copy)] +struct ReadDirOptions<'a> { + pub scope: Option<&'a FsScope>, +} + +#[derive(Debug, Serialize)] +#[non_exhaustive] +pub struct DiskEntry { + /// The path to the entry. + pub path: PathBuf, + /// The name of the entry (file name with extension or directory name). + pub name: Option, + /// The children of this entry if it's a directory. + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, +} + +fn read_dir_with_options>( + path: P, + recursive: bool, + options: ReadDirOptions<'_>, +) -> Result> { + let mut files_and_dirs: Vec = vec![]; + for entry in fs::read_dir(path)? { + let path = entry?.path(); + let path_as_string = path.display().to_string(); + + if let Ok(flag) = path.metadata().map(|m| m.is_dir()) { + let is_symlink = symlink_metadata(&path).map(|md| md.is_symlink())?; + files_and_dirs.push(DiskEntry { + path: path.clone(), + children: if flag { + Some( + if recursive + && (!is_symlink + || options.scope.map(|s| s.is_allowed(&path)).unwrap_or(true)) + { + read_dir_with_options(&path_as_string, true, options)? + } else { + vec![] + }, + ) + } else { + None + }, + name: path + .file_name() + .map(|name| name.to_string_lossy()) + .map(|name| name.to_string()), + }); + } + } + Result::Ok(files_and_dirs) +} + +#[tauri::command] +pub fn read_dir( + window: Window, + path: SafePathBuf, + options: Option, +) -> CommandResult> { + let (recursive, dir) = if let Some(options_value) = options { + (options_value.recursive, options_value.dir) + } else { + (false, None) + }; + let resolved_path = resolve_path(&window, path, dir)?; + read_dir_with_options( + &resolved_path, + recursive, + ReadDirOptions { + scope: Some(&window.fs_scope()), + }, + ) + .with_context(|| format!("path: {}", resolved_path.display())) + .map_err(Into::into) +} + +#[tauri::command] +pub fn copy_file( + window: Window, + source: SafePathBuf, + destination: SafePathBuf, + options: Option, +) -> CommandResult<()> { + let (src, dest) = match options.and_then(|o| o.dir) { + Some(dir) => ( + resolve_path(&window, source, Some(dir))?, + resolve_path(&window, destination, Some(dir))?, + ), + None => (source, destination), + }; + fs::copy(src.clone(), dest.clone()) + .with_context(|| format!("source: {}, dest: {}", src.display(), dest.display()))?; + Ok(()) +} + +#[tauri::command] +pub fn create_dir( + window: Window, + path: SafePathBuf, + options: Option, +) -> CommandResult<()> { + let (recursive, dir) = if let Some(options_value) = options { + (options_value.recursive, options_value.dir) + } else { + (false, None) + }; + let resolved_path = resolve_path(&window, path, dir)?; + if recursive { + fs::create_dir_all(&resolved_path) + .with_context(|| format!("path: {}", resolved_path.display()))?; + } else { + fs::create_dir(&resolved_path) + .with_context(|| format!("path: {} (non recursive)", resolved_path.display()))?; + } + + Ok(()) +} + +#[tauri::command] +pub fn remove_dir( + window: Window, + path: SafePathBuf, + options: Option, +) -> CommandResult<()> { + let (recursive, dir) = if let Some(options_value) = options { + (options_value.recursive, options_value.dir) + } else { + (false, None) + }; + let resolved_path = resolve_path(&window, path, dir)?; + if recursive { + fs::remove_dir_all(&resolved_path) + .with_context(|| format!("path: {}", resolved_path.display()))?; + } else { + fs::remove_dir(&resolved_path) + .with_context(|| format!("path: {} (non recursive)", resolved_path.display()))?; + } + + Ok(()) +} + +#[tauri::command] +pub fn remove_file( + window: Window, + path: SafePathBuf, + options: Option, +) -> CommandResult<()> { + let resolved_path = resolve_path(&window, path, options.and_then(|o| o.dir))?; + fs::remove_file(&resolved_path) + .with_context(|| format!("path: {}", resolved_path.display()))?; + Ok(()) +} + +#[tauri::command] +pub fn rename_file( + window: Window, + old_path: SafePathBuf, + new_path: SafePathBuf, + options: Option, +) -> CommandResult<()> { + let (old, new) = match options.and_then(|o| o.dir) { + Some(dir) => ( + resolve_path(&window, old_path, Some(dir))?, + resolve_path(&window, new_path, Some(dir))?, + ), + None => (old_path, new_path), + }; + fs::rename(&old, &new) + .with_context(|| format!("old: {}, new: {}", old.display(), new.display())) + .map_err(Into::into) +} + +#[tauri::command] +pub fn exists( + window: Window, + path: SafePathBuf, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path(&window, path, options.and_then(|o| o.dir))?; + Ok(resolved_path.as_ref().exists()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Permissions { + readonly: bool, + #[cfg(unix)] + mode: u32, +} + +#[cfg(unix)] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UnixMetadata { + dev: u64, + ino: u64, + mode: u32, + nlink: u64, + uid: u32, + gid: u32, + rdev: u64, + blksize: u64, + blocks: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + accessed_at_ms: u64, + created_at_ms: u64, + modified_at_ms: u64, + is_dir: bool, + is_file: bool, + is_symlink: bool, + size: u64, + permissions: Permissions, + #[cfg(unix)] + #[serde(flatten)] + unix: UnixMetadata, + #[cfg(windows)] + file_attributes: u32, +} + +fn system_time_to_ms(time: std::io::Result) -> u64 { + time.map(|t| { + let duration_since_epoch = t.duration_since(UNIX_EPOCH).unwrap(); + duration_since_epoch.as_millis() as u64 + }) + .unwrap_or_default() +} + +#[tauri::command] +pub async fn metadata(path: PathBuf) -> Result { + let metadata = std::fs::metadata(path)?; + let file_type = metadata.file_type(); + let permissions = metadata.permissions(); + Ok(Metadata { + accessed_at_ms: system_time_to_ms(metadata.accessed()), + created_at_ms: system_time_to_ms(metadata.created()), + modified_at_ms: system_time_to_ms(metadata.modified()), + is_dir: file_type.is_dir(), + is_file: file_type.is_file(), + is_symlink: file_type.is_symlink(), + size: metadata.len(), + permissions: Permissions { + readonly: permissions.readonly(), + #[cfg(unix)] + mode: permissions.mode(), + }, + #[cfg(unix)] + unix: UnixMetadata { + dev: metadata.dev(), + ino: metadata.ino(), + mode: metadata.mode(), + nlink: metadata.nlink(), + uid: metadata.uid(), + gid: metadata.gid(), + rdev: metadata.rdev(), + blksize: metadata.blksize(), + blocks: metadata.blocks(), + }, + #[cfg(windows)] + file_attributes: metadata.file_attributes(), + }) +} diff --git a/plugins/fs/src/error.rs b/plugins/fs/src/error.rs new file mode 100644 index 00000000..df109d43 --- /dev/null +++ b/plugins/fs/src/error.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +use serde::{Serialize, Serializer}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("forbidden path: {0}")] + PathForbidden(PathBuf), + #[error("failed to resolve path: {0}")] + CannotResolvePath(tauri::path::Error), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/fs/src/lib.rs b/plugins/fs/src/lib.rs new file mode 100644 index 00000000..adf95f80 --- /dev/null +++ b/plugins/fs/src/lib.rs @@ -0,0 +1,33 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tauri::{ + plugin::{Builder as PluginBuilder, TauriPlugin}, + Runtime, +}; + +mod commands; +mod error; + +use error::Error; + +type Result = std::result::Result; + +pub fn init() -> TauriPlugin { + PluginBuilder::new("fs") + .invoke_handler(tauri::generate_handler![ + commands::read_file, + commands::read_text_file, + commands::write_file, + commands::read_dir, + commands::copy_file, + commands::create_dir, + commands::remove_dir, + commands::remove_file, + commands::rename_file, + commands::exists, + commands::metadata + ]) + .build() +} diff --git a/plugins/fs-extra/tsconfig.json b/plugins/fs/tsconfig.json similarity index 100% rename from plugins/fs-extra/tsconfig.json rename to plugins/fs/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 766c3191..78ad3e3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,7 +77,7 @@ importers: specifier: ^2.4.1 version: 2.4.1 - plugins/fs-extra: + plugins/fs: dependencies: '@tauri-apps/api': specifier: ^1.2.0