pull/2322/merge
Julian Carrier 3 days ago committed by GitHub
commit 3d92105bf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,6 @@
---
"dialog": patch
"dialog-js": patch
---
Add support for folder picker on iOS and Android.

@ -47,6 +47,14 @@ class SaveFileDialogOptions {
lateinit var filters: Array<Filter> lateinit var filters: Array<Filter>
} }
@InvokeArg
class FolderPickerOptions {
var title: String? = null
var multiple: Boolean? = null
var recursive: Boolean? = null
var canCreateDirectories: Boolean? = null
}
@TauriPlugin @TauriPlugin
class DialogPlugin(private val activity: Activity): Plugin(activity) { class DialogPlugin(private val activity: Activity): Plugin(activity) {
var filePickerOptions: FilePickerOptions? = null var filePickerOptions: FilePickerOptions? = null
@ -249,4 +257,55 @@ class DialogPlugin(private val activity: Activity): Plugin(activity) {
invoke.reject(message) 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
}
} }

@ -198,6 +198,22 @@ class FilePickerUtils {
} }
return os.toByteArray() 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
}
}
} }
} }

@ -38,6 +38,14 @@ struct SaveFileDialogOptions: Decodable {
var defaultPath: String? var defaultPath: String?
} }
struct FolderPickerOptions: Decodable {
var title: String?
var defaultPath: String?
var multiple: Bool?
var recursive: Bool?
var canCreateDirectories: Bool?
}
class DialogPlugin: Plugin { class DialogPlugin: Plugin {
var filePickerController: FilePickerController! 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) { private func presentViewController(_ viewControllerToPresent: UIViewController) {
self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil) self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil)
} }

