fix(dialog): do not create file copy for save file picker on iOS

On iOS the file picker returns a security scoped resource file path on the save() file picker: https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller?language=objc#Work-with-external-documents

this means we can't directly access it without calling [startAccessingSecurityScopedResource](https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc)

this PR changes the plugin to not access the save file early, leaving that to the user and returning its actual path
pull/2548/head
Lucas Nogueira 4 months ago
parent 38deef43dc
commit 84209f3564
No known key found for this signature in database
GPG Key ID: 7C32FCA95C8C95D7

@ -387,7 +387,7 @@
CODE_SIGN_ENTITLEMENTS = api_iOS/api_iOS.entitlements; CODE_SIGN_ENTITLEMENTS = api_iOS/api_iOS.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = Q93MBH6S2F; DEVELOPMENT_TEAM = "Q93MBH6S2F";
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64";
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
@ -442,7 +442,7 @@
CODE_SIGN_ENTITLEMENTS = api_iOS/api_iOS.entitlements; CODE_SIGN_ENTITLEMENTS = api_iOS/api_iOS.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = Q93MBH6S2F; DEVELOPMENT_TEAM = "Q93MBH6S2F";
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64";
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;

@ -156,6 +156,10 @@ type OpenDialogReturn<T extends OpenDialogOptions> = T['directory'] extends true
* } * }
* ``` * ```
* *
* ## Platform-specific
*
* - **iOS**: Returns a copy of the file to bypass [security scoped resource](https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc).
*
* @returns A promise resolving to the selected path(s) * @returns A promise resolving to the selected path(s)
* *
* @since 2.0.0 * @since 2.0.0
@ -190,6 +194,10 @@ async function open<T extends OpenDialogOptions>(
* }); * });
* ``` * ```
* *
* #### Platform-specific
*
* - **iOS**: Returns a copy of the file to bypass [security scoped resource](https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc).
*
* @returns A promise resolving to the selected path. * @returns A promise resolving to the selected path.
* *
* @since 2.0.0 * @since 2.0.0

