From feb1e93fcb9a913c002daa29e3b709f24b97c664 Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Mon, 2 Sep 2024 21:13:43 -0300 Subject: [PATCH] feat(dialog): implement save API on iOS (#1707) --- .changes/ios-dialog-save.md | 5 ++ plugins/dialog/ios/Sources/DialogPlugin.swift | 79 +++++++++++++++---- plugins/dialog/src/commands.rs | 55 ++++++------- plugins/dialog/src/error.rs | 3 - 4 files changed, 94 insertions(+), 48 deletions(-) create mode 100644 .changes/ios-dialog-save.md diff --git a/.changes/ios-dialog-save.md b/.changes/ios-dialog-save.md new file mode 100644 index 000000000..27e526450 --- /dev/null +++ b/.changes/ios-dialog-save.md @@ -0,0 +1,5 @@ +--- +"dialog": patch:feat +--- + +Implement `save` API on iOS. diff --git a/plugins/dialog/ios/Sources/DialogPlugin.swift b/plugins/dialog/ios/Sources/DialogPlugin.swift index 5e8d9e428..b3f7e7da6 100644 --- a/plugins/dialog/ios/Sources/DialogPlugin.swift +++ b/plugins/dialog/ios/Sources/DialogPlugin.swift @@ -17,10 +17,10 @@ enum FilePickerEvent { } struct MessageDialogOptions: Decodable { - let title: String? + var title: String? let message: String - let okButtonLabel: String? - let cancelButtonLabel: String? + var okButtonLabel: String? + var cancelButtonLabel: String? } struct Filter: Decodable { @@ -30,13 +30,18 @@ struct Filter: Decodable { struct FilePickerOptions: Decodable { var multiple: Bool? var filters: [Filter]? + var defaultPath: String? +} + +struct SaveFileDialogOptions: Decodable { + var fileName: String? + var defaultPath: String? } class DialogPlugin: Plugin { var filePickerController: FilePickerController! - var pendingInvoke: Invoke? = nil - var pendingInvokeArgs: FilePickerOptions? = nil + var onFilePickerResult: ((FilePickerEvent) -> Void)? = nil override init() { super.init() @@ -66,8 +71,16 @@ class DialogPlugin: Plugin { } } - pendingInvoke = invoke - pendingInvokeArgs = args + onFilePickerResult = { (event: FilePickerEvent) -> Void in + switch event { + case .selected(let urls): + invoke.resolve(["files": urls]) + case .cancelled: + invoke.resolve(["files": nil]) + case .error(let error): + invoke.reject(error) + } + } if uniqueMimeType == true || isMedia { DispatchQueue.main.async { @@ -104,6 +117,9 @@ class DialogPlugin: Plugin { let documentTypes = parsedTypes.isEmpty ? ["public.data"] : parsedTypes DispatchQueue.main.async { let picker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import) + if let defaultPath = args.defaultPath { + picker.directoryURL = URL(string: defaultPath) + } picker.delegate = self.filePickerController picker.allowsMultipleSelection = args.multiple ?? false picker.modalPresentationStyle = .fullScreen @@ -112,6 +128,46 @@ class DialogPlugin: Plugin { } } + @objc public func saveFileDialog(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(SaveFileDialogOptions.self) + + // The Tauri save dialog API prompts the user to select a path where a file must be saved + // This behavior maps to the operating system interfaces on all platforms except iOS, + // which only exposes a mechanism to "move file `srcPath` to a location defined by the user" + // + // so we have to work around it by creating an empty file matching the requested `args.fileName`, + // and using it as `srcPath` for the operation - returning the path the user selected + // so the app dev can write to it later - matching cross platform behavior as mentioned above + let fileManager = FileManager.default + let srcFolder = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let srcPath = srcFolder.appendingPathComponent(args.fileName ?? "file") + if !fileManager.fileExists(atPath: srcPath.path) { + // the file contents must be actually provided by the tauri dev after the path is resolved by the save API + try "".write(to: srcPath, atomically: true, encoding: .utf8) + } + + onFilePickerResult = { (event: FilePickerEvent) -> Void in + switch event { + case .selected(let urls): + invoke.resolve(["file": urls.first!]) + case .cancelled: + invoke.resolve(["file": nil]) + case .error(let error): + invoke.reject(error) + } + } + + DispatchQueue.main.async { + let picker = UIDocumentPickerViewController(url: srcPath, in: .exportToService) + if let defaultPath = args.defaultPath { + picker.directoryURL = URL(string: defaultPath) + } + picker.delegate = self.filePickerController + picker.modalPresentationStyle = .fullScreen + self.presentViewController(picker) + } + } + private func presentViewController(_ viewControllerToPresent: UIViewController) { self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil) } @@ -133,14 +189,7 @@ class DialogPlugin: Plugin { } public func onFilePickerEvent(_ event: FilePickerEvent) { - switch event { - case .selected(let urls): - pendingInvoke?.resolve(["files": urls]) - case .cancelled: - pendingInvoke?.resolve(["files": nil]) - case .error(let error): - pendingInvoke?.reject(error) - } + self.onFilePickerResult?(event) } @objc public func showMessageDialog(_ invoke: Invoke) throws { diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs index 25716e91e..2d884b6e0 100644 --- a/plugins/dialog/src/commands.rs +++ b/plugins/dialog/src/commands.rs @@ -197,41 +197,36 @@ pub(crate) async fn save( dialog: State<'_, Dialog>, options: SaveDialogOptions, ) -> Result> { - #[cfg(target_os = "ios")] - return Err(crate::Error::FileSaveDialogNotImplemented); - #[cfg(any(desktop, target_os = "android"))] + let mut dialog_builder = dialog.file(); + #[cfg(any(windows, target_os = "macos"))] { - let mut dialog_builder = dialog.file(); - #[cfg(any(windows, target_os = "macos"))] - { - dialog_builder = dialog_builder.set_parent(&window); - } - if let Some(title) = options.title { - dialog_builder = dialog_builder.set_title(title); - } - if let Some(default_path) = options.default_path { - dialog_builder = set_default_path(dialog_builder, default_path); - } - if let Some(can) = options.can_create_directories { - dialog_builder = dialog_builder.set_can_create_directories(can); - } - for filter in options.filters { - let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect(); - dialog_builder = dialog_builder.add_filter(filter.name, &extensions); - } + dialog_builder = dialog_builder.set_parent(&window); + } + if let Some(title) = options.title { + dialog_builder = dialog_builder.set_title(title); + } + if let Some(default_path) = options.default_path { + dialog_builder = set_default_path(dialog_builder, default_path); + } + if let Some(can) = options.can_create_directories { + dialog_builder = dialog_builder.set_can_create_directories(can); + } + for filter in options.filters { + let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect(); + dialog_builder = dialog_builder.add_filter(filter.name, &extensions); + } - let path = dialog_builder.blocking_save_file(); - if let Some(p) = &path { - if let Ok(path) = p.path() { - if let Some(s) = window.try_fs_scope() { - s.allow_file(&path); - } - window.state::().allow_file(&path)?; + let path = dialog_builder.blocking_save_file(); + if let Some(p) = &path { + if let Ok(path) = p.path() { + if let Some(s) = window.try_fs_scope() { + s.allow_file(&path); } + window.state::().allow_file(&path)?; } - - Ok(path.map(|p| p.simplified())) } + + Ok(path.map(|p| p.simplified())) } fn message_dialog( diff --git a/plugins/dialog/src/error.rs b/plugins/dialog/src/error.rs index 99fb37989..cb70e714f 100644 --- a/plugins/dialog/src/error.rs +++ b/plugins/dialog/src/error.rs @@ -18,9 +18,6 @@ pub enum Error { #[cfg(mobile)] #[error("Folder picker is not implemented on mobile")] FolderPickerNotImplemented, - #[cfg(target_os = "ios")] - #[error("File save dialog is not implemented on iOS")] - FileSaveDialogNotImplemented, #[error(transparent)] Fs(#[from] tauri_plugin_fs::Error), #[error("URL is not a valid path")]