From a9cf44025c6f3204dd9e6cf3c7349978a6785122 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Thu, 13 Apr 2023 17:11:08 -0300 Subject: [PATCH] feat: add ios file picker --- .../gen/apple/app.xcodeproj/project.pbxproj | 2 +- .../tauri-app/src-tauri/gen/apple/project.yml | 2 +- .../examples/tauri-app/src-tauri/src/lib.rs | 2 +- plugins/dialog/ios/Sources/DialogPlugin.swift | 213 ++++++++++++++-- .../ios/Sources/FilePickerController.swift | 229 ++++++++++++++++++ plugins/dialog/src/mobile.rs | 1 + 6 files changed, 423 insertions(+), 26 deletions(-) create mode 100644 plugins/dialog/ios/Sources/FilePickerController.swift diff --git a/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/app.xcodeproj/project.pbxproj b/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/app.xcodeproj/project.pbxproj index fb4af520..02e855a0 100644 --- a/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/app.xcodeproj/project.pbxproj +++ b/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/app.xcodeproj/project.pbxproj @@ -216,7 +216,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/Users/lucas/projects/tauri/tauri/tooling/cli/target/debug/./cargo-tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}"; + shellScript = "yarn tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/project.yml b/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/project.yml index e4909c9e..c416a2ed 100644 --- a/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/project.yml +++ b/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/project.yml @@ -77,7 +77,7 @@ targets: - sdk: UIKit.framework - sdk: WebKit.framework preBuildScripts: - - script: /Users/lucas/projects/tauri/tauri/tooling/cli/target/debug/./cargo-tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} + - script: yarn tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} name: Build Rust Code basedOnDependencyAnalysis: false outputFiles: diff --git a/plugins/dialog/examples/tauri-app/src-tauri/src/lib.rs b/plugins/dialog/examples/tauri-app/src-tauri/src/lib.rs index 218f94b9..58ccbba8 100644 --- a/plugins/dialog/examples/tauri-app/src-tauri/src/lib.rs +++ b/plugins/dialog/examples/tauri-app/src-tauri/src/lib.rs @@ -5,12 +5,12 @@ #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() - .plugin(tauri_plugin_dialog::init()) .plugin( tauri_plugin_log::Builder::default() .level(log::LevelFilter::Info) .build(), ) + .plugin(tauri_plugin_dialog::init()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/plugins/dialog/ios/Sources/DialogPlugin.swift b/plugins/dialog/ios/Sources/DialogPlugin.swift index 967b20d7..76fb9a8f 100644 --- a/plugins/dialog/ios/Sources/DialogPlugin.swift +++ b/plugins/dialog/ios/Sources/DialogPlugin.swift @@ -1,34 +1,201 @@ import UIKit +import MobileCoreServices +import PhotosUI +import Photos import WebKit import Tauri import SwiftRs +enum FilePickerEvent { + case selected([URL]) + case cancelled + case error(String) +} + class DialogPlugin: Plugin { - @objc public func showMessageDialog(_ invoke: Invoke) { - let manager = self.manager - let title = invoke.getString("title") - guard let message = invoke.getString("message") else { - invoke.reject("The `message` argument is required") - return + + var filePickerController: FilePickerController! + var pendingInvoke: Invoke? = nil + + override init() { + super.init() + filePickerController = FilePickerController(self) + } + + @objc public func showFilePicker(_ invoke: Invoke) { + let multiple = invoke.getBool("multiple", false) + let filters = invoke.getArray("filters") ?? [] + let parsedTypes = parseFiltersOption(filters) + + var isMedia = true + 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 + } + } + } + + pendingInvoke = invoke + + if uniqueMimeType == true || isMedia { + DispatchQueue.main.async { + if #available(iOS 14, *) { + var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) + configuration.selectionLimit = multiple ? 0 : 1 + + if uniqueMimeType == true { + if mimeKind == "image" { + configuration.filter = .images + } else if mimeKind == "video" { + configuration.filter = .videos + } + } + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self.filePickerController + picker.modalPresentationStyle = .fullScreen + self.presentViewController(picker) + } else { + let picker = UIImagePickerController() + picker.delegate = self.filePickerController + + if uniqueMimeType == true && mimeKind == "image" { + picker.sourceType = .photoLibrary + } + + picker.sourceType = .photoLibrary + picker.modalPresentationStyle = .fullScreen + self.presentViewController(picker) + } } - let okButtonLabel = invoke.getString("okButtonLabel") ?? "OK" - let cancelButtonLabel = invoke.getString("cancelButtonLabel") ?? "Cancel" - - DispatchQueue.main.async { [weak self] in - let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert) - alert.addAction(UIAlertAction(title: cancelButtonLabel, style: UIAlertAction.Style.default, handler: { (_) -> Void in - invoke.resolve([ - "value": false - ]) - })) - alert.addAction(UIAlertAction(title: okButtonLabel, style: UIAlertAction.Style.default, handler: { (_) -> Void in - invoke.resolve([ - "value": true - ]) - })) - - manager.viewController?.present(alert, animated: true, completion: nil) + } else { + let documentTypes = parsedTypes.isEmpty ? ["public.data"] : parsedTypes + DispatchQueue.main.async { + let picker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import) + picker.delegate = self.filePickerController + picker.allowsMultipleSelection = multiple + picker.modalPresentationStyle = .fullScreen + self.presentViewController(picker) } + } + } + + private func presentViewController(_ viewControllerToPresent: UIViewController) { + self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil) + } + + private func parseFiltersOption(_ filters: JSArray) -> [String] { + var parsedTypes: [String] = [] + for (_, filter) in filters.enumerated() { + let filterObj = filter as? JSObject + if let filterObj = filterObj { + let extensions = filterObj["extensions"] as? JSArray + if let extensions = extensions { + for e in extensions { + let ext = e as? String ?? "" + 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) { + switch event { + case .selected(let urls): + let readData = pendingInvoke?.getBool("readData", false) ?? false + do { + let filesResult = try urls.map {(url: URL) -> JSObject in + var file = JSObject() + + let mimeType = filePickerController.getMimeTypeFromUrl(url) + let isVideo = mimeType.hasPrefix("video") + let isImage = mimeType.hasPrefix("image") + + if readData { + file["data"] = try Data(contentsOf: url).base64EncodedString() + } + + if isVideo { + file["duration"] = filePickerController.getVideoDuration(url) + let (height, width) = filePickerController.getVideoDimensions(url) + if let height = height { + file["height"] = height + } + if let width = width { + file["width"] = width + } + } else if isImage { + let (height, width) = filePickerController.getImageDimensions(url) + if let height = height { + file["height"] = height + } + if let width = width { + file["width"] = width + } + } + + file["modifiedAt"] = filePickerController.getModifiedAtFromUrl(url) + file["mimeType"] = mimeType + file["name"] = url.lastPathComponent + file["path"] = url.absoluteString + file["size"] = try filePickerController.getSizeFromUrl(url) + return file + } + pendingInvoke?.resolve(["files": filesResult]) + } catch let error as NSError { + pendingInvoke?.reject(error.localizedDescription, nil, error) + return + } + + pendingInvoke?.resolve(["files": urls]) + case .cancelled: + let files: JSArray = [] + pendingInvoke?.resolve(["files": files]) + case .error(let error): + pendingInvoke?.reject(error) + } + } + + @objc public func showMessageDialog(_ invoke: Invoke) { + let manager = self.manager + let title = invoke.getString("title") + guard let message = invoke.getString("message") else { + invoke.reject("The `message` argument is required") + return + } + let okButtonLabel = invoke.getString("okButtonLabel") ?? "OK" + let cancelButtonLabel = invoke.getString("cancelButtonLabel") ?? "Cancel" + + DispatchQueue.main.async { [] in + let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert) + alert.addAction(UIAlertAction(title: cancelButtonLabel, style: UIAlertAction.Style.default, handler: { (_) -> Void in + invoke.resolve([ + "value": false + ]) + })) + alert.addAction(UIAlertAction(title: okButtonLabel, style: UIAlertAction.Style.default, handler: { (_) -> Void in + invoke.resolve([ + "value": true + ]) + })) + + manager.viewController?.present(alert, animated: true, completion: nil) + } } } diff --git a/plugins/dialog/ios/Sources/FilePickerController.swift b/plugins/dialog/ios/Sources/FilePickerController.swift new file mode 100644 index 00000000..fc636473 --- /dev/null +++ b/plugins/dialog/ios/Sources/FilePickerController.swift @@ -0,0 +1,229 @@ +import UIKit +import MobileCoreServices +import PhotosUI +import Photos +import Tauri + +public class FilePickerController: NSObject { + var plugin: DialogPlugin + + init(_ dialogPlugin: DialogPlugin) { + plugin = dialogPlugin + } + + private func dismissViewController(_ viewControllerToPresent: UIViewController, completion: (() -> Void)? = nil) { + viewControllerToPresent.dismiss(animated: true, completion: completion) + } + + public func getModifiedAtFromUrl(_ url: URL) -> Int? { + do { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + if let modifiedDateInSec = (attributes[.modificationDate] as? Date)?.timeIntervalSince1970 { + return Int(modifiedDateInSec * 1000.0) + } else { + return nil + } + } catch let error as NSError { + Logger.error("getModifiedAtFromUrl failed", error.localizedDescription) + return nil + } + } + + public func getMimeTypeFromUrl(_ url: URL) -> String { + let fileExtension = url.pathExtension as CFString + guard let extUTI = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileExtension, nil)?.takeUnretainedValue() else { + return "" + } + guard let mimeUTI = UTTypeCopyPreferredTagWithClass(extUTI, kUTTagClassMIMEType) else { + return "" + } + return mimeUTI.takeRetainedValue() as String + } + + public func getSizeFromUrl(_ url: URL) throws -> Int { + let values = try url.resourceValues(forKeys: [.fileSizeKey]) + return values.fileSize ?? 0 + } + + public func getVideoDuration(_ url: URL) -> Int { + let asset = AVAsset(url: url) + let duration = asset.duration + let durationTime = CMTimeGetSeconds(duration) + return Int(round(durationTime)) + } + + public func getImageDimensions(_ url: URL) -> (Int?, Int?) { + if let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil) { + if let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as Dictionary? { + return getHeightAndWidthFromImageProperties(imageProperties) + } + } + return (nil, nil) + } + + public func getVideoDimensions(_ url: URL) -> (Int?, Int?) { + guard let track = AVURLAsset(url: url).tracks(withMediaType: AVMediaType.video).first else { return (nil, nil) } + let size = track.naturalSize.applying(track.preferredTransform) + let height = abs(Int(size.height)) + let width = abs(Int(size.width)) + return (height, width) + } + + private func getHeightAndWidthFromImageProperties(_ properties: [NSObject: AnyObject]) -> (Int?, Int?) { + let width = properties[kCGImagePropertyPixelWidth] as? Int + let height = properties[kCGImagePropertyPixelHeight] as? Int + let orientation = properties[kCGImagePropertyOrientation] as? Int ?? UIImage.Orientation.up.rawValue + switch orientation { + case UIImage.Orientation.left.rawValue, UIImage.Orientation.right.rawValue, UIImage.Orientation.leftMirrored.rawValue, UIImage.Orientation.rightMirrored.rawValue: + return (width, height) + default: + return (height, width) + } + } + + private func getFileUrlByPath(_ path: String) -> URL? { + guard let url = URL.init(string: path) else { + return nil + } + if FileManager.default.fileExists(atPath: url.path) { + return url + } else { + return nil + } + } + + private func saveTemporaryFile(_ sourceUrl: URL) throws -> URL { + var directory = URL(fileURLWithPath: NSTemporaryDirectory()) + if let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first { + directory = cachesDirectory + } + let targetUrl = directory.appendingPathComponent(sourceUrl.lastPathComponent) + do { + try deleteFile(targetUrl) + } + try FileManager.default.copyItem(at: sourceUrl, to: targetUrl) + return targetUrl + } + + private func deleteFile(_ url: URL) throws { + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(atPath: url.path) + } + } +} + +extension FilePickerController: UIDocumentPickerDelegate { + public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + do { + let temporaryUrls = try urls.map { try saveTemporaryFile($0) } + self.plugin.onFilePickerEvent(.selected(temporaryUrls)) + } catch { + self.plugin.onFilePickerEvent(.error("Failed to create a temporary copy of the file")) + } + } + + public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + self.plugin.onFilePickerEvent(.cancelled) + } +} + +extension FilePickerController: UIImagePickerControllerDelegate, UINavigationControllerDelegate, UIPopoverPresentationControllerDelegate { + public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + dismissViewController(picker) + self.plugin.onFilePickerEvent(.cancelled) + } + + public func popoverPresentationControllerDidDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) { + self.plugin.onFilePickerEvent(.cancelled) + } + + public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + self.plugin.onFilePickerEvent(.cancelled) + } + + public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + dismissViewController(picker) { + if let url = info[.mediaURL] as? URL { + do { + let temporaryUrl = try self.saveTemporaryFile(url) + self.plugin.onFilePickerEvent(.selected([temporaryUrl])) + } catch { + self.plugin.onFilePickerEvent(.error("Failed to create a temporary copy of the file")) + } + } else { + self.plugin.onFilePickerEvent(.cancelled) + } + } + } +} + +@available(iOS 14, *) +extension FilePickerController: PHPickerViewControllerDelegate { + public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + dismissViewController(picker) + if results.first == nil { + self.plugin.onFilePickerEvent(.cancelled) + return + } + var temporaryUrls: [URL] = [] + var errorMessage: String? + let dispatchGroup = DispatchGroup() + for result in results { + if errorMessage != nil { + break + } + if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + dispatchGroup.enter() + result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier, completionHandler: { url, error in + defer { + dispatchGroup.leave() + } + if let error = error { + errorMessage = error.localizedDescription + return + } + guard let url = url else { + errorMessage = "Unknown error" + return + } + do { + let temporaryUrl = try self.saveTemporaryFile(url) + temporaryUrls.append(temporaryUrl) + } catch { + errorMessage = "Failed to create a temporary copy of the file" + } + }) + } else if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + dispatchGroup.enter() + result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier, completionHandler: { url, error in + defer { + dispatchGroup.leave() + } + if let error = error { + errorMessage = error.localizedDescription + return + } + guard let url = url else { + errorMessage = "Unknown error" + return + } + do { + let temporaryUrl = try self.saveTemporaryFile(url) + temporaryUrls.append(temporaryUrl) + } catch { + errorMessage = "Failed to create a temporary copy of the file" + } + }) + } else { + errorMessage = "Unsupported file type identifier" + } + } + dispatchGroup.notify(queue: .main) { + if let errorMessage = errorMessage { + self.plugin.onFilePickerEvent(.error(errorMessage)) + return + } + self.plugin.onFilePickerEvent(.selected(temporaryUrls)) + } + } +} \ No newline at end of file diff --git a/plugins/dialog/src/mobile.rs b/plugins/dialog/src/mobile.rs index 703186c0..6fbf56c8 100644 --- a/plugins/dialog/src/mobile.rs +++ b/plugins/dialog/src/mobile.rs @@ -87,6 +87,7 @@ pub fn pick_files>) + Send + 'sta #[derive(Debug, Deserialize)] struct ShowMessageDialogResponse { + #[serde(default)] cancelled: bool, value: bool, }