diff --git a/plugins/dialog/api-iife.js b/plugins/dialog/api-iife.js index a357f2c0bd..4bb3ff1acd 100644 --- a/plugins/dialog/api-iife.js +++ b/plugins/dialog/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_DIALOG__=function(t){"use strict";async function n(t,n={},e){return window.__TAURI_INTERNALS__.invoke(t,n,e)}function e(t){if(void 0!==t)return"string"==typeof t?t:"ok"in t&&"cancel"in t?{OkCancelCustom:[t.ok,t.cancel]}:"yes"in t&&"no"in t&&"cancel"in t?{YesNoCancelCustom:[t.yes,t.no,t.cancel]}:"ok"in t?{OkCustom:t.ok}:void 0}return"function"==typeof SuppressedError&&SuppressedError,t.ask=async function(t,e){const o="string"==typeof e?{title:e}:e;return await n("plugin:dialog|ask",{message:t.toString(),title:o?.title?.toString(),kind:o?.kind,yesButtonLabel:o?.okLabel?.toString(),noButtonLabel:o?.cancelLabel?.toString()})},t.confirm=async function(t,e){const o="string"==typeof e?{title:e}:e;return await n("plugin:dialog|confirm",{message:t.toString(),title:o?.title?.toString(),kind:o?.kind,okButtonLabel:o?.okLabel?.toString(),cancelButtonLabel:o?.cancelLabel?.toString()})},t.message=async function(t,o){const i="string"==typeof o?{title:o}:o;return n("plugin:dialog|message",{message:t.toString(),title:i?.title?.toString(),kind:i?.kind,okButtonLabel:i?.okLabel?.toString(),buttons:e(i?.buttons)})},t.open=async function(t={}){return"object"==typeof t&&Object.freeze(t),await n("plugin:dialog|open",{options:t})},t.save=async function(t={}){return"object"==typeof t&&Object.freeze(t),await n("plugin:dialog|save",{options:t})},t}({});Object.defineProperty(window.__TAURI__,"dialog",{value:__TAURI_PLUGIN_DIALOG__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_DIALOG__=function(t){"use strict";async function n(t,n={},e){return window.__TAURI_INTERNALS__.invoke(t,n,e)}"function"==typeof SuppressedError&&SuppressedError;class e{constructor(t){this.path=t}destroy(){return n("plugin:dialog|destroy_path",{path:this.path})}toPath(){return this.path}toString(){return this.toPath()}toJSON(){return{path:this.path}}}function o(t){if(void 0!==t)return"string"==typeof t?t:"ok"in t&&"cancel"in t?{OkCancelCustom:[t.ok,t.cancel]}:"yes"in t&&"no"in t&&"cancel"in t?{YesNoCancelCustom:[t.yes,t.no,t.cancel]}:"ok"in t?{OkCustom:t.ok}:void 0}return t.ask=async function(t,e){const o="string"==typeof e?{title:e}:e;return await n("plugin:dialog|ask",{message:t.toString(),title:o?.title?.toString(),kind:o?.kind,yesButtonLabel:o?.okLabel?.toString(),noButtonLabel:o?.cancelLabel?.toString()})},t.confirm=async function(t,e){const o="string"==typeof e?{title:e}:e;return await n("plugin:dialog|confirm",{message:t.toString(),title:o?.title?.toString(),kind:o?.kind,okButtonLabel:o?.okLabel?.toString(),cancelButtonLabel:o?.cancelLabel?.toString()})},t.message=async function(t,e){const i="string"==typeof e?{title:e}:e;return n("plugin:dialog|message",{message:t.toString(),title:i?.title?.toString(),kind:i?.kind,okButtonLabel:i?.okLabel?.toString(),buttons:o(i?.buttons)})},t.open=async function(t={}){"object"==typeof t&&Object.freeze(t);const o=await n("plugin:dialog|open",{options:t});return Array.isArray(o)?o.map((t=>new e(t))):o?new e(o):null},t.save=async function(t={}){"object"==typeof t&&Object.freeze(t);const o=await n("plugin:dialog|save",{options:t});return o?new e(o):null},t}({});Object.defineProperty(window.__TAURI__,"dialog",{value:__TAURI_PLUGIN_DIALOG__})} diff --git a/plugins/dialog/build.rs b/plugins/dialog/build.rs index 4b3bb8718f..28e2651d1a 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", + "destroy_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 a77857545e..6a9d3c68ca 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|destroy_path', { path: this.path }) + } + + toPath() { + return this.path + } + + toString() { + return this.toPath() + } + + toJSON() { + return { + path: this.path + } + } +} + /** * Extension filters for the file dialog. * @@ -224,13 +249,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. @@ -280,18 +299,32 @@ 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 */ async function open( options: T = {} as T -): Promise> { +): Promise { if (typeof options === 'object') { 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) } /** @@ -314,16 +347,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 710fd0bb25..d7f4cde3f6 100644 --- a/plugins/dialog/ios/Sources/DialogPlugin.swift +++ b/plugins/dialog/ios/Sources/DialogPlugin.swift @@ -39,6 +39,10 @@ struct SaveFileDialogOptions: Decodable { var defaultPath: String? } +struct StopAccessingPathOptions: Decodable { + var path: URL +} + class DialogPlugin: Plugin { var filePickerController: FilePickerController! @@ -75,6 +79,7 @@ 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]) @@ -150,6 +155,8 @@ 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]) @@ -169,6 +176,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) } diff --git a/plugins/dialog/ios/Sources/FilePickerController.swift b/plugins/dialog/ios/Sources/FilePickerController.swift index b2752f0b03..20c129d804 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/destroy_path.toml b/plugins/dialog/permissions/autogenerated/commands/destroy_path.toml new file mode 100644 index 0000000000..1674fbe637 --- /dev/null +++ b/plugins/dialog/permissions/autogenerated/commands/destroy_path.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-destroy-path" +description = "Enables the destroy_path command without any pre-configured scope." +commands.allow = ["destroy_path"] + +[[permission]] +identifier = "deny-destroy-path" +description = "Denies the destroy_path command without any pre-configured scope." +commands.deny = ["destroy_path"] diff --git a/plugins/dialog/permissions/autogenerated/reference.md b/plugins/dialog/permissions/autogenerated/reference.md index 3bbd265b85..2a7867e1c8 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-destroy-path` ## Permission Table @@ -79,6 +80,32 @@ Denies the confirm command without any pre-configured scope. +`dialog:allow-destroy-path` + + + + +Enables the destroy_path command without any pre-configured scope. + + + + + + + +`dialog:deny-destroy-path` + + + + +Denies the destroy_path command without any pre-configured scope. + + + + + + + `dialog:allow-message` diff --git a/plugins/dialog/permissions/default.toml b/plugins/dialog/permissions/default.toml index cc936d901b..735363eda2 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-destroy-path" ] diff --git a/plugins/dialog/permissions/schemas/schema.json b/plugins/dialog/permissions/schemas/schema.json index b47417ecb0..2d90df528c 100644 --- a/plugins/dialog/permissions/schemas/schema.json +++ b/plugins/dialog/permissions/schemas/schema.json @@ -318,6 +318,18 @@ "const": "deny-confirm", "markdownDescription": "Denies the confirm command without any pre-configured scope." }, + { + "description": "Enables the destroy_path command without any pre-configured scope.", + "type": "string", + "const": "allow-destroy-path", + "markdownDescription": "Enables the destroy_path command without any pre-configured scope." + }, + { + "description": "Denies the destroy_path command without any pre-configured scope.", + "type": "string", + "const": "deny-destroy-path", + "markdownDescription": "Denies the destroy_path command without any pre-configured scope." + }, { "description": "Enables the message command without any pre-configured scope.", "type": "string", @@ -355,10 +367,10 @@ "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": "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-destroy-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-destroy-path`" } ] } diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs index 5298de9d07..7cd7f5a030 100644 --- a/plugins/dialog/src/commands.rs +++ b/plugins/dialog/src/commands.rs @@ -241,6 +241,11 @@ pub(crate) async fn save( Ok(path.map(|p| p.simplified())) } +#[command] +pub fn destroy_path(_path: String) -> 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 8d3b08f955..d4ceb6b1e2 100644 --- a/plugins/dialog/src/desktop.rs +++ b/plugins/dialog/src/desktop.rs @@ -211,6 +211,10 @@ pub fn save_file) + Send + 'static>( }); } +pub fn destroy_path(_dialog: FileDialogBuilder, _path: String) -> 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 17d9a829d4..1277bb7d9b 100644 --- a/plugins/dialog/src/lib.rs +++ b/plugins/dialog/src/lib.rs @@ -180,6 +180,7 @@ pub fn init() -> TauriPlugin { .invoke_handler(tauri::generate_handler![ commands::open, commands::save, + commands::destroy_path, commands::message, commands::ask, commands::confirm @@ -485,6 +486,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) } @@ -596,9 +603,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 destroy_path(self, path: String) -> bool { + destroy_path(self, path) + } } /// Blocking APIs. @@ -618,6 +635,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) } @@ -696,7 +719,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_destroy_path(self, path: String) -> bool { + self.destroy_path(path) + } } diff --git a/plugins/dialog/src/mobile.rs b/plugins/dialog/src/mobile.rs index 46ea3a2769..2d1951c53a 100644 --- a/plugins/dialog/src/mobile.rs +++ b/plugins/dialog/src/mobile.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use serde::{de::DeserializeOwned, Deserialize}; +use serde::{de::DeserializeOwned, Serialize, Deserialize}; use tauri::{ plugin::{PluginApi, PluginHandle}, AppHandle, Runtime, @@ -105,6 +105,28 @@ pub fn save_file) + Send + 'static>( }); } +#[derive(Debug, Serialize, Deserialize)] +pub struct DestroyPathOptions { + path: String, +} + +#[allow(unused_variables)] +pub fn destroy_path(dialog: FileDialogBuilder, path: String) -> bool { + #[cfg(target_os = "ios")] + { + let res = dialog + .dialog + .0 + .run_mobile_plugin::<()>("stopAccessingPath", DestroyPathOptions { path }); + + if res.is_err() { + return false; + } + } + + true +} + #[derive(Debug, Deserialize)] struct ShowMessageDialogResponse { value: String,