Skip to content

Commit 03e6dad

Browse files
committed
feat(dialog) - Support fileAccessMode for open dialog (tauri-apps#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 tauri-apps#3185 to see how these scoped files will be accessible using security scoping.
1 parent 4a2ecb6 commit 03e6dad

10 files changed

Lines changed: 177 additions & 59 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"dialog": minor
3+
"dialog-js": minor
4+
---
5+
6+
Add `fileAccessMode` option to file picker.

examples/api/src/views/Dialog.svelte

Lines changed: 61 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
let filter = null;
99
let multiple = false;
1010
let directory = false;
11-
let pickerMode = "";
11+
let pickerMode = "document";
12+
let fileAccessMode = "scoped";
1213
1314
function arrayBufferToBase64(buffer, callback) {
1415
var blob = new Blob([buffer], {
@@ -52,54 +53,60 @@
5253
.catch(onMessage);
5354
}
5455
55-
function openDialog() {
56-
open({
57-
title: "My wonderful open dialog",
58-
defaultPath,
59-
filters: filter
60-
? [
61-
{
62-
name: "Tauri Example",
63-
extensions: filter.split(",").map((f) => f.trim()),
64-
},
65-
]
66-
: [],
67-
multiple,
68-
directory,
69-
pickerMode: pickerMode === "" ? undefined : pickerMode,
70-
})
71-
.then(function (res) {
72-
if (Array.isArray(res)) {
73-
onMessage(res);
74-
} else {
75-
var pathToRead = res;
76-
var isFile = pathToRead.match(/\S+\.\S+$/g);
77-
readFile(pathToRead)
78-
.then(function (response) {
79-
if (isFile) {
80-
if (
81-
pathToRead.includes(".png") ||
82-
pathToRead.includes(".jpg") ||
83-
pathToRead.includes(".jpeg")
84-
) {
85-
arrayBufferToBase64(
86-
new Uint8Array(response),
87-
function (base64) {
88-
var src = "data:image/png;base64," + base64;
89-
insecureRenderHtml('<img src="' + src + '"></img>');
90-
}
91-
);
92-
} else {
93-
onMessage(res);
94-
}
56+
async function openDialog() {
57+
try {
58+
var result = await open({
59+
title: "My wonderful open dialog",
60+
defaultPath,
61+
filters: filter
62+
? [
63+
{
64+
name: "Tauri Example",
65+
extensions: filter.split(",").map((f) => f.trim()),
66+
},
67+
]
68+
: [],
69+
multiple,
70+
directory,
71+
pickerMode,
72+
fileAccessMode,
73+
})
74+
75+
if (Array.isArray(result)) {
76+
onMessage(result);
77+
} else {
78+
var pathToRead = result;
79+
var isFile = pathToRead.match(/\S+\.\S+$/g);
80+
81+
await readFile(pathToRead)
82+
.then(function (res) {
83+
if (isFile) {
84+
if (
85+
pathToRead.includes(".png") ||
86+
pathToRead.includes(".jpg") ||
87+
pathToRead.includes(".jpeg")
88+
) {
89+
arrayBufferToBase64(
90+
new Uint8Array(res),
91+
function (base64) {
92+
var src = "data:image/png;base64," + base64;
93+
insecureRenderHtml('<img src="' + src + '"></img>');
94+
}
95+
);
9596
} else {
96-
onMessage(res);
97+
// Convert byte array to UTF-8 string
98+
const decoder = new TextDecoder('utf-8');
99+
const text = decoder.decode(new Uint8Array(res));
100+
onMessage(text);
97101
}
98-
})
99-
.catch(onMessage);
100-
}
101-
})
102-
.catch(onMessage);
102+
} else {
103+
onMessage(res);
104+
}
105+
})
106+
}
107+
} catch(exception) {
108+
onMessage(exception)
109+
}
103110
}
104111
105112
function saveDialog() {
@@ -154,6 +161,13 @@
154161
<option value="document">Document</option>
155162
</select>
156163
</div>
164+
<div>
165+
<label for="dialog-file-access-mode">File Access Mode:</label>
166+
<select id="dialog-file-access-mode" bind:value={fileAccessMode}>
167+
<option value="copy">Copy</option>
168+
<option value="scoped">Scoped</option>
169+
</select>
170+
</div>
157171
<br />
158172

159173
<div class="flex flex-wrap flex-col md:flex-row gap-2 children:flex-shrink-0">

plugins/dialog/build.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
// SPDX-License-Identifier: Apache-2.0
33
// SPDX-License-Identifier: MIT
44

5-
const COMMANDS: &[&str] = &["open", "save", "message", "ask", "confirm"];
5+
const COMMANDS: &[&str] = &[
6+
"open",
7+
"save",
8+
"message",
9+
"ask",
10+
"confirm",
11+
];
612

713
fn main() {
814
let result = tauri_plugin::Builder::new(COMMANDS)

plugins/dialog/guest-js/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,31 @@ interface OpenDialogOptions {
7676
* On desktop, this option is ignored.
7777
*/
7878
pickerMode?: PickerMode
79+
/**
80+
* The file access mode of the dialog.
81+
* If not provided, `copy` is used, which matches the behavior of the {@linkcode open} method before the introduction of this option.
82+
*
83+
* **Usage**
84+
* If a file is opened with {@linkcode fileAccessMode: 'copy'}, it will be copied to the app's sandbox.
85+
* This means the file can be read, edited, deleted, copied, or any other operation without any issues, since the file
86+
* now belongs to the app.
87+
* This also means that the caller has responsibility of deleting the file if this file is not meant to be retained
88+
* in the app sandbox.
89+
*
90+
* If a file is opened with {@linkcode fileAccessMode: 'scoped'}, the file will remain in its original location
91+
* and security-scoped access will be automatically managed by the system.
92+
*
93+
* **Note**
94+
* This is specifically meant for document pickers on iOS or MacOS, in conjunction with [security scoped resources](https://developer.apple.com/documentation/foundation/nsurl/startaccessingsecurityscopedresource()).
95+
*
96+
* Why only document pickers, and not image or video pickers?
97+
* The image and video pickers on iOS behave differently from the document pickers, and return [NSItemProvider](https://developer.apple.com/documentation/foundation/nsitemprovider) objects instead of file URLs.
98+
* These are meant to be ephemeral (only available within the callback of the picker), and are not accessible outside of the callback.
99+
* So for image and video pickers, the only way to access the file is to copy it to the app's sandbox, and this is the URL that is returned from this API.
100+
* This means there is no provision for using `scoped` mode with image or video pickers.
101+
* If an image or video picker is used, `copy` is always used.
102+
*/
103+
fileAccessMode?: FileAccessMode
79104
}
80105

81106
/**
@@ -111,6 +136,16 @@ interface SaveDialogOptions {
111136
*/
112137
export type PickerMode = 'document' | 'media' | 'image' | 'video'
113138

139+
/**
140+
* The file access mode of the dialog.
141+
*
142+
* - `copy`: copy/move the picked file to the app sandbox; no scoped access required.
143+
* - `scoped`: keep file in place; security-scoped access is automatically managed.
144+
*
145+
* **Note:** This option is only supported on iOS 14 and above. This parameter is ignored on iOS 13 and below.
146+
*/
147+
export type FileAccessMode = 'copy' | 'scoped'
148+
114149
/**
115150
* Default buttons for a message dialog.
116151
*

plugins/dialog/ios/Sources/DialogPlugin.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ struct FilePickerOptions: Decodable {
3434
var filters: [Filter]?
3535
var defaultPath: String?
3636
var pickerMode: PickerMode?
37+
var fileAccessMode: String?
3738
}
3839

3940
struct SaveFileDialogOptions: Decodable {
@@ -52,12 +53,14 @@ class DialogPlugin: Plugin {
5253

5354
var filePickerController: FilePickerController!
5455
var onFilePickerResult: ((FilePickerEvent) -> Void)? = nil
56+
5557

5658
override init() {
5759
super.init()
5860
filePickerController = FilePickerController(self)
61+
5962
}
60-
63+
6164
@objc public func showFilePicker(_ invoke: Invoke) throws {
6265
let args = try invoke.parseArgs(FilePickerOptions.self)
6366

@@ -70,7 +73,7 @@ class DialogPlugin: Plugin {
7073
case .error(let error):
7174
invoke.reject(error)
7275
}
73-
}
76+
}
7477

7578
if #available(iOS 14, *) {
7679
let parsedTypes = parseFiltersOption(args.filters ?? [])
@@ -107,7 +110,9 @@ class DialogPlugin: Plugin {
107110
DispatchQueue.main.async {
108111
// The UTType.item is the catch-all, allowing for any file type to be selected.
109112
let contentTypes = parsedTypes.isEmpty ? [UTType.item] : parsedTypes
110-
let picker: UIDocumentPickerViewController = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes, asCopy: true)
113+
let picker: UIDocumentPickerViewController = UIDocumentPickerViewController(
114+
forOpeningContentTypes: contentTypes,
115+
asCopy: args.fileAccessMode == "scoped" ? false : true)
111116

112117
if let defaultPath = args.defaultPath {
113118
picker.directoryURL = URL(string: defaultPath)
@@ -292,6 +297,8 @@ class DialogPlugin: Plugin {
292297
manager.viewController?.present(alert, animated: true, completion: nil)
293298
}
294299
}
300+
301+
295302
}
296303

297304
@_cdecl("init_plugin_dialog")

plugins/dialog/ios/Sources/FilePickerController.swift

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,33 @@ public class FilePickerController: NSObject {
9595
return nil
9696
}
9797
}
98-
98+
99+
/// ## In which cases do we need to save a copy of a file selected by a user to the app sandbox?
100+
/// In short, only when the file is **not** selected using UIDocumentPickerDelegate.
101+
/// For the rest of the cases, we need to write a copy of the file to the app sandbox.
102+
///
103+
/// For PHPicker (used for photos and videos), `NSItemProvider.loadFileRepresentation` returns a temporary file URL that is deleted after the completion handler.
104+
/// The recommendation is to [Persist](https://developer.apple.com/documentation/foundation/nsitemprovider/2888338-loadfilerepresentation) the file by moving/copying
105+
/// it to your app’s directory within the completion handler.
106+
///
107+
/// If available, `loadInPlaceFileRepresentation` can open a file in place; Photos assets typically do not support true in-place access,
108+
/// so fall back to persisting a local file.
109+
/// Ref: https://developer.apple.com/documentation/foundation/nsitemprovider/2888335-loadinplacefilerepresentation
110+
///
111+
/// For UIDocumentPicker, prefer "open in place" and avoid copying when possible.
112+
/// Ref: https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller
99113
private func saveTemporaryFile(_ sourceUrl: URL) throws -> URL {
114+
100115
var directory = URL(fileURLWithPath: NSTemporaryDirectory())
101116
if let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
102117
directory = cachesDirectory
103118
}
119+
104120
let targetUrl = directory.appendingPathComponent(sourceUrl.lastPathComponent)
105121
do {
106122
try deleteFile(targetUrl)
107123
}
124+
108125
try FileManager.default.copyItem(at: sourceUrl, to: targetUrl)
109126
return targetUrl
110127
}
@@ -119,8 +136,7 @@ public class FilePickerController: NSObject {
119136
extension FilePickerController: UIDocumentPickerDelegate {
120137
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
121138
do {
122-
let temporaryUrls = try urls.map { try saveTemporaryFile($0) }
123-
self.plugin.onFilePickerEvent(.selected(temporaryUrls))
139+
self.plugin.onFilePickerEvent(.selected(urls))
124140
} catch {
125141
self.plugin.onFilePickerEvent(.error("Failed to create a temporary copy of the file"))
126142
}
@@ -191,6 +207,8 @@ extension FilePickerController: PHPickerViewControllerDelegate {
191207
return
192208
}
193209
do {
210+
// 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.
211+
// 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`.
194212
let temporaryUrl = try self.saveTemporaryFile(url)
195213
temporaryUrls.append(temporaryUrl)
196214
} catch {
@@ -212,6 +230,8 @@ extension FilePickerController: PHPickerViewControllerDelegate {
212230
return
213231
}
214232
do {
233+
// 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.
234+
// 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`.
215235
let temporaryUrl = try self.saveTemporaryFile(url)
216236
temporaryUrls.append(temporaryUrl)
217237
} catch {

plugins/dialog/src/commands.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
use std::path::PathBuf;
66

77
use serde::{Deserialize, Serialize};
8-
use tauri::{command, Manager, Runtime, State, Window};
8+
use tauri::{AppHandle, Manager, Runtime, State, Window, command};
99
use tauri_plugin_fs::FsExt;
1010

1111
use crate::{
12-
Dialog, FileDialogBuilder, FilePath, MessageDialogBuilder, MessageDialogButtons,
13-
MessageDialogKind, MessageDialogResult, PickerMode, Result, CANCEL, NO, OK, YES,
12+
Dialog, FileAccessMode, FileDialogBuilder, FilePath, MessageDialogBuilder,
13+
MessageDialogButtons, MessageDialogKind, MessageDialogResult, PickerMode, Result, CANCEL, NO,
14+
OK, YES,
1415
};
1516

1617
#[derive(Serialize)]
@@ -63,6 +64,10 @@ pub struct OpenDialogOptions {
6364
#[serde(default)]
6465
#[cfg_attr(mobile, allow(dead_code))]
6566
picker_mode: Option<PickerMode>,
67+
/// The file access mode of the dialog.
68+
#[serde(default)]
69+
#[cfg_attr(mobile, allow(dead_code))]
70+
file_access_mode: Option<FileAccessMode>,
6671
}
6772

6873
/// The options for the save dialog API.
@@ -141,6 +146,9 @@ pub(crate) async fn open<R: Runtime>(
141146
let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect();
142147
dialog_builder = dialog_builder.add_filter(filter.name, &extensions);
143148
}
149+
if let Some(file_access_mode) = options.file_access_mode {
150+
dialog_builder = dialog_builder.set_file_access_mode(file_access_mode);
151+
}
144152

145153
let res = if options.directory {
146154
#[cfg(desktop)]
@@ -359,3 +367,4 @@ pub(crate) async fn confirm<R: Runtime>(
359367

360368
Ok(dialog.blocking_show())
361369
}
370+

0 commit comments

Comments
 (0)