@ -7,6 +7,7 @@ import MobileCoreServices
import PhotosUI import PhotosUI
import Photos import Photos
import Tauri import Tauri
import UniformTypeIdentifiers
public class FilePickerController: NSObject { public class FilePickerController: NSObject {
var plugin: DialogPlugin var plugin: DialogPlugin
@ -118,6 +119,15 @@ public class FilePickerController: NSObject {
extension FilePickerController: UIDocumentPickerDelegate { extension FilePickerController: UIDocumentPickerDelegate {
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { 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 { do {
let temporaryUrls = try urls.map { try saveTemporaryFile($0) } let temporaryUrls = try urls.map { try saveTemporaryFile($0) }
self.plugin.onFilePickerEvent(.selected(temporaryUrls)) self.plugin.onFilePickerEvent(.selected(temporaryUrls))

@ -16,9 +16,7 @@ use crate::{
#[derive(Serialize)] #[derive(Serialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum OpenResponse { pub enum OpenResponse {
#[cfg(desktop)]
Folders(Option<Vec<FilePath>>), Folders(Option<Vec<FilePath>>),
#[cfg(desktop)]
Folder(Option<FilePath>), Folder(Option<FilePath>),
Files(Option<Vec<FilePath>>), Files(Option<Vec<FilePath>>),
File(Option<FilePath>), File(Option<FilePath>),
@ -132,8 +130,7 @@ pub(crate) async fn open<R: Runtime>(
dialog_builder = dialog_builder.add_filter(filter.name, &extensions); dialog_builder = dialog_builder.add_filter(filter.name, &extensions);
} }
let res = if options.directory { if options.directory {
#[cfg(desktop)]
{ {
let tauri_scope = window.state::<tauri::scope::Scopes>(); let tauri_scope = window.state::<tauri::scope::Scopes>();
@ -149,9 +146,9 @@ pub(crate) async fn open<R: Runtime>(
} }
} }
} }
OpenResponse::Folders( return Ok(OpenResponse::Folders(folders.map(|folders| {
folders.map(|folders| folders.into_iter().map(|p| p.simplified()).collect()), folders.into_iter().map(|p| p.simplified()).collect()
) })));
} else { } else {
let folder = dialog_builder.blocking_pick_folder(); let folder = dialog_builder.blocking_pick_folder();
if let Some(folder) = &folder { if let Some(folder) = &folder {
@ -162,14 +159,14 @@ pub(crate) async fn open<R: Runtime>(
tauri_scope.allow_directory(&path, options.directory)?; tauri_scope.allow_directory(&path, options.directory)?;
} }
} }
OpenResponse::Folder(folder.map(|p| p.simplified())) return Ok(OpenResponse::Folder(folder.map(|p| p.simplified())));
} }
} }
#[cfg(mobile)] }
return Err(crate::Error::FolderPickerNotImplemented);
} else if options.multiple {
let tauri_scope = window.state::<tauri::scope::Scopes>();
// Handle file selection
if options.multiple {
let tauri_scope = window.state::<tauri::scope::Scopes>();
let files = dialog_builder.blocking_pick_files(); let files = dialog_builder.blocking_pick_files();
if let Some(files) = &files { if let Some(files) = &files {
for file in files { for file in files {
@ -177,16 +174,16 @@ pub(crate) async fn open<R: Runtime>(
if let Some(s) = window.try_fs_scope() { if let Some(s) = window.try_fs_scope() {
s.allow_file(&path)?; s.allow_file(&path)?;
} }
tauri_scope.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 { } else {
let tauri_scope = window.state::<tauri::scope::Scopes>(); let tauri_scope = window.state::<tauri::scope::Scopes>();
let file = dialog_builder.blocking_pick_file(); let file = dialog_builder.blocking_pick_file();
if let Some(file) = &file { if let Some(file) = &file {
if let Ok(path) = file.clone().into_path() { if let Ok(path) = file.clone().into_path() {
if let Some(s) = window.try_fs_scope() { if let Some(s) = window.try_fs_scope() {
@ -195,9 +192,8 @@ pub(crate) async fn open<R: Runtime>(
tauri_scope.allow_file(&path)?; tauri_scope.allow_file(&path)?;
} }
} }
OpenResponse::File(file.map(|f| f.simplified())) Ok(OpenResponse::File(file.map(|f| f.simplified())))
}; }
Ok(res)
} }
#[allow(unused_variables)] #[allow(unused_variables)]

@ -16,9 +16,6 @@ pub enum Error {
#[cfg(mobile)] #[cfg(mobile)]
#[error(transparent)] #[error(transparent)]
PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError),
#[cfg(mobile)]
#[error("Folder picker is not implemented on mobile")]
FolderPickerNotImplemented,
#[error(transparent)] #[error(transparent)]
Fs(#[from] tauri_plugin_fs::Error), Fs(#[from] tauri_plugin_fs::Error),
} }

@ -506,7 +506,6 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// Ok(()) /// Ok(())
/// }); /// });
/// ``` /// ```
#[cfg(desktop)]
pub fn pick_folder<F: FnOnce(Option<FilePath>) + Send + 'static>(self, f: F) { pub fn pick_folder<F: FnOnce(Option<FilePath>) + Send + 'static>(self, f: F) {
pick_folder(self, f) pick_folder(self, f)
} }
@ -528,7 +527,6 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// Ok(()) /// Ok(())
/// }); /// });
/// ``` /// ```
#[cfg(desktop)]
pub fn pick_folders<F: FnOnce(Option<Vec<FilePath>>) + Send + 'static>(self, f: F) { pub fn pick_folders<F: FnOnce(Option<Vec<FilePath>>) + Send + 'static>(self, f: F) {
pick_folders(self, f) pick_folders(self, f)
} }
@ -611,7 +609,6 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// // the folder path is `None` if the user closed the dialog /// // the folder path is `None` if the user closed the dialog
/// } /// }
/// ``` /// ```
#[cfg(desktop)]
pub fn blocking_pick_folder(self) -> Option<FilePath> { pub fn blocking_pick_folder(self) -> Option<FilePath> {
blocking_fn!(self, pick_folder) blocking_fn!(self, pick_folder)
} }
@ -631,7 +628,6 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// // the folder paths value is `None` if the user closed the dialog /// // the folder paths value is `None` if the user closed the dialog
/// } /// }
/// ``` /// ```
#[cfg(desktop)]
pub fn blocking_pick_folders(self) -> Option<Vec<FilePath>> { pub fn blocking_pick_folders(self) -> Option<Vec<FilePath>> {
blocking_fn!(self, pick_folders) blocking_fn!(self, pick_folders)
} }

@ -54,6 +54,11 @@ struct SaveFileResponse {
file: FilePath, file: FilePath,
} }
#[derive(Debug, Deserialize)]
struct FolderPickerResponse {
directories: Vec<FilePath>,
}
pub fn pick_file<R: Runtime, F: FnOnce(Option<FilePath>) + Send + 'static>( pub fn pick_file<R: Runtime, F: FnOnce(Option<FilePath>) + Send + 'static>(
dialog: FileDialogBuilder<R>, dialog: FileDialogBuilder<R>,
f: F, f: F,
@ -125,3 +130,39 @@ pub fn show_message_dialog<R: Runtime, F: FnOnce(bool) + Send + 'static>(
f(res.map(|r| r.value).unwrap_or_default()) f(res.map(|r| r.value).unwrap_or_default())
}); });
} }
#[cfg(mobile)]
pub fn pick_folders<R: Runtime, F: FnOnce(Option<Vec<FilePath>>) + Send + 'static>(
dialog: FileDialogBuilder<R>,
f: F,
) {
std::thread::spawn(move || {
let res = dialog
.dialog
.0
.run_mobile_plugin::<FolderPickerResponse>("showFolderPicker", dialog.payload(true));
if let Ok(response) = res {
f(Some(response.directories))
} else {
f(None)
}
});
}
#[cfg(mobile)]
pub fn pick_folder<R: Runtime, F: FnOnce(Option<FilePath>) + Send + 'static>(
dialog: FileDialogBuilder<R>,
f: F,
) {
std::thread::spawn(move || {
let res = dialog
.dialog
.0
.run_mobile_plugin::<FolderPickerResponse>("showFolderPicker", dialog.payload(false));
if let Ok(response) = res {
f(Some(response.directories.into_iter().next().unwrap()))
} else {
f(None)
}
});
}

Loading…
Cancel
Save