Files
archived-plugins-workspace/plugins/dialog/ios/Sources/FilePickerController.swift
Andrew de Waal d7a0bb325d feat(dialog) - Support fileAccessMode for open dialog (#3030) (#3136)
* 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>
2026-01-14 08:35:06 -03:00

253 lines
9.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
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
}
}
/// ## In which cases do we need to save a copy of a file selected by a user to the app sandbox?
/// In short, only when the file is **not** selected using UIDocumentPickerDelegate.
/// For the rest of the cases, we need to write a copy of the file to the app sandbox.
///
/// For PHPicker (used for photos and videos), `NSItemProvider.loadFileRepresentation` returns a temporary file URL that is deleted after the completion handler.
/// The recommendation is to [Persist](https://developer.apple.com/documentation/foundation/nsitemprovider/2888338-loadfilerepresentation) the file by moving/copying
/// it to your apps directory within the completion handler.
///
/// If available, `loadInPlaceFileRepresentation` can open a file in place; Photos assets typically do not support true in-place access,
/// so fall back to persisting a local file.
/// Ref: https://developer.apple.com/documentation/foundation/nsitemprovider/2888335-loadinplacefilerepresentation
///
/// For UIDocumentPicker, prefer "open in place" and avoid copying when possible.
/// Ref: https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller
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 {
self.plugin.onFilePickerEvent(.selected(urls))
} 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 {
// We have to make a copy of the file to the app sandbox here, as PHPicker returns an NSItemProvider with either an ephemeral file URL or content that is deleted after the completion handler.
// This is a different behavior from UIDocumentPicker, where the file can either be copied to the app sandbox or opened in place, and then accessed with `startAccessingSecurityScopedResource`.
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 {
// We have to make a copy of the file to the app sandbox here, as PHPicker returns an NSItemProvider with either an ephemeral file URL or content that is deleted after the completion handler.
// This is a different behavior from UIDocumentPicker, where the file can either be copied to the app sandbox or opened in place, and then accessed with `startAccessingSecurityScopedResource`.
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))
}
}
}