From ad5fae489c09813736d02e134365eeaf4719ed48 Mon Sep 17 00:00:00 2001 From: mo0nbase <42557632+Mo0nbase@users.noreply.github.com> Date: Tue, 14 Jan 2025 17:43:14 -0500 Subject: [PATCH 1/5] feat(dialog): add `showFolderPicker` --- .changes/dialog-ios-fix.md | 6 +++ plugins/dialog/ios/Sources/DialogPlugin.swift | 49 +++++++++++++++++++ .../ios/Sources/FilePickerController.swift | 10 ++++ plugins/dialog/src/commands.rs | 8 +-- plugins/dialog/src/lib.rs | 8 +-- plugins/dialog/src/mobile.rs | 41 ++++++++++++++++ 6 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 .changes/dialog-ios-fix.md diff --git a/.changes/dialog-ios-fix.md b/.changes/dialog-ios-fix.md new file mode 100644 index 00000000..da79ba01 --- /dev/null +++ b/.changes/dialog-ios-fix.md @@ -0,0 +1,6 @@ +--- +"dialog": patch +"dialog-js": patch +--- + +Add support for folder picker on iOS. \ No newline at end of file diff --git a/plugins/dialog/ios/Sources/DialogPlugin.swift b/plugins/dialog/ios/Sources/DialogPlugin.swift index b3f7e7da..6ebb801d 100644 --- a/plugins/dialog/ios/Sources/DialogPlugin.swift +++ b/plugins/dialog/ios/Sources/DialogPlugin.swift @@ -38,6 +38,14 @@ struct SaveFileDialogOptions: Decodable { var defaultPath: String? } +struct FolderPickerOptions: Decodable { + var title: String? + var defaultPath: String? + var multiple: Bool? + var recursive: Bool? + var canCreateDirectories: Bool? +} + class DialogPlugin: Plugin { var filePickerController: FilePickerController! @@ -168,6 +176,47 @@ class DialogPlugin: Plugin { } } + @objc public func showFolderPicker(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(FolderPickerOptions.self) + + onFilePickerResult = { (event: FilePickerEvent) -> Void in + switch event { + case .selected(let urls): + invoke.resolve(["directories": urls]) + case .cancelled: + invoke.resolve(["directories": nil]) + case .error(let error): + invoke.reject(error) + } + } + + DispatchQueue.main.async { + let picker: UIDocumentPickerViewController + if #available(iOS 14.0, *) { + picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder]) + } else { + picker = UIDocumentPickerViewController(documentTypes: [kUTTypeFolder as String], in: .open) + } + + if let title = args.title { + picker.title = title + } + + if let defaultPath = args.defaultPath { + picker.directoryURL = URL(string: defaultPath) + } + + picker.delegate = self.filePickerController + picker.allowsMultipleSelection = args.multiple ?? false + + // Note: canCreateDirectories is only supported on macOS + // recursive is handled at the filesystem access level, not in the picker + + picker.modalPresentationStyle = .fullScreen + self.presentViewController(picker) + } + } + 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 b2752f0b..4da7c46f 100644 --- a/plugins/dialog/ios/Sources/FilePickerController.swift +++ b/plugins/dialog/ios/Sources/FilePickerController.swift @@ -7,6 +7,7 @@ import MobileCoreServices import PhotosUI import Photos import Tauri +import UniformTypeIdentifiers public class FilePickerController: NSObject { var plugin: DialogPlugin @@ -118,6 +119,15 @@ public class FilePickerController: NSObject { extension FilePickerController: UIDocumentPickerDelegate { public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + // Check if this is a folder picker by examining the URLs + let isFolder = urls.first?.hasDirectoryPath ?? false + + if isFolder { + self.plugin.onFilePickerEvent(.selected(urls)) + return + } + + // Handle regular files do { let temporaryUrls = try urls.map { try saveTemporaryFile($0) } self.plugin.onFilePickerEvent(.selected(temporaryUrls)) diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs index c3caf027..915fd711 100644 --- a/plugins/dialog/src/commands.rs +++ b/plugins/dialog/src/commands.rs @@ -16,9 +16,9 @@ use crate::{ #[derive(Serialize)] #[serde(untagged)] pub enum OpenResponse { - #[cfg(desktop)] + #[cfg(any(desktop, target_os = "ios"))] Folders(Option>), - #[cfg(desktop)] + #[cfg(any(desktop, target_os = "ios"))] Folder(Option), Files(Option>), File(Option), @@ -133,7 +133,7 @@ pub(crate) async fn open( } let res = if options.directory { - #[cfg(desktop)] + #[cfg(any(desktop, target_os = "ios"))] { let tauri_scope = window.state::(); @@ -165,7 +165,7 @@ pub(crate) async fn open( OpenResponse::Folder(folder.map(|p| p.simplified())) } } - #[cfg(mobile)] + #[cfg(all(mobile, not(target_os = "ios")))] return Err(crate::Error::FolderPickerNotImplemented); } else if options.multiple { let tauri_scope = window.state::(); diff --git a/plugins/dialog/src/lib.rs b/plugins/dialog/src/lib.rs index 2ef1c1ea..5f2aa49a 100644 --- a/plugins/dialog/src/lib.rs +++ b/plugins/dialog/src/lib.rs @@ -506,7 +506,7 @@ impl FileDialogBuilder { /// Ok(()) /// }); /// ``` - #[cfg(desktop)] + #[cfg(any(desktop, target_os = "ios"))] pub fn pick_folder) + Send + 'static>(self, f: F) { pick_folder(self, f) } @@ -528,7 +528,7 @@ impl FileDialogBuilder { /// Ok(()) /// }); /// ``` - #[cfg(desktop)] + #[cfg(any(desktop, target_os = "ios"))] pub fn pick_folders>) + Send + 'static>(self, f: F) { pick_folders(self, f) } @@ -611,7 +611,7 @@ impl FileDialogBuilder { /// // the folder path is `None` if the user closed the dialog /// } /// ``` - #[cfg(desktop)] + #[cfg(any(desktop, target_os = "ios"))] pub fn blocking_pick_folder(self) -> Option { blocking_fn!(self, pick_folder) } @@ -631,7 +631,7 @@ impl FileDialogBuilder { /// // the folder paths value is `None` if the user closed the dialog /// } /// ``` - #[cfg(desktop)] + #[cfg(any(desktop, target_os = "ios"))] pub fn blocking_pick_folders(self) -> Option> { blocking_fn!(self, pick_folders) } diff --git a/plugins/dialog/src/mobile.rs b/plugins/dialog/src/mobile.rs index b73def4f..a2e4e29c 100644 --- a/plugins/dialog/src/mobile.rs +++ b/plugins/dialog/src/mobile.rs @@ -54,6 +54,11 @@ struct SaveFileResponse { file: FilePath, } +#[derive(Debug, Deserialize)] +struct FolderPickerResponse { + directories: Vec, +} + pub fn pick_file) + Send + 'static>( dialog: FileDialogBuilder, f: F, @@ -125,3 +130,39 @@ pub fn show_message_dialog( f(res.map(|r| r.value).unwrap_or_default()) }); } + +#[cfg(target_os = "ios")] +pub fn pick_folders>) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + std::thread::spawn(move || { + let res = dialog + .dialog + .0 + .run_mobile_plugin::("showFolderPicker", dialog.payload(true)); + if let Ok(response) = res { + f(Some(response.directories)) + } else { + f(None) + } + }); +} + +#[cfg(target_os = "ios")] +pub fn pick_folder) + Send + 'static>( + dialog: FileDialogBuilder, + f: F, +) { + std::thread::spawn(move || { + let res = dialog + .dialog + .0 + .run_mobile_plugin::("showFolderPicker", dialog.payload(false)); + if let Ok(response) = res { + f(Some(response.directories.into_iter().next().unwrap())) + } else { + f(None) + } + }); +} From 48800d885112bd8d1cc2e0204dd130854caa4796 Mon Sep 17 00:00:00 2001 From: mo0nbase <42557632+Mo0nbase@users.noreply.github.com> Date: Tue, 14 Jan 2025 19:20:24 -0500 Subject: [PATCH 2/5] feat(dialog): add `showFolderPicker` to android --- ...log-ios-fix.md => dialog-folder-mobile.md} | 0 .../android/src/main/java/DialogPlugin.kt | 59 +++++++++++++++++++ .../android/src/main/java/FilePickerUtils.kt | 16 +++++ plugins/dialog/src/commands.rs | 31 +++++----- plugins/dialog/src/error.rs | 3 - plugins/dialog/src/lib.rs | 8 +-- plugins/dialog/src/mobile.rs | 4 +- 7 files changed, 95 insertions(+), 26 deletions(-) rename .changes/{dialog-ios-fix.md => dialog-folder-mobile.md} (100%) diff --git a/.changes/dialog-ios-fix.md b/.changes/dialog-folder-mobile.md similarity index 100% rename from .changes/dialog-ios-fix.md rename to .changes/dialog-folder-mobile.md diff --git a/plugins/dialog/android/src/main/java/DialogPlugin.kt b/plugins/dialog/android/src/main/java/DialogPlugin.kt index af0467d8..1aec3735 100644 --- a/plugins/dialog/android/src/main/java/DialogPlugin.kt +++ b/plugins/dialog/android/src/main/java/DialogPlugin.kt @@ -47,6 +47,14 @@ class SaveFileDialogOptions { lateinit var filters: Array } +@InvokeArg +class FolderPickerOptions { + var title: String? = null + var multiple: Boolean? = null + var recursive: Boolean? = null + var canCreateDirectories: Boolean? = null +} + @TauriPlugin class DialogPlugin(private val activity: Activity): Plugin(activity) { var filePickerOptions: FilePickerOptions? = null @@ -249,4 +257,55 @@ class DialogPlugin(private val activity: Activity): Plugin(activity) { invoke.reject(message) } } + + @Command + fun showFolderPicker(invoke: Invoke) { + try { + val args = invoke.parseArgs(FolderPickerOptions::class.java) + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + + if (args.title != null) { + intent.putExtra(Intent.EXTRA_TITLE, args.title) + } + + // Note: Android's document tree picker doesn't support multiple selection natively + // recursive and canCreateDirectories are handled at filesystem level + + startActivityForResult(invoke, intent, "folderPickerResult") + } catch (ex: Exception) { + val message = ex.message ?: "Failed to pick folder" + Logger.error(message) + invoke.reject(message) + } + } + + @ActivityCallback + fun folderPickerResult(invoke: Invoke, result: ActivityResult) { + try { + when (result.resultCode) { + Activity.RESULT_OK -> { + val callResult = createFolderPickerResult(result.data) + invoke.resolve(callResult) + } + Activity.RESULT_CANCELED -> invoke.reject("Folder picker cancelled") + else -> invoke.reject("Failed to pick folder") + } + } catch (ex: java.lang.Exception) { + val message = ex.message ?: "Failed to read folder pick result" + Logger.error(message) + invoke.reject(message) + } + } + + private fun createFolderPickerResult(data: Intent?): JSObject { + val callResult = JSObject() + if (data == null) { + callResult.put("directories", null) + return callResult + } + val uri = data.data + val directories = JSArray.from(arrayOf(uri.toString())) + callResult.put("directories", directories) + return callResult + } } \ No newline at end of file diff --git a/plugins/dialog/android/src/main/java/FilePickerUtils.kt b/plugins/dialog/android/src/main/java/FilePickerUtils.kt index 7029d4c6..33932a44 100644 --- a/plugins/dialog/android/src/main/java/FilePickerUtils.kt +++ b/plugins/dialog/android/src/main/java/FilePickerUtils.kt @@ -198,6 +198,22 @@ class FilePickerUtils { } return os.toByteArray() } + + private fun isTreeUri(uri: Uri): Boolean { + return DocumentsContract.isTreeUri(uri) + } + + fun getTreePathFromUri(context: Context, uri: Uri): String? { + if (!isTreeUri(uri)) return null + + val docId = DocumentsContract.getTreeDocumentId(uri) + val split = docId.split(":") + return if ("primary".equals(split[0], ignoreCase = true)) { + "${Environment.getExternalStorageDirectory()}/${split[1]}" + } else { + null + } + } } } diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs index 915fd711..728a855b 100644 --- a/plugins/dialog/src/commands.rs +++ b/plugins/dialog/src/commands.rs @@ -16,9 +16,9 @@ use crate::{ #[derive(Serialize)] #[serde(untagged)] pub enum OpenResponse { - #[cfg(any(desktop, target_os = "ios"))] + #[cfg(any(desktop, target_os = "ios", target_os = "android"))] Folders(Option>), - #[cfg(any(desktop, target_os = "ios"))] + #[cfg(any(desktop, target_os = "ios", target_os = "android"))] Folder(Option), Files(Option>), File(Option), @@ -132,8 +132,8 @@ pub(crate) async fn open( dialog_builder = dialog_builder.add_filter(filter.name, &extensions); } - let res = if options.directory { - #[cfg(any(desktop, target_os = "ios"))] + if options.directory { + #[cfg(any(desktop, target_os = "ios", target_os = "android"))] { let tauri_scope = window.state::(); @@ -149,9 +149,9 @@ pub(crate) async fn open( } } } - OpenResponse::Folders( + return Ok(OpenResponse::Folders( folders.map(|folders| folders.into_iter().map(|p| p.simplified()).collect()), - ) + )); } else { let folder = dialog_builder.blocking_pick_folder(); if let Some(folder) = &folder { @@ -162,14 +162,14 @@ pub(crate) async fn open( tauri_scope.allow_directory(&path, options.directory)?; } } - OpenResponse::Folder(folder.map(|p| p.simplified())) + return Ok(OpenResponse::Folder(folder.map(|p| p.simplified()))); } } - #[cfg(all(mobile, not(target_os = "ios")))] - return Err(crate::Error::FolderPickerNotImplemented); - } else if options.multiple { - let tauri_scope = window.state::(); + } + // Handle file selection + if options.multiple { + let tauri_scope = window.state::(); let files = dialog_builder.blocking_pick_files(); if let Some(files) = &files { for file in files { @@ -177,16 +177,14 @@ pub(crate) async fn open( if let Some(s) = window.try_fs_scope() { s.allow_file(&path)?; } - tauri_scope.allow_file(&path)?; } } } - OpenResponse::Files(files.map(|files| files.into_iter().map(|f| f.simplified()).collect())) + Ok(OpenResponse::Files(files.map(|files| files.into_iter().map(|f| f.simplified()).collect()))) } else { let tauri_scope = window.state::(); let file = dialog_builder.blocking_pick_file(); - if let Some(file) = &file { if let Ok(path) = file.clone().into_path() { if let Some(s) = window.try_fs_scope() { @@ -195,9 +193,8 @@ pub(crate) async fn open( tauri_scope.allow_file(&path)?; } } - OpenResponse::File(file.map(|f| f.simplified())) - }; - Ok(res) + Ok(OpenResponse::File(file.map(|f| f.simplified()))) + } } #[allow(unused_variables)] diff --git a/plugins/dialog/src/error.rs b/plugins/dialog/src/error.rs index 0c3ed5b8..8d46b32d 100644 --- a/plugins/dialog/src/error.rs +++ b/plugins/dialog/src/error.rs @@ -16,9 +16,6 @@ pub enum Error { #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), - #[cfg(mobile)] - #[error("Folder picker is not implemented on mobile")] - FolderPickerNotImplemented, #[error(transparent)] Fs(#[from] tauri_plugin_fs::Error), } diff --git a/plugins/dialog/src/lib.rs b/plugins/dialog/src/lib.rs index 5f2aa49a..e489e0d6 100644 --- a/plugins/dialog/src/lib.rs +++ b/plugins/dialog/src/lib.rs @@ -506,7 +506,7 @@ impl FileDialogBuilder { /// Ok(()) /// }); /// ``` - #[cfg(any(desktop, target_os = "ios"))] + #[cfg(any(desktop, target_os = "ios", target_os = "android"))] pub fn pick_folder) + Send + 'static>(self, f: F) { pick_folder(self, f) } @@ -528,7 +528,7 @@ impl FileDialogBuilder { /// Ok(()) /// }); /// ``` - #[cfg(any(desktop, target_os = "ios"))] + #[cfg(any(desktop, target_os = "ios", target_os = "android"))] pub fn pick_folders>) + Send + 'static>(self, f: F) { pick_folders(self, f) } @@ -611,7 +611,7 @@ impl FileDialogBuilder { /// // the folder path is `None` if the user closed the dialog /// } /// ``` - #[cfg(any(desktop, target_os = "ios"))] + #[cfg(any(desktop, target_os = "ios", target_os = "android"))] pub fn blocking_pick_folder(self) -> Option { blocking_fn!(self, pick_folder) } @@ -631,7 +631,7 @@ impl FileDialogBuilder { /// // the folder paths value is `None` if the user closed the dialog /// } /// ``` - #[cfg(any(desktop, target_os = "ios"))] + #[cfg(any(desktop, target_os = "ios", target_os = "android"))] pub fn blocking_pick_folders(self) -> Option> { blocking_fn!(self, pick_folders) } diff --git a/plugins/dialog/src/mobile.rs b/plugins/dialog/src/mobile.rs index a2e4e29c..ebe156e4 100644 --- a/plugins/dialog/src/mobile.rs +++ b/plugins/dialog/src/mobile.rs @@ -131,7 +131,7 @@ pub fn show_message_dialog( }); } -#[cfg(target_os = "ios")] +#[cfg(mobile)] pub fn pick_folders>) + Send + 'static>( dialog: FileDialogBuilder, f: F, @@ -149,7 +149,7 @@ pub fn pick_folders>) + Send + 'stati }); } -#[cfg(target_os = "ios")] +#[cfg(mobile)] pub fn pick_folder) + Send + 'static>( dialog: FileDialogBuilder, f: F, From 3578fed258375498e5e9cbb8cb98dd38a8f8aef9 Mon Sep 17 00:00:00 2001 From: mo0nbase <42557632+Mo0nbase@users.noreply.github.com> Date: Tue, 14 Jan 2025 19:22:38 -0500 Subject: [PATCH 3/5] amend changes --- .changes/dialog-folder-mobile.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/dialog-folder-mobile.md b/.changes/dialog-folder-mobile.md index da79ba01..e0cee606 100644 --- a/.changes/dialog-folder-mobile.md +++ b/.changes/dialog-folder-mobile.md @@ -3,4 +3,4 @@ "dialog-js": patch --- -Add support for folder picker on iOS. \ No newline at end of file +Add support for folder picker on iOS and Android. \ No newline at end of file From e5479decfaa28de6d3b7a22ec9ab0ce2a67a2e0b Mon Sep 17 00:00:00 2001 From: mo0nbase <42557632+Mo0nbase@users.noreply.github.com> Date: Tue, 14 Jan 2025 19:31:05 -0500 Subject: [PATCH 4/5] remove unessecary compilation attributes --- plugins/dialog/src/commands.rs | 3 --- plugins/dialog/src/lib.rs | 4 ---- 2 files changed, 7 deletions(-) diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs index 728a855b..937cfabb 100644 --- a/plugins/dialog/src/commands.rs +++ b/plugins/dialog/src/commands.rs @@ -16,9 +16,7 @@ use crate::{ #[derive(Serialize)] #[serde(untagged)] pub enum OpenResponse { - #[cfg(any(desktop, target_os = "ios", target_os = "android"))] Folders(Option>), - #[cfg(any(desktop, target_os = "ios", target_os = "android"))] Folder(Option), Files(Option>), File(Option), @@ -133,7 +131,6 @@ pub(crate) async fn open( } if options.directory { - #[cfg(any(desktop, target_os = "ios", target_os = "android"))] { let tauri_scope = window.state::(); diff --git a/plugins/dialog/src/lib.rs b/plugins/dialog/src/lib.rs index e489e0d6..78364f5a 100644 --- a/plugins/dialog/src/lib.rs +++ b/plugins/dialog/src/lib.rs @@ -506,7 +506,6 @@ impl FileDialogBuilder { /// Ok(()) /// }); /// ``` - #[cfg(any(desktop, target_os = "ios", target_os = "android"))] pub fn pick_folder) + Send + 'static>(self, f: F) { pick_folder(self, f) } @@ -528,7 +527,6 @@ impl FileDialogBuilder { /// Ok(()) /// }); /// ``` - #[cfg(any(desktop, target_os = "ios", target_os = "android"))] pub fn pick_folders>) + Send + 'static>(self, f: F) { pick_folders(self, f) } @@ -611,7 +609,6 @@ impl FileDialogBuilder { /// // the folder path is `None` if the user closed the dialog /// } /// ``` - #[cfg(any(desktop, target_os = "ios", target_os = "android"))] pub fn blocking_pick_folder(self) -> Option { blocking_fn!(self, pick_folder) } @@ -631,7 +628,6 @@ impl FileDialogBuilder { /// // the folder paths value is `None` if the user closed the dialog /// } /// ``` - #[cfg(any(desktop, target_os = "ios", target_os = "android"))] pub fn blocking_pick_folders(self) -> Option> { blocking_fn!(self, pick_folders) } From 083c2f24b5686b9983e6ccee3080403ef53d2126 Mon Sep 17 00:00:00 2001 From: mo0nbase <42557632+Mo0nbase@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:43:00 -0500 Subject: [PATCH 5/5] cargo fmt --- plugins/dialog/src/commands.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs index 937cfabb..ae74bf8a 100644 --- a/plugins/dialog/src/commands.rs +++ b/plugins/dialog/src/commands.rs @@ -146,9 +146,9 @@ pub(crate) async fn open( } } } - return Ok(OpenResponse::Folders( - folders.map(|folders| folders.into_iter().map(|p| p.simplified()).collect()), - )); + return Ok(OpenResponse::Folders(folders.map(|folders| { + folders.into_iter().map(|p| p.simplified()).collect() + }))); } else { let folder = dialog_builder.blocking_pick_folder(); if let Some(folder) = &folder { @@ -178,7 +178,9 @@ pub(crate) async fn open( } } } - Ok(OpenResponse::Files(files.map(|files| files.into_iter().map(|f| f.simplified()).collect()))) + Ok(OpenResponse::Files(files.map(|files| { + files.into_iter().map(|f| f.simplified()).collect() + }))) } else { let tauri_scope = window.state::(); let file = dialog_builder.blocking_pick_file();