@ -74,10 +74,18 @@ class DialogPlugin: Plugin {
onFilePickerResult = { (event: FilePickerEvent) -> Void in onFilePickerResult = { (event: FilePickerEvent) -> Void in
switch event { switch event {
case .selected(let urls): case .selected(let urls):
invoke.resolve(["files": urls]) do {
let temporaryUrls = try urls.map { try self.saveTemporaryFile($0) }
invoke.resolve(["files": temporaryUrls])
} catch {
let message = "Failed to create a temporary copy of the file: \(error)"
Logger.error("\(message)")
invoke.reject(message)
}
case .cancelled: case .cancelled:
invoke.resolve(["files": nil]) invoke.resolve(["files": nil])
case .error(let error): case .error(let error):
Logger.error("failed to pick file: \(error)")
invoke.reject(error) invoke.reject(error)
} }
} }
@ -153,6 +161,7 @@ class DialogPlugin: Plugin {
case .cancelled: case .cancelled:
invoke.resolve(["file": nil]) invoke.resolve(["file": nil])
case .error(let error): case .error(let error):
Logger.error("failed to pick file to save: \(error)")
invoke.reject(error) invoke.reject(error)
} }
} }
@ -192,6 +201,27 @@ class DialogPlugin: Plugin {
self.onFilePickerResult?(event) self.onFilePickerResult?(event)
} }
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)
}
}
@objc public func showMessageDialog(_ invoke: Invoke) throws { @objc public func showMessageDialog(_ invoke: Invoke) throws {
let manager = self.manager let manager = self.manager
let args = try invoke.parseArgs(MessageDialogOptions.self) let args = try invoke.parseArgs(MessageDialogOptions.self)
@ -206,8 +236,6 @@ class DialogPlugin: Plugin {
UIAlertAction( UIAlertAction(
title: cancelButtonLabel, style: UIAlertAction.Style.default, title: cancelButtonLabel, style: UIAlertAction.Style.default,
handler: { (_) -> Void in handler: { (_) -> Void in
Logger.error("cancel")
invoke.resolve([ invoke.resolve([
"value": false, "value": false,
"cancelled": false, "cancelled": false,
@ -221,8 +249,6 @@ class DialogPlugin: Plugin {
UIAlertAction( UIAlertAction(
title: okButtonLabel, style: UIAlertAction.Style.default, title: okButtonLabel, style: UIAlertAction.Style.default,
handler: { (_) -> Void in handler: { (_) -> Void in
Logger.error("ok")
invoke.resolve([ invoke.resolve([
"value": true, "value": true,
"cancelled": false, "cancelled": false,

@ -95,35 +95,11 @@ public class FilePickerController: NSObject {
return nil 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 { extension FilePickerController: UIDocumentPickerDelegate {
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
do { self.plugin.onFilePickerEvent(.selected(urls))
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) { public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
@ -148,12 +124,7 @@ extension FilePickerController: UIImagePickerControllerDelegate, UINavigationCon
public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
dismissViewController(picker) { dismissViewController(picker) {
if let url = info[.mediaURL] as? URL { if let url = info[.mediaURL] as? URL {
do { self.plugin.onFilePickerEvent(.selected([url]))
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 { } else {
self.plugin.onFilePickerEvent(.cancelled) self.plugin.onFilePickerEvent(.cancelled)
} }
@ -169,7 +140,7 @@ extension FilePickerController: PHPickerViewControllerDelegate {
self.plugin.onFilePickerEvent(.cancelled) self.plugin.onFilePickerEvent(.cancelled)
return return
} }
var temporaryUrls: [URL] = [] var urls: [URL] = []
var errorMessage: String? var errorMessage: String?
let dispatchGroup = DispatchGroup() let dispatchGroup = DispatchGroup()
for result in results { for result in results {
@ -190,12 +161,7 @@ extension FilePickerController: PHPickerViewControllerDelegate {
errorMessage = "Unknown error" errorMessage = "Unknown error"
return return
} }
do { urls.append(url)
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) { } else if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
dispatchGroup.enter() dispatchGroup.enter()
@ -211,12 +177,7 @@ extension FilePickerController: PHPickerViewControllerDelegate {
errorMessage = "Unknown error" errorMessage = "Unknown error"
return return
} }
do { urls.append(url)
let temporaryUrl = try self.saveTemporaryFile(url)
temporaryUrls.append(temporaryUrl)
} catch {
errorMessage = "Failed to create a temporary copy of the file"
}
}) })
} else { } else {
errorMessage = "Unsupported file type identifier" errorMessage = "Unsupported file type identifier"
@ -227,7 +188,7 @@ extension FilePickerController: PHPickerViewControllerDelegate {
self.plugin.onFilePickerEvent(.error(errorMessage)) self.plugin.onFilePickerEvent(.error(errorMessage))
return return
} }
self.plugin.onFilePickerEvent(.selected(temporaryUrls)) self.plugin.onFilePickerEvent(.selected(urls))
} }
} }
} }

@ -440,6 +440,12 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// Ok(()) /// Ok(())
/// }); /// });
/// ``` /// ```
///
/// ## Platform-specific
///
/// - **iOS**: Returns a copy of the file to bypass [security scoped resource].
///
/// [security scoped resource]: https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc
pub fn pick_file<F: FnOnce(Option<FilePath>) + Send + 'static>(self, f: F) { pub fn pick_file<F: FnOnce(Option<FilePath>) + Send + 'static>(self, f: F) {
pick_file(self, f) pick_file(self, f)
} }
@ -551,6 +557,12 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// Ok(()) /// Ok(())
/// }); /// });
/// ``` /// ```
///
/// ## Platform-specific
///
/// - **iOS**: Returns a [security scoped resource] so you must request access before reading or writing to the file.
///
/// [security scoped resource]: https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc
pub fn save_file<F: FnOnce(Option<FilePath>) + Send + 'static>(self, f: F) { pub fn save_file<F: FnOnce(Option<FilePath>) + Send + 'static>(self, f: F) {
save_file(self, f) save_file(self, f)
} }
@ -573,6 +585,12 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// // the file path is `None` if the user closed the dialog /// // the file path is `None` if the user closed the dialog
/// } /// }
/// ``` /// ```
///
/// ## Platform-specific
///
/// - **iOS**: Returns a copy of the file to bypass [security scoped resource].
///
/// [security scoped resource]: https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc
pub fn blocking_pick_file(self) -> Option<FilePath> { pub fn blocking_pick_file(self) -> Option<FilePath> {
blocking_fn!(self, pick_file) blocking_fn!(self, pick_file)
} }
@ -651,6 +669,12 @@ impl<R: Runtime> FileDialogBuilder<R> {
/// // the file path is `None` if the user closed the dialog /// // the file path is `None` if the user closed the dialog
/// } /// }
/// ``` /// ```
///
/// ## Platform-specific
///
/// - **iOS**: Returns a [security scoped resource] so you must request access before reading or writing to the file.
///
/// [security scoped resource]: https://developer.apple.com/documentation/foundation/nsurl/1417051-startaccessingsecurityscopedreso?language=objc
pub fn blocking_save_file(self) -> Option<FilePath> { pub fn blocking_save_file(self) -> Option<FilePath> {
blocking_fn!(self, save_file) blocking_fn!(self, save_file)
} }

Loading…
Cancel
Save