pull/2322/merge
Julian Carrier 2 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>
}
@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
}
}

@ -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
}
}
}
}

@ -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)
}

@ -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))

@ -16,9 +16,7 @@ use crate::{
#[derive(Serialize)]
#[serde(untagged)]
pub enum OpenResponse {
#[cfg(desktop)]
Folders(Option<Vec<FilePath>>),
#[cfg(desktop)]
Folder(Option<FilePath>),
Files(Option<Vec<FilePath>>),
File(Option<FilePath>),
@ -132,8 +130,7 @@ pub(crate) async fn open<R: Runtime>(
dialog_builder = dialog_builder.add_filter(filter.name, &extensions);
}
let res = if options.directory {
#[cfg(desktop)]
if options.directory {
{
let tauri_scope = window.state::<tauri::scope::Scopes>();
@ -149,9 +146,9 @@ pub(crate) async fn open<R: Runtime>(
}
}
}
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 {
@ -162,14 +159,14 @@ pub(crate) async fn open<R: Runtime>(
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();
if let Some(files) = &files {
for file in files {
@ -177,16 +174,16 @@ pub(crate) async fn open<R: Runtime>(
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::<tauri::scope::Scopes>();
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 +192,8 @@ pub(crate) async fn open<R: Runtime>(
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)]

@ -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),
}

@ -506,7 +506,6 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// Ok(())
/// });
/// ```
#[cfg(desktop)]
pub fn pick_folder<F: FnOnce(Option<FilePath>) + Send + 'static>(self, f: F) {
pick_folder(self, f)
}
@ -528,7 +527,6 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// Ok(())
/// });
/// ```
#[cfg(desktop)]
pub fn pick_folders<F: FnOnce(Option<Vec<FilePath>>) + Send + 'static>(self, f: 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
/// }
/// ```
#[cfg(desktop)]
pub fn blocking_pick_folder(self) -> Option<FilePath> {
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
/// }
/// ```
#[cfg(desktop)]
pub fn blocking_pick_folders(self) -> Option<Vec<FilePath>> {
blocking_fn!(self, pick_folders)
}

@ -54,6 +54,11 @@ struct SaveFileResponse {
file: FilePath,
}
#[derive(Debug, Deserialize)]
struct FolderPickerResponse {
directories: Vec<FilePath>,
}
pub fn pick_file<R: Runtime, F: FnOnce(Option<FilePath>) + Send + 'static>(
dialog: FileDialogBuilder<R>,
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())
});
}
#[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