diff --git a/README.md b/README.md index d00c637..4d10632 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ import { exists, getMetadata, getPlatformFsCapabilities, + openReadFileStream, + openWriteFileStream, readDir, readTextFile, removeDirAll, @@ -157,6 +159,8 @@ Portable functions include: - `readFile`, `readTextFile` - `writeFile`, `writeTextFile` +- `openReadFileStream`, `openWriteFileStream` +- `closeAllFileStreams`, `countAllFileStreams` - `readDir` - `createDir`, `createNewFile`, `createNewDir` - `copyFile` @@ -216,6 +220,83 @@ if (dir) { } ``` +Copy a large file without loading it into WebView memory: + +```ts +import { + openReadFileStream, + openWriteFileStream, + showOpenFilePicker, + showSaveFilePicker, +} from '@vnidrop/tauri-plugin-fs' + +const [source] = await showOpenFilePicker() +const destination = await showSaveFilePicker('backup.bin', 'application/octet-stream') + +if (source && destination) { + const input = await openReadFileStream(source, { + // Larger chunks reduce IPC overhead. Tune this for your data pipeline. + bufferByteLength: 1024 * 1024, + }) + const output = await openWriteFileStream(destination, { + create: true, + append: false, + bufferByteLength: 1024 * 1024, + }) + + await input.pipeTo(output) +} +``` + +Use `closeAllFileStreams()` as a cleanup escape hatch during app shutdown, +failed stream operations, or tests. Normal streams release their native handles when +they reach EOF, close, abort, or error. + +## Rust Backend API + +Frontend calls are not required for backend filesystem workflows. Register the +plugin, import `VnidropFsExt`, and use the Rust manager from an `AppHandle`, +`App`, or command state. + +```rust +use std::io::{Read, Write}; +use tauri_plugin_vnidrop_fs::{VnidropFsExt, VnidropOpenWriteOptions}; + +fn copy_in_rust( + app: &tauri::AppHandle, + source: &std::path::Path, + destination: &std::path::Path, +) -> tauri_plugin_vnidrop_fs::Result { + let fs = app.vnidrop_fs(); + let mut reader = fs.open_read(source)?; + let mut writer = fs.open_write( + destination, + VnidropOpenWriteOptions::default().create(true), + )?; + + let mut buffer = vec![0; 1024 * 1024]; + let mut copied = 0; + + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + writer.write_all(&buffer[..read])?; + copied += read as u64; + } + + writer.flush()?; + Ok(copied) +} +``` + +The Rust API accepts normal paths, Android `FileUri` values, and iOS +`IosFsUri` values. Desktop and app-container paths use `std::fs`. Android URI +targets use the existing Android file descriptor bridge. iOS `IosFsUri` targets +use native stream resources so security-scoped access stays active until the +Rust reader or writer is closed or dropped. + Show different UI for platform-specific capabilities: ```ts @@ -369,14 +450,15 @@ iOS supports the shared root API for: - file, directory, and save pickers - read/write bytes and text +- read/write byte streams - directory listing - create and unique create - copy, rename, remove - metadata and existence checks - security-scoped bookmark list/resolve/release/persist helpers -Android public storage, thumbnails, streams, and share/view intents are not iOS -features and remain Android-only. +Android public storage, thumbnails, line streams, and share/view intents are not +iOS features and remain Android-only. ## Testing @@ -406,6 +488,17 @@ npm install npm run tauri dev ``` +Run the Rust-side example app: + +```sh +cd examples/tauri-rust-side-app +npm install +npm run tauri dev +``` + +This second example keeps filesystem logic in Rust commands with +`app.vnidrop_fs()`. The frontend only handles picker UI and command invocation. + iOS simulator: ```sh diff --git a/examples/tauri-rust-side-app/.gitignore b/examples/tauri-rust-side-app/.gitignore new file mode 100644 index 0000000..12191e3 --- /dev/null +++ b/examples/tauri-rust-side-app/.gitignore @@ -0,0 +1,18 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +node_modules +dist +dist-ssr +*.local + +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.sw? diff --git a/examples/tauri-rust-side-app/README.md b/examples/tauri-rust-side-app/README.md new file mode 100644 index 0000000..2dbc073 --- /dev/null +++ b/examples/tauri-rust-side-app/README.md @@ -0,0 +1,31 @@ +# Vnidrop FS Rust-Side Example + +This example keeps file transfer logic in Rust. + +The frontend only selects files with Vnidrop FS pickers and calls Tauri +commands. The commands use `app.vnidrop_fs()` to read, write, and stream-copy +files from Rust without routing file bytes through frontend IPC. + +## Desktop + +```sh +npm install +npm run tauri dev +``` + +## Android + +```sh +npm install +npm run tauri android dev +``` + +## iOS + +```sh +npm install +npm run tauri ios dev +``` + +Use the app to pick a source file, choose a destination with the save picker, +then run the Rust streaming copy command. diff --git a/examples/tauri-rust-side-app/index.html b/examples/tauri-rust-side-app/index.html new file mode 100644 index 0000000..2318452 --- /dev/null +++ b/examples/tauri-rust-side-app/index.html @@ -0,0 +1,12 @@ + + + + + + Vnidrop FS Rust-Side Example + + +
+ + + diff --git a/examples/tauri-rust-side-app/jsconfig.json b/examples/tauri-rust-side-app/jsconfig.json new file mode 100644 index 0000000..5182c37 --- /dev/null +++ b/examples/tauri-rust-side-app/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "checkJs": true, + "moduleResolution": "bundler" + }, + "exclude": ["node_modules", "src-tauri"] +} diff --git a/examples/tauri-rust-side-app/package-lock.json b/examples/tauri-rust-side-app/package-lock.json new file mode 100644 index 0000000..41b3829 --- /dev/null +++ b/examples/tauri-rust-side-app/package-lock.json @@ -0,0 +1,1667 @@ +{ + "name": "vnidrop-fs-rust-side-example", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vnidrop-fs-rust-side-example", + "version": "0.1.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", + "@vnidrop/tauri-plugin-fs": "file:../../" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0", + "@tauri-apps/cli": "^2.0.0", + "svelte": "^5.0.0", + "vite": "^7.0.0" + } + }, + "../..": { + "name": "@vnidrop/tauri-plugin-fs", + "version": "1.0.0-rc", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", + "create-web-stream": "^1.1.3" + }, + "devDependencies": { + "@rollup/plugin-typescript": "^12.3.0", + "rollup": "^4.62.0", + "tslib": "^2.8.1", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", + "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.2", + "@tauri-apps/cli-darwin-x64": "2.11.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", + "@tauri-apps/cli-linux-arm64-musl": "2.11.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-musl": "2.11.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", + "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", + "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", + "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", + "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", + "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", + "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", + "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", + "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", + "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", + "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", + "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", + "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", + "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vnidrop/tauri-plugin-fs": { + "resolved": "../..", + "link": true + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", + "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", + "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.11", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/examples/tauri-rust-side-app/package.json b/examples/tauri-rust-side-app/package.json new file mode 100644 index 0000000..f4f0459 --- /dev/null +++ b/examples/tauri-rust-side-app/package.json @@ -0,0 +1,24 @@ +{ + "name": "vnidrop-fs-rust-side-example", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", + "@vnidrop/tauri-plugin-fs": "file:../../" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0", + "@tauri-apps/cli": "^2.0.0", + "svelte": "^5.0.0", + "vite": "^7.0.0" + } +} diff --git a/examples/tauri-rust-side-app/public/svelte.svg b/examples/tauri-rust-side-app/public/svelte.svg new file mode 100644 index 0000000..c5e0848 --- /dev/null +++ b/examples/tauri-rust-side-app/public/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/tauri-rust-side-app/public/tauri.svg b/examples/tauri-rust-side-app/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/examples/tauri-rust-side-app/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/examples/tauri-rust-side-app/public/vite.svg b/examples/tauri-rust-side-app/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/tauri-rust-side-app/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/tauri-rust-side-app/src-tauri/.gitignore b/examples/tauri-rust-side-app/src-tauri/.gitignore new file mode 100644 index 0000000..79c707c --- /dev/null +++ b/examples/tauri-rust-side-app/src-tauri/.gitignore @@ -0,0 +1,2 @@ +/target/ +/gen/ diff --git a/examples/tauri-rust-side-app/src-tauri/Cargo.toml b/examples/tauri-rust-side-app/src-tauri/Cargo.toml new file mode 100644 index 0000000..ba785b7 --- /dev/null +++ b/examples/tauri-rust-side-app/src-tauri/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "vnidrop-fs-rust-side-example" +version = "0.1.0" +description = "Rust-side Vnidrop FS plugin example" +authors = ["Vnidrop"] +license = "" +repository = "" +edition = "2021" +rust-version = "1.77.2" + +[lib] +name = "tauri_app_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.6.2", default-features = false, features = [] } + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri = { version = "2.11.2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-fs = "2" +tauri-plugin-vnidrop-fs = { path = "../../../" } diff --git a/examples/tauri-rust-side-app/src-tauri/build.rs b/examples/tauri-rust-side-app/src-tauri/build.rs new file mode 100644 index 0000000..795b9b7 --- /dev/null +++ b/examples/tauri-rust-side-app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/examples/tauri-rust-side-app/src-tauri/capabilities/default.json b/examples/tauri-rust-side-app/src-tauri/capabilities/default.json new file mode 100644 index 0000000..4eca58b --- /dev/null +++ b/examples/tauri-rust-side-app/src-tauri/capabilities/default.json @@ -0,0 +1,18 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables permissions for the Rust-side Vnidrop FS example", + "windows": ["main"], + "permissions": [ + "core:default", + "dialog:default", + "fs:default", + "fs:read-all", + "fs:write-all", + { + "identifier": "fs:scope", + "allow": ["**"] + }, + "vnidrop-fs:all" + ] +} diff --git a/examples/tauri-rust-side-app/src-tauri/icons/128x128.png b/examples/tauri-rust-side-app/src-tauri/icons/128x128.png new file mode 100644 index 0000000..77e7d23 Binary files /dev/null and b/examples/tauri-rust-side-app/src-tauri/icons/128x128.png differ diff --git a/examples/tauri-rust-side-app/src-tauri/icons/128x128@2x.png b/examples/tauri-rust-side-app/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..0f7976f Binary files /dev/null and b/examples/tauri-rust-side-app/src-tauri/icons/128x128@2x.png differ diff --git a/examples/tauri-rust-side-app/src-tauri/icons/32x32.png b/examples/tauri-rust-side-app/src-tauri/icons/32x32.png new file mode 100644 index 0000000..98fda06 Binary files /dev/null and b/examples/tauri-rust-side-app/src-tauri/icons/32x32.png differ diff --git a/examples/tauri-rust-side-app/src-tauri/icons/icon.icns b/examples/tauri-rust-side-app/src-tauri/icons/icon.icns new file mode 100644 index 0000000..29d6685 Binary files /dev/null and b/examples/tauri-rust-side-app/src-tauri/icons/icon.icns differ diff --git a/examples/tauri-rust-side-app/src-tauri/icons/icon.ico b/examples/tauri-rust-side-app/src-tauri/icons/icon.ico new file mode 100644 index 0000000..06c23c8 Binary files /dev/null and b/examples/tauri-rust-side-app/src-tauri/icons/icon.ico differ diff --git a/examples/tauri-rust-side-app/src-tauri/icons/icon.png b/examples/tauri-rust-side-app/src-tauri/icons/icon.png new file mode 100644 index 0000000..d1756ce Binary files /dev/null and b/examples/tauri-rust-side-app/src-tauri/icons/icon.png differ diff --git a/examples/tauri-rust-side-app/src-tauri/src/lib.rs b/examples/tauri-rust-side-app/src-tauri/src/lib.rs new file mode 100644 index 0000000..0b736f8 --- /dev/null +++ b/examples/tauri-rust-side-app/src-tauri/src/lib.rs @@ -0,0 +1,157 @@ +use std::io::{Read, Write}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::{AppHandle, Runtime}; +use tauri_plugin_vnidrop_fs::{ + FileUri, IosFsUri, VnidropFsExt, VnidropFsTarget, VnidropOpenWriteOptions, +}; + +const BUFFER_LEN: usize = 1024 * 1024; +const PREVIEW_LIMIT: usize = 64 * 1024; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RustFsReport { + bytes: u64, + message: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RustFsPreview { + bytes: u64, + text: String, + truncated: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AndroidTarget { + uri: String, + document_top_tree_uri: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IosTarget { + uri: String, + bookmark_id: Option, + is_directory: Option, +} + +#[tauri::command] +fn rust_read_preview( + app: AppHandle, + target: Value, +) -> Result { + let target = decode_target(target)?; + let mut reader = app + .vnidrop_fs() + .open_read(target) + .map_err(error_to_string)?; + let mut buffer = vec![0; PREVIEW_LIMIT]; + let read = reader.read(&mut buffer).map_err(error_to_string)?; + buffer.truncate(read); + + Ok(RustFsPreview { + bytes: read as u64, + text: String::from_utf8_lossy(&buffer).into_owned(), + truncated: read == PREVIEW_LIMIT, + }) +} + +#[tauri::command] +fn rust_write_text( + app: AppHandle, + target: Value, + text: String, +) -> Result { + let target = decode_target(target)?; + let mut writer = app + .vnidrop_fs() + .open_write(target, VnidropOpenWriteOptions::default().create(true)) + .map_err(error_to_string)?; + writer.write_all(text.as_bytes()).map_err(error_to_string)?; + writer.flush().map_err(error_to_string)?; + + Ok(RustFsReport { + bytes: text.len() as u64, + message: "wrote text from Rust".into(), + }) +} + +#[tauri::command] +fn rust_copy_streaming( + app: AppHandle, + source: Value, + destination: Value, +) -> Result { + let source = decode_target(source)?; + let destination = decode_target(destination)?; + let fs = app.vnidrop_fs(); + let mut reader = fs.open_read(source).map_err(error_to_string)?; + let mut writer = fs + .open_write(destination, VnidropOpenWriteOptions::default().create(true)) + .map_err(error_to_string)?; + let mut buffer = vec![0; BUFFER_LEN]; + let mut copied = 0; + + loop { + let read = reader.read(&mut buffer).map_err(error_to_string)?; + if read == 0 { + break; + } + writer.write_all(&buffer[..read]).map_err(error_to_string)?; + copied += read as u64; + } + writer.flush().map_err(error_to_string)?; + + Ok(RustFsReport { + bytes: copied, + message: "streamed copy from Rust".into(), + }) +} + +fn decode_target(value: Value) -> Result { + match value { + Value::String(path) => Ok(VnidropFsTarget::from(std::path::PathBuf::from(path))), + Value::Object(map) if map.contains_key("bookmarkId") => { + let uri = serde_json::from_value::(Value::Object(map)) + .map_err(error_to_string)?; + Ok(VnidropFsTarget::from(IosFsUri { + uri: uri.uri, + bookmark_id: uri.bookmark_id, + is_directory: uri.is_directory, + })) + } + Value::Object(map) if map.contains_key("uri") => { + let uri = serde_json::from_value::(Value::Object(map)) + .map_err(error_to_string)?; + Ok(VnidropFsTarget::from(FileUri { + uri: uri.uri, + document_top_tree_uri: uri.document_top_tree_uri, + })) + } + _ => Err("expected a desktop path string, AndroidFsUri, or IosFsUri".into()), + } +} + +fn error_to_string(error: impl std::fmt::Display) -> String { + error.to_string() +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_vnidrop_fs::init()) + .invoke_handler(tauri::generate_handler![ + rust_copy_streaming, + rust_read_preview, + rust_write_text, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/examples/tauri-rust-side-app/src-tauri/src/main.rs b/examples/tauri-rust-side-app/src-tauri/src/main.rs new file mode 100644 index 0000000..66115d2 --- /dev/null +++ b/examples/tauri-rust-side-app/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + tauri_app_lib::run(); +} diff --git a/examples/tauri-rust-side-app/src-tauri/tauri.conf.json b/examples/tauri-rust-side-app/src-tauri/tauri.conf.json new file mode 100644 index 0000000..2b86a99 --- /dev/null +++ b/examples/tauri-rust-side-app/src-tauri/tauri.conf.json @@ -0,0 +1,37 @@ +{ + "productName": "Vnidrop FS Rust-Side Example", + "version": "0.1.0", + "identifier": "com.vnidrop.fs.rustside.example", + "build": { + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build", + "devUrl": "http://localhost:1430", + "frontendDist": "../dist" + }, + "app": { + "withGlobalTauri": false, + "security": { + "csp": null + }, + "windows": [ + { + "fullscreen": false, + "height": 720, + "resizable": true, + "title": "Vnidrop FS Rust-Side Example", + "width": 980 + } + ] + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/examples/tauri-rust-side-app/src/App.svelte b/examples/tauri-rust-side-app/src/App.svelte new file mode 100644 index 0000000..ec1fd76 --- /dev/null +++ b/examples/tauri-rust-side-app/src/App.svelte @@ -0,0 +1,162 @@ + + +
+
+
+

Vnidrop FS Rust-Side Example

+

Pick files in the frontend, then stream and write them from Rust with app.vnidrop_fs().

+
+
+ Platform + {capabilities.platform} +
+
+ +
+
+

Targets

+
+
Source
+
{label(source)}
+
Destination
+
{label(destination)}
+
+
+ + +
+
+ +
+

Rust Commands

+ +
+ + + +
+
+
+ +
+
+

Preview

+
{preview || 'No preview loaded.'}
+
+ +
+

Log

+ {#each logs as item} +
+ {item.time} · {item.action} +
{item.value}
+
+ {/each} + {#if logs.length === 0} +

No actions yet.

+ {/if} +
+
+
diff --git a/examples/tauri-rust-side-app/src/main.js b/examples/tauri-rust-side-app/src/main.js new file mode 100644 index 0000000..f16e755 --- /dev/null +++ b/examples/tauri-rust-side-app/src/main.js @@ -0,0 +1,9 @@ +import './style.css' +import App from './App.svelte' +import { mount } from 'svelte' + +const app = mount(App, { + target: document.getElementById('app'), +}) + +export default app diff --git a/examples/tauri-rust-side-app/src/style.css b/examples/tauri-rust-side-app/src/style.css new file mode 100644 index 0000000..fb16d2a --- /dev/null +++ b/examples/tauri-rust-side-app/src/style.css @@ -0,0 +1,195 @@ +:root { + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #111827; + background: #f5f7fa; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; +} + +main { + width: min(1120px, 100%); + margin: 0 auto; + padding: 24px; +} + +header { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 6px; + font-size: 28px; +} + +h2 { + margin-bottom: 12px; + font-size: 17px; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.platform { + display: grid; + gap: 4px; + min-width: 150px; + padding: 12px; + border: 1px solid #d5dbe3; + border-radius: 8px; + background: #fff; +} + +.platform span { + font-size: 12px; + font-weight: 700; + color: #4b5563; + text-transform: uppercase; +} + +.platform strong { + text-transform: uppercase; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.lower { + margin-top: 16px; +} + +article { + padding: 16px; + border: 1px solid #d5dbe3; + border-radius: 8px; + background: #fff; +} + +dl { + margin: 0 0 12px; +} + +dt { + font-size: 12px; + font-weight: 700; + color: #4b5563; + text-transform: uppercase; +} + +dd { + overflow-wrap: anywhere; + margin: 0 0 10px; + padding: 8px; + border-radius: 6px; + background: #f3f4f6; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +label { + display: grid; + gap: 6px; + margin-bottom: 12px; + font-size: 13px; + font-weight: 700; +} + +textarea { + width: 100%; + min-height: 96px; + resize: vertical; + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 9px 10px; + color: #111827; + background: #fff; + font: inherit; +} + +.buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +button { + border: 1px solid #155e75; + border-radius: 6px; + padding: 8px 10px; + color: #fff; + background: #155e75; + cursor: pointer; + font: inherit; +} + +button:hover:not(:disabled) { + background: #164e63; +} + +button:disabled { + border-color: #cbd5e1; + color: #6b7280; + background: #e5e7eb; + cursor: not-allowed; +} + +pre { + overflow: auto; + max-height: 320px; + margin: 0; + padding: 10px; + border-radius: 6px; + background: #111827; + color: #f9fafb; + font-size: 12px; +} + +.log { + display: grid; + align-content: start; + gap: 10px; +} + +.log-item { + display: grid; + gap: 6px; +} + +.log-item strong { + font-size: 12px; + color: #374151; +} + +@media (max-width: 760px) { + header, + .grid { + grid-template-columns: 1fr; + } + + header { + display: grid; + } +} diff --git a/examples/tauri-rust-side-app/vite.config.js b/examples/tauri-rust-side-app/vite.config.js new file mode 100644 index 0000000..13131ac --- /dev/null +++ b/examples/tauri-rust-side-app/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +const host = process.env.TAURI_DEV_HOST + +export default defineConfig({ + plugins: [svelte()], + clearScreen: false, + server: { + host: host || false, + port: 1430, + strictPort: true, + hmr: host + ? { + protocol: 'ws', + host, + port: 1431, + } + : undefined, + }, +}) diff --git a/guest-js/index.ts b/guest-js/index.ts index 7e032ea..31c4a18 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -9,6 +9,8 @@ export type { AndroidFsUri, AndroidOpenDirPickerOptions, AndroidOpenFilePickerOptions, + AndroidOpenReadFileStreamOptions, + AndroidOpenWriteFileStreamOptions, AndroidReadDirOptions, AndroidReadTextFileOptions, AndroidSaveFilePickerOptions, @@ -22,6 +24,8 @@ export type { IosFsUri, IosOpenDirPickerOptions, IosOpenFilePickerOptions, + IosOpenReadFileStreamOptions, + IosOpenWriteFileStreamOptions, IosReadDirOptions, IosReadTextFileOptions, IosSaveFilePickerOptions, @@ -60,30 +64,153 @@ export type FsPath = Android.FsPath */ export type VnidropFsPath = Android.FsPath | Android.AndroidFsUri | Ios.IosFsUri +/** + * Desktop-only options forwarded to `@tauri-apps/plugin-fs.readFile`. + */ export type DesktopReadFileOptions = Parameters[1] + +/** + * Desktop-only options forwarded to `@tauri-apps/plugin-fs.readTextFile`. + */ export type DesktopReadTextFileOptions = Parameters[1] + +/** + * Desktop-only options forwarded to `@tauri-apps/plugin-fs.writeFile`. + */ export type DesktopWriteFileOptions = Parameters[2] + +/** + * Desktop-only options forwarded to `@tauri-apps/plugin-fs.writeTextFile`. + */ export type DesktopWriteTextFileOptions = Parameters[2] + +/** + * Desktop file-handle options used by stream APIs. + */ +export type DesktopOpenOptions = Parameters[1] + +/** + * Desktop-only options forwarded to `@tauri-apps/plugin-fs.readDir`. + */ export type DesktopReadDirOptions = Parameters[1] + +/** + * Desktop-only options forwarded to `@tauri-apps/plugin-fs.mkdir`. + */ export type DesktopMkdirOptions = Parameters[1] + +/** + * Desktop-only options forwarded to `@tauri-apps/plugin-fs.create`. + */ export type DesktopCreateOptions = Parameters[1] + +/** + * Desktop-only options forwarded to `@tauri-apps/plugin-fs.remove`. + */ export type DesktopRemoveOptions = Parameters[1] + +/** + * Desktop-only options forwarded to `@tauri-apps/plugin-fs.rename`. + */ export type DesktopRenameOptions = Parameters[2] + +/** + * Desktop-only options forwarded to `@tauri-apps/plugin-fs.exists`. + */ export type DesktopExistsOptions = Parameters[1] + +/** + * Desktop-only options forwarded to `@tauri-apps/plugin-fs.stat`. + */ export type DesktopStatOptions = Parameters[1] + +/** + * Desktop dialog options accepted by portable open pickers. + */ export type DesktopOpenDialogOptions = Parameters[0] + +/** + * Desktop dialog options accepted by the portable save picker. + */ export type DesktopSaveDialogOptions = Parameters[0] +/** + * Text decoding options accepted by `readTextFile` on every platform. + */ export type UnifiedReadTextFileOptions = Android.AndroidReadTextFileOptions | Ios.IosReadTextFileOptions | DesktopReadTextFileOptions + +/** + * Byte write options accepted by `writeFile` on every platform. + */ export type UnifiedWriteFileOptions = Android.AndroidWriteFileOptions | Ios.IosWriteFileOptions | DesktopWriteFileOptions + +/** + * Text encoding/write options accepted by `writeTextFile` on every platform. + */ export type UnifiedWriteTextFileOptions = Android.AndroidWriteTextFileOptions | Ios.IosWriteTextFileOptions | DesktopWriteTextFileOptions + +/** + * Chunked byte-read options accepted by `openReadFileStream`. + */ +export type UnifiedOpenReadFileStreamOptions = Android.AndroidOpenReadFileStreamOptions | Ios.IosOpenReadFileStreamOptions | (DesktopOpenOptions & { + /** Number of bytes requested for each stream pull. Defaults to 512 KiB. */ + bufferByteLength?: number + /** Byte offset where reading should begin. */ + offset?: number + /** Aborts the stream and releases the file handle. */ + signal?: AbortSignal +}) + +/** + * Chunked byte-write options accepted by `openWriteFileStream`. + */ +export type UnifiedOpenWriteFileStreamOptions = Android.AndroidOpenWriteFileStreamOptions | Ios.IosOpenWriteFileStreamOptions | (DesktopOpenOptions & { + /** Number of bytes buffered before writing. Defaults to 512 KiB. */ + bufferByteLength?: number + /** Byte offset where writing should begin. */ + offset?: number + /** Aborts the stream and releases the file handle. */ + signal?: AbortSignal +}) + +/** + * Directory-listing options accepted by `readDir`. + */ export type UnifiedReadDirOptions = Android.AndroidReadDirOptions | Ios.IosReadDirOptions | DesktopReadDirOptions + +/** + * Removal options accepted by recursive directory deletion on desktop. + */ export type UnifiedRemoveDirAllOptions = DesktopRemoveOptions + +/** + * Removal options accepted by empty directory deletion on desktop. + */ export type UnifiedRemoveEmptyDirOptions = DesktopRemoveOptions + +/** + * Existence-check options accepted by desktop paths. + */ export type UnifiedExistsOptions = DesktopExistsOptions + +/** + * Metadata options accepted by desktop paths. + */ export type UnifiedMetadataOptions = DesktopStatOptions + +/** + * File picker options accepted by the portable file picker. + */ export type UnifiedOpenFilePickerOptions = Android.AndroidOpenFilePickerOptions | Ios.IosOpenFilePickerOptions | DesktopOpenDialogOptions + +/** + * Directory picker options accepted by the portable directory picker. + */ export type UnifiedOpenDirPickerOptions = Android.AndroidOpenDirPickerOptions | Ios.IosOpenDirPickerOptions | DesktopOpenDialogOptions + +/** + * Save picker options accepted by the portable save picker. + */ export type UnifiedSaveFilePickerOptions = Android.AndroidSaveFilePickerOptions | Ios.IosSaveFilePickerOptions | DesktopSaveDialogOptions /** @@ -133,6 +260,9 @@ export type PlatformFsCapabilities = { /** `true` when iOS security-scoped bookmarks are available. */ supportsSecurityScopedBookmarks: boolean + + /** `true` when byte streams can be opened without loading entire files. */ + supportsFileStreams: boolean } /** @@ -158,6 +288,7 @@ export function getPlatformFsCapabilities(): PlatformFsCapabilities { supportsPersistedPickerPermissions: true, supportsThumbnails: true, supportsSecurityScopedBookmarks: false, + supportsFileStreams: true, } } @@ -170,6 +301,7 @@ export function getPlatformFsCapabilities(): PlatformFsCapabilities { supportsPersistedPickerPermissions: false, supportsThumbnails: false, supportsSecurityScopedBookmarks: true, + supportsFileStreams: true, } } @@ -181,9 +313,13 @@ export function getPlatformFsCapabilities(): PlatformFsCapabilities { supportsPersistedPickerPermissions: false, supportsThumbnails: false, supportsSecurityScopedBookmarks: false, + supportsFileStreams: true, } } +const DEFAULT_STREAM_BUFFER_BYTE_LENGTH = 512 * 1024 +const desktopStreamHandles = new Set() + function isAndroidFsUri(value: unknown): value is Android.AndroidFsUri { return ( typeof value === 'object' && @@ -207,11 +343,147 @@ function mapDesktopPath(path: Android.FsPath): string { return path instanceof URL ? path.toString() : path } +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason ?? new Error('The operation was aborted.') + } +} + +function mapStreamBufferByteLength(value?: number): number { + if (value == null) { + return DEFAULT_STREAM_BUFFER_BYTE_LENGTH + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError('bufferByteLength must be a positive safe integer.') + } + return value +} + +function mapStreamOffset(value?: number): number | null { + if (value == null) { + return null + } + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError('offset must be a non-negative safe integer.') + } + return value +} + async function createDesktopEmptyFile(path: Android.FsPath, options?: DesktopCreateOptions): Promise { const file = await TauriFs.create(mapDesktopPath(path), options) await file.close() } +async function closeDesktopFileHandle(file: TauriFs.FileHandle): Promise { + if (!desktopStreamHandles.delete(file)) return + await file.close() +} + +function mapDesktopReadStreamOptions(options?: UnifiedOpenReadFileStreamOptions): DesktopOpenOptions { + const { bufferByteLength: _bufferByteLength, offset: _offset, signal: _signal, ...openOptions } = (options ?? {}) as DesktopOpenOptions & { + bufferByteLength?: number + offset?: number + signal?: AbortSignal + } + return { + ...openOptions, + read: true, + } +} + +function mapDesktopWriteStreamOptions(options?: UnifiedOpenWriteFileStreamOptions): DesktopOpenOptions { + const { bufferByteLength: _bufferByteLength, offset, signal: _signal, notification: _notification, ...openOptions } = (options ?? {}) as DesktopOpenOptions & { + bufferByteLength?: number + offset?: number + signal?: AbortSignal + notification?: unknown + } + const append = openOptions.append ?? false + return { + ...openOptions, + write: true, + create: openOptions.create ?? false, + append, + truncate: openOptions.truncate ?? (!append && offset == null), + } +} + +async function openDesktopReadFileStream( + path: Android.FsPath, + options?: UnifiedOpenReadFileStreamOptions +): Promise>> { + throwIfAborted((options as { signal?: AbortSignal } | undefined)?.signal) + const bufferByteLength = mapStreamBufferByteLength((options as { bufferByteLength?: number } | undefined)?.bufferByteLength) + const offset = mapStreamOffset((options as { offset?: number } | undefined)?.offset) + const signal = (options as { signal?: AbortSignal } | undefined)?.signal + const file = await TauriFs.open(mapDesktopPath(path), mapDesktopReadStreamOptions(options)) + desktopStreamHandles.add(file) + if (offset != null) { + await file.seek(offset, TauriFs.SeekMode.Start) + } + + return new ReadableStream>({ + start: controller => { + signal?.addEventListener('abort', () => { + void closeDesktopFileHandle(file).finally(() => controller.error(signal.reason ?? new Error('The operation was aborted.'))) + }, { once: true }) + }, + async pull(controller) { + try { + throwIfAborted(signal) + const buffer = new Uint8Array(bufferByteLength) + const read = await file.read(buffer) + if (read == null) { + await closeDesktopFileHandle(file) + controller.close() + return + } + controller.enqueue(buffer.slice(0, read) as Uint8Array) + } + catch (error) { + await closeDesktopFileHandle(file) + controller.error(error) + } + }, + cancel: () => closeDesktopFileHandle(file), + }) +} + +async function openDesktopWriteFileStream( + path: Android.FsPath, + options?: UnifiedOpenWriteFileStreamOptions +): Promise>> { + throwIfAborted((options as { signal?: AbortSignal } | undefined)?.signal) + const offset = mapStreamOffset((options as { offset?: number } | undefined)?.offset) + const signal = (options as { signal?: AbortSignal } | undefined)?.signal + const file = await TauriFs.open(mapDesktopPath(path), mapDesktopWriteStreamOptions(options)) + desktopStreamHandles.add(file) + if (offset != null) { + await file.seek(offset, TauriFs.SeekMode.Start) + } + + return new WritableStream>({ + start: controller => { + signal?.addEventListener('abort', () => { + void closeDesktopFileHandle(file).finally(() => controller.error(signal.reason ?? new Error('The operation was aborted.'))) + }, { once: true }) + }, + async write(chunk) { + throwIfAborted(signal) + let written = 0 + while (written < chunk.byteLength) { + const next = await file.write(chunk.subarray(written)) + if (next <= 0) { + throw new Error('Unable to write stream chunk.') + } + written += next + } + }, + close: () => closeDesktopFileHandle(file), + abort: () => closeDesktopFileHandle(file), + }) +} + function incrementDesktopPath(path: Android.FsPath, index: number): Android.FsPath { const raw = mapDesktopPath(path) const sepIndex = Math.max(raw.lastIndexOf('/'), raw.lastIndexOf('\\')) @@ -396,6 +668,94 @@ export async function writeTextFile( return TauriFs.writeTextFile(mapDesktopPath(path as Android.FsPath), data, options as DesktopWriteTextFileOptions) } +/** + * Opens a file as a byte `ReadableStream`. + * + * This is the portable large-file read API. It reads chunks on demand instead + * of loading the whole file into WebView memory. Desktop uses + * `@tauri-apps/plugin-fs.open`; Android and iOS keep native file descriptors + * open until the stream reaches EOF, is canceled, errors, or the abort signal + * fires. + * + * @param path File path, file URL, Android file URI, or iOS file URI. + * @param options Chunk size, optional start offset, abort signal, and desktop + * open options where applicable. + */ +export async function openReadFileStream( + path: VnidropFsPath, + options?: UnifiedOpenReadFileStreamOptions +): Promise>> { + if (Android.isAndroid()) { + return Android.openReadFileStream(path as Android.FsPath | Android.AndroidFsUri, options as Android.AndroidOpenReadFileStreamOptions) + } + if (Ios.isIos()) { + return Ios.openReadFileStream(path as Ios.FsPath | Ios.IosFsUri, options as Ios.IosOpenReadFileStreamOptions) + } + + return openDesktopReadFileStream(path as Android.FsPath, options) +} + +/** + * Opens a file as a byte `WritableStream`. + * + * This is the portable large-file write API. It sends chunks to native code as + * the stream is written, avoiding whole-file buffering. Desktop uses + * `@tauri-apps/plugin-fs.open`; Android and iOS keep native file descriptors + * open until the stream is closed, aborted, errors, or the abort signal fires. + * + * @param path Destination path, file URL, Android file URI, or iOS file URI. + * @param options Creation/append/truncate behavior, optional write offset, + * chunk size, abort signal, and desktop open options where applicable. + */ +export async function openWriteFileStream( + path: VnidropFsPath, + options?: UnifiedOpenWriteFileStreamOptions +): Promise>> { + if (Android.isAndroid()) { + return Android.openWriteFileStream(path as Android.FsPath | Android.AndroidFsUri, options as Android.AndroidOpenWriteFileStreamOptions) + } + if (Ios.isIos()) { + return Ios.openWriteFileStream(path as Ios.FsPath | Ios.IosFsUri, options as Ios.IosOpenWriteFileStreamOptions) + } + + return openDesktopWriteFileStream(path as Android.FsPath, options) +} + +/** + * Forcibly closes every native file stream opened by this plugin. + * + * Use this as a cleanup escape hatch during app shutdown, failed stream + * operations, or test teardown. Existing JavaScript stream objects should be + * discarded after this call. + */ +export async function closeAllFileStreams(): Promise { + if (Android.isAndroid()) { + return Android.closeAllFileStreams() + } + if (Ios.isIos()) { + return Ios.closeAllFileStreams() + } + + await Promise.all([...desktopStreamHandles].map(file => closeDesktopFileHandle(file))) +} + +/** + * Returns the number of currently open file streams owned by this plugin. + * + * On desktop this counts streams opened through the portable root API. On + * mobile it asks the native backend for its open descriptor/resource count. + */ +export async function countAllFileStreams(): Promise { + if (Android.isAndroid()) { + return Android.countAllFileStreams() + } + if (Ios.isIos()) { + return Ios.countAllFileStreams() + } + + return desktopStreamHandles.size +} + /** * Reads the immediate children of a directory. * diff --git a/guest-js/ios.ts b/guest-js/ios.ts index 198efa8..ee75b79 100644 --- a/guest-js/ios.ts +++ b/guest-js/ios.ts @@ -54,6 +54,61 @@ export type IosSaveFilePickerOptions = { mimeType?: string | null } +export type IosOpenReadFileStreamOptions = { + /** + * Number of bytes requested from native code for each stream pull. + * + * Defaults to 512 KiB. Larger chunks reduce IPC overhead, but also increase + * the amount of data temporarily held by the WebView. + */ + bufferByteLength?: number + + /** + * Byte offset where reading should begin. + */ + offset?: number + + /** + * Aborts the stream and releases the native file handle. + */ + signal?: AbortSignal +} + +export type IosOpenWriteFileStreamOptions = { + /** + * Number of bytes buffered before data is sent to native code. + * + * Defaults to 512 KiB. + */ + bufferByteLength?: number + + /** + * Creates the file when it does not exist. + */ + create?: boolean + + /** + * Appends to the end of the file instead of replacing existing contents. + */ + append?: boolean + + /** + * Truncates the file before writing. Defaults to `true` when `append` is + * `false` and no explicit `offset` is provided. + */ + truncate?: boolean + + /** + * Byte offset where writing should begin. + */ + offset?: number + + /** + * Aborts the stream and releases the native file handle. + */ + signal?: AbortSignal +} + export type IosEntryMetadata = | { type: 'Dir' @@ -90,6 +145,32 @@ function mapFsPathForInput(uri: FsPath | IosFsUri): string | IosFsUri { return uri instanceof URL ? uri.toString() : uri } +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason ?? new Error('The operation was aborted.') + } +} + +function mapBufferByteLength(value?: number): number { + if (value == null) { + return 512 * 1024 + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError('bufferByteLength must be a positive safe integer.') + } + return value +} + +function mapOffset(value?: number): number | null { + if (value == null) { + return null + } + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError('offset must be a non-negative safe integer.') + } + return value +} + function mapMetadata(entry: IosEntryMetadataInner): IosEntryMetadata { return { ...entry, @@ -161,6 +242,61 @@ export async function readFile(uri: IosFsUri | FsPath): Promise } +/** + * Opens an iOS file as a byte `ReadableStream`. + * + * The native file handle remains open until the stream reaches EOF, is + * canceled, errors, or the provided abort signal is triggered. External + * document-provider files keep their security-scoped access active for the + * lifetime of the stream. + */ +export async function openReadFileStream( + uri: IosFsUri | FsPath, + options?: IosOpenReadFileStreamOptions +): Promise>> { + throwIfAborted(options?.signal) + const bufferByteLength = mapBufferByteLength(options?.bufferByteLength) + const id = await invoke('plugin:vnidrop-fs|openReadFileStream', { + uri: mapFsPathForInput(uri), + offset: mapOffset(options?.offset), + }) + let closed = false + + const close = async (): Promise => { + if (closed) return + closed = true + await invoke('plugin:vnidrop-fs|closeFileStream', { id }).catch(() => undefined) + } + + return new ReadableStream>({ + start: controller => { + options?.signal?.addEventListener('abort', () => { + void close().finally(() => controller.error(options.signal?.reason ?? new Error('The operation was aborted.'))) + }, { once: true }) + }, + async pull(controller) { + try { + throwIfAborted(options?.signal) + const bytes = await invoke('plugin:vnidrop-fs|readFileStreamChunk', { + id, + length: bufferByteLength, + }) + if (bytes.length === 0) { + await close() + controller.close() + return + } + controller.enqueue(new Uint8Array(bytes) as Uint8Array) + } + catch (error) { + await close() + controller.error(error) + } + }, + cancel: close, + }) +} + /** * Reads an iOS app-local file URL or picker URI as text. */ @@ -183,6 +319,54 @@ export async function writeFile( }) } +/** + * Opens an iOS file as a byte `WritableStream`. + * + * The native file handle remains open until the stream is closed, aborted, or + * errors. External document-provider files keep their security-scoped access + * active for the lifetime of the stream. + */ +export async function openWriteFileStream( + uri: IosFsUri | FsPath, + options?: IosOpenWriteFileStreamOptions +): Promise>> { + throwIfAborted(options?.signal) + const id = await invoke('plugin:vnidrop-fs|openWriteFileStream', { + uri: mapFsPathForInput(uri), + create: options?.create ?? false, + append: options?.append ?? false, + truncate: options?.truncate ?? (!(options?.append ?? false) && options?.offset == null), + offset: mapOffset(options?.offset), + }) + let closed = false + + const close = async (): Promise => { + if (closed) return + closed = true + await invoke('plugin:vnidrop-fs|closeFileStream', { id }).catch(() => undefined) + } + + return new WritableStream>({ + start: controller => { + options?.signal?.addEventListener('abort', () => { + void close().finally(() => controller.error(options.signal?.reason ?? new Error('The operation was aborted.'))) + }, { once: true }) + }, + async write(chunk) { + throwIfAborted(options?.signal) + await invoke('plugin:vnidrop-fs|writeFileStreamChunk', { + id, + data: Array.from(chunk), + }) + }, + async close() { + await invoke('plugin:vnidrop-fs|flushFileStream', { id }) + await close() + }, + abort: close, + }) +} + /** * Writes text to an iOS app-local file URL or picker URI. */ @@ -300,6 +484,22 @@ export async function getMetadata(uri: IosFsUri | FsPath): Promise { + await invoke('plugin:vnidrop-fs|closeAllFileStreams') +} + +/** + * Returns the number of currently open iOS native file streams. + */ +export async function countAllFileStreams(): Promise { + return invoke('plugin:vnidrop-fs|countAllFileStreams') +} + /** * Opens the iOS file picker in place. Cancellation returns an empty array. */ diff --git a/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift b/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift index 6cdb6f1..2d52675 100644 --- a/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift +++ b/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift @@ -80,6 +80,33 @@ private final class SaveFilePickerArgs: Decodable { let mimeType: String? } +private final class OpenReadFileStreamArgs: Decodable { + let uri: IosFsUriOrString + let offset: UInt64? +} + +private final class OpenWriteFileStreamArgs: Decodable { + let uri: IosFsUriOrString + let create: Bool + let append: Bool + let truncate: Bool + let offset: UInt64? +} + +private final class FileStreamIdArgs: Decodable { + let id: Int +} + +private final class ReadFileStreamChunkArgs: Decodable { + let id: Int + let length: Int +} + +private final class WriteFileStreamChunkArgs: Decodable { + let id: Int + let data: [UInt8] +} + private final class BookmarkIdArgs: Decodable { let bookmarkId: String } @@ -105,6 +132,10 @@ private final class DocumentPickerDelegate: NSObject, UIDocumentPickerDelegate { final class VnidropFsPlugin: Plugin { private let store = SecurityScopedBookmarkStore(defaults: .standard) private var retainedDelegates: [DocumentPickerDelegate] = [] + private let streamLock = NSLock() + private var nextStreamId = 1 + private var readStreams: [Int: IosReadFileStream] = [:] + private var writeStreams: [Int: IosWriteFileStream] = [:] @objc public func listSecurityScopedBookmarks(_ invoke: Invoke) throws { let values = store.bookmarkIds().compactMap { id -> IosFsUri? in @@ -141,6 +172,36 @@ final class VnidropFsPlugin: Plugin { } } + @objc public func openReadFileStream(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(OpenReadFileStreamArgs.self) + run(invoke) { + let resolved = try self.resolve(args.uri) + let accessed = resolved.url.startAccessingSecurityScopedResource() + do { + let handle = try FileHandle(forReadingFrom: resolved.url) + if let offset = args.offset { + try handle.seek(toOffset: offset) + } + return self.insertReadStream(IosReadFileStream(url: resolved.url, handle: handle, accessed: accessed)) + } catch { + if accessed { + resolved.url.stopAccessingSecurityScopedResource() + } + throw error + } + } + } + + @objc public func readFileStreamChunk(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(ReadFileStreamChunkArgs.self) + run(invoke) { + guard args.length > 0 else { + throw FsError("length must be greater than zero") + } + return Array(try self.readStream(id: args.id).read(maxLength: args.length)) + } + } + @objc public func readTextFile(_ invoke: Invoke) throws { let args = try invoke.parseArgs(ReadTextFileArgs.self) run(invoke) { @@ -164,6 +225,67 @@ final class VnidropFsPlugin: Plugin { } } + @objc public func openWriteFileStream(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(OpenWriteFileStreamArgs.self) + run(invoke) { + let resolved = try self.resolve(args.uri) + let accessed = resolved.url.startAccessingSecurityScopedResource() + do { + let exists = FileManager.default.fileExists(atPath: resolved.url.path) + if !exists { + guard args.create else { + throw FsError("file does not exist") + } + guard FileManager.default.createFile(atPath: resolved.url.path, contents: Data()) else { + throw FsError("unable to create file") + } + } + let handle = try FileHandle(forWritingTo: resolved.url) + if args.append { + handle.seekToEndOfFile() + } else if args.truncate { + handle.truncateFile(atOffset: 0) + } + if let offset = args.offset { + try handle.seek(toOffset: offset) + } + return self.insertWriteStream(IosWriteFileStream(url: resolved.url, handle: handle, accessed: accessed)) + } catch { + if accessed { + resolved.url.stopAccessingSecurityScopedResource() + } + throw error + } + } + } + + @objc public func writeFileStreamChunk(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(WriteFileStreamChunkArgs.self) + runVoid(invoke) { + try self.writeStream(id: args.id).write(Data(args.data)) + } + } + + @objc public func flushFileStream(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(FileStreamIdArgs.self) + runVoid(invoke) { + try self.writeStream(id: args.id).flush() + } + } + + @objc public func closeFileStream(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(FileStreamIdArgs.self) + runVoid(invoke) { + if let stream = self.removeReadStream(id: args.id) { + try stream.close() + return + } + if let stream = self.removeWriteStream(id: args.id) { + try stream.close() + } + } + } + @objc public func writeTextFile(_ invoke: Invoke) throws { let args = try invoke.parseArgs(WriteTextFileArgs.self) runVoid(invoke) { @@ -296,6 +418,19 @@ final class VnidropFsPlugin: Plugin { } } + @objc public func closeAllFileStreams(_ invoke: Invoke) throws { + runVoid(invoke) { + let streams = self.removeAllStreams() + for stream in streams { + try stream.close() + } + } + } + + @objc public func countAllFileStreams(_ invoke: Invoke) throws { + invoke.resolve(countStreams()) + } + @objc public func showOpenFilePicker(_ invoke: Invoke) throws { let args = try invoke.parseArgs(OpenFilePickerArgs.self) presentPicker(invoke: invoke, mode: .open, documentTypes: documentTypes(for: args.mimeTypes), allowsMultipleSelection: args.multiple, cancelValue: [IosFsUri]()) { urls in @@ -351,6 +486,69 @@ final class VnidropFsPlugin: Plugin { } } + private func insertReadStream(_ stream: IosReadFileStream) -> Int { + streamLock.lock() + defer { streamLock.unlock() } + let id = nextStreamId + nextStreamId += 1 + readStreams[id] = stream + return id + } + + private func insertWriteStream(_ stream: IosWriteFileStream) -> Int { + streamLock.lock() + defer { streamLock.unlock() } + let id = nextStreamId + nextStreamId += 1 + writeStreams[id] = stream + return id + } + + private func readStream(id: Int) throws -> IosReadFileStream { + streamLock.lock() + defer { streamLock.unlock() } + guard let stream = readStreams[id] else { + throw FsError("read stream not found") + } + return stream + } + + private func writeStream(id: Int) throws -> IosWriteFileStream { + streamLock.lock() + defer { streamLock.unlock() } + guard let stream = writeStreams[id] else { + throw FsError("write stream not found") + } + return stream + } + + private func removeReadStream(id: Int) -> IosReadFileStream? { + streamLock.lock() + defer { streamLock.unlock() } + return readStreams.removeValue(forKey: id) + } + + private func removeWriteStream(id: Int) -> IosWriteFileStream? { + streamLock.lock() + defer { streamLock.unlock() } + return writeStreams.removeValue(forKey: id) + } + + private func removeAllStreams() -> [IosClosableFileStream] { + streamLock.lock() + defer { streamLock.unlock() } + let streams: [IosClosableFileStream] = Array(readStreams.values) + Array(writeStreams.values) + readStreams.removeAll() + writeStreams.removeAll() + return streams + } + + private func countStreams() -> Int { + streamLock.lock() + defer { streamLock.unlock() } + return readStreams.count + writeStreams.count + } + private func resolve(_ input: IosFsUriOrString) throws -> (url: URL, uri: IosFsUri?) { switch input { case .string(let value): @@ -537,6 +735,106 @@ private struct IosFsEntry: Encodable { } } +private protocol IosClosableFileStream { + func close() throws +} + +private final class IosReadFileStream: IosClosableFileStream { + private let url: URL + private let handle: FileHandle + private let accessed: Bool + private let lock = NSLock() + private var closed = false + + init(url: URL, handle: FileHandle, accessed: Bool) { + self.url = url + self.handle = handle + self.accessed = accessed + } + + func read(maxLength: Int) throws -> Data { + lock.lock() + defer { lock.unlock() } + try assertOpen() + return handle.readData(ofLength: maxLength) + } + + func close() throws { + lock.lock() + defer { lock.unlock() } + guard !closed else { return } + closed = true + defer { + if accessed { + url.stopAccessingSecurityScopedResource() + } + } + try handle.close() + } + + deinit { + try? close() + } + + private func assertOpen() throws { + if closed { + throw FsError("stream is closed") + } + } +} + +private final class IosWriteFileStream: IosClosableFileStream { + private let url: URL + private let handle: FileHandle + private let accessed: Bool + private let lock = NSLock() + private var closed = false + + init(url: URL, handle: FileHandle, accessed: Bool) { + self.url = url + self.handle = handle + self.accessed = accessed + } + + func write(_ data: Data) throws { + lock.lock() + defer { lock.unlock() } + try assertOpen() + handle.write(data) + } + + func flush() throws { + lock.lock() + defer { lock.unlock() } + try assertOpen() + try handle.synchronize() + } + + func close() throws { + lock.lock() + defer { lock.unlock() } + guard !closed else { return } + closed = true + defer { + if accessed { + url.stopAccessingSecurityScopedResource() + } + } + try handle.synchronize() + try handle.close() + } + + deinit { + try? close() + } + + private func assertOpen() throws { + if closed { + throw FsError("stream is closed") + } + } +} + private struct FsError: LocalizedError { let message: String diff --git a/permissions/all-without-delete.toml b/permissions/all-without-delete.toml index f6ada51..6279c8e 100644 --- a/permissions/all-without-delete.toml +++ b/permissions/all-without-delete.toml @@ -54,6 +54,9 @@ commands.allow = [ "releaseSecurityScopedBookmark", "persistSecurityScopedBookmark", "readFile", + "openReadFileStream", + "readFileStreamChunk", + "closeFileStream", "readTextFile", "readDir", "createDir", @@ -63,6 +66,8 @@ commands.allow = [ "renameDir", "exists", "getMetadata", + "closeAllFileStreams", + "countAllFileStreams", "showOpenFilePicker", "showOpenDirPicker", "showSaveFilePicker" diff --git a/permissions/all.toml b/permissions/all.toml index 6560c80..c564525 100644 --- a/permissions/all.toml +++ b/permissions/all.toml @@ -62,8 +62,14 @@ commands.allow = [ "releaseSecurityScopedBookmark", "persistSecurityScopedBookmark", "readFile", + "openReadFileStream", + "readFileStreamChunk", "readTextFile", "writeFile", + "openWriteFileStream", + "writeFileStreamChunk", + "flushFileStream", + "closeFileStream", "writeTextFile", "readDir", "createDir", @@ -77,6 +83,8 @@ commands.allow = [ "removeDirAll", "exists", "getMetadata", + "closeAllFileStreams", + "countAllFileStreams", "showOpenFilePicker", "showOpenDirPicker", "showSaveFilePicker" diff --git a/permissions/read-only.toml b/permissions/read-only.toml index 5be489e..dc81068 100644 --- a/permissions/read-only.toml +++ b/permissions/read-only.toml @@ -35,10 +35,15 @@ commands.allow = [ "listSecurityScopedBookmarks", "resolveSecurityScopedBookmark", "readFile", + "openReadFileStream", + "readFileStreamChunk", + "closeFileStream", "readTextFile", "readDir", "exists", "getMetadata", + "closeAllFileStreams", + "countAllFileStreams", "showOpenFilePicker", "showOpenDirPicker" ] diff --git a/src/api/models/error.rs b/src/api/models/error.rs index 8675342..ae100b5 100644 --- a/src/api/models/error.rs +++ b/src/api/models/error.rs @@ -59,6 +59,9 @@ impl From for tauri::Error { #[cfg(target_os = "android")] InnerError::PluginInvoke(e) => tauri::Error::PluginInvoke(e), + #[cfg(target_os = "ios")] + InnerError::PluginInvoke(e) => tauri::Error::Anyhow(e.into()), + e => tauri::Error::Anyhow(e.into()), } } @@ -70,7 +73,7 @@ enum InnerError { #[error("{0}")] Raw(Cow<'static, str>), - #[cfg(target_os = "android")] + #[cfg(any(target_os = "android", target_os = "ios"))] #[error(transparent)] PluginInvoke(tauri::plugin::mobile::PluginInvokeError), @@ -125,7 +128,7 @@ macro_rules! impl_into_err_from_inner { }; } -#[cfg(target_os = "android")] +#[cfg(any(target_os = "android", target_os = "ios"))] impl_into_err_from_inner!(tauri::plugin::mobile::PluginInvokeError, e => crate::Error { inner: InnerError::PluginInvoke(e) }); #[cfg(target_os = "android")] diff --git a/src/api/models/ios_uri.rs b/src/api/models/ios_uri.rs new file mode 100644 index 0000000..0225c43 --- /dev/null +++ b/src/api/models/ios_uri.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +/// iOS file or directory reference returned by Vnidrop FS picker and bookmark APIs. +/// +/// External document-provider files should keep the `bookmark_id` value that +/// came from the picker or bookmark resolver. The Rust backend API uses it to +/// ask the native iOS plugin to open a security-scoped stream. +#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IosFsUri { + /// File URL string for the selected entry. + pub uri: String, + + /// Persisted security-scoped bookmark identifier, or `None` for app-local files. + pub bookmark_id: Option, + + /// Indicates whether the referenced entry is a directory when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_directory: Option, +} diff --git a/src/api/models/mod.rs b/src/api/models/mod.rs index 914d968..44314dd 100644 --- a/src/api/models/mod.rs +++ b/src/api/models/mod.rs @@ -5,6 +5,7 @@ mod file_uri; mod file_picker; mod file_access; mod image; +mod ios_uri; mod notification; mod storage_volume; @@ -15,7 +16,8 @@ pub use file_uri::*; pub use file_picker::*; pub use file_access::*; pub use image::*; +pub use ios_uri::*; pub use notification::*; pub use storage_volume::*; -pub type Result = std::result::Result; \ No newline at end of file +pub type Result = std::result::Result; diff --git a/src/fs.rs b/src/fs.rs new file mode 100644 index 0000000..410d1e2 --- /dev/null +++ b/src/fs.rs @@ -0,0 +1,636 @@ +use std::{ + io::{Read, Seek, SeekFrom, Write}, + marker::PhantomData, + path::{Path, PathBuf}, +}; + +#[cfg(target_os = "ios")] +use serde::Serialize; + +use crate::{Error, FileUri, IosFsUri, Result}; + +const DEFAULT_COPY_BUFFER_LEN: usize = 1024 * 1024; + +/// File target accepted by the Rust backend API. +/// +/// Use filesystem paths for desktop and app-container files. Use [`FileUri`] +/// for Android SAF/content targets. Use [`IosFsUri`] for external iOS files +/// selected through Vnidrop FS pickers or bookmark helpers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VnidropFsTarget { + /// Local filesystem path. + Path(PathBuf), + + /// Android file/content URI. + AndroidUri(FileUri), + + /// iOS file URL with optional security-scoped bookmark metadata. + IosUri(IosFsUri), +} + +impl From for VnidropFsTarget { + fn from(value: PathBuf) -> Self { + Self::Path(value) + } +} + +impl From<&Path> for VnidropFsTarget { + fn from(value: &Path) -> Self { + Self::Path(value.to_path_buf()) + } +} + +impl From<&PathBuf> for VnidropFsTarget { + fn from(value: &PathBuf) -> Self { + Self::Path(value.clone()) + } +} + +impl From for VnidropFsTarget { + fn from(value: FileUri) -> Self { + Self::AndroidUri(value) + } +} + +impl From<&FileUri> for VnidropFsTarget { + fn from(value: &FileUri) -> Self { + Self::AndroidUri(value.clone()) + } +} + +impl From for VnidropFsTarget { + fn from(value: IosFsUri) -> Self { + Self::IosUri(value) + } +} + +impl From<&IosFsUri> for VnidropFsTarget { + fn from(value: &IosFsUri) -> Self { + Self::IosUri(value.clone()) + } +} + +/// Controls how the Rust backend API opens a file for writing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VnidropOpenWriteOptions { + /// Creates the file when it does not already exist. + pub create: bool, + + /// Moves the write cursor to the end before writing. + pub append: bool, + + /// Clears existing contents before writing. + pub truncate: bool, + + /// Moves the write cursor to this byte position after open/truncate/append handling. + pub offset: Option, +} + +impl Default for VnidropOpenWriteOptions { + fn default() -> Self { + Self { + create: false, + append: false, + truncate: true, + offset: None, + } + } +} + +impl VnidropOpenWriteOptions { + /// Opens missing files instead of failing. + pub fn create(mut self, create: bool) -> Self { + self.create = create; + self + } + + /// Preserves existing contents and writes from the end of the file. + pub fn append(mut self, append: bool) -> Self { + self.append = append; + if append { + self.truncate = false; + } + self + } + + /// Replaces existing contents before writing. + pub fn truncate(mut self, truncate: bool) -> Self { + self.truncate = truncate; + if truncate { + self.append = false; + } + self + } + + /// Starts writing at a byte offset. + pub fn offset(mut self, offset: u64) -> Self { + self.offset = Some(offset); + self + } +} + +/// Rust-side filesystem manager exposed through [`VnidropFsExt`]. +/// +/// The methods return standard Rust `Read` and `Write` handles so backend code +/// can stream large files without involving frontend IPC or loading entire +/// files into memory. +pub struct VnidropFs { + #[cfg(target_os = "android")] + android: crate::api::api_sync::AndroidFs, + + #[cfg(target_os = "ios")] + handle: tauri::plugin::PluginHandle, + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + _runtime: PhantomData R>, +} + +impl VnidropFs { + #[cfg(target_os = "android")] + pub(crate) fn android(handle: tauri::plugin::PluginHandle) -> Self { + Self { + android: crate::api::api_sync::AndroidFs { handle }, + } + } + + #[cfg(target_os = "ios")] + pub(crate) fn ios(handle: tauri::plugin::PluginHandle) -> Self { + Self { handle } + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + pub(crate) fn desktop() -> Self { + Self { + _runtime: PhantomData, + } + } + + /// Opens a target for chunked reading. + pub fn open_read(&self, target: impl Into) -> Result> { + match target.into() { + VnidropFsTarget::Path(path) => self.open_path_read(path), + VnidropFsTarget::AndroidUri(uri) => self.open_android_read(uri), + VnidropFsTarget::IosUri(uri) => self.open_ios_read(uri), + } + } + + /// Opens a target for chunked writing. + pub fn open_write( + &self, + target: impl Into, + options: VnidropOpenWriteOptions, + ) -> Result> { + match target.into() { + VnidropFsTarget::Path(path) => self.open_path_write(path, options), + VnidropFsTarget::AndroidUri(uri) => self.open_android_write(uri, options), + VnidropFsTarget::IosUri(uri) => self.open_ios_write(uri, options), + } + } + + /// Reads the full target into memory. + /// + /// Prefer [`VnidropFs::open_read`] for large files. + pub fn read(&self, target: impl Into) -> Result> { + let mut reader = self.open_read(target)?; + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes)?; + Ok(bytes) + } + + /// Replaces a target with the provided bytes. + /// + /// Prefer [`VnidropFs::open_write`] for large files. + pub fn write( + &self, + target: impl Into, + data: impl AsRef<[u8]>, + ) -> Result<()> { + let mut writer = self.open_write(target, VnidropOpenWriteOptions::default().create(true))?; + writer.write_all(data.as_ref())?; + writer.flush()?; + Ok(()) + } + + /// Streams bytes from one target to another with a fixed-size buffer. + pub fn copy_streaming( + &self, + source: impl Into, + destination: impl Into, + ) -> Result { + let mut reader = self.open_read(source)?; + let mut writer = self.open_write( + destination, + VnidropOpenWriteOptions::default().create(true), + )?; + let copied = copy_with_buffer(&mut reader, &mut writer, DEFAULT_COPY_BUFFER_LEN)?; + writer.flush()?; + Ok(copied) + } + + fn open_path_read(&self, path: PathBuf) -> Result> { + let file = std::fs::File::open(path)?; + Ok(VnidropFileReader::from_std(file)) + } + + fn open_path_write( + &self, + path: PathBuf, + options: VnidropOpenWriteOptions, + ) -> Result> { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(options.create) + .append(options.append) + .truncate(options.truncate && !options.append) + .open(path)?; + if let Some(offset) = options.offset { + file.seek(SeekFrom::Start(offset))?; + } + Ok(VnidropFileWriter::from_std(file)) + } + + fn open_android_read(&self, uri: FileUri) -> Result> { + #[cfg(target_os = "android")] + { + return self.android.open_file_readable(&uri).map(VnidropFileReader::from_std); + } + + #[cfg(not(target_os = "android"))] + { + if let Some(path) = uri.to_path() { + self.open_path_read(path) + } else { + Err(Error::invalid_uri_scheme(uri.uri)) + } + } + } + + fn open_android_write( + &self, + uri: FileUri, + options: VnidropOpenWriteOptions, + ) -> Result> { + #[cfg(target_os = "android")] + { + let mode = if options.append { + crate::FileAccessMode::WriteAppend + } else if options.truncate { + crate::FileAccessMode::WriteTruncate + } else { + #[allow(deprecated)] + crate::FileAccessMode::Write + }; + let mut file = self.android.open_file(&uri, mode)?; + if let Some(offset) = options.offset { + file.seek(SeekFrom::Start(offset))?; + } + return Ok(VnidropFileWriter::from_std(file)); + } + + #[cfg(not(target_os = "android"))] + { + if let Some(path) = uri.to_path() { + self.open_path_write(path, options) + } else { + Err(Error::invalid_uri_scheme(uri.uri)) + } + } + } + + fn open_ios_read(&self, uri: IosFsUri) -> Result> { + #[cfg(target_os = "ios")] + { + let id = self + .handle + .run_mobile_plugin::("openReadFileStream", IosOpenReadFileStreamArgs { + uri: IosStreamTarget::Uri(uri), + offset: None, + })?; + return Ok(VnidropFileReader::from_ios(self.handle.clone(), id)); + } + + #[cfg(not(target_os = "ios"))] + { + if let Some(path) = ios_file_url_to_path(&uri.uri) { + self.open_path_read(path) + } else { + Err(Error::invalid_uri_scheme(uri.uri)) + } + } + } + + fn open_ios_write( + &self, + uri: IosFsUri, + options: VnidropOpenWriteOptions, + ) -> Result> { + #[cfg(target_os = "ios")] + { + let id = self + .handle + .run_mobile_plugin::("openWriteFileStream", IosOpenWriteFileStreamArgs { + uri: IosStreamTarget::Uri(uri), + create: options.create, + append: options.append, + truncate: options.truncate, + offset: options.offset, + })?; + return Ok(VnidropFileWriter::from_ios(self.handle.clone(), id)); + } + + #[cfg(not(target_os = "ios"))] + { + if let Some(path) = ios_file_url_to_path(&uri.uri) { + self.open_path_write(path, options) + } else { + Err(Error::invalid_uri_scheme(uri.uri)) + } + } + } +} + +/// Reader returned by [`VnidropFs::open_read`]. +pub struct VnidropFileReader { + inner: VnidropFileReaderInner, +} + +enum VnidropFileReaderInner { + Std(std::fs::File, PhantomData R>), + + #[cfg(target_os = "ios")] + Ios(IosReadStream), +} + +impl VnidropFileReader { + fn from_std(file: std::fs::File) -> Self { + Self { + inner: VnidropFileReaderInner::Std(file, PhantomData), + } + } + + #[cfg(target_os = "ios")] + fn from_ios(handle: tauri::plugin::PluginHandle, id: i32) -> Self { + Self { + inner: VnidropFileReaderInner::Ios(IosReadStream::new(handle, id)), + } + } + + /// Closes the native resource before the value is dropped. + pub fn close(&mut self) -> Result<()> { + match &mut self.inner { + VnidropFileReaderInner::Std(_, _) => Ok(()), + + #[cfg(target_os = "ios")] + VnidropFileReaderInner::Ios(stream) => stream.close(), + } + } +} + +impl Read for VnidropFileReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match &mut self.inner { + VnidropFileReaderInner::Std(file, _) => file.read(buf), + + #[cfg(target_os = "ios")] + VnidropFileReaderInner::Ios(stream) => stream.read(buf).map_err(Into::into), + } + } +} + +/// Writer returned by [`VnidropFs::open_write`]. +pub struct VnidropFileWriter { + inner: VnidropFileWriterInner, +} + +enum VnidropFileWriterInner { + Std(std::fs::File, PhantomData R>), + + #[cfg(target_os = "ios")] + Ios(IosWriteStream), +} + +impl VnidropFileWriter { + fn from_std(file: std::fs::File) -> Self { + Self { + inner: VnidropFileWriterInner::Std(file, PhantomData), + } + } + + #[cfg(target_os = "ios")] + fn from_ios(handle: tauri::plugin::PluginHandle, id: i32) -> Self { + Self { + inner: VnidropFileWriterInner::Ios(IosWriteStream::new(handle, id)), + } + } + + /// Flushes and closes the native resource before the value is dropped. + pub fn close(&mut self) -> Result<()> { + match &mut self.inner { + VnidropFileWriterInner::Std(file, _) => { + file.flush()?; + Ok(()) + } + + #[cfg(target_os = "ios")] + VnidropFileWriterInner::Ios(stream) => stream.close(), + } + } +} + +impl Write for VnidropFileWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match &mut self.inner { + VnidropFileWriterInner::Std(file, _) => file.write(buf), + + #[cfg(target_os = "ios")] + VnidropFileWriterInner::Ios(stream) => { + stream.write_all(buf).map_err(std::io::Error::from)?; + Ok(buf.len()) + } + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match &mut self.inner { + VnidropFileWriterInner::Std(file, _) => file.flush(), + + #[cfg(target_os = "ios")] + VnidropFileWriterInner::Ios(stream) => stream.flush().map_err(Into::into), + } + } +} + +#[cfg(target_os = "ios")] +struct IosReadStream { + handle: tauri::plugin::PluginHandle, + id: i32, + closed: bool, +} + +#[cfg(target_os = "ios")] +impl IosReadStream { + fn new(handle: tauri::plugin::PluginHandle, id: i32) -> Self { + Self { + handle, + id, + closed: false, + } + } + + fn read(&self, buf: &mut [u8]) -> Result { + if buf.is_empty() { + return Ok(0); + } + + let bytes = self + .handle + .run_mobile_plugin::>("readFileStreamChunk", IosReadChunkArgs { + id: self.id, + length: buf.len(), + })?; + let len = bytes.len(); + buf[..len].copy_from_slice(&bytes); + Ok(len) + } + + fn close(&mut self) -> Result<()> { + if self.closed { + return Ok(()); + } + self.closed = true; + self.handle + .run_mobile_plugin::<()>("closeFileStream", IosStreamIdArgs { id: self.id })?; + Ok(()) + } +} + +#[cfg(target_os = "ios")] +impl Drop for IosReadStream { + fn drop(&mut self) { + let _ = self.close(); + } +} + +#[cfg(target_os = "ios")] +struct IosWriteStream { + handle: tauri::plugin::PluginHandle, + id: i32, + closed: bool, +} + +#[cfg(target_os = "ios")] +impl IosWriteStream { + fn new(handle: tauri::plugin::PluginHandle, id: i32) -> Self { + Self { + handle, + id, + closed: false, + } + } + + fn write_all(&self, data: &[u8]) -> Result<()> { + self.handle.run_mobile_plugin::<()>( + "writeFileStreamChunk", + IosWriteChunkArgs { + id: self.id, + data, + }, + )?; + Ok(()) + } + + fn flush(&self) -> Result<()> { + self.handle + .run_mobile_plugin::<()>("flushFileStream", IosStreamIdArgs { id: self.id })?; + Ok(()) + } + + fn close(&mut self) -> Result<()> { + if self.closed { + return Ok(()); + } + self.closed = true; + self.handle + .run_mobile_plugin::<()>("closeFileStream", IosStreamIdArgs { id: self.id })?; + Ok(()) + } +} + +#[cfg(target_os = "ios")] +impl Drop for IosWriteStream { + fn drop(&mut self) { + let _ = self.close(); + } +} + +#[cfg(target_os = "ios")] +#[derive(Serialize)] +#[serde(untagged)] +enum IosStreamTarget { + Uri(IosFsUri), +} + +#[cfg(target_os = "ios")] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct IosOpenReadFileStreamArgs { + uri: IosStreamTarget, + offset: Option, +} + +#[cfg(target_os = "ios")] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct IosOpenWriteFileStreamArgs { + uri: IosStreamTarget, + create: bool, + append: bool, + truncate: bool, + offset: Option, +} + +#[cfg(target_os = "ios")] +#[derive(Serialize)] +struct IosStreamIdArgs { + id: i32, +} + +#[cfg(target_os = "ios")] +#[derive(Serialize)] +struct IosReadChunkArgs { + id: i32, + length: usize, +} + +#[cfg(target_os = "ios")] +#[derive(Serialize)] +struct IosWriteChunkArgs<'a> { + id: i32, + data: &'a [u8], +} + +fn copy_with_buffer( + reader: &mut R, + writer: &mut W, + buffer_len: usize, +) -> std::io::Result { + let mut buffer = vec![0; buffer_len]; + let mut copied = 0; + + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + return Ok(copied); + } + writer.write_all(&buffer[..read])?; + copied += read as u64; + } +} + +#[cfg(not(target_os = "ios"))] +fn ios_file_url_to_path(uri: &str) -> Option { + uri.strip_prefix("file://") + .map(percent_encoding::percent_decode_str) + .and_then(|decoded| decoded.decode_utf8().ok()) + .map(|decoded| PathBuf::from(decoded.as_ref())) +} diff --git a/src/lib.rs b/src/lib.rs index 7b6dfc9..fc5e400 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ mod protocols; mod config; mod scope; mod utils; +mod fs; pub mod api; @@ -14,6 +15,13 @@ use utils::*; pub use api::models::*; pub use api::consts::*; +pub use fs::{ + VnidropFileReader, + VnidropFileWriter, + VnidropFs, + VnidropFsTarget, + VnidropOpenWriteOptions, +}; #[cfg(target_os = "ios")] tauri::ios_plugin_binding!(init_plugin_vnidrop_fs); @@ -48,6 +56,7 @@ pub fn init() -> tauri::plugin::TauriPlugin() -> tauri::plugin::TauriPlugin { handle: Default::default() }; @@ -68,6 +78,9 @@ pub fn init() -> tauri::plugin::TauriPlugin::desktop()); + } Ok(()) }); @@ -164,6 +177,15 @@ pub trait AndroidFsExt { fn android_fs_async(&self) -> &api::api_async::AndroidFs; } +pub trait VnidropFsExt { + /// Provides the Rust backend filesystem API. + /// + /// This API is intended for Rust-side filesystem workflows. It returns + /// standard Rust `Read` and `Write` handles so large files can be streamed + /// without routing bytes through frontend IPC. + fn vnidrop_fs(&self) -> &VnidropFs; +} + impl> AndroidFsExt for T { fn android_fs(&self) -> &api::api_sync::AndroidFs { @@ -178,3 +200,11 @@ impl> AndroidFsExt for T { .expect("tauri_plugin_vnidrop_fs should be initialized to use; see https://crates.io/crates/tauri-plugin-vnidrop-fs") } } + +impl> VnidropFsExt for T { + fn vnidrop_fs(&self) -> &VnidropFs { + self.try_state::>() + .map(|i| i.inner()) + .expect("tauri_plugin_vnidrop_fs should be initialized to use; see https://crates.io/crates/tauri-plugin-vnidrop-fs") + } +} diff --git a/tests/js/index.test.ts b/tests/js/index.test.ts index 1677d69..1037510 100644 --- a/tests/js/index.test.ts +++ b/tests/js/index.test.ts @@ -8,10 +8,12 @@ const tauriFsMock = vi.hoisted(() => ({ readDir: vi.fn(), mkdir: vi.fn(), create: vi.fn(), + open: vi.fn(), remove: vi.fn(), rename: vi.fn(), exists: vi.fn(), stat: vi.fn(), + SeekMode: { Start: 0, Current: 1, End: 2 }, })) const tauriDialogMock = vi.hoisted(() => ({ @@ -25,6 +27,10 @@ const androidMock = vi.hoisted(() => ({ readTextFile: vi.fn(), writeFile: vi.fn(), writeTextFile: vi.fn(), + openReadFileStream: vi.fn(), + openWriteFileStream: vi.fn(), + closeAllFileStreams: vi.fn(), + countAllFileStreams: vi.fn(), readDir: vi.fn(), createDir: vi.fn(), showOpenFilePicker: vi.fn(), @@ -48,6 +54,10 @@ const iosMock = vi.hoisted(() => ({ readTextFile: vi.fn(), writeFile: vi.fn(), writeTextFile: vi.fn(), + openReadFileStream: vi.fn(), + openWriteFileStream: vi.fn(), + closeAllFileStreams: vi.fn(), + countAllFileStreams: vi.fn(), readDir: vi.fn(), createDir: vi.fn(), showOpenFilePicker: vi.fn(), @@ -123,6 +133,7 @@ describe('platform capability helpers', () => { supportsPersistedPickerPermissions: false, supportsThumbnails: false, supportsSecurityScopedBookmarks: false, + supportsFileStreams: true, }) }) @@ -139,6 +150,7 @@ describe('platform capability helpers', () => { supportsPersistedPickerPermissions: true, supportsThumbnails: true, supportsSecurityScopedBookmarks: false, + supportsFileStreams: true, }) }) @@ -155,6 +167,7 @@ describe('platform capability helpers', () => { supportsPersistedPickerPermissions: false, supportsThumbnails: false, supportsSecurityScopedBookmarks: true, + supportsFileStreams: true, }) }) }) @@ -174,6 +187,14 @@ describe('desktop routing', () => { tauriFsMock.stat.mockResolvedValue({ isFile: true }) tauriFsMock.exists.mockResolvedValue(false) tauriFsMock.create.mockResolvedValue({ close: vi.fn().mockResolvedValue(undefined) }) + tauriFsMock.open.mockResolvedValue({ + read: vi.fn() + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(null), + write: vi.fn().mockResolvedValue(2), + seek: vi.fn().mockResolvedValue(3), + close: vi.fn().mockResolvedValue(undefined), + }) tauriDialogMock.open.mockResolvedValue(null) tauriDialogMock.save.mockResolvedValue(null) }) @@ -246,6 +267,30 @@ describe('desktop routing', () => { expect(tauriFsMock.create).toHaveBeenCalledWith('/tmp/report (1).txt', undefined) expect(tauriFsMock.mkdir).toHaveBeenCalledWith('/tmp/photos (1)', undefined) }) + + it('opens desktop read and write streams with official file handles', async () => { + const fs = await import('../../guest-js/index') + + const readStream = await fs.openReadFileStream('/tmp/read.bin', { bufferByteLength: 2, offset: 3, baseDir: 1 } as never) + const read = readStream.getReader() + await expect(read.read()).resolves.toEqual({ done: false, value: new Uint8Array([0, 0]) }) + await expect(read.read()).resolves.toEqual({ done: true, value: undefined }) + + const writeStream = await fs.openWriteFileStream('/tmp/write.bin', { create: true, offset: 3, baseDir: 1 } as never) + const writer = writeStream.getWriter() + await writer.write(new Uint8Array([1, 2])) + await writer.close() + + expect(tauriFsMock.open).toHaveBeenCalledWith('/tmp/read.bin', { baseDir: 1, read: true }) + expect(tauriFsMock.open).toHaveBeenCalledWith('/tmp/write.bin', { + baseDir: 1, + create: true, + append: false, + truncate: false, + write: true, + }) + await expect(fs.countAllFileStreams()).resolves.toBe(0) + }) }) describe('Android routing', () => { @@ -256,6 +301,10 @@ describe('Android routing', () => { androidMock.readTextFile.mockResolvedValue('android') androidMock.writeFile.mockResolvedValue(undefined) androidMock.writeTextFile.mockResolvedValue(undefined) + androidMock.openReadFileStream.mockResolvedValue(new ReadableStream()) + androidMock.openWriteFileStream.mockResolvedValue(new WritableStream()) + androidMock.closeAllFileStreams.mockResolvedValue(undefined) + androidMock.countAllFileStreams.mockResolvedValue(2) androidMock.readDir.mockResolvedValue([]) androidMock.createDir.mockResolvedValue({ uri: 'content://dir/new', documentTopTreeUri: null }) androidMock.showOpenFilePicker.mockResolvedValue([{ uri: 'content://file', documentTopTreeUri: null }]) @@ -275,6 +324,10 @@ describe('Android routing', () => { await fs.readTextFile(base, { encoding: 'utf-8' }) await fs.writeFile(base, new Uint8Array([1])) await fs.writeTextFile(base, 'body') + await fs.openReadFileStream(base, { bufferByteLength: 1 }) + await fs.openWriteFileStream(base, { create: true }) + await fs.closeAllFileStreams() + await expect(fs.countAllFileStreams()).resolves.toBe(2) await fs.readDir(base, { limit: 10 }) await fs.createDir(base, 'nested') await fs.showOpenFilePicker({ mimeTypes: 'text/plain' }) @@ -289,6 +342,10 @@ describe('Android routing', () => { expect(androidMock.readTextFile).toHaveBeenCalledWith(base, { encoding: 'utf-8' }) expect(androidMock.writeFile).toHaveBeenCalledWith(base, new Uint8Array([1]), undefined) expect(androidMock.writeTextFile).toHaveBeenCalledWith(base, 'body', undefined) + expect(androidMock.openReadFileStream).toHaveBeenCalledWith(base, { bufferByteLength: 1 }) + expect(androidMock.openWriteFileStream).toHaveBeenCalledWith(base, { create: true }) + expect(androidMock.closeAllFileStreams).toHaveBeenCalled() + expect(androidMock.countAllFileStreams).toHaveBeenCalled() expect(androidMock.readDir).toHaveBeenCalledWith(base, { limit: 10 }) expect(androidMock.createDir).toHaveBeenCalledWith(base, 'nested') expect(androidMock.showOpenFilePicker).toHaveBeenCalledWith({ mimeTypes: 'text/plain' }) @@ -316,6 +373,10 @@ describe('iOS routing', () => { iosMock.readTextFile.mockResolvedValue('ios') iosMock.writeFile.mockResolvedValue(undefined) iosMock.writeTextFile.mockResolvedValue(undefined) + iosMock.openReadFileStream.mockResolvedValue(new ReadableStream()) + iosMock.openWriteFileStream.mockResolvedValue(new WritableStream()) + iosMock.closeAllFileStreams.mockResolvedValue(undefined) + iosMock.countAllFileStreams.mockResolvedValue(1) iosMock.readDir.mockResolvedValue([]) iosMock.createDir.mockResolvedValue({ uri: 'file:///dir/new', bookmarkId: 'dir-new', isDirectory: true }) iosMock.showOpenFilePicker.mockResolvedValue([{ uri: 'file:///file.txt', bookmarkId: 'file' }]) @@ -335,6 +396,10 @@ describe('iOS routing', () => { await fs.readTextFile(base, { encoding: 'utf-8' }) await fs.writeFile(base, new Uint8Array([1])) await fs.writeTextFile(base, 'body') + await fs.openReadFileStream(base, { bufferByteLength: 1 }) + await fs.openWriteFileStream(base, { create: true }) + await fs.closeAllFileStreams() + await expect(fs.countAllFileStreams()).resolves.toBe(1) await fs.readDir(base, { limit: 10 }) await fs.createDir(base, 'nested') await fs.showOpenFilePicker({ mimeTypes: 'text/plain' }) @@ -349,6 +414,10 @@ describe('iOS routing', () => { expect(iosMock.readTextFile).toHaveBeenCalledWith(base, { encoding: 'utf-8' }) expect(iosMock.writeFile).toHaveBeenCalledWith(base, new Uint8Array([1]), undefined) expect(iosMock.writeTextFile).toHaveBeenCalledWith(base, 'body', undefined) + expect(iosMock.openReadFileStream).toHaveBeenCalledWith(base, { bufferByteLength: 1 }) + expect(iosMock.openWriteFileStream).toHaveBeenCalledWith(base, { create: true }) + expect(iosMock.closeAllFileStreams).toHaveBeenCalled() + expect(iosMock.countAllFileStreams).toHaveBeenCalled() expect(iosMock.readDir).toHaveBeenCalledWith(base, { limit: 10 }) expect(iosMock.createDir).toHaveBeenCalledWith(base, 'nested') expect(iosMock.showOpenFilePicker).toHaveBeenCalledWith({ mimeTypes: 'text/plain' }) diff --git a/tests/model_contracts.rs b/tests/model_contracts.rs index 1082491..10b1613 100644 --- a/tests/model_contracts.rs +++ b/tests/model_contracts.rs @@ -2,7 +2,7 @@ mod support; use std::path::Path; -use tauri_plugin_vnidrop_fs::FileUri; +use tauri_plugin_vnidrop_fs::{FileUri, IosFsUri}; #[test] fn file_uri_serializes_with_android_field_names() { @@ -17,6 +17,23 @@ fn file_uri_serializes_with_android_field_names() { assert_eq!(FileUri::from_json_str(json).expect("uri should deserialize"), uri); } +#[test] +fn ios_uri_serializes_with_frontend_field_names() { + let uri = IosFsUri { + uri: "file:///Documents/report.txt".into(), + bookmark_id: Some("bookmark-1".into()), + is_directory: Some(false), + }; + + let json = serde_json::to_string(&uri).expect("iOS URI should serialize"); + + assert_eq!( + json, + r#"{"uri":"file:///Documents/report.txt","bookmarkId":"bookmark-1","isDirectory":false}"# + ); + assert_eq!(serde_json::from_str::(&json).expect("iOS URI should deserialize"), uri); +} + #[test] fn file_path_round_trip_preserves_spaces_unicode_and_reserved_characters() { let uri = FileUri::from_path(Path::new("/tmp/Vnidrop Test/é file @#.txt")); diff --git a/tests/plugin_mock_runtime.rs b/tests/plugin_mock_runtime.rs index 260be98..18c6bbe 100644 --- a/tests/plugin_mock_runtime.rs +++ b/tests/plugin_mock_runtime.rs @@ -1,4 +1,4 @@ -use tauri_plugin_vnidrop_fs::AndroidFsExt as _; +use tauri_plugin_vnidrop_fs::{AndroidFsExt as _, VnidropFsExt as _}; #[test] fn plugin_registers_android_fs_state_with_tauri_mock_runtime() { @@ -9,4 +9,5 @@ fn plugin_registers_android_fs_state_with_tauri_mock_runtime() { let _sync_api = app.android_fs(); let _async_api = app.android_fs_async(); + let _backend_api = app.vnidrop_fs(); } diff --git a/tests/rust_backend_streams.rs b/tests/rust_backend_streams.rs new file mode 100644 index 0000000..8cf74b5 --- /dev/null +++ b/tests/rust_backend_streams.rs @@ -0,0 +1,37 @@ +use std::io::{Read, Write}; + +use tauri_plugin_vnidrop_fs::{VnidropFsExt as _, VnidropOpenWriteOptions}; + +#[test] +fn backend_api_streams_local_files_without_frontend_ipc() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let source = temp.path().join("source.bin"); + let destination = temp.path().join("destination.bin"); + std::fs::write(&source, b"large-ish fixture").expect("source should be written"); + + let app = tauri::test::mock_builder() + .plugin(tauri_plugin_vnidrop_fs::init()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app should build with vnidrop fs plugin"); + + let fs = app.vnidrop_fs(); + let mut reader = fs.open_read(&source).expect("reader should open"); + let mut writer = fs + .open_write(&destination, VnidropOpenWriteOptions::default().create(true)) + .expect("writer should open"); + + let mut buffer = [0; 4]; + loop { + let read = reader.read(&mut buffer).expect("read should succeed"); + if read == 0 { + break; + } + writer.write_all(&buffer[..read]).expect("write should succeed"); + } + writer.flush().expect("flush should succeed"); + + assert_eq!( + std::fs::read(destination).expect("destination should be readable"), + b"large-ish fixture" + ); +} diff --git a/tests/types/package-imports.ts b/tests/types/package-imports.ts index 5a5a4bb..1b07d1c 100644 --- a/tests/types/package-imports.ts +++ b/tests/types/package-imports.ts @@ -2,6 +2,8 @@ import { createNewDir, createNewFile, getPlatformFsCapabilities, + openReadFileStream, + openWriteFileStream, readTextFile, showOpenDirPicker, showOpenFilePicker, @@ -17,6 +19,8 @@ import { } from '@vnidrop/tauri-plugin-fs/android' import { listSecurityScopedBookmarks, + openReadFileStream as openIosReadFileStream, + openWriteFileStream as openIosWriteFileStream, persistSecurityScopedBookmark, type IosFsUri, } from '@vnidrop/tauri-plugin-fs/ios' @@ -24,6 +28,8 @@ import { async function checkRootImports(path: string): Promise { await writeTextFile(path, 'body') const text: string = await readTextFile(path) + const readStream: ReadableStream> = await openReadFileStream(path) + const writeStream: WritableStream> = await openWriteFileStream(path, { create: true }) const file: PickedFile | undefined = (await showOpenFilePicker())[0] const dir: PickedDirectory | null = await showOpenDirPicker() const newFile = await createNewFile(path) @@ -31,6 +37,8 @@ async function checkRootImports(path: string): Promise { const capabilities = getPlatformFsCapabilities() void text + void readStream + void writeStream void file void dir void newFile @@ -49,9 +57,13 @@ async function checkAndroidImports(uri: AndroidFsUri): Promise { async function checkIosImports(uri: IosFsUri): Promise { const persisted = await persistSecurityScopedBookmark(uri) const bookmarks: IosFsUri[] = await listSecurityScopedBookmarks() + const readStream = await openIosReadFileStream(uri) + const writeStream = await openIosWriteFileStream(uri, { create: true }) void persisted void bookmarks + void readStream + void writeStream } void checkRootImports