mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-04-29 12:06:01 +02:00
feat(fs): resolve content URIs on Android (#1658)
* Implemented writeTextFile on Android. * Added license headers. * fix fmt checks. * implement more file APIs * change file * cleanup * refactor dialog plugin to leverage new FS APIs * implement metadata functions * fix build * expose FS rust API * resolve resources on android * update pnpm * update docs --------- Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"dialog": patch
|
||||
"dialog-js": patch
|
||||
---
|
||||
|
||||
The `open` function now returns a string representing either the file path or URI instead of an object.
|
||||
To read the file data, use the `fs` APIs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"fs": patch:feat
|
||||
---
|
||||
|
||||
Resolve `content://` path URIs on Android.
|
||||
Generated
+17
-16
@@ -6359,9 +6359,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.0.0-rc.3"
|
||||
version = "2.0.0-rc.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79776954e2cd6b6c3b56e2cd99905a3a166017495a39ac8eb4c85dd8ea8704b4"
|
||||
checksum = "7d386b956b09cf88301912453829269f3914b3c813020d429ed8110c75e9dded"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -6412,9 +6412,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.0.0-rc.3"
|
||||
version = "2.0.0-rc.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc103bde77870e08d5fc8765615b9615997827550b626fbc4ebbd7a1fbfe2a2"
|
||||
checksum = "1e79aafbbfc8262d7937675cb44c397e975ab8e0cd722db1c37de694fd443570"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
@@ -6436,9 +6436,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-codegen"
|
||||
version = "2.0.0-rc.3"
|
||||
version = "2.0.0-rc.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea061e6be9b37ab455eadc189f45617deafc85c94f78f9cd584862a6deaa83d1"
|
||||
checksum = "5ce4e521130c5d7b377ddfdc43310ece626b67ec07ae74174407ad7e6cd17d20"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
@@ -6463,9 +6463,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-macros"
|
||||
version = "2.0.0-rc.3"
|
||||
version = "2.0.0-rc.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e20d6f6f96f55a43339c465b3c8205d71940372d54d7c665c5329e8e4ba35d0"
|
||||
checksum = "a5995206394cd30411fc5c8ae195e498357f63e11ed960ea32b53512dcb2a5a5"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -6477,9 +6477,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.0.0-rc.3"
|
||||
version = "2.0.0-rc.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec01af01098a286d3e430c1fa947bfd77bc8011ecb209438af4444b02d82b29e"
|
||||
checksum = "0ebbdbf4e6d7328e0c0f2427b4f56d792ee1ae84ab4fb0286b81a2e408836046"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
@@ -6612,6 +6612,7 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6971,9 +6972,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.0.0-rc.3"
|
||||
version = "2.0.0-rc.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4e736d3293f8347e5d2c5b250fe0e5b873499f5483578b139445dbbf802e2e5"
|
||||
checksum = "6e49398fb1d7736e41099aa7efaf45d599e480a36b3e7f88977b547b662d7253"
|
||||
dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
@@ -6990,9 +6991,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime-wry"
|
||||
version = "2.0.0-rc.3"
|
||||
version = "2.0.0-rc.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fead81c1bd0205d5f02580e64f522704618274e784c2d1c127e4ba19acd0b79"
|
||||
checksum = "8b2ce1dca90243bd4a77a1020847688590e1ded2f6d190d5a96877b0039f0500"
|
||||
dependencies = [
|
||||
"cocoa 0.26.0",
|
||||
"gtk",
|
||||
@@ -7014,9 +7015,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.0.0-rc.3"
|
||||
version = "2.0.0-rc.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "285af18e09665ea15fdda04cb28fb579a4d71b4e1640628489fecca98838ca9a"
|
||||
checksum = "2d702b62eed4cf89034926cb1834e2d13a7d745ea08a457fd336f94cde48f2fb"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"brotli",
|
||||
|
||||
+4
-4
@@ -11,10 +11,10 @@ resolver = "2"
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.0.0-rc.3", default-features = false }
|
||||
tauri-build = "2.0.0-rc.3"
|
||||
tauri-plugin = "2.0.0-rc.3"
|
||||
tauri-utils = "2.0.0-rc.3"
|
||||
tauri = { version = "2.0.0-rc.5", default-features = false }
|
||||
tauri-build = "2.0.0-rc.5"
|
||||
tauri-plugin = "2.0.0-rc.5"
|
||||
tauri-utils = "2.0.0-rc.5"
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
url = "2"
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@iconify-json/codicon": "^1.1.37",
|
||||
"@iconify-json/ph": "^1.1.8",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.1",
|
||||
"@tauri-apps/cli": "2.0.0-rc.4",
|
||||
"@tauri-apps/cli": "2.0.0-rc.6",
|
||||
"@unocss/extractor-svelte": "^0.62.0",
|
||||
"svelte": "^4.2.8",
|
||||
"unocss": "^0.62.0",
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"clipboard-manager:allow-write-text",
|
||||
"clipboard-manager:allow-read-image",
|
||||
"clipboard-manager:allow-write-image",
|
||||
"fs:allow-open",
|
||||
"fs:allow-write",
|
||||
"fs:allow-read",
|
||||
"fs:allow-rename",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-remove",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<option value="$PROJECT_DIR$/../../../../../plugins/biometric/android" />
|
||||
<option value="$PROJECT_DIR$/../../../../../plugins/clipboard-manager/android" />
|
||||
<option value="$PROJECT_DIR$/../../../../../plugins/dialog/android" />
|
||||
<option value="$PROJECT_DIR$/../../../../../plugins/fs/android" />
|
||||
<option value="$PROJECT_DIR$/../../../../../plugins/nfc/android" />
|
||||
<option value="$PROJECT_DIR$/../../../../../plugins/notification/android" />
|
||||
<option value="$PROJECT_DIR$/../../../../../plugins/shell/android" />
|
||||
|
||||
@@ -23,11 +23,7 @@
|
||||
|
||||
async function prompt() {
|
||||
confirm("Do you want to do something?")
|
||||
.then((res) =>
|
||||
onMessage(
|
||||
res ? "Yes" : "No"
|
||||
)
|
||||
)
|
||||
.then((res) => onMessage(res ? "Yes" : "No"))
|
||||
.catch(onMessage);
|
||||
}
|
||||
|
||||
@@ -67,14 +63,15 @@
|
||||
if (Array.isArray(res)) {
|
||||
onMessage(res);
|
||||
} else {
|
||||
var pathToRead = typeof res === "string" ? res : res.path;
|
||||
var pathToRead = res;
|
||||
var isFile = pathToRead.match(/\S+\.\S+$/g);
|
||||
readFile(pathToRead)
|
||||
.then(function (response) {
|
||||
if (isFile) {
|
||||
if (
|
||||
pathToRead.includes(".png") ||
|
||||
pathToRead.includes(".jpg")
|
||||
pathToRead.includes(".jpg") ||
|
||||
pathToRead.includes(".jpeg")
|
||||
) {
|
||||
arrayBufferToBase64(
|
||||
new Uint8Array(response),
|
||||
@@ -144,5 +141,7 @@
|
||||
>Open save dialog</button
|
||||
>
|
||||
<button class="btn" id="prompt-dialog" on:click={prompt}>Prompt</button>
|
||||
<button class="btn" id="custom-prompt-dialog" on:click={promptCustom}>Prompt (custom)</button>
|
||||
<button class="btn" id="custom-prompt-dialog" on:click={promptCustom}
|
||||
>Prompt (custom)</button
|
||||
>
|
||||
<button class="btn" id="message-dialog" on:click={msg}>Message</button>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@tauri-apps/plugin-deep-link": "2.0.0-rc.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.0.0-rc.4",
|
||||
"@tauri-apps/cli": "2.0.0-rc.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.13"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ targets = ["x86_64-unknown-linux-gnu", "x86_64-linux-android"]
|
||||
[build-dependencies]
|
||||
tauri-plugin = { workspace = true, features = [ "build" ] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { workspace = true, features = [ "wry" ] }
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -24,6 +27,7 @@ tauri = { workspace = true }
|
||||
log = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
url = { workspace = true }
|
||||
tauri-plugin-fs = { path = "../fs", version = "2.0.0-rc.0" }
|
||||
|
||||
[target.'cfg(target_os = "ios")'.dependencies]
|
||||
|
||||
@@ -30,7 +30,6 @@ class Filter {
|
||||
class FilePickerOptions {
|
||||
lateinit var filters: Array<Filter>
|
||||
var multiple: Boolean? = null
|
||||
var readData: Boolean? = null
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
@@ -95,7 +94,7 @@ class DialogPlugin(private val activity: Activity): Plugin(activity) {
|
||||
try {
|
||||
when (result.resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
val callResult = createPickFilesResult(result.data, filePickerOptions?.readData ?: false)
|
||||
val callResult = createPickFilesResult(result.data)
|
||||
invoke.resolve(callResult)
|
||||
}
|
||||
Activity.RESULT_CANCELED -> invoke.reject("File picker cancelled")
|
||||
@@ -108,49 +107,23 @@ class DialogPlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPickFilesResult(data: Intent?, readData: Boolean): JSObject {
|
||||
private fun createPickFilesResult(data: Intent?): JSObject {
|
||||
val callResult = JSObject()
|
||||
val filesResultList: MutableList<JSObject> = ArrayList()
|
||||
if (data == null) {
|
||||
callResult.put("files", JSArray.from(filesResultList))
|
||||
callResult.put("files", null)
|
||||
return callResult
|
||||
}
|
||||
val uris: MutableList<Uri?> = ArrayList()
|
||||
val uris: MutableList<String?> = ArrayList()
|
||||
if (data.clipData == null) {
|
||||
val uri: Uri? = data.data
|
||||
uris.add(uri)
|
||||
uris.add(uri?.toString())
|
||||
} else {
|
||||
for (i in 0 until data.clipData!!.itemCount) {
|
||||
val uri: Uri = data.clipData!!.getItemAt(i).uri
|
||||
uris.add(uri)
|
||||
uris.add(uri.toString())
|
||||
}
|
||||
}
|
||||
for (i in uris.indices) {
|
||||
val uri = uris[i] ?: continue
|
||||
val fileResult = JSObject()
|
||||
if (readData) {
|
||||
fileResult.put("base64Data", FilePickerUtils.getDataFromUri(activity, uri))
|
||||
}
|
||||
val duration = FilePickerUtils.getDurationFromUri(activity, uri)
|
||||
if (duration != null) {
|
||||
fileResult.put("duration", duration)
|
||||
}
|
||||
val resolution = FilePickerUtils.getHeightAndWidthFromUri(activity, uri)
|
||||
if (resolution != null) {
|
||||
fileResult.put("height", resolution.height)
|
||||
fileResult.put("width", resolution.width)
|
||||
}
|
||||
fileResult.put("mimeType", FilePickerUtils.getMimeTypeFromUri(activity, uri))
|
||||
val modifiedAt = FilePickerUtils.getModifiedAtFromUri(activity, uri)
|
||||
if (modifiedAt != null) {
|
||||
fileResult.put("modifiedAt", modifiedAt)
|
||||
}
|
||||
fileResult.put("name", FilePickerUtils.getNameFromUri(activity, uri))
|
||||
fileResult.put("path", FilePickerUtils.getPathFromUri(activity, uri))
|
||||
fileResult.put("size", FilePickerUtils.getSizeFromUri(activity, uri))
|
||||
filesResultList.add(fileResult)
|
||||
}
|
||||
callResult.put("files", JSArray.from(filesResultList.toTypedArray()))
|
||||
callResult.put("files", JSArray.from(uris.toTypedArray()))
|
||||
return callResult
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,6 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface FileResponse {
|
||||
base64Data?: string;
|
||||
duration?: number;
|
||||
height?: number;
|
||||
width?: number;
|
||||
mimeType?: string;
|
||||
modifiedAt?: number;
|
||||
name?: string;
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension filters for the file dialog.
|
||||
*
|
||||
@@ -117,8 +105,8 @@ type OpenDialogReturn<T extends OpenDialogOptions> = T["directory"] extends true
|
||||
? string[] | null
|
||||
: string | null
|
||||
: T["multiple"] extends true
|
||||
? FileResponse[] | null
|
||||
: FileResponse | null;
|
||||
? string[] | null
|
||||
: string | null;
|
||||
|
||||
/**
|
||||
* Open a file/directory selection dialog.
|
||||
@@ -306,7 +294,6 @@ async function confirm(
|
||||
|
||||
export type {
|
||||
DialogFilter,
|
||||
FileResponse,
|
||||
OpenDialogOptions,
|
||||
OpenDialogReturn,
|
||||
SaveDialogOptions,
|
||||
|
||||
@@ -29,7 +29,6 @@ struct Filter: Decodable {
|
||||
|
||||
struct FilePickerOptions: Decodable {
|
||||
var multiple: Bool?
|
||||
var readData: Bool?
|
||||
var filters: [Filter]?
|
||||
}
|
||||
|
||||
@@ -136,55 +135,9 @@ class DialogPlugin: Plugin {
|
||||
public func onFilePickerEvent(_ event: FilePickerEvent) {
|
||||
switch event {
|
||||
case .selected(let urls):
|
||||
let readData = pendingInvokeArgs?.readData ?? false
|
||||
do {
|
||||
let filesResult = try urls.map { (url: URL) -> JSObject in
|
||||
var file = JSObject()
|
||||
|
||||
let mimeType = filePickerController.getMimeTypeFromUrl(url)
|
||||
let isVideo = mimeType.hasPrefix("video")
|
||||
let isImage = mimeType.hasPrefix("image")
|
||||
|
||||
if readData {
|
||||
file["data"] = try Data(contentsOf: url).base64EncodedString()
|
||||
}
|
||||
|
||||
if isVideo {
|
||||
file["duration"] = filePickerController.getVideoDuration(url)
|
||||
let (height, width) = filePickerController.getVideoDimensions(url)
|
||||
if let height = height {
|
||||
file["height"] = height
|
||||
}
|
||||
if let width = width {
|
||||
file["width"] = width
|
||||
}
|
||||
} else if isImage {
|
||||
let (height, width) = filePickerController.getImageDimensions(url)
|
||||
if let height = height {
|
||||
file["height"] = height
|
||||
}
|
||||
if let width = width {
|
||||
file["width"] = width
|
||||
}
|
||||
}
|
||||
|
||||
file["modifiedAt"] = filePickerController.getModifiedAtFromUrl(url)
|
||||
file["mimeType"] = mimeType
|
||||
file["name"] = url.lastPathComponent
|
||||
file["path"] = url.absoluteString
|
||||
file["size"] = try filePickerController.getSizeFromUrl(url)
|
||||
return file
|
||||
}
|
||||
pendingInvoke?.resolve(["files": filesResult])
|
||||
} catch let error as NSError {
|
||||
pendingInvoke?.reject(error.localizedDescription, error: error)
|
||||
return
|
||||
}
|
||||
|
||||
pendingInvoke?.resolve(["files": urls])
|
||||
case .cancelled:
|
||||
let files: JSArray = []
|
||||
pendingInvoke?.resolve(["files": files])
|
||||
pendingInvoke?.resolve(["files": nil])
|
||||
case .error(let error):
|
||||
pendingInvoke?.reject(error)
|
||||
}
|
||||
|
||||
@@ -8,17 +8,17 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::{command, Manager, Runtime, State, Window};
|
||||
use tauri_plugin_fs::FsExt;
|
||||
|
||||
use crate::{Dialog, FileDialogBuilder, FileResponse, MessageDialogKind, Result};
|
||||
use crate::{Dialog, FileDialogBuilder, FilePath, MessageDialogKind, Result};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum OpenResponse {
|
||||
#[cfg(desktop)]
|
||||
Folders(Option<Vec<PathBuf>>),
|
||||
Folders(Option<Vec<FilePath>>),
|
||||
#[cfg(desktop)]
|
||||
Folder(Option<PathBuf>),
|
||||
Files(Option<Vec<FileResponse>>),
|
||||
File(Option<FileResponse>),
|
||||
Folder(Option<FilePath>),
|
||||
Files(Option<Vec<FilePath>>),
|
||||
File(Option<FilePath>),
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -136,25 +136,26 @@ pub(crate) async fn open<R: Runtime>(
|
||||
let folders = dialog_builder.blocking_pick_folders();
|
||||
if let Some(folders) = &folders {
|
||||
for folder in folders {
|
||||
if let Some(s) = window.try_fs_scope() {
|
||||
s.allow_directory(folder, options.recursive);
|
||||
if let Ok(path) = folder.path() {
|
||||
if let Some(s) = window.try_fs_scope() {
|
||||
s.allow_directory(path, options.recursive);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OpenResponse::Folders(folders.map(|folders| {
|
||||
folders
|
||||
.iter()
|
||||
.map(|p| dunce::simplified(p).to_path_buf())
|
||||
.collect()
|
||||
}))
|
||||
OpenResponse::Folders(
|
||||
folders.map(|folders| folders.into_iter().map(|p| p.simplified()).collect()),
|
||||
)
|
||||
} else {
|
||||
let folder = dialog_builder.blocking_pick_folder();
|
||||
if let Some(path) = &folder {
|
||||
if let Some(s) = window.try_fs_scope() {
|
||||
s.allow_directory(path, options.recursive);
|
||||
if let Some(folder) = &folder {
|
||||
if let Ok(path) = folder.path() {
|
||||
if let Some(s) = window.try_fs_scope() {
|
||||
s.allow_directory(path, options.recursive);
|
||||
}
|
||||
}
|
||||
}
|
||||
OpenResponse::Folder(folder.map(|p| dunce::simplified(&p).to_path_buf()))
|
||||
OpenResponse::Folder(folder.map(|p| p.simplified()))
|
||||
}
|
||||
}
|
||||
#[cfg(mobile)]
|
||||
@@ -163,37 +164,28 @@ pub(crate) async fn open<R: Runtime>(
|
||||
let files = dialog_builder.blocking_pick_files();
|
||||
if let Some(files) = &files {
|
||||
for file in files {
|
||||
if let Some(s) = window.try_fs_scope() {
|
||||
s.allow_file(&file.path);
|
||||
if let Ok(path) = file.path() {
|
||||
if let Some(s) = window.try_fs_scope() {
|
||||
s.allow_file(&path);
|
||||
}
|
||||
|
||||
window.state::<tauri::scope::Scopes>().allow_file(&path)?;
|
||||
}
|
||||
window
|
||||
.state::<tauri::scope::Scopes>()
|
||||
.allow_file(&file.path)?;
|
||||
}
|
||||
}
|
||||
OpenResponse::Files(files.map(|files| {
|
||||
files
|
||||
.into_iter()
|
||||
.map(|mut f| {
|
||||
f.path = dunce::simplified(&f.path).to_path_buf();
|
||||
f
|
||||
})
|
||||
.collect()
|
||||
}))
|
||||
OpenResponse::Files(files.map(|files| files.into_iter().map(|f| f.simplified()).collect()))
|
||||
} else {
|
||||
let file = dialog_builder.blocking_pick_file();
|
||||
|
||||
if let Some(file) = &file {
|
||||
if let Some(s) = window.try_fs_scope() {
|
||||
s.allow_file(&file.path);
|
||||
if let Ok(path) = file.path() {
|
||||
if let Some(s) = window.try_fs_scope() {
|
||||
s.allow_file(&path);
|
||||
}
|
||||
window.state::<tauri::scope::Scopes>().allow_file(&path)?;
|
||||
}
|
||||
window
|
||||
.state::<tauri::scope::Scopes>()
|
||||
.allow_file(&file.path)?;
|
||||
}
|
||||
OpenResponse::File(file.map(|mut f| {
|
||||
f.path = dunce::simplified(&f.path).to_path_buf();
|
||||
f
|
||||
}))
|
||||
OpenResponse::File(file.map(|f| f.simplified()))
|
||||
};
|
||||
Ok(res)
|
||||
}
|
||||
@@ -204,7 +196,7 @@ pub(crate) async fn save<R: Runtime>(
|
||||
window: Window<R>,
|
||||
dialog: State<'_, Dialog<R>>,
|
||||
options: SaveDialogOptions,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
) -> Result<Option<FilePath>> {
|
||||
#[cfg(target_os = "ios")]
|
||||
return Err(crate::Error::FileSaveDialogNotImplemented);
|
||||
#[cfg(any(desktop, target_os = "android"))]
|
||||
@@ -230,13 +222,15 @@ pub(crate) async fn save<R: Runtime>(
|
||||
|
||||
let path = dialog_builder.blocking_save_file();
|
||||
if let Some(p) = &path {
|
||||
if let Some(s) = window.try_fs_scope() {
|
||||
s.allow_file(p);
|
||||
if let Ok(path) = p.path() {
|
||||
if let Some(s) = window.try_fs_scope() {
|
||||
s.allow_file(&path);
|
||||
}
|
||||
window.state::<tauri::scope::Scopes>().allow_file(&path)?;
|
||||
}
|
||||
window.state::<tauri::scope::Scopes>().allow_file(p)?;
|
||||
}
|
||||
|
||||
Ok(path.map(|p| dunce::simplified(&p).to_path_buf()))
|
||||
Ok(path.map(|p| p.simplified()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
//! to give results back. This is particularly useful when running dialogs from the main thread.
|
||||
//! When using on asynchronous contexts such as async commands, the [`blocking`] APIs are recommended.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use rfd::{AsyncFileDialog, AsyncMessageDialog};
|
||||
use serde::de::DeserializeOwned;
|
||||
use tauri::{plugin::PluginApi, AppHandle, Runtime};
|
||||
|
||||
use crate::{models::*, FileDialogBuilder, MessageDialogBuilder};
|
||||
use crate::{models::*, FileDialogBuilder, FilePath, MessageDialogBuilder};
|
||||
|
||||
const OK: &str = "Ok";
|
||||
|
||||
@@ -115,11 +113,11 @@ impl<R: Runtime> From<MessageDialogBuilder<R>> for AsyncMessageDialog {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pick_file<R: Runtime, F: FnOnce(Option<PathBuf>) + Send + 'static>(
|
||||
pub fn pick_file<R: Runtime, F: FnOnce(Option<FilePath>) + Send + 'static>(
|
||||
dialog: FileDialogBuilder<R>,
|
||||
f: F,
|
||||
) {
|
||||
let f = |path: Option<rfd::FileHandle>| f(path.map(|p| p.path().to_path_buf()));
|
||||
let f = |path: Option<rfd::FileHandle>| f(path.map(|p| p.path().to_path_buf().into()));
|
||||
let handle = dialog.dialog.app_handle().to_owned();
|
||||
let _ = handle.run_on_main_thread(move || {
|
||||
let dialog = AsyncFileDialog::from(dialog).pick_file();
|
||||
@@ -127,12 +125,16 @@ pub fn pick_file<R: Runtime, F: FnOnce(Option<PathBuf>) + Send + 'static>(
|
||||
});
|
||||
}
|
||||
|
||||
pub fn pick_files<R: Runtime, F: FnOnce(Option<Vec<PathBuf>>) + Send + 'static>(
|
||||
pub fn pick_files<R: Runtime, F: FnOnce(Option<Vec<FilePath>>) + Send + 'static>(
|
||||
dialog: FileDialogBuilder<R>,
|
||||
f: F,
|
||||
) {
|
||||
let f = |paths: Option<Vec<rfd::FileHandle>>| {
|
||||
f(paths.map(|list| list.into_iter().map(|p| p.path().to_path_buf()).collect()))
|
||||
f(paths.map(|list| {
|
||||
list.into_iter()
|
||||
.map(|p| p.path().to_path_buf().into())
|
||||
.collect()
|
||||
}))
|
||||
};
|
||||
let handle = dialog.dialog.app_handle().to_owned();
|
||||
let _ = handle.run_on_main_thread(move || {
|
||||
@@ -141,11 +143,11 @@ pub fn pick_files<R: Runtime, F: FnOnce(Option<Vec<PathBuf>>) + Send + 'static>(
|
||||
});
|
||||
}
|
||||
|
||||
pub fn pick_folder<R: Runtime, F: FnOnce(Option<PathBuf>) + Send + 'static>(
|
||||
pub fn pick_folder<R: Runtime, F: FnOnce(Option<FilePath>) + Send + 'static>(
|
||||
dialog: FileDialogBuilder<R>,
|
||||
f: F,
|
||||
) {
|
||||
let f = |path: Option<rfd::FileHandle>| f(path.map(|p| p.path().to_path_buf()));
|
||||
let f = |path: Option<rfd::FileHandle>| f(path.map(|p| p.path().to_path_buf().into()));
|
||||
let handle = dialog.dialog.app_handle().to_owned();
|
||||
let _ = handle.run_on_main_thread(move || {
|
||||
let dialog = AsyncFileDialog::from(dialog).pick_folder();
|
||||
@@ -153,12 +155,16 @@ pub fn pick_folder<R: Runtime, F: FnOnce(Option<PathBuf>) + Send + 'static>(
|
||||
});
|
||||
}
|
||||
|
||||
pub fn pick_folders<R: Runtime, F: FnOnce(Option<Vec<PathBuf>>) + Send + 'static>(
|
||||
pub fn pick_folders<R: Runtime, F: FnOnce(Option<Vec<FilePath>>) + Send + 'static>(
|
||||
dialog: FileDialogBuilder<R>,
|
||||
f: F,
|
||||
) {
|
||||
let f = |paths: Option<Vec<rfd::FileHandle>>| {
|
||||
f(paths.map(|list| list.into_iter().map(|p| p.path().to_path_buf()).collect()))
|
||||
f(paths.map(|list| {
|
||||
list.into_iter()
|
||||
.map(|p| p.path().to_path_buf().into())
|
||||
.collect()
|
||||
}))
|
||||
};
|
||||
let handle = dialog.dialog.app_handle().to_owned();
|
||||
let _ = handle.run_on_main_thread(move || {
|
||||
@@ -167,11 +173,11 @@ pub fn pick_folders<R: Runtime, F: FnOnce(Option<Vec<PathBuf>>) + Send + 'static
|
||||
});
|
||||
}
|
||||
|
||||
pub fn save_file<R: Runtime, F: FnOnce(Option<PathBuf>) + Send + 'static>(
|
||||
pub fn save_file<R: Runtime, F: FnOnce(Option<FilePath>) + Send + 'static>(
|
||||
dialog: FileDialogBuilder<R>,
|
||||
f: F,
|
||||
) {
|
||||
let f = |path: Option<rfd::FileHandle>| f(path.map(|p| p.path().to_path_buf()));
|
||||
let f = |path: Option<rfd::FileHandle>| f(path.map(|p| p.path().to_path_buf().into()));
|
||||
let handle = dialog.dialog.app_handle().to_owned();
|
||||
let _ = handle.run_on_main_thread(move || {
|
||||
let dialog = AsyncFileDialog::from(dialog).save_file();
|
||||
|
||||
@@ -23,6 +23,8 @@ pub enum Error {
|
||||
FileSaveDialogNotImplemented,
|
||||
#[error(transparent)]
|
||||
Fs(#[from] tauri_plugin_fs::Error),
|
||||
#[error("URL is not a valid path")]
|
||||
InvalidPathUrl,
|
||||
}
|
||||
|
||||
impl Serialize for Error {
|
||||
|
||||
+190
-102
@@ -17,9 +17,6 @@ use tauri::{
|
||||
Manager, Runtime,
|
||||
};
|
||||
|
||||
#[cfg(any(desktop, target_os = "ios"))]
|
||||
use std::fs;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::mpsc::sync_channel,
|
||||
@@ -66,6 +63,87 @@ impl<R: Runtime, T: Manager<R>> crate::DialogExt<R> for T {
|
||||
}
|
||||
|
||||
impl<R: Runtime> Dialog<R> {
|
||||
/// Create a new messaging dialog builder.
|
||||
/// The dialog can optionally ask the user for confirmation or include an OK button.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// - Message dialog:
|
||||
///
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
///
|
||||
/// tauri::Builder::default()
|
||||
/// .setup(|app| {
|
||||
/// app
|
||||
/// .dialog()
|
||||
/// .message("Tauri is Awesome!")
|
||||
/// .show(|_| {
|
||||
/// println!("dialog closed");
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// - Ask dialog:
|
||||
///
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
///
|
||||
/// tauri::Builder::default()
|
||||
/// .setup(|app| {
|
||||
/// app.dialog()
|
||||
/// .message("Are you sure?")
|
||||
/// .ok_button_label("Yes")
|
||||
/// .cancel_button_label("No")
|
||||
/// .show(|yes| {
|
||||
/// println!("user said {}", if yes { "yes" } else { "no" });
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// - Message dialog with OK button:
|
||||
///
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
///
|
||||
/// tauri::Builder::default()
|
||||
/// .setup(|app| {
|
||||
/// app.dialog()
|
||||
/// .message("Job completed successfully")
|
||||
/// .ok_button_label("Ok")
|
||||
/// .show(|_| {
|
||||
/// println!("dialog closed");
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// # `show` vs `blocking_show`
|
||||
///
|
||||
/// The dialog builder includes two separate APIs for rendering the dialog: `show` and `blocking_show`.
|
||||
/// The `show` function is asynchronous and takes a closure to be executed when the dialog is closed.
|
||||
/// To block the current thread until the user acted on the dialog, you can use `blocking_show`,
|
||||
/// but note that it cannot be executed on the main thread as it will freeze your application.
|
||||
///
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
///
|
||||
/// tauri::Builder::default()
|
||||
/// .setup(|app| {
|
||||
/// let handle = app.handle().clone();
|
||||
/// std::thread::spawn(move || {
|
||||
/// let yes = handle.dialog()
|
||||
/// .message("Are you sure?")
|
||||
/// .ok_button_label("Yes")
|
||||
/// .cancel_button_label("No")
|
||||
/// .blocking_show();
|
||||
/// });
|
||||
///
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
pub fn message(&self, message: impl Into<String>) -> MessageDialogBuilder<R> {
|
||||
MessageDialogBuilder::new(
|
||||
self.clone(),
|
||||
@@ -74,6 +152,7 @@ impl<R: Runtime> Dialog<R> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a new builder for dialogs that lets ths user select file(s) or folder(s).
|
||||
pub fn file(&self) -> FileDialogBuilder<R> {
|
||||
FileDialogBuilder::new(self.clone())
|
||||
}
|
||||
@@ -216,36 +295,52 @@ impl<R: Runtime> MessageDialogBuilder<R> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct FileResponse {
|
||||
pub base64_data: Option<String>,
|
||||
pub duration: Option<u64>,
|
||||
pub height: Option<usize>,
|
||||
pub width: Option<usize>,
|
||||
pub mime_type: Option<String>,
|
||||
pub modified_at: Option<u64>,
|
||||
pub name: Option<String>,
|
||||
pub path: PathBuf,
|
||||
pub size: u64,
|
||||
/// Represents either a filesystem path or a URI pointing to a file
|
||||
/// such as `file://` URIs or Android `content://` URIs.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum FilePath {
|
||||
Url(url::Url),
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
impl FileResponse {
|
||||
#[cfg(desktop)]
|
||||
fn new(path: PathBuf) -> Self {
|
||||
let metadata = fs::metadata(&path);
|
||||
let metadata = metadata.as_ref();
|
||||
Self {
|
||||
base64_data: None,
|
||||
duration: None,
|
||||
height: None,
|
||||
width: None,
|
||||
mime_type: None,
|
||||
modified_at: metadata.ok().and_then(|m| to_msec(m.modified())),
|
||||
name: path.file_name().map(|f| f.to_string_lossy().into_owned()),
|
||||
path,
|
||||
size: metadata.map(|m| m.len()).unwrap_or(0),
|
||||
impl From<PathBuf> for FilePath {
|
||||
fn from(value: PathBuf) -> Self {
|
||||
Self::Path(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::Url> for FilePath {
|
||||
fn from(value: url::Url) -> Self {
|
||||
Self::Url(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FilePath> for tauri_plugin_fs::FilePath {
|
||||
fn from(value: FilePath) -> Self {
|
||||
match value {
|
||||
FilePath::Path(p) => tauri_plugin_fs::FilePath::Path(p),
|
||||
FilePath::Url(url) => tauri_plugin_fs::FilePath::Url(url),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FilePath {
|
||||
fn simplified(self) -> Self {
|
||||
match self {
|
||||
Self::Url(url) => Self::Url(url),
|
||||
Self::Path(p) => Self::Path(dunce::simplified(&p).to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn path(&self) -> Result<PathBuf> {
|
||||
match self {
|
||||
Self::Url(url) => url
|
||||
.to_file_path()
|
||||
.map(PathBuf::from)
|
||||
.map_err(|_| Error::InvalidPathUrl),
|
||||
Self::Path(p) => Ok(p.to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,21 +458,18 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
/// tauri::Builder::default()
|
||||
/// .build(tauri::generate_context!("test/tauri.conf.json"))
|
||||
/// .expect("failed to build tauri app")
|
||||
/// .run(|app, _event| {
|
||||
/// .setup(|app| {
|
||||
/// app.dialog().file().pick_file(|file_path| {
|
||||
/// // do something with the optional file path here
|
||||
/// // the file path is `None` if the user closed the dialog
|
||||
/// })
|
||||
/// })
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
pub fn pick_file<F: FnOnce(Option<FileResponse>) + Send + 'static>(self, f: F) {
|
||||
#[cfg(desktop)]
|
||||
let f = |path: Option<PathBuf>| f(path.map(FileResponse::new));
|
||||
pub fn pick_file<F: FnOnce(Option<FilePath>) + Send + 'static>(self, f: F) {
|
||||
pick_file(self, f)
|
||||
}
|
||||
|
||||
@@ -385,29 +477,44 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
/// This is not a blocking operation,
|
||||
/// and should be used when running on the main thread to avoid deadlocks with the event loop.
|
||||
///
|
||||
/// # Reading the files
|
||||
///
|
||||
/// The file paths cannot be read directly on Android as they are behind a content URI.
|
||||
/// The recommended way to read the files is using the [`fs`](https://v2.tauri.app/plugin/file-system/) plugin:
|
||||
///
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
/// use tauri_plugin_fs::FsExt;
|
||||
/// tauri::Builder::default()
|
||||
/// .setup(|app| {
|
||||
/// let handle = app.handle().clone();
|
||||
/// app.dialog().file().pick_file(move |file_path| {
|
||||
/// let Some(path) = file_path else { return };
|
||||
/// let Ok(contents) = handle.fs().read_to_string(path) else {
|
||||
/// eprintln!("failed to read file, <todo add error handling!>");
|
||||
/// return;
|
||||
/// };
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// See <https://developer.android.com/guide/topics/providers/content-provider-basics> for more information.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
/// tauri::Builder::default()
|
||||
/// .build(tauri::generate_context!("test/tauri.conf.json"))
|
||||
/// .expect("failed to build tauri app")
|
||||
/// .run(|app, _event| {
|
||||
/// .setup(|app| {
|
||||
/// app.dialog().file().pick_files(|file_paths| {
|
||||
/// // do something with the optional file paths here
|
||||
/// // the file paths value is `None` if the user closed the dialog
|
||||
/// })
|
||||
/// })
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
pub fn pick_files<F: FnOnce(Option<Vec<FileResponse>>) + Send + 'static>(self, f: F) {
|
||||
#[cfg(desktop)]
|
||||
let f = |paths: Option<Vec<PathBuf>>| {
|
||||
f(paths.map(|p| {
|
||||
p.into_iter()
|
||||
.map(FileResponse::new)
|
||||
.collect::<Vec<FileResponse>>()
|
||||
}))
|
||||
};
|
||||
pub fn pick_files<F: FnOnce(Option<Vec<FilePath>>) + Send + 'static>(self, f: F) {
|
||||
pick_files(self, f)
|
||||
}
|
||||
|
||||
@@ -417,20 +524,19 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
/// tauri::Builder::default()
|
||||
/// .build(tauri::generate_context!("test/tauri.conf.json"))
|
||||
/// .expect("failed to build tauri app")
|
||||
/// .run(|app, _event| {
|
||||
/// .setup(|app| {
|
||||
/// app.dialog().file().pick_folder(|folder_path| {
|
||||
/// // do something with the optional folder path here
|
||||
/// // the folder path is `None` if the user closed the dialog
|
||||
/// })
|
||||
/// })
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
#[cfg(desktop)]
|
||||
pub fn pick_folder<F: FnOnce(Option<PathBuf>) + Send + 'static>(self, f: F) {
|
||||
pub fn pick_folder<F: FnOnce(Option<FilePath>) + Send + 'static>(self, f: F) {
|
||||
pick_folder(self, f)
|
||||
}
|
||||
|
||||
@@ -440,20 +546,19 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
/// tauri::Builder::default()
|
||||
/// .build(tauri::generate_context!("test/tauri.conf.json"))
|
||||
/// .expect("failed to build tauri app")
|
||||
/// .run(|app, _event| {
|
||||
/// .setup(|app| {
|
||||
/// app.dialog().file().pick_folders(|file_paths| {
|
||||
/// // do something with the optional folder paths here
|
||||
/// // the folder paths value is `None` if the user closed the dialog
|
||||
/// })
|
||||
/// })
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
#[cfg(desktop)]
|
||||
pub fn pick_folders<F: FnOnce(Option<Vec<PathBuf>>) + Send + 'static>(self, f: F) {
|
||||
pub fn pick_folders<F: FnOnce(Option<Vec<FilePath>>) + Send + 'static>(self, f: F) {
|
||||
pick_folders(self, f)
|
||||
}
|
||||
|
||||
@@ -464,19 +569,18 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
/// tauri::Builder::default()
|
||||
/// .build(tauri::generate_context!("test/tauri.conf.json"))
|
||||
/// .expect("failed to build tauri app")
|
||||
/// .run(|app, _event| {
|
||||
/// .setup(|app| {
|
||||
/// app.dialog().file().save_file(|file_path| {
|
||||
/// // do something with the optional file path here
|
||||
/// // the file path is `None` if the user closed the dialog
|
||||
/// })
|
||||
/// })
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
pub fn save_file<F: FnOnce(Option<PathBuf>) + Send + 'static>(self, f: F) {
|
||||
pub fn save_file<F: FnOnce(Option<FilePath>) + Send + 'static>(self, f: F) {
|
||||
save_file(self, f)
|
||||
}
|
||||
}
|
||||
@@ -489,7 +593,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
/// #[tauri::command]
|
||||
/// async fn my_command(app: tauri::AppHandle) {
|
||||
@@ -498,7 +602,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
/// // the file path is `None` if the user closed the dialog
|
||||
/// }
|
||||
/// ```
|
||||
pub fn blocking_pick_file(self) -> Option<FileResponse> {
|
||||
pub fn blocking_pick_file(self) -> Option<FilePath> {
|
||||
blocking_fn!(self, pick_file)
|
||||
}
|
||||
|
||||
@@ -508,7 +612,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
/// #[tauri::command]
|
||||
/// async fn my_command(app: tauri::AppHandle) {
|
||||
@@ -517,7 +621,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
/// // the file paths value is `None` if the user closed the dialog
|
||||
/// }
|
||||
/// ```
|
||||
pub fn blocking_pick_files(self) -> Option<Vec<FileResponse>> {
|
||||
pub fn blocking_pick_files(self) -> Option<Vec<FilePath>> {
|
||||
blocking_fn!(self, pick_files)
|
||||
}
|
||||
|
||||
@@ -527,7 +631,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
/// #[tauri::command]
|
||||
/// async fn my_command(app: tauri::AppHandle) {
|
||||
@@ -537,7 +641,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(desktop)]
|
||||
pub fn blocking_pick_folder(self) -> Option<PathBuf> {
|
||||
pub fn blocking_pick_folder(self) -> Option<FilePath> {
|
||||
blocking_fn!(self, pick_folder)
|
||||
}
|
||||
|
||||
@@ -547,7 +651,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
/// #[tauri::command]
|
||||
/// async fn my_command(app: tauri::AppHandle) {
|
||||
@@ -557,7 +661,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(desktop)]
|
||||
pub fn blocking_pick_folders(self) -> Option<Vec<PathBuf>> {
|
||||
pub fn blocking_pick_folders(self) -> Option<Vec<FilePath>> {
|
||||
blocking_fn!(self, pick_folders)
|
||||
}
|
||||
|
||||
@@ -567,7 +671,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// ```
|
||||
/// use tauri_plugin_dialog::DialogExt;
|
||||
/// #[tauri::command]
|
||||
/// async fn my_command(app: tauri::AppHandle) {
|
||||
@@ -576,23 +680,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
|
||||
/// // the file path is `None` if the user closed the dialog
|
||||
/// }
|
||||
/// ```
|
||||
pub fn blocking_save_file(self) -> Option<PathBuf> {
|
||||
pub fn blocking_save_file(self) -> Option<FilePath> {
|
||||
blocking_fn!(self, save_file)
|
||||
}
|
||||
}
|
||||
|
||||
// taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L913
|
||||
#[cfg(desktop)]
|
||||
#[inline]
|
||||
fn to_msec(maybe_time: std::result::Result<std::time::SystemTime, std::io::Error>) -> Option<u64> {
|
||||
match maybe_time {
|
||||
Ok(time) => {
|
||||
let msec = time
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|t| t.as_millis() as u64)
|
||||
.unwrap_or_else(|err| err.duration().as_millis() as u64);
|
||||
Some(msec)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{de::DeserializeOwned, Deserialize};
|
||||
use tauri::{
|
||||
@@ -9,7 +8,7 @@ use tauri::{
|
||||
AppHandle, Runtime,
|
||||
};
|
||||
|
||||
use crate::{FileDialogBuilder, FileResponse, MessageDialogBuilder};
|
||||
use crate::{FileDialogBuilder, FilePath, MessageDialogBuilder};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
const PLUGIN_IDENTIFIER: &str = "app.tauri.dialog";
|
||||
@@ -47,15 +46,15 @@ impl<R: Runtime> Dialog<R> {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FilePickerResponse {
|
||||
files: Vec<FileResponse>,
|
||||
files: Vec<FilePath>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SaveFileResponse {
|
||||
file: PathBuf,
|
||||
file: FilePath,
|
||||
}
|
||||
|
||||
pub fn pick_file<R: Runtime, F: FnOnce(Option<FileResponse>) + Send + 'static>(
|
||||
pub fn pick_file<R: Runtime, F: FnOnce(Option<FilePath>) + Send + 'static>(
|
||||
dialog: FileDialogBuilder<R>,
|
||||
f: F,
|
||||
) {
|
||||
@@ -72,7 +71,7 @@ pub fn pick_file<R: Runtime, F: FnOnce(Option<FileResponse>) + Send + 'static>(
|
||||
});
|
||||
}
|
||||
|
||||
pub fn pick_files<R: Runtime, F: FnOnce(Option<Vec<FileResponse>>) + Send + 'static>(
|
||||
pub fn pick_files<R: Runtime, F: FnOnce(Option<Vec<FilePath>>) + Send + 'static>(
|
||||
dialog: FileDialogBuilder<R>,
|
||||
f: F,
|
||||
) {
|
||||
@@ -89,7 +88,7 @@ pub fn pick_files<R: Runtime, F: FnOnce(Option<Vec<FileResponse>>) + Send + 'sta
|
||||
});
|
||||
}
|
||||
|
||||
pub fn save_file<R: Runtime, F: FnOnce(Option<PathBuf>) + Send + 'static>(
|
||||
pub fn save_file<R: Runtime, F: FnOnce(Option<FilePath>) + Send + 'static>(
|
||||
dialog: FileDialogBuilder<R>,
|
||||
f: F,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/.tauri
|
||||
@@ -0,0 +1,45 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.plugin.fs"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("androidx.core:core-ktx:1.9.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.0")
|
||||
implementation("com.google.android.material:material:1.7.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
implementation(project(":tauri-android"))
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,31 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
}
|
||||
resolutionStrategy {
|
||||
eachPlugin {
|
||||
switch (requested.id.id) {
|
||||
case "com.android.library":
|
||||
useVersion("8.0.2")
|
||||
break
|
||||
case "org.jetbrains.kotlin.android":
|
||||
useVersion("1.8.20")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
include ':tauri-android'
|
||||
project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package com.plugin.fs
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.plugin.fs", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package com.plugin.fs
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.res.AssetManager.ACCESS_BUFFER
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.Invoke
|
||||
import app.tauri.plugin.JSObject
|
||||
import app.tauri.plugin.Plugin
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
@InvokeArg
|
||||
class WriteTextFileArgs {
|
||||
val uri: String = ""
|
||||
val content: String = ""
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
class GetFileDescriptorArgs {
|
||||
lateinit var uri: String
|
||||
lateinit var mode: String
|
||||
}
|
||||
|
||||
@TauriPlugin
|
||||
class FsPlugin(private val activity: Activity): Plugin(activity) {
|
||||
@SuppressLint("Recycle")
|
||||
@Command
|
||||
fun getFileDescriptor(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(GetFileDescriptorArgs::class.java)
|
||||
|
||||
val res = JSObject()
|
||||
|
||||
if (args.uri.startsWith(app.tauri.TAURI_ASSETS_DIRECTORY_URI)) {
|
||||
val path = args.uri.substring(app.tauri.TAURI_ASSETS_DIRECTORY_URI.length)
|
||||
try {
|
||||
val fd = activity.assets.openFd(path).parcelFileDescriptor?.detachFd()
|
||||
res.put("fd", fd)
|
||||
} catch (e: IOException) {
|
||||
// if the asset is compressed, we cannot open a file descriptor directly
|
||||
// so we copy it to the cache and get a fd from there
|
||||
// this is a lot faster than serializing the file and sending it as invoke response
|
||||
// because on the Rust side we can leverage the custom protocol IPC and read the file directly
|
||||
val cacheFile = File(activity.cacheDir, "_assets/$path")
|
||||
cacheFile.parentFile?.mkdirs()
|
||||
copyAsset(path, cacheFile)
|
||||
|
||||
val fd = ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.parseMode(args.mode)).detachFd()
|
||||
res.put("fd", fd)
|
||||
}
|
||||
} else {
|
||||
val fd = activity.contentResolver.openAssetFileDescriptor(
|
||||
Uri.parse(args.uri),
|
||||
args.mode
|
||||
)?.parcelFileDescriptor?.detachFd()
|
||||
res.put("fd", fd)
|
||||
}
|
||||
|
||||
invoke.resolve(res)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun copy(input: InputStream, output: OutputStream) {
|
||||
val buf = ByteArray(1024)
|
||||
var len: Int
|
||||
while ((input.read(buf).also { len = it }) > 0) {
|
||||
output.write(buf, 0, len)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun copyAsset(assetPath: String, cacheFile: File) {
|
||||
val input = activity.assets.open(assetPath, ACCESS_BUFFER)
|
||||
input.use { i ->
|
||||
val output = FileOutputStream(cacheFile, false)
|
||||
output.use { o ->
|
||||
copy(i, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package com.plugin.fs
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -190,5 +190,6 @@ permissions = [
|
||||
tauri_plugin::Builder::new(COMMANDS)
|
||||
.global_api_script_path("./api-iife.js")
|
||||
.global_scope_schema(schemars::schema_for!(FsScopeEntry))
|
||||
.android_path("android")
|
||||
.build();
|
||||
}
|
||||
|
||||
+335
-164
@@ -9,18 +9,60 @@ use tauri::{
|
||||
ipc::{CommandScope, GlobalScope},
|
||||
path::{BaseDirectory, SafePathBuf},
|
||||
utils::config::FsScope,
|
||||
Manager, Resource, ResourceId, Runtime, Webview,
|
||||
AppHandle, Manager, Resource, ResourceId, Runtime, Webview,
|
||||
};
|
||||
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufReader, Lines, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
sync::Mutex,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use crate::{scope::Entry, Error, FsExt};
|
||||
use crate::{scope::Entry, Error, FilePath, FsExt};
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum SafeFilePath {
|
||||
Url(url::Url),
|
||||
Path(SafePathBuf),
|
||||
}
|
||||
|
||||
impl From<SafeFilePath> for FilePath {
|
||||
fn from(value: SafeFilePath) -> Self {
|
||||
match value {
|
||||
SafeFilePath::Url(url) => FilePath::Url(url),
|
||||
SafeFilePath::Path(p) => FilePath::Path(p.as_ref().to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SafeFilePath {
|
||||
type Err = CommandError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
if let Ok(url) = url::Url::from_str(s) {
|
||||
Ok(Self::Url(url))
|
||||
} else {
|
||||
Ok(Self::Path(SafePathBuf::new(s.into())?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SafeFilePath {
|
||||
#[inline]
|
||||
fn into_path(self) -> CommandResult<SafePathBuf> {
|
||||
match self {
|
||||
Self::Url(url) => SafePathBuf::new(
|
||||
url.to_file_path()
|
||||
.map_err(|_| format!("failed to get path from {url}"))?,
|
||||
)
|
||||
.map_err(Into::into),
|
||||
Self::Path(p) => Ok(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CommandError {
|
||||
@@ -31,6 +73,8 @@ pub enum CommandError {
|
||||
#[error(transparent)]
|
||||
Tauri(#[from] tauri::Error),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
UrlParseError(#[from] url::ParseError),
|
||||
#[cfg(feature = "watch")]
|
||||
#[error(transparent)]
|
||||
@@ -64,7 +108,7 @@ impl Serialize for CommandError {
|
||||
|
||||
pub type CommandResult<T> = std::result::Result<T, CommandError>;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BaseOptions {
|
||||
base_dir: Option<BaseDirectory>,
|
||||
@@ -75,7 +119,7 @@ pub fn create<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<ResourceId> {
|
||||
let resolved_path = resolve_path(
|
||||
@@ -95,29 +139,13 @@ pub fn create<R: Runtime>(
|
||||
Ok(rid)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenOptions {
|
||||
#[serde(flatten)]
|
||||
base: BaseOptions,
|
||||
#[serde(default = "default_true")]
|
||||
read: bool,
|
||||
#[serde(default)]
|
||||
write: bool,
|
||||
#[serde(default)]
|
||||
append: bool,
|
||||
#[serde(default)]
|
||||
truncate: bool,
|
||||
#[serde(default)]
|
||||
create: bool,
|
||||
#[serde(default)]
|
||||
create_new: bool,
|
||||
#[allow(unused)]
|
||||
mode: Option<u32>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
#[serde(flatten)]
|
||||
options: crate::OpenOptions,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -125,44 +153,35 @@ pub fn open<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
options: Option<OpenOptions>,
|
||||
) -> CommandResult<ResourceId> {
|
||||
let resolved_path = resolve_path(
|
||||
let (file, _path) = resolve_file(
|
||||
&webview,
|
||||
&global_scope,
|
||||
&command_scope,
|
||||
path,
|
||||
options.as_ref().and_then(|o| o.base.base_dir),
|
||||
)?;
|
||||
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
// default to read-only
|
||||
opts.read(true);
|
||||
|
||||
if let Some(options) = options {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
if let Some(mode) = options.mode {
|
||||
opts.mode(mode);
|
||||
if let Some(opts) = options {
|
||||
OpenOptions {
|
||||
base: opts.base,
|
||||
options: opts.options,
|
||||
}
|
||||
}
|
||||
|
||||
opts.read(options.read)
|
||||
.create(options.create)
|
||||
.write(options.write)
|
||||
.truncate(options.truncate)
|
||||
.append(options.append)
|
||||
.create_new(options.create_new);
|
||||
}
|
||||
|
||||
let file = opts.open(&resolved_path).map_err(|e| {
|
||||
format!(
|
||||
"failed to open file at path: {} with error: {e}",
|
||||
resolved_path.display()
|
||||
)
|
||||
})?;
|
||||
} else {
|
||||
OpenOptions {
|
||||
base: BaseOptions { base_dir: None },
|
||||
options: crate::OpenOptions {
|
||||
read: true,
|
||||
write: false,
|
||||
truncate: false,
|
||||
create: false,
|
||||
create_new: false,
|
||||
append: false,
|
||||
mode: None,
|
||||
custom_flags: None,
|
||||
},
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
let rid = webview.resources_table().add(StdFileResource::new(file));
|
||||
|
||||
@@ -186,8 +205,8 @@ pub async fn copy_file<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
from_path: SafePathBuf,
|
||||
to_path: SafePathBuf,
|
||||
from_path: SafeFilePath,
|
||||
to_path: SafeFilePath,
|
||||
options: Option<CopyFileOptions>,
|
||||
) -> CommandResult<()> {
|
||||
let resolved_from_path = resolve_path(
|
||||
@@ -228,7 +247,7 @@ pub fn mkdir<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
options: Option<MkdirOptions>,
|
||||
) -> CommandResult<()> {
|
||||
let resolved_path = resolve_path(
|
||||
@@ -295,7 +314,7 @@ pub async fn read_dir<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<Vec<DirEntry>> {
|
||||
let resolved_path = resolve_path(
|
||||
@@ -334,25 +353,35 @@ pub async fn read_file<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<tauri::ipc::Response> {
|
||||
let resolved_path = resolve_path(
|
||||
let (mut file, path) = resolve_file(
|
||||
&webview,
|
||||
&global_scope,
|
||||
&command_scope,
|
||||
path,
|
||||
options.as_ref().and_then(|o| o.base_dir),
|
||||
OpenOptions {
|
||||
base: BaseOptions {
|
||||
base_dir: options.as_ref().and_then(|o| o.base_dir),
|
||||
},
|
||||
options: crate::OpenOptions {
|
||||
read: true,
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
)?;
|
||||
std::fs::read(&resolved_path)
|
||||
.map(tauri::ipc::Response::new)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"failed to read file at path: {} with error: {e}",
|
||||
resolved_path.display()
|
||||
)
|
||||
})
|
||||
.map_err(Into::into)
|
||||
|
||||
let mut contents = Vec::new();
|
||||
|
||||
file.read_to_end(&mut contents).map_err(|e| {
|
||||
format!(
|
||||
"failed to read file as text at path: {} with error: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(tauri::ipc::Response::new(contents))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -360,24 +389,35 @@ pub async fn read_text_file<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<String> {
|
||||
let resolved_path = resolve_path(
|
||||
let (mut file, path) = resolve_file(
|
||||
&webview,
|
||||
&global_scope,
|
||||
&command_scope,
|
||||
path,
|
||||
options.as_ref().and_then(|o| o.base_dir),
|
||||
OpenOptions {
|
||||
base: BaseOptions {
|
||||
base_dir: options.as_ref().and_then(|o| o.base_dir),
|
||||
},
|
||||
options: crate::OpenOptions {
|
||||
read: true,
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
)?;
|
||||
std::fs::read_to_string(&resolved_path)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"failed to read file as text at path: {} with error: {e}",
|
||||
resolved_path.display()
|
||||
)
|
||||
})
|
||||
.map_err(Into::into)
|
||||
|
||||
let mut contents = String::new();
|
||||
|
||||
file.read_to_string(&mut contents).map_err(|e| {
|
||||
format!(
|
||||
"failed to read file as text at path: {} with error: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -385,7 +425,7 @@ pub fn read_text_file_lines<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<ResourceId> {
|
||||
use std::io::BufRead;
|
||||
@@ -441,7 +481,7 @@ pub fn remove<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
options: Option<RemoveOptions>,
|
||||
) -> CommandResult<()> {
|
||||
let resolved_path = resolve_path(
|
||||
@@ -509,8 +549,8 @@ pub fn rename<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
old_path: SafePathBuf,
|
||||
new_path: SafePathBuf,
|
||||
old_path: SafeFilePath,
|
||||
new_path: SafeFilePath,
|
||||
options: Option<RenameOptions>,
|
||||
) -> CommandResult<()> {
|
||||
let resolved_old_path = resolve_path(
|
||||
@@ -566,27 +606,109 @@ pub async fn seek<R: Runtime>(
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stat<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
#[cfg(target_os = "android")]
|
||||
fn get_metadata<R: Runtime, F: FnOnce(&PathBuf) -> std::io::Result<std::fs::Metadata>>(
|
||||
metadata_fn: F,
|
||||
webview: &Webview<R>,
|
||||
global_scope: &GlobalScope<Entry>,
|
||||
command_scope: &CommandScope<Entry>,
|
||||
path: SafeFilePath,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<FileInfo> {
|
||||
) -> CommandResult<std::fs::Metadata> {
|
||||
match path {
|
||||
SafeFilePath::Url(url) => {
|
||||
let (file, path) = resolve_file(
|
||||
webview,
|
||||
global_scope,
|
||||
command_scope,
|
||||
SafeFilePath::Url(url),
|
||||
OpenOptions {
|
||||
base: BaseOptions { base_dir: None },
|
||||
options: crate::OpenOptions {
|
||||
read: true,
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
)?;
|
||||
file.metadata().map_err(|e| {
|
||||
format!(
|
||||
"failed to get metadata of path: {} with error: {e}",
|
||||
path.display()
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
SafeFilePath::Path(p) => get_fs_metadata(
|
||||
metadata_fn,
|
||||
webview,
|
||||
global_scope,
|
||||
command_scope,
|
||||
SafeFilePath::Path(p),
|
||||
options,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn get_metadata<R: Runtime, F: FnOnce(&PathBuf) -> std::io::Result<std::fs::Metadata>>(
|
||||
metadata_fn: F,
|
||||
webview: &Webview<R>,
|
||||
global_scope: &GlobalScope<Entry>,
|
||||
command_scope: &CommandScope<Entry>,
|
||||
path: SafeFilePath,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<std::fs::Metadata> {
|
||||
get_fs_metadata(
|
||||
metadata_fn,
|
||||
webview,
|
||||
global_scope,
|
||||
command_scope,
|
||||
path,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
fn get_fs_metadata<R: Runtime, F: FnOnce(&PathBuf) -> std::io::Result<std::fs::Metadata>>(
|
||||
metadata_fn: F,
|
||||
webview: &Webview<R>,
|
||||
global_scope: &GlobalScope<Entry>,
|
||||
command_scope: &CommandScope<Entry>,
|
||||
path: SafeFilePath,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<std::fs::Metadata> {
|
||||
let resolved_path = resolve_path(
|
||||
&webview,
|
||||
&global_scope,
|
||||
&command_scope,
|
||||
webview,
|
||||
global_scope,
|
||||
command_scope,
|
||||
path,
|
||||
options.as_ref().and_then(|o| o.base_dir),
|
||||
)?;
|
||||
let metadata = std::fs::metadata(&resolved_path).map_err(|e| {
|
||||
let metadata = metadata_fn(&resolved_path).map_err(|e| {
|
||||
format!(
|
||||
"failed to get metadata of path: {} with error: {e}",
|
||||
resolved_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stat<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafeFilePath,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<FileInfo> {
|
||||
let metadata = get_metadata(
|
||||
|p| std::fs::metadata(p),
|
||||
&webview,
|
||||
&global_scope,
|
||||
&command_scope,
|
||||
path,
|
||||
options,
|
||||
)?;
|
||||
|
||||
Ok(get_stat(metadata))
|
||||
}
|
||||
|
||||
@@ -595,22 +717,17 @@ pub fn lstat<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<FileInfo> {
|
||||
let resolved_path = resolve_path(
|
||||
let metadata = get_metadata(
|
||||
|p| std::fs::symlink_metadata(p),
|
||||
&webview,
|
||||
&global_scope,
|
||||
&command_scope,
|
||||
path,
|
||||
options.as_ref().and_then(|o| o.base_dir),
|
||||
options,
|
||||
)?;
|
||||
let metadata = std::fs::symlink_metadata(&resolved_path).map_err(|e| {
|
||||
format!(
|
||||
"failed to get metadata of path: {} with error: {e}",
|
||||
resolved_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(get_stat(metadata))
|
||||
}
|
||||
|
||||
@@ -627,7 +744,7 @@ pub async fn truncate<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
len: Option<u64>,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<()> {
|
||||
@@ -704,49 +821,51 @@ fn write_file_inner<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: &GlobalScope<Entry>,
|
||||
command_scope: &CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
data: &[u8],
|
||||
options: Option<WriteFileOptions>,
|
||||
) -> CommandResult<()> {
|
||||
let resolved_path = resolve_path(
|
||||
let (mut file, path) = resolve_file(
|
||||
&webview,
|
||||
global_scope,
|
||||
command_scope,
|
||||
path,
|
||||
options.as_ref().and_then(|o| o.base.base_dir),
|
||||
)?;
|
||||
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
// defaults
|
||||
opts.read(false).write(true).truncate(true).create(true);
|
||||
|
||||
if let Some(options) = options {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
if let Some(mode) = options.mode {
|
||||
opts.mode(mode);
|
||||
if let Some(opts) = options {
|
||||
OpenOptions {
|
||||
base: opts.base,
|
||||
options: crate::OpenOptions {
|
||||
read: false,
|
||||
write: true,
|
||||
create: opts.create,
|
||||
truncate: !opts.append,
|
||||
append: opts.append,
|
||||
create_new: opts.create_new,
|
||||
mode: opts.mode,
|
||||
custom_flags: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
opts.create(options.create)
|
||||
.append(options.append)
|
||||
.truncate(!options.append)
|
||||
.create_new(options.create_new);
|
||||
}
|
||||
|
||||
let mut file = opts.open(&resolved_path).map_err(|e| {
|
||||
format!(
|
||||
"failed to open file at path: {} with error: {e}",
|
||||
resolved_path.display()
|
||||
)
|
||||
})?;
|
||||
} else {
|
||||
OpenOptions {
|
||||
base: BaseOptions { base_dir: None },
|
||||
options: crate::OpenOptions {
|
||||
read: false,
|
||||
write: true,
|
||||
truncate: true,
|
||||
create: true,
|
||||
create_new: false,
|
||||
append: false,
|
||||
mode: None,
|
||||
custom_flags: None,
|
||||
},
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
file.write_all(data)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"failed to write bytes to file at path: {} with error: {e}",
|
||||
resolved_path.display()
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
.map_err(Into::into)
|
||||
@@ -768,7 +887,7 @@ pub async fn write_file<R: Runtime>(
|
||||
p.to_str()
|
||||
.map_err(|e| anyhow::anyhow!("invalid path: {e}").into())
|
||||
})
|
||||
.and_then(|p| SafePathBuf::new(p.into()).map_err(CommandError::from))?;
|
||||
.and_then(|p| SafeFilePath::from_str(p).map_err(CommandError::from))?;
|
||||
let options = request
|
||||
.headers()
|
||||
.get("options")
|
||||
@@ -782,12 +901,13 @@ pub async fn write_file<R: Runtime>(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_text_file<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
#[allow(unused)] app: AppHandle<R>,
|
||||
#[allow(unused)] webview: Webview<R>,
|
||||
#[allow(unused)] global_scope: GlobalScope<Entry>,
|
||||
#[allow(unused)] command_scope: CommandScope<Entry>,
|
||||
path: SafeFilePath,
|
||||
data: String,
|
||||
options: Option<WriteFileOptions>,
|
||||
#[allow(unused)] options: Option<WriteFileOptions>,
|
||||
) -> CommandResult<()> {
|
||||
write_file_inner(
|
||||
webview,
|
||||
@@ -804,7 +924,7 @@ pub fn exists<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
command_scope: CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
options: Option<BaseOptions>,
|
||||
) -> CommandResult<bool> {
|
||||
let resolved_path = resolve_path(
|
||||
@@ -817,24 +937,87 @@ pub fn exists<R: Runtime>(
|
||||
Ok(resolved_path.exists())
|
||||
}
|
||||
|
||||
pub fn resolve_path<R: Runtime>(
|
||||
app: &Webview<R>,
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn resolve_file<R: Runtime>(
|
||||
webview: &Webview<R>,
|
||||
global_scope: &GlobalScope<Entry>,
|
||||
command_scope: &CommandScope<Entry>,
|
||||
path: SafePathBuf,
|
||||
path: SafeFilePath,
|
||||
open_options: OpenOptions,
|
||||
) -> CommandResult<(File, PathBuf)> {
|
||||
resolve_file_in_fs(webview, global_scope, command_scope, path, open_options)
|
||||
}
|
||||
|
||||
fn resolve_file_in_fs<R: Runtime>(
|
||||
webview: &Webview<R>,
|
||||
global_scope: &GlobalScope<Entry>,
|
||||
command_scope: &CommandScope<Entry>,
|
||||
path: SafeFilePath,
|
||||
open_options: OpenOptions,
|
||||
) -> CommandResult<(File, PathBuf)> {
|
||||
let path = resolve_path(
|
||||
webview,
|
||||
global_scope,
|
||||
command_scope,
|
||||
path,
|
||||
open_options.base.base_dir,
|
||||
)?;
|
||||
|
||||
let file = std::fs::OpenOptions::from(open_options.options)
|
||||
.open(&path)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"failed to open file at path: {} with error: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok((file, path))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn resolve_file<R: Runtime>(
|
||||
webview: &Webview<R>,
|
||||
global_scope: &GlobalScope<Entry>,
|
||||
command_scope: &CommandScope<Entry>,
|
||||
path: SafeFilePath,
|
||||
open_options: OpenOptions,
|
||||
) -> CommandResult<(File, PathBuf)> {
|
||||
match path {
|
||||
SafeFilePath::Url(url) => {
|
||||
let path = url.as_str().into();
|
||||
let file = webview
|
||||
.fs()
|
||||
.open(SafeFilePath::Url(url), open_options.options)?;
|
||||
Ok((file, path))
|
||||
}
|
||||
SafeFilePath::Path(path) => resolve_file_in_fs(
|
||||
webview,
|
||||
global_scope,
|
||||
command_scope,
|
||||
SafeFilePath::Path(path),
|
||||
open_options,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_path<R: Runtime>(
|
||||
webview: &Webview<R>,
|
||||
global_scope: &GlobalScope<Entry>,
|
||||
command_scope: &CommandScope<Entry>,
|
||||
path: SafeFilePath,
|
||||
base_dir: Option<BaseDirectory>,
|
||||
) -> CommandResult<PathBuf> {
|
||||
let path = file_url_to_safe_pathbuf(path)?;
|
||||
let path = path.into_path()?;
|
||||
let path = if let Some(base_dir) = base_dir {
|
||||
app.path().resolve(&path, base_dir)?
|
||||
webview.path().resolve(&path, base_dir)?
|
||||
} else {
|
||||
path.as_ref().to_path_buf()
|
||||
};
|
||||
|
||||
let scope = tauri::scope::fs::Scope::new(
|
||||
app,
|
||||
webview,
|
||||
&FsScope::Scope {
|
||||
allow: app
|
||||
allow: webview
|
||||
.fs_scope()
|
||||
.allowed
|
||||
.lock()
|
||||
@@ -844,7 +1027,7 @@ pub fn resolve_path<R: Runtime>(
|
||||
.chain(global_scope.allows().iter().map(|e| e.path.clone()))
|
||||
.chain(command_scope.allows().iter().map(|e| e.path.clone()))
|
||||
.collect(),
|
||||
deny: app
|
||||
deny: webview
|
||||
.fs_scope()
|
||||
.denied
|
||||
.lock()
|
||||
@@ -854,7 +1037,7 @@ pub fn resolve_path<R: Runtime>(
|
||||
.chain(global_scope.denies().iter().map(|e| e.path.clone()))
|
||||
.chain(command_scope.denies().iter().map(|e| e.path.clone()))
|
||||
.collect(),
|
||||
require_literal_leading_dot: app.fs_scope().require_literal_leading_dot,
|
||||
require_literal_leading_dot: webview.fs_scope().require_literal_leading_dot,
|
||||
},
|
||||
)?;
|
||||
|
||||
@@ -865,18 +1048,6 @@ pub fn resolve_path<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn file_url_to_safe_pathbuf(path: SafePathBuf) -> CommandResult<SafePathBuf> {
|
||||
if path.as_ref().starts_with("file:") {
|
||||
let url = url::Url::parse(&path.display().to_string())?
|
||||
.to_file_path()
|
||||
.map_err(|_| "failed to get path from `file:` url")?;
|
||||
SafePathBuf::new(url).map_err(Into::into)
|
||||
} else {
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
struct StdFileResource(Mutex<File>);
|
||||
|
||||
impl StdFileResource {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
use crate::{FilePath, OpenOptions};
|
||||
|
||||
pub struct Fs<R: Runtime>(pub(crate) AppHandle<R>);
|
||||
|
||||
fn path_or_err<P: Into<FilePath>>(p: P) -> std::io::Result<PathBuf> {
|
||||
match p.into() {
|
||||
FilePath::Path(p) => Ok(p),
|
||||
FilePath::Url(u) if u.scheme() == "file" => u
|
||||
.to_file_path()
|
||||
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid file URL")),
|
||||
FilePath::Url(_) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"cannot use a URL to load files on desktop and iOS",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> Fs<R> {
|
||||
pub fn open<P: Into<FilePath>>(
|
||||
&self,
|
||||
path: P,
|
||||
opts: OpenOptions,
|
||||
) -> std::io::Result<std::fs::File> {
|
||||
let path = path_or_err(path)?;
|
||||
std::fs::OpenOptions::from(opts).open(path)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ pub enum Error {
|
||||
#[cfg(feature = "watch")]
|
||||
#[error(transparent)]
|
||||
Watch(#[from] notify::Error),
|
||||
#[cfg(target_os = "android")]
|
||||
#[error(transparent)]
|
||||
PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError),
|
||||
}
|
||||
|
||||
impl Serialize for Error {
|
||||
|
||||
@@ -11,6 +11,15 @@
|
||||
html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
|
||||
)]
|
||||
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
fmt,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
use tauri::{
|
||||
ipc::ScopeObject,
|
||||
plugin::{Builder as PluginBuilder, TauriPlugin},
|
||||
@@ -20,16 +29,375 @@ use tauri::{
|
||||
|
||||
mod commands;
|
||||
mod config;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
mod desktop;
|
||||
mod error;
|
||||
#[cfg(target_os = "android")]
|
||||
mod mobile;
|
||||
#[cfg(target_os = "android")]
|
||||
mod models;
|
||||
mod scope;
|
||||
#[cfg(feature = "watch")]
|
||||
mod watcher;
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub use desktop::Fs;
|
||||
#[cfg(target_os = "android")]
|
||||
pub use mobile::Fs;
|
||||
|
||||
pub use error::Error;
|
||||
pub use scope::{Event as ScopeEvent, Scope};
|
||||
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Represents either a filesystem path or a URI pointing to a file
|
||||
/// such as `file://` URIs or Android `content://` URIs.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum FilePath {
|
||||
Url(url::Url),
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
impl FromStr for FilePath {
|
||||
type Err = Infallible;
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
if let Ok(url) = url::Url::from_str(s) {
|
||||
Ok(Self::Url(url))
|
||||
} else {
|
||||
Ok(Self::Path(PathBuf::from(s)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PathBuf> for FilePath {
|
||||
fn from(value: PathBuf) -> Self {
|
||||
Self::Path(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Path> for FilePath {
|
||||
fn from(value: &Path) -> Self {
|
||||
Self::Path(value.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PathBuf> for FilePath {
|
||||
fn from(value: &PathBuf) -> Self {
|
||||
Self::Path(value.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::Url> for FilePath {
|
||||
fn from(value: url::Url) -> Self {
|
||||
Self::Url(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FilePath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Url(u) => u.fmt(f),
|
||||
Self::Path(p) => p.display().fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenOptions {
|
||||
#[serde(default = "default_true")]
|
||||
read: bool,
|
||||
#[serde(default)]
|
||||
write: bool,
|
||||
#[serde(default)]
|
||||
append: bool,
|
||||
#[serde(default)]
|
||||
truncate: bool,
|
||||
#[serde(default)]
|
||||
create: bool,
|
||||
#[serde(default)]
|
||||
create_new: bool,
|
||||
#[serde(default)]
|
||||
mode: Option<u32>,
|
||||
#[serde(default)]
|
||||
custom_flags: Option<i32>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl From<OpenOptions> for std::fs::OpenOptions {
|
||||
fn from(open_options: OpenOptions) -> Self {
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
if let Some(mode) = open_options.mode {
|
||||
opts.mode(mode);
|
||||
}
|
||||
if let Some(flags) = open_options.custom_flags {
|
||||
opts.custom_flags(flags);
|
||||
}
|
||||
}
|
||||
|
||||
opts.read(open_options.read)
|
||||
.write(open_options.write)
|
||||
.create(open_options.create)
|
||||
.append(open_options.append)
|
||||
.truncate(open_options.truncate)
|
||||
.create_new(open_options.create_new);
|
||||
|
||||
opts
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenOptions {
|
||||
/// Creates a blank new set of options ready for configuration.
|
||||
///
|
||||
/// All options are initially set to `false`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tauri_plugin_fs::OpenOptions;
|
||||
///
|
||||
/// let mut options = OpenOptions::new();
|
||||
/// let file = options.read(true).open("foo.txt");
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets the option for read access.
|
||||
///
|
||||
/// This option, when true, will indicate that the file should be
|
||||
/// `read`-able if opened.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tauri_plugin_fs::OpenOptions;
|
||||
///
|
||||
/// let file = OpenOptions::new().read(true).open("foo.txt");
|
||||
/// ```
|
||||
pub fn read(&mut self, read: bool) -> &mut Self {
|
||||
self.read = read;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the option for write access.
|
||||
///
|
||||
/// This option, when true, will indicate that the file should be
|
||||
/// `write`-able if opened.
|
||||
///
|
||||
/// If the file already exists, any write calls on it will overwrite its
|
||||
/// contents, without truncating it.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tauri_plugin_fs::OpenOptions;
|
||||
///
|
||||
/// let file = OpenOptions::new().write(true).open("foo.txt");
|
||||
/// ```
|
||||
pub fn write(&mut self, write: bool) -> &mut Self {
|
||||
self.write = write;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the option for the append mode.
|
||||
///
|
||||
/// This option, when true, means that writes will append to a file instead
|
||||
/// of overwriting previous contents.
|
||||
/// Note that setting `.write(true).append(true)` has the same effect as
|
||||
/// setting only `.append(true)`.
|
||||
///
|
||||
/// Append mode guarantees that writes will be positioned at the current end of file,
|
||||
/// even when there are other processes or threads appending to the same file. This is
|
||||
/// unlike <code>[seek]\([SeekFrom]::[End]\(0))</code> followed by `write()`, which
|
||||
/// has a race between seeking and writing during which another writer can write, with
|
||||
/// our `write()` overwriting their data.
|
||||
///
|
||||
/// Keep in mind that this does not necessarily guarantee that data appended by
|
||||
/// different processes or threads does not interleave. The amount of data accepted a
|
||||
/// single `write()` call depends on the operating system and file system. A
|
||||
/// successful `write()` is allowed to write only part of the given data, so even if
|
||||
/// you're careful to provide the whole message in a single call to `write()`, there
|
||||
/// is no guarantee that it will be written out in full. If you rely on the filesystem
|
||||
/// accepting the message in a single write, make sure that all data that belongs
|
||||
/// together is written in one operation. This can be done by concatenating strings
|
||||
/// before passing them to [`write()`].
|
||||
///
|
||||
/// If a file is opened with both read and append access, beware that after
|
||||
/// opening, and after every write, the position for reading may be set at the
|
||||
/// end of the file. So, before writing, save the current position (using
|
||||
/// <code>[Seek]::[stream_position]</code>), and restore it before the next read.
|
||||
///
|
||||
/// ## Note
|
||||
///
|
||||
/// This function doesn't create the file if it doesn't exist. Use the
|
||||
/// [`OpenOptions::create`] method to do so.
|
||||
///
|
||||
/// [`write()`]: Write::write "io::Write::write"
|
||||
/// [`flush()`]: Write::flush "io::Write::flush"
|
||||
/// [stream_position]: Seek::stream_position "io::Seek::stream_position"
|
||||
/// [seek]: Seek::seek "io::Seek::seek"
|
||||
/// [Current]: SeekFrom::Current "io::SeekFrom::Current"
|
||||
/// [End]: SeekFrom::End "io::SeekFrom::End"
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tauri_plugin_fs::OpenOptions;
|
||||
///
|
||||
/// let file = OpenOptions::new().append(true).open("foo.txt");
|
||||
/// ```
|
||||
pub fn append(&mut self, append: bool) -> &mut Self {
|
||||
self.append = append;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the option for truncating a previous file.
|
||||
///
|
||||
/// If a file is successfully opened with this option set it will truncate
|
||||
/// the file to 0 length if it already exists.
|
||||
///
|
||||
/// The file must be opened with write access for truncate to work.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tauri_plugin_fs::OpenOptions;
|
||||
///
|
||||
/// let file = OpenOptions::new().write(true).truncate(true).open("foo.txt");
|
||||
/// ```
|
||||
pub fn truncate(&mut self, truncate: bool) -> &mut Self {
|
||||
self.truncate = truncate;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the option to create a new file, or open it if it already exists.
|
||||
///
|
||||
/// In order for the file to be created, [`OpenOptions::write`] or
|
||||
/// [`OpenOptions::append`] access must be used.
|
||||
///
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tauri_plugin_fs::OpenOptions;
|
||||
///
|
||||
/// let file = OpenOptions::new().write(true).create(true).open("foo.txt");
|
||||
/// ```
|
||||
pub fn create(&mut self, create: bool) -> &mut Self {
|
||||
self.create = create;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the option to create a new file, failing if it already exists.
|
||||
///
|
||||
/// No file is allowed to exist at the target location, also no (dangling) symlink. In this
|
||||
/// way, if the call succeeds, the file returned is guaranteed to be new.
|
||||
/// If a file exists at the target location, creating a new file will fail with [`AlreadyExists`]
|
||||
/// or another error based on the situation. See [`OpenOptions::open`] for a
|
||||
/// non-exhaustive list of likely errors.
|
||||
///
|
||||
/// This option is useful because it is atomic. Otherwise between checking
|
||||
/// whether a file exists and creating a new one, the file may have been
|
||||
/// created by another process (a TOCTOU race condition / attack).
|
||||
///
|
||||
/// If `.create_new(true)` is set, [`.create()`] and [`.truncate()`] are
|
||||
/// ignored.
|
||||
///
|
||||
/// The file must be opened with write or append access in order to create
|
||||
/// a new file.
|
||||
///
|
||||
/// [`.create()`]: OpenOptions::create
|
||||
/// [`.truncate()`]: OpenOptions::truncate
|
||||
/// [`AlreadyExists`]: io::ErrorKind::AlreadyExists
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tauri_plugin_fs::OpenOptions;
|
||||
///
|
||||
/// let file = OpenOptions::new().write(true)
|
||||
/// .create_new(true)
|
||||
/// .open("foo.txt");
|
||||
/// ```
|
||||
pub fn create_new(&mut self, create_new: bool) -> &mut Self {
|
||||
self.create_new = create_new;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl std::os::unix::fs::OpenOptionsExt for OpenOptions {
|
||||
fn custom_flags(&mut self, flags: i32) -> &mut Self {
|
||||
self.custom_flags.replace(flags);
|
||||
self
|
||||
}
|
||||
|
||||
fn mode(&mut self, mode: u32) -> &mut Self {
|
||||
self.mode.replace(mode);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenOptions {
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_mode(&self) -> String {
|
||||
let mut mode = String::new();
|
||||
|
||||
if self.read {
|
||||
mode.push('r');
|
||||
}
|
||||
if self.write {
|
||||
mode.push('w');
|
||||
}
|
||||
if self.truncate {
|
||||
mode.push('t');
|
||||
}
|
||||
if self.append {
|
||||
mode.push('a');
|
||||
}
|
||||
|
||||
mode
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> Fs<R> {
|
||||
pub fn read_to_string<P: Into<FilePath>>(&self, path: P) -> std::io::Result<String> {
|
||||
let mut s = String::new();
|
||||
self.open(
|
||||
path,
|
||||
OpenOptions {
|
||||
read: true,
|
||||
..Default::default()
|
||||
},
|
||||
)?
|
||||
.read_to_string(&mut s)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn read<P: Into<FilePath>>(&self, path: P) -> std::io::Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
self.open(
|
||||
path,
|
||||
OpenOptions {
|
||||
read: true,
|
||||
..Default::default()
|
||||
},
|
||||
)?
|
||||
.read_to_end(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
// implement ScopeObject here instead of in the scope module because it is also used on the build script
|
||||
// and we don't want to add tauri as a build dependency
|
||||
impl ScopeObject for scope::Entry {
|
||||
@@ -55,6 +423,9 @@ impl ScopeObject for scope::Entry {
|
||||
pub trait FsExt<R: Runtime> {
|
||||
fn fs_scope(&self) -> &Scope;
|
||||
fn try_fs_scope(&self) -> Option<&Scope>;
|
||||
|
||||
/// Cross platform file system APIs that also support manipulating Android files.
|
||||
fn fs(&self) -> &Fs<R>;
|
||||
}
|
||||
|
||||
impl<R: Runtime, T: Manager<R>> FsExt<R> for T {
|
||||
@@ -65,6 +436,10 @@ impl<R: Runtime, T: Manager<R>> FsExt<R> for T {
|
||||
fn try_fs_scope(&self) -> Option<&Scope> {
|
||||
self.try_state::<Scope>().map(|s| s.inner())
|
||||
}
|
||||
|
||||
fn fs(&self) -> &Fs<R> {
|
||||
self.state::<Fs<R>>().inner()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R, Option<config::Config>> {
|
||||
@@ -104,6 +479,15 @@ pub fn init<R: Runtime>() -> TauriPlugin<R, Option<config::Config>> {
|
||||
.config()
|
||||
.as_ref()
|
||||
.and_then(|c| c.require_literal_leading_dot);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let fs = mobile::init(app, api)?;
|
||||
app.manage(fs);
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
app.manage(Fs(app.clone()));
|
||||
|
||||
app.manage(scope);
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use tauri::{
|
||||
plugin::{PluginApi, PluginHandle},
|
||||
AppHandle, Runtime,
|
||||
};
|
||||
|
||||
use crate::{models::*, FilePath, OpenOptions};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
const PLUGIN_IDENTIFIER: &str = "com.plugin.fs";
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
tauri::ios_plugin_binding!(init_plugin_fs);
|
||||
|
||||
// initializes the Kotlin or Swift plugin classes
|
||||
pub fn init<R: Runtime, C: DeserializeOwned>(
|
||||
_app: &AppHandle<R>,
|
||||
api: PluginApi<R, C>,
|
||||
) -> crate::Result<Fs<R>> {
|
||||
#[cfg(target_os = "android")]
|
||||
let handle = api
|
||||
.register_android_plugin(PLUGIN_IDENTIFIER, "FsPlugin")
|
||||
.unwrap();
|
||||
#[cfg(target_os = "ios")]
|
||||
let handle = api.register_ios_plugin(init_plugin_android - intent - send)?;
|
||||
Ok(Fs(handle))
|
||||
}
|
||||
|
||||
/// Access to the android-intent-send APIs.
|
||||
pub struct Fs<R: Runtime>(PluginHandle<R>);
|
||||
|
||||
impl<R: Runtime> Fs<R> {
|
||||
pub fn open<P: Into<FilePath>>(
|
||||
&self,
|
||||
path: P,
|
||||
opts: OpenOptions,
|
||||
) -> std::io::Result<std::fs::File> {
|
||||
match path.into() {
|
||||
FilePath::Url(u) => self
|
||||
.resolve_content_uri(u.to_string(), opts.android_mode())
|
||||
.map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("failed to open file: {e}"),
|
||||
)
|
||||
}),
|
||||
FilePath::Path(p) => {
|
||||
// tauri::utils::platform::resources_dir() returns a PathBuf with the Android asset URI prefix
|
||||
// we must resolve that file with the Android API
|
||||
if p.strip_prefix(tauri::utils::platform::ANDROID_ASSET_PROTOCOL_URI_PREFIX)
|
||||
.is_ok()
|
||||
{
|
||||
self.resolve_content_uri(p.to_string_lossy(), opts.android_mode())
|
||||
.map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("failed to open file: {e}"),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
std::fs::OpenOptions::from(opts).open(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn resolve_content_uri(
|
||||
&self,
|
||||
uri: impl Into<String>,
|
||||
mode: impl Into<String>,
|
||||
) -> crate::Result<std::fs::File> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let result = self.0.run_mobile_plugin::<GetFileDescriptorResponse>(
|
||||
"getFileDescriptor",
|
||||
GetFileDescriptorPayload {
|
||||
uri: uri.into(),
|
||||
mode: mode.into(),
|
||||
},
|
||||
)?;
|
||||
if let Some(fd) = result.fd {
|
||||
Ok(unsafe {
|
||||
use std::os::fd::FromRawFd;
|
||||
std::fs::File::from_raw_fd(fd)
|
||||
})
|
||||
} else {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetFileDescriptorPayload {
|
||||
pub uri: String,
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetFileDescriptorResponse {
|
||||
pub fd: Option<i32>,
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use notify_debouncer_full::{new_debouncer, DebounceEventResult, Debouncer, FileI
|
||||
use serde::Deserialize;
|
||||
use tauri::{
|
||||
ipc::{Channel, CommandScope, GlobalScope},
|
||||
path::{BaseDirectory, SafePathBuf},
|
||||
path::BaseDirectory,
|
||||
Manager, Resource, ResourceId, Runtime, Webview,
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
commands::{resolve_path, CommandResult},
|
||||
commands::{resolve_path, CommandResult, SafeFilePath},
|
||||
scope::Entry,
|
||||
};
|
||||
|
||||
@@ -83,7 +83,7 @@ pub struct WatchOptions {
|
||||
#[tauri::command]
|
||||
pub async fn watch<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
paths: Vec<SafePathBuf>,
|
||||
paths: Vec<SafeFilePath>,
|
||||
options: WatchOptions,
|
||||
on_event: Channel<Event>,
|
||||
global_scope: GlobalScope<Entry>,
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.0.0-rc.4"
|
||||
"@tauri-apps/cli": "2.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.0.0-rc.4",
|
||||
"@tauri-apps/cli": "2.0.0-rc.6",
|
||||
"vite": "^5.0.12",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.0.0-rc.4",
|
||||
"@tauri-apps/cli": "2.0.0-rc.6",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.13"
|
||||
},
|
||||
|
||||
Generated
+513
-356
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user