mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-01-31 00:45:24 +01:00
* feat(dialog) - Support fileAccessMode for open dialog (#3030) On iOS, when trying to access a file that exists outside of the app sandbox, one of 2 things need to happen to be able to perform any operations on said file: * A copy of the file needs to be made to the internal app sandbox * The method startAccessingSecurityScopedResource needs to be called. Previously, a copy of the file was always being made when a file was selected through the picker dialog. While this did ensure there were no file access exceptions when reading from the file, it does not scale well for large files. To resolve this, we now support `fileAccessMode`, which allows a file handle to be returned without copying the file to the app sandbox. This MR only supports this change for iOS; MacOS has a different set of needs for security scoped resources. See discussion in #3716 for more discussion of the difference between iOS and MacOS. See MR #3185 to see how these scoped files will be accessible using security scoping. * fmt, clippy * use enum --------- Co-authored-by: Lucas Nogueira <lucas@tauri.app>
314 lines
10 KiB
Swift
314 lines
10 KiB
Swift
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
import MobileCoreServices
|
|
import Photos
|
|
import PhotosUI
|
|
import SwiftRs
|
|
import Tauri
|
|
import UIKit
|
|
import UniformTypeIdentifiers
|
|
import WebKit
|
|
|
|
enum FilePickerEvent {
|
|
case selected([URL])
|
|
case cancelled
|
|
case error(String)
|
|
}
|
|
|
|
struct MessageDialogOptions: Decodable {
|
|
var title: String?
|
|
let message: String
|
|
var okButtonLabel: String?
|
|
var noButtonLabel: String?
|
|
var cancelButtonLabel: String?
|
|
}
|
|
|
|
struct Filter: Decodable {
|
|
var extensions: [String]?
|
|
}
|
|
|
|
struct FilePickerOptions: Decodable {
|
|
var multiple: Bool?
|
|
var filters: [Filter]?
|
|
var defaultPath: String?
|
|
var pickerMode: PickerMode?
|
|
var fileAccessMode: FileAccessMode?
|
|
}
|
|
|
|
struct SaveFileDialogOptions: Decodable {
|
|
var fileName: String?
|
|
var defaultPath: String?
|
|
}
|
|
|
|
enum FileAccessMode: String, Decodable {
|
|
case copy
|
|
case scoped
|
|
}
|
|
|
|
enum PickerMode: String, Decodable {
|
|
case document
|
|
case media
|
|
case image
|
|
case video
|
|
}
|
|
|
|
class DialogPlugin: Plugin {
|
|
|
|
var filePickerController: FilePickerController!
|
|
var onFilePickerResult: ((FilePickerEvent) -> Void)? = nil
|
|
|
|
override init() {
|
|
super.init()
|
|
filePickerController = FilePickerController(self)
|
|
|
|
}
|
|
|
|
@objc public func showFilePicker(_ invoke: Invoke) throws {
|
|
let args = try invoke.parseArgs(FilePickerOptions.self)
|
|
|
|
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 #available(iOS 14, *) {
|
|
let parsedTypes = parseFiltersOption(args.filters ?? [])
|
|
|
|
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())
|
|
configuration.selectionLimit = (args.multiple ?? false) ? 0 : 1
|
|
|
|
// If the filters include image or video, use the appropriate filter.
|
|
// If both are true, don't define a filter, which means we will display all media.
|
|
if args.pickerMode == .image || (filtersIncludeImage && !filtersIncludeVideo) {
|
|
configuration.filter = .images
|
|
} else if args.pickerMode == .video || (filtersIncludeVideo && !filtersIncludeImage) {
|
|
configuration.filter = .videos
|
|
}
|
|
|
|
let picker = PHPickerViewController(configuration: configuration)
|
|
picker.delegate = self.filePickerController
|
|
picker.modalPresentationStyle = .fullScreen
|
|
self.presentViewController(picker)
|
|
}
|
|
} else {
|
|
DispatchQueue.main.async {
|
|
// The UTType.item is the catch-all, allowing for any file type to be selected.
|
|
let contentTypes = parsedTypes.isEmpty ? [UTType.item] : parsedTypes
|
|
let picker: UIDocumentPickerViewController = UIDocumentPickerViewController(
|
|
forOpeningContentTypes: contentTypes,
|
|
asCopy: args.fileAccessMode == .scoped ? false : true)
|
|
|
|
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)
|
|
}
|
|
}
|
|
} else {
|
|
showFilePickerLegacy(args: args)
|
|
}
|
|
}
|
|
|
|
@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)
|
|
}
|
|
|
|
@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] = []
|
|
for filter in filters {
|
|
for ext in filter.extensions ?? [] {
|
|
guard
|
|
let utType: String = UTTypeCreatePreferredIdentifierForTag(
|
|
kUTTagClassMIMEType, ext as CFString, nil)?.takeRetainedValue() as String?
|
|
else {
|
|
continue
|
|
}
|
|
parsedTypes.append(utType)
|
|
}
|
|
}
|
|
|
|
return parsedTypes
|
|
}
|
|
|
|
public func onFilePickerEvent(_ event: FilePickerEvent) {
|
|
self.onFilePickerResult?(event)
|
|
}
|
|
|
|
@objc public func showMessageDialog(_ invoke: Invoke) throws {
|
|
let manager = self.manager
|
|
let args = try invoke.parseArgs(MessageDialogOptions.self)
|
|
|
|
DispatchQueue.main.async { [] in
|
|
let alert = UIAlertController(
|
|
title: args.title, message: args.message, preferredStyle: UIAlertController.Style.alert)
|
|
|
|
if let cancelButtonLabel = args.cancelButtonLabel {
|
|
alert.addAction(
|
|
UIAlertAction(
|
|
title: cancelButtonLabel, style: UIAlertAction.Style.default,
|
|
handler: { (_) -> Void in
|
|
invoke.resolve(["value": cancelButtonLabel])
|
|
}
|
|
)
|
|
)
|
|
}
|
|
|
|
if let noButtonLabel = args.noButtonLabel {
|
|
alert.addAction(
|
|
UIAlertAction(
|
|
title: noButtonLabel, style: UIAlertAction.Style.default,
|
|
handler: { (_) -> Void in
|
|
invoke.resolve(["value": noButtonLabel])
|
|
}
|
|
)
|
|
)
|
|
}
|
|
|
|
let okButtonLabel = args.okButtonLabel ?? "Ok"
|
|
alert.addAction(
|
|
UIAlertAction(
|
|
title: okButtonLabel, style: UIAlertAction.Style.default,
|
|
handler: { (_) -> Void in
|
|
invoke.resolve(["value": okButtonLabel])
|
|
}
|
|
)
|
|
)
|
|
|
|
manager.viewController?.present(alert, animated: true, completion: nil)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
@_cdecl("init_plugin_dialog")
|
|
func initPlugin() -> Plugin {
|
|
return DialogPlugin()
|
|
}
|