feat: add ios file picker

pull/306/head
Lucas Nogueira 2 years ago
parent 778c99aa0c
commit a9cf44025c
No known key found for this signature in database
GPG Key ID: 7C32FCA95C8C95D7

@ -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 */

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

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

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

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

@ -87,6 +87,7 @@ pub fn pick_files<R: Runtime, F: FnOnce(Option<Vec<FileResponse>>) + Send + 'sta
#[derive(Debug, Deserialize)]
struct ShowMessageDialogResponse {
#[serde(default)]
cancelled: bool,
value: bool,
}

Loading…
Cancel
Save