Skip to content

Commit d7a0bb3

Browse files
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>
1 parent f3d75f7 commit d7a0bb3

8 files changed

Lines changed: 182 additions & 67 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/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: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,19 @@ struct FilePickerOptions: Decodable {
3434
var filters: [Filter]?
3535
var defaultPath: String?
3636
var pickerMode: PickerMode?
37+
var fileAccessMode: FileAccessMode?
3738
}
3839

3940
struct SaveFileDialogOptions: Decodable {
4041
var fileName: String?
4142
var defaultPath: String?
4243
}
4344

45+
enum FileAccessMode: String, Decodable {
46+
case copy
47+
case scoped
48+
}
49+
4450
enum PickerMode: String, Decodable {
4551
case document
4652
case media
@@ -56,6 +62,7 @@ class DialogPlugin: Plugin {
5662
override init() {
5763
super.init()
5864
filePickerController = FilePickerController(self)
65+
5966
}
6067

6168
@objc public func showFilePicker(_ invoke: Invoke) throws {
@@ -70,12 +77,13 @@ class DialogPlugin: Plugin {
7077
case .error(let error):
7178
invoke.reject(error)
7279
}
73-
}
80+
}
7481

7582
if #available(iOS 14, *) {
7683
let parsedTypes = parseFiltersOption(args.filters ?? [])
7784

78-
let mimeKinds = Set(parsedTypes.compactMap { $0.preferredMIMEType?.components(separatedBy: "/")[0] })
85+
let mimeKinds = Set(
86+
parsedTypes.compactMap { $0.preferredMIMEType?.components(separatedBy: "/")[0] })
7987
let filtersIncludeImage = mimeKinds.contains("image")
8088
let filtersIncludeVideo = mimeKinds.contains("video")
8189
let filtersIncludeNonMedia = mimeKinds.contains(where: { $0 != "image" && $0 != "video" })
@@ -85,7 +93,8 @@ class DialogPlugin: Plugin {
8593
if args.pickerMode == .media
8694
|| args.pickerMode == .image
8795
|| args.pickerMode == .video
88-
|| (!filtersIncludeNonMedia && (filtersIncludeImage || filtersIncludeVideo)) {
96+
|| (!filtersIncludeNonMedia && (filtersIncludeImage || filtersIncludeVideo))
97+
{
8998
DispatchQueue.main.async {
9099
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
91100
configuration.selectionLimit = (args.multiple ?? false) ? 0 : 1
@@ -107,8 +116,10 @@ class DialogPlugin: Plugin {
107116
DispatchQueue.main.async {
108117
// The UTType.item is the catch-all, allowing for any file type to be selected.
109118
let contentTypes = parsedTypes.isEmpty ? [UTType.item] : parsedTypes
110-
let picker: UIDocumentPickerViewController = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes, asCopy: true)
111-
119+
let picker: UIDocumentPickerViewController = UIDocumentPickerViewController(
120+
forOpeningContentTypes: contentTypes,
121+
asCopy: args.fileAccessMode == .scoped ? false : true)
122+
112123
if let defaultPath = args.defaultPath {
113124
picker.directoryURL = URL(string: defaultPath)
114125
}
@@ -181,7 +192,7 @@ class DialogPlugin: Plugin {
181192
}
182193
}
183194
}
184-
195+
185196
return parsedTypes
186197
}
187198

@@ -203,14 +214,14 @@ class DialogPlugin: Plugin {
203214
if !filtersIncludeNonMedia && (filtersIncludeImage || filtersIncludeVideo) {
204215
DispatchQueue.main.async {
205216
let picker = UIImagePickerController()
206-
picker.delegate = self.filePickerController
217+
picker.delegate = self.filePickerController
207218

208-
if filtersIncludeImage && !filtersIncludeVideo {
209-
picker.sourceType = .photoLibrary
210-
}
219+
if filtersIncludeImage && !filtersIncludeVideo {
220+
picker.sourceType = .photoLibrary
221+
}
211222

212-
picker.modalPresentationStyle = .fullScreen
213-
self.presentViewController(picker)
223+
picker.modalPresentationStyle = .fullScreen
224+
self.presentViewController(picker)
214225
}
215226
} else {
216227
let documentTypes = parsedTypes.isEmpty ? ["public.data"] : parsedTypes
@@ -234,7 +245,8 @@ class DialogPlugin: Plugin {
234245
for filter in filters {
235246
for ext in filter.extensions ?? [] {
236247
guard
237-
let utType: String = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, ext as CFString, nil)?.takeRetainedValue() as String?
248+
let utType: String = UTTypeCreatePreferredIdentifierForTag(
249+
kUTTagClassMIMEType, ext as CFString, nil)?.takeRetainedValue() as String?
238250
else {
239251
continue
240252
}
@@ -292,6 +304,7 @@ class DialogPlugin: Plugin {
292304
manager.viewController?.present(alert, animated: true, completion: nil)
293305
}
294306
}
307+
295308
}
296309

297310
@_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: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use tauri::{command, Manager, Runtime, State, Window};
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)]

0 commit comments

Comments
 (0)