diff --git a/plugins/dialog/build.rs b/plugins/dialog/build.rs index 4b3bb871..0d575ce7 100644 --- a/plugins/dialog/build.rs +++ b/plugins/dialog/build.rs @@ -2,7 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -const COMMANDS: &[&str] = &["open", "save", "message", "ask", "confirm"]; +const COMMANDS: &[&str] = &[ + "open", + "save", + "stop_accessing_path", + "message", + "ask", + "confirm", +]; fn main() { let result = tauri_plugin::Builder::new(COMMANDS) diff --git a/plugins/dialog/guest-js/index.ts b/plugins/dialog/guest-js/index.ts index 150be95a..b80de382 100644 --- a/plugins/dialog/guest-js/index.ts +++ b/plugins/dialog/guest-js/index.ts @@ -4,6 +4,31 @@ import { invoke } from '@tauri-apps/api/core' +class Path { + public path: string + constructor(path: string) { + this.path = path + } + + destroy() { + return invoke('plugin:dialog|stop-accessing-path', { path: this.path }) + } + + toPath() { + return this.path + } + + toString() { + return this.toPath() + } + + toJSON() { + return { + path: this.path + } + } +} + /** * Extension filters for the file dialog. * @@ -100,13 +125,7 @@ interface ConfirmDialogOptions { cancelLabel?: string } -type OpenDialogReturn = T['directory'] extends true - ? T['multiple'] extends true - ? string[] | null - : string | null - : T['multiple'] extends true - ? string[] | null - : string | null +type OpenDialogReturn = T['multiple'] extends true ? Path[] | null : Path | null /** * Open a file/directory selection dialog. @@ -156,6 +175,10 @@ type OpenDialogReturn = T['directory'] extends true * } * ``` * + * ## Platform-specific + * + * - **iOS**: Returns a copy of the file to bypass [security scoped resource](https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc). + * * @returns A promise resolving to the selected path(s) * * @since 2.0.0 @@ -167,7 +190,17 @@ async function open( Object.freeze(options) } - return await invoke('plugin:dialog|open', { options }) + const path = await invoke('plugin:dialog|open', { options }) + + if (Array.isArray(path)) { + return path.map((p) => new Path(p)) + } + + if (!path) { + return null + } + + return new Path(path) } /** @@ -190,16 +223,26 @@ async function open( * }); * ``` * + * #### Platform-specific + * + * - **iOS**: Returns a copy of the file to bypass [security scoped resource](https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc). + * * @returns A promise resolving to the selected path. * * @since 2.0.0 */ -async function save(options: SaveDialogOptions = {}): Promise { +async function save(options: SaveDialogOptions = {}): Promise { if (typeof options === 'object') { Object.freeze(options) } - return await invoke('plugin:dialog|save', { options }) + const path = await invoke('plugin:dialog|save', { options }) + + if (!path) { + return null + } + + return new Path(path) } /** diff --git a/plugins/dialog/ios/Sources/DialogPlugin.swift b/plugins/dialog/ios/Sources/DialogPlugin.swift index b3f7e7da..a992b644 100644 --- a/plugins/dialog/ios/Sources/DialogPlugin.swift +++ b/plugins/dialog/ios/Sources/DialogPlugin.swift @@ -38,6 +38,10 @@ struct SaveFileDialogOptions: Decodable { var defaultPath: String? } +struct StopAccessingPathOptions: Decodable { + var path: URL +} + class DialogPlugin: Plugin { var filePickerController: FilePickerController! @@ -74,10 +78,12 @@ class DialogPlugin: Plugin { onFilePickerResult = { (event: FilePickerEvent) -> Void in switch event { case .selected(let urls): + urls.forEach { $0.startAccessingSecurityScopedResource() } invoke.resolve(["files": urls]) case .cancelled: invoke.resolve(["files": nil]) case .error(let error): + Logger.error("failed to pick file: \(error)") invoke.reject(error) } } @@ -149,10 +155,13 @@ class DialogPlugin: Plugin { onFilePickerResult = { (event: FilePickerEvent) -> Void in switch event { case .selected(let urls): + Logger.info("picked file to save: \(urls.first!)") + urls.first!.startAccessingSecurityScopedResource() invoke.resolve(["file": urls.first!]) case .cancelled: invoke.resolve(["file": nil]) case .error(let error): + Logger.error("failed to pick file to save: \(error)") invoke.reject(error) } } @@ -168,6 +177,12 @@ class DialogPlugin: Plugin { } } + @objc public func stopAccessingPath(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(StopAccessingPathOptions.self) + args.path.stopAccessingSecurityScopedResource() + invoke.resolve() + } + private func presentViewController(_ viewControllerToPresent: UIViewController) { self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil) } @@ -206,8 +221,6 @@ class DialogPlugin: Plugin { UIAlertAction( title: cancelButtonLabel, style: UIAlertAction.Style.default, handler: { (_) -> Void in - Logger.error("cancel") - invoke.resolve([ "value": false, "cancelled": false, @@ -221,8 +234,6 @@ class DialogPlugin: Plugin { UIAlertAction( title: okButtonLabel, style: UIAlertAction.Style.default, handler: { (_) -> Void in - Logger.error("ok") - invoke.resolve([ "value": true, "cancelled": false, diff --git a/plugins/dialog/ios/Sources/FilePickerController.swift b/plugins/dialog/ios/Sources/FilePickerController.swift index b2752f0b..20c129d8 100644 --- a/plugins/dialog/ios/Sources/FilePickerController.swift +++ b/plugins/dialog/ios/Sources/FilePickerController.swift @@ -95,35 +95,11 @@ public class FilePickerController: NSObject { return nil } } - - private func saveTemporaryFile(_ sourceUrl: URL) throws -> URL { - var directory = URL(fileURLWithPath: NSTemporaryDirectory()) - if let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first { - directory = cachesDirectory - } - let targetUrl = directory.appendingPathComponent(sourceUrl.lastPathComponent) - do { - try deleteFile(targetUrl) - } - try FileManager.default.copyItem(at: sourceUrl, to: targetUrl) - return targetUrl - } - - private func deleteFile(_ url: URL) throws { - if FileManager.default.fileExists(atPath: url.path) { - try FileManager.default.removeItem(atPath: url.path) - } - } } extension FilePickerController: UIDocumentPickerDelegate { public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - do { - let temporaryUrls = try urls.map { try saveTemporaryFile($0) } - self.plugin.onFilePickerEvent(.selected(temporaryUrls)) - } catch { - self.plugin.onFilePickerEvent(.error("Failed to create a temporary copy of the file")) - } + self.plugin.onFilePickerEvent(.selected(urls)) } public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { @@ -148,12 +124,7 @@ extension FilePickerController: UIImagePickerControllerDelegate, UINavigationCon public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { dismissViewController(picker) { if let url = info[.mediaURL] as? URL { - do { - let temporaryUrl = try self.saveTemporaryFile(url) - self.plugin.onFilePickerEvent(.selected([temporaryUrl])) - } catch { - self.plugin.onFilePickerEvent(.error("Failed to create a temporary copy of the file")) - } + self.plugin.onFilePickerEvent(.selected([url])) } else { self.plugin.onFilePickerEvent(.cancelled) } @@ -169,7 +140,7 @@ extension FilePickerController: PHPickerViewControllerDelegate { self.plugin.onFilePickerEvent(.cancelled) return } - var temporaryUrls: [URL] = [] + var urls: [URL] = [] var errorMessage: String? let dispatchGroup = DispatchGroup() for result in results { @@ -190,12 +161,7 @@ extension FilePickerController: PHPickerViewControllerDelegate { errorMessage = "Unknown error" return } - do { - let temporaryUrl = try self.saveTemporaryFile(url) - temporaryUrls.append(temporaryUrl) - } catch { - errorMessage = "Failed to create a temporary copy of the file" - } + urls.append(url) }) } else if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { dispatchGroup.enter() @@ -211,12 +177,7 @@ extension FilePickerController: PHPickerViewControllerDelegate { errorMessage = "Unknown error" return } - do { - let temporaryUrl = try self.saveTemporaryFile(url) - temporaryUrls.append(temporaryUrl) - } catch { - errorMessage = "Failed to create a temporary copy of the file" - } + urls.append(url) }) } else { errorMessage = "Unsupported file type identifier" @@ -227,7 +188,7 @@ extension FilePickerController: PHPickerViewControllerDelegate { self.plugin.onFilePickerEvent(.error(errorMessage)) return } - self.plugin.onFilePickerEvent(.selected(temporaryUrls)) + self.plugin.onFilePickerEvent(.selected(urls)) } } -} \ No newline at end of file +} diff --git a/plugins/dialog/permissions/autogenerated/commands/stop_accessing_path.toml b/plugins/dialog/permissions/autogenerated/commands/stop_accessing_path.toml new file mode 100644 index 00000000..5f47a5e6 --- /dev/null +++ b/plugins/dialog/permissions/autogenerated/commands/stop_accessing_path.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-stop-accessing-path" +description = "Enables the stop_accessing_path command without any pre-configured scope." +commands.allow = ["stop_accessing_path"] + +[[permission]] +identifier = "deny-stop-accessing-path" +description = "Denies the stop_accessing_path command without any pre-configured scope." +commands.deny = ["stop_accessing_path"] diff --git a/plugins/dialog/permissions/autogenerated/reference.md b/plugins/dialog/permissions/autogenerated/reference.md index 3bbd265b..e427d1bf 100644 --- a/plugins/dialog/permissions/autogenerated/reference.md +++ b/plugins/dialog/permissions/autogenerated/reference.md @@ -14,6 +14,7 @@ All dialog types are enabled. - `allow-message` - `allow-save` - `allow-open` +- `allow-stop-accessing-path` ## Permission Table @@ -151,6 +152,32 @@ Enables the save command without any pre-configured scope. Denies the save command without any pre-configured scope. + + + + + + +`dialog:allow-stop-accessing-path` + + + + +Enables the stop_accessing_path command without any pre-configured scope. + + + + + + + +`dialog:deny-stop-accessing-path` + + + + +Denies the stop_accessing_path command without any pre-configured scope. + diff --git a/plugins/dialog/permissions/default.toml b/plugins/dialog/permissions/default.toml index cc936d90..a927a54a 100644 --- a/plugins/dialog/permissions/default.toml +++ b/plugins/dialog/permissions/default.toml @@ -17,4 +17,5 @@ permissions = [ "allow-message", "allow-save", "allow-open", + "allow-stop-accessing-path" ] diff --git a/plugins/dialog/permissions/schemas/schema.json b/plugins/dialog/permissions/schemas/schema.json index b47417ec..1026e020 100644 --- a/plugins/dialog/permissions/schemas/schema.json +++ b/plugins/dialog/permissions/schemas/schema.json @@ -355,10 +355,22 @@ "markdownDescription": "Denies the save command without any pre-configured scope." }, { - "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "description": "Enables the stop_accessing_path command without any pre-configured scope.", + "type": "string", + "const": "allow-stop-accessing-path", + "markdownDescription": "Enables the stop_accessing_path command without any pre-configured scope." + }, + { + "description": "Denies the stop_accessing_path command without any pre-configured scope.", + "type": "string", + "const": "deny-stop-accessing-path", + "markdownDescription": "Denies the stop_accessing_path command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`\n- `allow-stop-accessing-path`", "type": "string", "const": "default", - "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`" + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`\n- `allow-stop-accessing-path`" } ] } diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs index c3caf027..97375bc3 100644 --- a/plugins/dialog/src/commands.rs +++ b/plugins/dialog/src/commands.rs @@ -9,8 +9,8 @@ use tauri::{command, Manager, Runtime, State, Window}; use tauri_plugin_fs::FsExt; use crate::{ - Dialog, FileDialogBuilder, FilePath, MessageDialogButtons, MessageDialogKind, Result, CANCEL, - NO, OK, YES, + Dialog, FileDialogBuilder, FilePath, MessageDialogButtons, MessageDialogKind, Result, + StopAccessingPath, CANCEL, NO, OK, YES, }; #[derive(Serialize)] @@ -241,6 +241,11 @@ pub(crate) async fn save( Ok(path.map(|p| p.simplified())) } +#[command] +pub fn stop_accessing_path(_p: StopAccessingPath) -> bool { + true +} + fn message_dialog( #[allow(unused_variables)] window: Window, dialog: State<'_, Dialog>, diff --git a/plugins/dialog/src/desktop.rs b/plugins/dialog/src/desktop.rs index d1a3e8b2..aeadf6c7 100644 --- a/plugins/dialog/src/desktop.rs +++ b/plugins/dialog/src/desktop.rs @@ -13,7 +13,7 @@ use rfd::{AsyncFileDialog, AsyncMessageDialog}; use serde::de::DeserializeOwned; use tauri::{plugin::PluginApi, AppHandle, Runtime}; -use crate::{models::*, FileDialogBuilder, FilePath, MessageDialogBuilder, OK}; +use crate::{models::*, FileDialogBuilder, FilePath, MessageDialogBuilder, StopAccessingPath, OK}; pub fn init( app: &AppHandle, @@ -207,6 +207,13 @@ pub fn save_file) + Send + 'static>( }); } +pub fn stop_accessing_path( + _dialog: FileDialogBuilder, + _p: StopAccessingPath, +) -> bool { + true +} + /// Shows a message dialog pub fn show_message_dialog( dialog: MessageDialogBuilder, diff --git a/plugins/dialog/src/lib.rs b/plugins/dialog/src/lib.rs index 2ef1c1ea..dd92476d 100644 --- a/plugins/dialog/src/lib.rs +++ b/plugins/dialog/src/lib.rs @@ -9,7 +9,7 @@ html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" )] -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tauri::{ plugin::{Builder, TauriPlugin}, Manager, Runtime, @@ -180,6 +180,7 @@ pub fn init() -> TauriPlugin { .invoke_handler(tauri::generate_handler![ commands::open, commands::save, + commands::stop_accessing_path, commands::message, commands::ask, commands::confirm @@ -337,6 +338,11 @@ pub(crate) struct FileDialogPayload<'a> { multiple: bool, } +#[derive(Debug, Serialize, Deserialize)] +pub struct StopAccessingPath { + path: FilePath, +} + // raw window handle :( unsafe impl Send for FileDialogBuilder {} @@ -440,6 +446,12 @@ impl FileDialogBuilder { /// Ok(()) /// }); /// ``` + /// + /// ## Platform-specific + /// + /// - **iOS**: Returns a copy of the file to bypass [security scoped resource]. + /// + /// [security scoped resource]: https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc pub fn pick_file) + Send + 'static>(self, f: F) { pick_file(self, f) } @@ -551,9 +563,19 @@ impl FileDialogBuilder { /// Ok(()) /// }); /// ``` + /// + /// ## Platform-specific + /// + /// - **iOS**: Returns a [security scoped resource] so you must request access before reading or writing to the file. + /// + /// [security scoped resource]: https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc pub fn save_file) + Send + 'static>(self, f: F) { save_file(self, f) } + + pub fn stop_accessing_path(self, p: StopAccessingPath) -> bool { + stop_accessing_path(self, p) + } } /// Blocking APIs. @@ -573,6 +595,12 @@ impl FileDialogBuilder { /// // the file path is `None` if the user closed the dialog /// } /// ``` + /// + /// ## Platform-specific + /// + /// - **iOS**: Returns a copy of the file to bypass [security scoped resource]. + /// + /// [security scoped resource]: https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc pub fn blocking_pick_file(self) -> Option { blocking_fn!(self, pick_file) } @@ -651,7 +679,17 @@ impl FileDialogBuilder { /// // the file path is `None` if the user closed the dialog /// } /// ``` + /// + /// ## Platform-specific + /// + /// - **iOS**: Returns a [security scoped resource] so you must request access before reading or writing to the file. + /// + /// [security scoped resource]: https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc pub fn blocking_save_file(self) -> Option { blocking_fn!(self, save_file) } + + pub fn blocking_stop_accessing_path(self, p: StopAccessingPath) -> bool { + self.stop_accessing_path(p) + } } diff --git a/plugins/dialog/src/mobile.rs b/plugins/dialog/src/mobile.rs index b73def4f..d4906a40 100644 --- a/plugins/dialog/src/mobile.rs +++ b/plugins/dialog/src/mobile.rs @@ -8,7 +8,7 @@ use tauri::{ AppHandle, Runtime, }; -use crate::{FileDialogBuilder, FilePath, MessageDialogBuilder}; +use crate::{FileDialogBuilder, FilePath, MessageDialogBuilder, StopAccessingPath}; #[cfg(target_os = "android")] const PLUGIN_IDENTIFIER: &str = "app.tauri.dialog"; @@ -105,6 +105,23 @@ pub fn save_file) + Send + 'static>( }); } +#[allow(unused_variables)] +pub fn stop_accessing_path(dialog: FileDialogBuilder, p: StopAccessingPath) -> bool { + #[cfg(target_os = "ios")] + { + let res = dialog + .dialog + .0 + .run_mobile_plugin::<()>("stopAccessingPath", p); + + if res.is_err() { + return false; + } + } + + true +} + #[derive(Debug, Deserialize)] struct ShowMessageDialogResponse { #[allow(dead_code)]