Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: v2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion plugins/dialog/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

const COMMANDS: &[&str] = &["open", "save", "message", "ask", "confirm"];
const COMMANDS: &[&str] = &[
"open",
"save",
"destroy_path",
"message",
"ask",
"confirm",
];

fn main() {
let result = tauri_plugin::Builder::new(COMMANDS)
Expand Down
63 changes: 53 additions & 10 deletions plugins/dialog/guest-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@

import { invoke } from '@tauri-apps/api/core'

class Path {
public path: string
constructor(path: string) {
this.path = path
}

destroy() {
return invoke('plugin:dialog|destroy_path', { path: this.path })
}

toPath() {
return this.path
}

toString() {
return this.toPath()
}

toJSON() {
return {
path: this.path
}
}
}

/**
* Extension filters for the file dialog.
*
Expand Down Expand Up @@ -100,13 +125,7 @@ interface ConfirmDialogOptions {
cancelLabel?: string
}

type OpenDialogReturn<T extends OpenDialogOptions> = T['directory'] extends true
? T['multiple'] extends true
? string[] | null
: string | null
: T['multiple'] extends true
? string[] | null
: string | null
type OpenDialogReturn<T extends OpenDialogOptions> = T['multiple'] extends true ? Path[] | null : Path | null

/**
* Open a file/directory selection dialog.
Expand Down Expand Up @@ -156,6 +175,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)
*
* @since 2.0.0
Expand All @@ -167,7 +190,17 @@ async function open<T extends OpenDialogOptions>(
Object.freeze(options)
}

return await invoke('plugin:dialog|open', { options })
const path = await invoke<string[] | string | null>('plugin:dialog|open', { options })

if (Array.isArray(path)) {
return path.map((p) => new Path(p))
}

if (!path) {
return null
}

return new Path(path)
}

/**
Expand All @@ -190,16 +223,26 @@ 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.
*
* @since 2.0.0
*/
async function save(options: SaveDialogOptions = {}): Promise<string | null> {
async function save(options: SaveDialogOptions = {}): Promise<Path | null> {
if (typeof options === 'object') {
Object.freeze(options)
}

return await invoke('plugin:dialog|save', { options })
const path = await invoke<string | null>('plugin:dialog|save', { options })

if (!path) {
return null
}

return new Path(path)
}

/**
Expand Down
19 changes: 15 additions & 4 deletions plugins/dialog/ios/Sources/DialogPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ struct SaveFileDialogOptions: Decodable {
var defaultPath: String?
}

struct StopAccessingPathOptions: Decodable {
var path: URL
}

class DialogPlugin: Plugin {

var filePickerController: FilePickerController!
Expand Down Expand Up @@ -74,10 +78,12 @@ class DialogPlugin: Plugin {
onFilePickerResult = { (event: FilePickerEvent) -> Void in
switch event {
case .selected(let urls):
urls.forEach { $0.startAccessingSecurityScopedResource() }
invoke.resolve(["files": urls])
case .cancelled:
invoke.resolve(["files": nil])
case .error(let error):
Logger.error("failed to pick file: \(error)")
invoke.reject(error)
}
}
Expand Down Expand Up @@ -149,10 +155,13 @@ class DialogPlugin: Plugin {
onFilePickerResult = { (event: FilePickerEvent) -> Void in
switch event {
case .selected(let urls):
Logger.info("picked file to save: \(urls.first!)")
urls.first!.startAccessingSecurityScopedResource()
invoke.resolve(["file": urls.first!])
case .cancelled:
invoke.resolve(["file": nil])
case .error(let error):
Logger.error("failed to pick file to save: \(error)")
invoke.reject(error)
}
}
Expand All @@ -168,6 +177,12 @@ class DialogPlugin: Plugin {
}
}

@objc public func stopAccessingPath(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(StopAccessingPathOptions.self)
args.path.stopAccessingSecurityScopedResource()
invoke.resolve()
}

private func presentViewController(_ viewControllerToPresent: UIViewController) {
self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil)
}
Expand Down Expand Up @@ -206,8 +221,6 @@ class DialogPlugin: Plugin {
UIAlertAction(
title: cancelButtonLabel, style: UIAlertAction.Style.default,
handler: { (_) -> Void in
Logger.error("cancel")

invoke.resolve([
"value": false,
"cancelled": false,
Expand All @@ -221,8 +234,6 @@ class DialogPlugin: Plugin {
UIAlertAction(
title: okButtonLabel, style: UIAlertAction.Style.default,
handler: { (_) -> Void in
Logger.error("ok")

invoke.resolve([
"value": true,
"cancelled": false,
Expand Down
53 changes: 7 additions & 46 deletions plugins/dialog/ios/Sources/FilePickerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,35 +95,11 @@ public class FilePickerController: NSObject {
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"))
}
self.plugin.onFilePickerEvent(.selected(urls))
}

public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
Expand All @@ -148,12 +124,7 @@ extension FilePickerController: UIImagePickerControllerDelegate, UINavigationCon
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"))
}
self.plugin.onFilePickerEvent(.selected([url]))
} else {
self.plugin.onFilePickerEvent(.cancelled)
}
Expand All @@ -169,7 +140,7 @@ extension FilePickerController: PHPickerViewControllerDelegate {
self.plugin.onFilePickerEvent(.cancelled)
return
}
var temporaryUrls: [URL] = []
var urls: [URL] = []
var errorMessage: String?
let dispatchGroup = DispatchGroup()
for result in results {
Expand All @@ -190,12 +161,7 @@ extension FilePickerController: PHPickerViewControllerDelegate {
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"
}
urls.append(url)
})
} else if result.itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
dispatchGroup.enter()
Expand All @@ -211,12 +177,7 @@ extension FilePickerController: PHPickerViewControllerDelegate {
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"
}
urls.append(url)
})
} else {
errorMessage = "Unsupported file type identifier"
Expand All @@ -227,7 +188,7 @@ extension FilePickerController: PHPickerViewControllerDelegate {
self.plugin.onFilePickerEvent(.error(errorMessage))
return
}
self.plugin.onFilePickerEvent(.selected(temporaryUrls))
self.plugin.onFilePickerEvent(.selected(urls))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!

"$schema" = "../../schemas/schema.json"

[[permission]]
identifier = "allow-destroy-path"
description = "Enables the destroy_path command without any pre-configured scope."
commands.allow = ["destroy_path"]

[[permission]]
identifier = "deny-destroy-path"
description = "Denies the destroy_path command without any pre-configured scope."
commands.deny = ["destroy_path"]
27 changes: 27 additions & 0 deletions plugins/dialog/permissions/autogenerated/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ All dialog types are enabled.
- `allow-message`
- `allow-save`
- `allow-open`
- `allow-destroy-path`

## Permission Table

Expand Down Expand Up @@ -79,6 +80,32 @@ Denies the confirm command without any pre-configured scope.
<tr>
<td>

`dialog:allow-destroy-path`

</td>
<td>

Enables the destroy_path command without any pre-configured scope.

</td>
</tr>

<tr>
<td>

`dialog:deny-destroy-path`

</td>
<td>

Denies the destroy_path command without any pre-configured scope.

</td>
</tr>

<tr>
<td>

`dialog:allow-message`

</td>
Expand Down
1 change: 1 addition & 0 deletions plugins/dialog/permissions/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ permissions = [
"allow-message",
"allow-save",
"allow-open",
"allow-destroy-path"
]
16 changes: 14 additions & 2 deletions plugins/dialog/permissions/schemas/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,18 @@
"const": "deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
},
{
"description": "Enables the destroy_path command without any pre-configured scope.",
"type": "string",
"const": "allow-destroy-path",
"markdownDescription": "Enables the destroy_path command without any pre-configured scope."
},
{
"description": "Denies the destroy_path command without any pre-configured scope.",
"type": "string",
"const": "deny-destroy-path",
"markdownDescription": "Denies the destroy_path command without any pre-configured scope."
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
Expand Down Expand Up @@ -355,10 +367,10 @@
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`\n- `allow-destroy-path`",
"type": "string",
"const": "default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`\n- `allow-destroy-path`"
}
]
}
Expand Down
5 changes: 5 additions & 0 deletions plugins/dialog/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,11 @@ pub(crate) async fn save<R: Runtime>(
Ok(path.map(|p| p.simplified()))
}

#[command]
pub fn destroy_path(_path: String) -> bool {
true
}

fn message_dialog<R: Runtime>(
#[allow(unused_variables)] window: Window<R>,
dialog: State<'_, Dialog<R>>,
Expand Down
Loading