feat(dialog) - Support picker mode for open dialog (#3030) (#3034)

Co-authored-by: Andrew de Waal <andrewldewaal@gmail.com>
This commit is contained in:
Andrew de Waal
2025-11-20 13:28:13 -08:00
committed by GitHub
parent a4aa53ab90
commit dff6fa986a
8 changed files with 223 additions and 59 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"dialog": minor
"dialog-js": minor
---
Add `pickerMode` option to file picker (currently only used on iOS)
Generated
+14
View File
@@ -4565,6 +4565,12 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]] [[package]]
name = "poly1305" name = "poly1305"
version = "0.8.0" version = "0.8.0"
@@ -5094,7 +5100,9 @@ dependencies = [
"objc2-app-kit", "objc2-app-kit",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.0", "objc2-foundation 0.3.0",
"pollster",
"raw-window-handle", "raw-window-handle",
"urlencoding",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
@@ -7739,6 +7747,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.3.0" version = "0.3.0"
+15 -3
View File
@@ -8,6 +8,7 @@
let filter = null; let filter = null;
let multiple = false; let multiple = false;
let directory = false; let directory = false;
let pickerMode = "";
function arrayBufferToBase64(buffer, callback) { function arrayBufferToBase64(buffer, callback) {
var blob = new Blob([buffer], { var blob = new Blob([buffer], {
@@ -65,6 +66,7 @@
: [], : [],
multiple, multiple,
directory, directory,
pickerMode: pickerMode === "" ? undefined : pickerMode,
}) })
.then(function (res) { .then(function (res) {
if (Array.isArray(res)) { if (Array.isArray(res)) {
@@ -94,7 +96,7 @@
onMessage(res); onMessage(res);
} }
}) })
.catch(onMessage(res)); .catch(onMessage);
} }
}) })
.catch(onMessage); .catch(onMessage);
@@ -112,7 +114,7 @@
}, },
] ]
: [], : [],
}) })
.then(onMessage) .then(onMessage)
.catch(onMessage); .catch(onMessage);
} }
@@ -142,6 +144,16 @@
<input type="checkbox" id="dialog-directory" bind:checked={directory} /> <input type="checkbox" id="dialog-directory" bind:checked={directory} />
<label for="dialog-directory">Directory</label> <label for="dialog-directory">Directory</label>
</div> </div>
<div>
<label for="dialog-picker-mode">Picker Mode:</label>
<select id="dialog-picker-mode" bind:value={pickerMode}>
<option value="">None</option>
<option value="media">Media</option>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="document">Document</option>
</select>
</div>
<br /> <br />
<div class="flex flex-wrap flex-col md:flex-row gap-2 children:flex-shrink-0"> <div class="flex flex-wrap flex-col md:flex-row gap-2 children:flex-shrink-0">
@@ -156,4 +168,4 @@
<button class="btn" id="message-dialog" on:click={msg}>Message</button> <button class="btn" id="message-dialog" on:click={msg}>Message</button>
<button class="btn" id="message-dialog" on:click={msgCustom}>Message (custom)</button> <button class="btn" id="message-dialog" on:click={msgCustom}>Message (custom)</button>
</div> </div>
@@ -31,6 +31,7 @@ class Filter {
class FilePickerOptions { class FilePickerOptions {
lateinit var filters: Array<Filter> lateinit var filters: Array<Filter>
var multiple: Boolean? = null var multiple: Boolean? = null
var pickerMode: String? = null
} }
@InvokeArg @InvokeArg
@@ -61,10 +62,19 @@ class DialogPlugin(private val activity: Activity): Plugin(activity) {
// TODO: ACTION_OPEN_DOCUMENT ?? // TODO: ACTION_OPEN_DOCUMENT ??
val intent = Intent(Intent.ACTION_GET_CONTENT) val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE) intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*"
if (parsedTypes.isNotEmpty()) { if (args.pickerMode == "image") {
intent.type = "image/*"
} else if (args.pickerMode == "video") {
intent.type = "video/*"
} else if (args.pickerMode == "media") {
intent.type = "*/*"
intent.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("video/*", "image/*"))
} else if (parsedTypes.isNotEmpty()) {
intent.type = "*/*"
intent.putExtra(Intent.EXTRA_MIME_TYPES, parsedTypes) intent.putExtra(Intent.EXTRA_MIME_TYPES, parsedTypes)
} else {
intent.type = "*/*"
} }
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, args.multiple ?: false) intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, args.multiple ?: false)
+35 -1
View File
@@ -14,6 +14,16 @@ interface DialogFilter {
name: string name: string
/** /**
* Extensions to filter, without a `.` prefix. * Extensions to filter, without a `.` prefix.
*
* **Note:** Mobile platforms have different APIs for filtering that may not support extensions.
* iOS: Extensions are supported in the document picker, but not in the media picker.
* Android: Extensions are not supported.
*
* For these platforms, MIME types are the primary way to filter files, as opposed to extensions.
* This means the string values here labeled as `extensions` may also be a MIME type.
* This property name of `extensions` is being kept for backwards compatibility, but this may be revisited to
* specify the difference between extension or MIME type filtering.
*
* @example * @example
* ```typescript * ```typescript
* extensions: ['svg', 'png'] * extensions: ['svg', 'png']
@@ -30,7 +40,14 @@ interface DialogFilter {
interface OpenDialogOptions { interface OpenDialogOptions {
/** The title of the dialog window (desktop only). */ /** The title of the dialog window (desktop only). */
title?: string title?: string
/** The filters of the dialog. */ /**
* The filters of the dialog.
* On mobile platforms, if either:
* A) the {@linkcode pickerMode} is set to `media`, `image`, or `video`
* -- or --
* B) the filters include **only** either image or video mime types, the media picker will be displayed.
* Otherwise, the document picker will be displayed.
*/
filters?: DialogFilter[] filters?: DialogFilter[]
/** /**
* Initial directory or file path. * Initial directory or file path.
@@ -52,6 +69,13 @@ interface OpenDialogOptions {
recursive?: boolean recursive?: boolean
/** Whether to allow creating directories in the dialog. Enabled by default. **macOS Only** */ /** Whether to allow creating directories in the dialog. Enabled by default. **macOS Only** */
canCreateDirectories?: boolean canCreateDirectories?: boolean
/**
* The preferred mode of the dialog.
* This is meant for mobile platforms (iOS and Android) which have distinct file and media pickers.
* If not provided, the dialog will automatically choose the best mode based on the MIME types or extensions of the {@linkcode filters}.
* On desktop, this option is ignored.
*/
pickerMode?: PickerMode
} }
/** /**
@@ -77,6 +101,16 @@ interface SaveDialogOptions {
canCreateDirectories?: boolean canCreateDirectories?: boolean
} }
/**
* The preferred mode of the dialog.
* This is meant for mobile platforms (iOS and Android) which have distinct file and media pickers.
* On desktop, this option is ignored.
* If not provided, the dialog will automatically choose the best mode based on the MIME types or extensions of the {@linkcode filters}.
*
* **Note:** This option is only supported on iOS 14 and above. This parameter is ignored on iOS 13 and below.
*/
export type PickerMode = 'document' | 'media' | 'image' | 'video'
/** /**
* Default buttons for a message dialog. * Default buttons for a message dialog.
* *
+107 -51
View File
@@ -8,6 +8,7 @@ import PhotosUI
import SwiftRs import SwiftRs
import Tauri import Tauri
import UIKit import UIKit
import UniformTypeIdentifiers
import WebKit import WebKit
enum FilePickerEvent { enum FilePickerEvent {
@@ -32,6 +33,7 @@ struct FilePickerOptions: Decodable {
var multiple: Bool? var multiple: Bool?
var filters: [Filter]? var filters: [Filter]?
var defaultPath: String? var defaultPath: String?
var pickerMode: PickerMode?
} }
struct SaveFileDialogOptions: Decodable { struct SaveFileDialogOptions: Decodable {
@@ -39,6 +41,13 @@ struct SaveFileDialogOptions: Decodable {
var defaultPath: String? var defaultPath: String?
} }
enum PickerMode: String, Decodable {
case document
case media
case image
case video
}
class DialogPlugin: Plugin { class DialogPlugin: Plugin {
var filePickerController: FilePickerController! var filePickerController: FilePickerController!
@@ -52,26 +61,6 @@ class DialogPlugin: Plugin {
@objc public func showFilePicker(_ invoke: Invoke) throws { @objc public func showFilePicker(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(FilePickerOptions.self) let args = try invoke.parseArgs(FilePickerOptions.self)
let parsedTypes = parseFiltersOption(args.filters ?? [])
var isMedia = !parsedTypes.isEmpty
var uniqueMimeType: Bool? = nil
var mimeKind: String? = nil
if !parsedTypes.isEmpty {
uniqueMimeType = true
for mime in parsedTypes {
let kind = mime.components(separatedBy: "/")[0]
if kind != "image" && kind != "video" {
isMedia = false
}
if mimeKind == nil {
mimeKind = kind
} else if mimeKind != kind {
uniqueMimeType = false
}
}
}
onFilePickerResult = { (event: FilePickerEvent) -> Void in onFilePickerResult = { (event: FilePickerEvent) -> Void in
switch event { switch event {
case .selected(let urls): case .selected(let urls):
@@ -81,51 +70,57 @@ class DialogPlugin: Plugin {
case .error(let error): case .error(let error):
invoke.reject(error) invoke.reject(error)
} }
} }
if uniqueMimeType == true || isMedia { if #available(iOS 14, *) {
DispatchQueue.main.async { let parsedTypes = parseFiltersOption(args.filters ?? [])
if #available(iOS 14, *) {
let mimeKinds = Set(parsedTypes.compactMap { $0.preferredMIMEType?.components(separatedBy: "/")[0] })
let filtersIncludeImage = mimeKinds.contains("image")
let filtersIncludeVideo = mimeKinds.contains("video")
let filtersIncludeNonMedia = mimeKinds.contains(where: { $0 != "image" && $0 != "video" })
// If the picker mode is media, images, or videos, we always want to show the media picker regardless of what's in the filters.
// Otherwise, if the filters A) do not include non-media types and B) include either image or video, we want to show the media picker.
if args.pickerMode == .media
|| args.pickerMode == .image
|| args.pickerMode == .video
|| (!filtersIncludeNonMedia && (filtersIncludeImage || filtersIncludeVideo)) {
DispatchQueue.main.async {
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
configuration.selectionLimit = (args.multiple ?? false) ? 0 : 1 configuration.selectionLimit = (args.multiple ?? false) ? 0 : 1
if uniqueMimeType == true { // If the filters include image or video, use the appropriate filter.
if mimeKind == "image" { // If both are true, don't define a filter, which means we will display all media.
configuration.filter = .images if args.pickerMode == .image || (filtersIncludeImage && !filtersIncludeVideo) {
} else if mimeKind == "video" { configuration.filter = .images
configuration.filter = .videos } else if args.pickerMode == .video || (filtersIncludeVideo && !filtersIncludeImage) {
} configuration.filter = .videos
} }
let picker = PHPickerViewController(configuration: configuration) let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self.filePickerController picker.delegate = self.filePickerController
picker.modalPresentationStyle = .fullScreen picker.modalPresentationStyle = .fullScreen
self.presentViewController(picker) self.presentViewController(picker)
} else { }
let picker = UIImagePickerController() } else {
picker.delegate = self.filePickerController DispatchQueue.main.async {
// The UTType.item is the catch-all, allowing for any file type to be selected.
if uniqueMimeType == true && mimeKind == "image" { let contentTypes = parsedTypes.isEmpty ? [UTType.item] : parsedTypes
picker.sourceType = .photoLibrary let picker: UIDocumentPickerViewController = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes, asCopy: true)
if let defaultPath = args.defaultPath {
picker.directoryURL = URL(string: defaultPath)
} }
picker.sourceType = .photoLibrary picker.delegate = self.filePickerController
picker.allowsMultipleSelection = args.multiple ?? false
picker.modalPresentationStyle = .fullScreen picker.modalPresentationStyle = .fullScreen
self.presentViewController(picker) self.presentViewController(picker)
} }
} }
} else { } else {
let documentTypes = parsedTypes.isEmpty ? ["public.data"] : parsedTypes showFilePickerLegacy(args: args)
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
self.presentViewController(picker)
}
} }
} }
@@ -173,19 +168,80 @@ class DialogPlugin: Plugin {
self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil) self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil)
} }
private func parseFiltersOption(_ filters: [Filter]) -> [String] { @available(iOS 14, *)
private func parseFiltersOption(_ filters: [Filter]) -> [UTType] {
var parsedTypes: [UTType] = []
for filter in filters {
for ext in filter.extensions ?? [] {
// We need to support extensions as well as MIME types.
if let utType = UTType(mimeType: ext) {
parsedTypes.append(utType)
} else if let utType = UTType(filenameExtension: ext) {
parsedTypes.append(utType)
}
}
}
return parsedTypes
}
/// This function is only used for iOS < 14, and should be removed if/when the deployment target is raised to 14.
private func showFilePickerLegacy(args: FilePickerOptions) {
let parsedTypes = parseFiltersOptionLegacy(args.filters ?? [])
var filtersIncludeImage: Bool = false
var filtersIncludeVideo: Bool = false
var filtersIncludeNonMedia: Bool = false
if !parsedTypes.isEmpty {
let mimeKinds = Set(parsedTypes.map { $0.components(separatedBy: "/")[0] })
filtersIncludeImage = mimeKinds.contains("image")
filtersIncludeVideo = mimeKinds.contains("video")
filtersIncludeNonMedia = mimeKinds.contains(where: { $0 != "image" && $0 != "video" })
}
if !filtersIncludeNonMedia && (filtersIncludeImage || filtersIncludeVideo) {
DispatchQueue.main.async {
let picker = UIImagePickerController()
picker.delegate = self.filePickerController
if filtersIncludeImage && !filtersIncludeVideo {
picker.sourceType = .photoLibrary
}
picker.modalPresentationStyle = .fullScreen
self.presentViewController(picker)
}
} else {
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
self.presentViewController(picker)
}
}
}
/// This function is only used for iOS < 14, and should be removed if/when the deployment target is raised to 14.
private func parseFiltersOptionLegacy(_ filters: [Filter]) -> [String] {
var parsedTypes: [String] = [] var parsedTypes: [String] = []
for filter in filters { for filter in filters {
for ext in filter.extensions ?? [] { for ext in filter.extensions ?? [] {
guard guard
let utType: String = UTTypeCreatePreferredIdentifierForTag( let utType: String = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, ext as CFString, nil)?.takeRetainedValue() as String?
kUTTagClassMIMEType, ext as CFString, nil)?.takeRetainedValue() as String?
else { else {
continue continue
} }
parsedTypes.append(utType) parsedTypes.append(utType)
} }
} }
return parsedTypes return parsedTypes
} }
+11 -1
View File
@@ -10,7 +10,7 @@ use tauri_plugin_fs::FsExt;
use crate::{ use crate::{
Dialog, FileDialogBuilder, FilePath, MessageDialogBuilder, MessageDialogButtons, Dialog, FileDialogBuilder, FilePath, MessageDialogBuilder, MessageDialogButtons,
MessageDialogKind, MessageDialogResult, Result, CANCEL, NO, OK, YES, MessageDialogKind, MessageDialogResult, PickerMode, Result, CANCEL, NO, OK, YES,
}; };
#[derive(Serialize)] #[derive(Serialize)]
@@ -56,6 +56,13 @@ pub struct OpenDialogOptions {
recursive: bool, recursive: bool,
/// Whether to allow creating directories in the dialog **macOS Only** /// Whether to allow creating directories in the dialog **macOS Only**
can_create_directories: Option<bool>, can_create_directories: Option<bool>,
/// The preferred mode of the dialog.
/// This is meant for mobile platforms (iOS and Android) which have distinct file and media pickers.
/// On desktop, this option is ignored.
/// If not provided, the dialog will automatically choose the best mode based on the MIME types of the filters.
#[serde(default)]
#[cfg_attr(mobile, allow(dead_code))]
picker_mode: Option<PickerMode>,
} }
/// The options for the save dialog API. /// The options for the save dialog API.
@@ -127,6 +134,9 @@ pub(crate) async fn open<R: Runtime>(
if let Some(can) = options.can_create_directories { if let Some(can) = options.can_create_directories {
dialog_builder = dialog_builder.set_can_create_directories(can); dialog_builder = dialog_builder.set_can_create_directories(can);
} }
if let Some(picker_mode) = options.picker_mode {
dialog_builder = dialog_builder.set_picker_mode(picker_mode);
}
for filter in options.filters { for filter in options.filters {
let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect(); let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect();
dialog_builder = dialog_builder.add_filter(filter.name, &extensions); dialog_builder = dialog_builder.add_filter(filter.name, &extensions);
+23 -1
View File
@@ -9,7 +9,7 @@
html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
)] )]
use serde::Serialize; use serde::{Deserialize, Serialize};
use tauri::{ use tauri::{
plugin::{Builder, TauriPlugin}, plugin::{Builder, TauriPlugin},
Manager, Runtime, Manager, Runtime,
@@ -44,6 +44,15 @@ pub use desktop::Dialog;
#[cfg(mobile)] #[cfg(mobile)]
pub use mobile::Dialog; pub use mobile::Dialog;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum PickerMode {
Document,
Media,
Image,
Video,
}
pub(crate) const OK: &str = "Ok"; pub(crate) const OK: &str = "Ok";
pub(crate) const CANCEL: &str = "Cancel"; pub(crate) const CANCEL: &str = "Cancel";
pub(crate) const YES: &str = "Yes"; pub(crate) const YES: &str = "Yes";
@@ -369,6 +378,7 @@ pub struct FileDialogBuilder<R: Runtime> {
pub(crate) file_name: Option<String>, pub(crate) file_name: Option<String>,
pub(crate) title: Option<String>, pub(crate) title: Option<String>,
pub(crate) can_create_directories: Option<bool>, pub(crate) can_create_directories: Option<bool>,
pub(crate) picker_mode: Option<PickerMode>,
#[cfg(desktop)] #[cfg(desktop)]
pub(crate) parent: Option<crate::desktop::WindowHandle>, pub(crate) parent: Option<crate::desktop::WindowHandle>,
} }
@@ -380,6 +390,7 @@ pub(crate) struct FileDialogPayload<'a> {
file_name: &'a Option<String>, file_name: &'a Option<String>,
filters: &'a Vec<Filter>, filters: &'a Vec<Filter>,
multiple: bool, multiple: bool,
picker_mode: &'a Option<PickerMode>,
} }
// raw window handle :( // raw window handle :(
@@ -395,6 +406,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
file_name: None, file_name: None,
title: None, title: None,
can_create_directories: None, can_create_directories: None,
picker_mode: None,
#[cfg(desktop)] #[cfg(desktop)]
parent: None, parent: None,
} }
@@ -406,6 +418,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
file_name: &self.file_name, file_name: &self.file_name,
filters: &self.filters, filters: &self.filters,
multiple, multiple,
picker_mode: &self.picker_mode,
} }
} }
@@ -466,6 +479,15 @@ impl<R: Runtime> FileDialogBuilder<R> {
self self
} }
/// Set the picker mode of the dialog.
/// This is meant for mobile platforms (iOS and Android) which have distinct file and media pickers.
/// On desktop, this option is ignored.
/// If not provided, the dialog will automatically choose the best mode based on the MIME types of the filters.
pub fn set_picker_mode(mut self, mode: PickerMode) -> Self {
self.picker_mode.replace(mode);
self
}
/// Shows the dialog to select a single file. /// Shows the dialog to select a single file.
/// ///
/// This is not a blocking operation, /// This is not a blocking operation,