Skip to content

Commit d4c8e4b

Browse files
andrewdewaalonehumandev
authored andcommitted
feat(dialog) - Support startFileAccess and endFileAccess on iOS (#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: 1) A copy of the file needs to be made to the internal app sandbox 2) 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 calling `startAccessingSecurityScopedResource`. This is called by `startFileAccess` in the TS API, and when done accessing the file, `endFileAccess` should be called. 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.
1 parent 26ed789 commit d4c8e4b

File tree

16 files changed

+527
-63
lines changed

16 files changed

+527
-63
lines changed

examples/api/src-tauri/capabilities/base.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
"dialog:allow-save",
2929
"dialog:allow-confirm",
3030
"dialog:allow-message",
31+
"dialog:allow-start-file-access",
32+
"dialog:allow-end-file-access",
3133
{
3234
"identifier": "shell:allow-spawn",
3335
"allow": [

examples/api/src/views/Dialog.svelte

Lines changed: 80 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script>
2-
import { open, save, confirm, message } from "@tauri-apps/plugin-dialog";
2+
import { open, save, confirm, message, startFileAccess, endFileAccess } from "@tauri-apps/plugin-dialog";
33
import { readFile } from "@tauri-apps/plugin-fs";
44
55
export let onMessage;
@@ -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,80 @@
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+
// WHERE TO PICK THIS UP NEXT TIME:
76+
// test out media pickers, and figure out how to make the api better so it's easier to understand if the user needs to call startFileAccess or not
77+
// we can't rely on the fileAccessMode to determine if we need to call startFileAccess or not, because the fileAccessMode is only used for the document dialog,
78+
// and the pickermode may not be set to document.
79+
// so we really need a return value that tells us both the URI and if it exists outside the app sandbox.
80+
if (Array.isArray(result)) {
81+
onMessage(result);
82+
} else {
83+
var pathToRead = result;
84+
var isFile = pathToRead.match(/\S+\.\S+$/g);
85+
86+
let resourceId = null;
87+
if (fileAccessMode === "scoped") {
88+
try {
89+
resourceId = await startFileAccess({ path: pathToRead })
90+
onMessage("started file access for " + pathToRead + " with resourceId " + resourceId)
91+
}
92+
catch (exception) {
93+
onMessage(exception)
94+
}
95+
}
96+
97+
await readFile(pathToRead)
98+
.then(function (res) {
99+
if (isFile) {
100+
if (
101+
pathToRead.includes(".png") ||
102+
pathToRead.includes(".jpg") ||
103+
pathToRead.includes(".jpeg")
104+
) {
105+
arrayBufferToBase64(
106+
new Uint8Array(res),
107+
function (base64) {
108+
var src = "data:image/png;base64," + base64;
109+
insecureRenderHtml('<img src="' + src + '"></img>');
110+
}
111+
);
95112
} else {
96113
onMessage(res);
97114
}
98-
})
99-
.catch(onMessage);
115+
} else {
116+
onMessage(res);
117+
}
118+
})
119+
120+
if (resourceId) {
121+
await endFileAccess({ resourceId })
122+
onMessage("ended file access for " + pathToRead)
123+
} else {
124+
onMessage("No resource to end access for " + pathToRead)
100125
}
101-
})
102-
.catch(onMessage);
126+
}
127+
} catch(exception) {
128+
onMessage(exception)
129+
}
103130
}
104131
105132
function saveDialog() {
@@ -154,6 +181,13 @@
154181
<option value="document">Document</option>
155182
</select>
156183
</div>
184+
<div>
185+
<label for="dialog-file-access-mode">File Access Mode:</label>
186+
<select id="dialog-file-access-mode" bind:value={fileAccessMode}>
187+
<option value="copy">Copy</option>
188+
<option value="scoped">Scoped</option>
189+
</select>
190+
</div>
157191
<br />
158192

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

plugins/dialog/api-iife.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/dialog/build.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
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+
"start_file_access",
12+
"end_file_access",
13+
];
614

715
fn main() {
816
let result = tauri_plugin::Builder::new(COMMANDS)

plugins/dialog/guest-js/index.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,49 @@ interface OpenDialogOptions {
7676
* On desktop, this option is ignored.
7777
*/
7878
pickerMode?: PickerMode
79+
/**
80+
* The file access mode of the dialog.
81+
* 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()).
82+
*
83+
* **Limitations**
84+
* Why only document pickers, and not image or video pickers?
85+
* 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.
86+
* These are meant to be ephemeral (only available within the callback of the picker), and are not accessible outside of the callback.
87+
* 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.
88+
*
89+
* **Usage**
90+
* If a file is opened with {@linkcode fileAccessMode: 'copy'}, it will be copied to the app's sandbox.
91+
* This means the file can be read, edited, deleted, copied, or any other operation without any issues, since the file
92+
* now belongs to the app.
93+
* This also means that the caller has responsibility of deleting the file if this file is not meant to be retained
94+
* in the app sandbox.
95+
*
96+
* If a file is opened with {@linkcode fileAccessMode: 'scoped'}, the caller is required to subsequently call {@linkcode startFileAccess} to
97+
* perform any file operations, and then call {@linkcode endFileAccess} to release the file.
98+
* If this is not done (for example, if one tries to read the file without calling {@linkcode startFileAccess}), an exception
99+
* will be thrown.
100+
*
101+
* ** Note:**
102+
* If this method is called on a URL that is already present within the app sandbox, a null value is returned, but no exception is thrown.
103+
*
104+
* Sample usage:
105+
* ```ts
106+
* const path = await open({ fileAccessMode: 'scoped' });
107+
*
108+
* startFileAccess({ path })
109+
* .then(() => {
110+
* // perform file operations
111+
* })
112+
* .catch(onMessage);
113+
*
114+
* endFileAccess({ path })
115+
* .then(() => {
116+
* // perform file operations
117+
* })
118+
* .catch(onMessage);
119+
* ```
120+
*/
121+
fileAccessMode?: FileAccessMode
79122
}
80123

81124
/**
@@ -111,6 +154,16 @@ interface SaveDialogOptions {
111154
*/
112155
export type PickerMode = 'document' | 'media' | 'image' | 'video'
113156

157+
/**
158+
* The file access mode of the dialog.
159+
*
160+
* - `copy`: copy/move the picked file to the app sandbox; no scoped access required.
161+
* - `scoped`: keep file in place; call `startFileAccess` before I/O and `endFileAccess` after.
162+
*
163+
* **Note:** This option is only supported on iOS 14 and above. This parameter is ignored on iOS 13 and below.
164+
*/
165+
export type FileAccessMode = 'copy' | 'scoped'
166+
114167
/**
115168
* Default buttons for a message dialog.
116169
*
@@ -328,6 +381,34 @@ async function open<T extends OpenDialogOptions>(
328381
return await invoke('plugin:dialog|open', { options })
329382
}
330383

384+
/**
385+
* Start file access for a file.
386+
* This is meant to be used when a file was opened using {@linkcode fileAccessMode: 'scoped'}.
387+
*
388+
* See the documentation on the property {@linkcode fileAccessMode} for more information on when this is needed,
389+
* and expected behavior when using this API.
390+
*
391+
* @param options
392+
* @returns The resource ID of the scoped resource.
393+
*/
394+
async function startFileAccess(options: { path: string }): Promise<string> {
395+
const result = await invoke<{ resourceId: string }>(
396+
'plugin:dialog|start_file_access',
397+
{ options }
398+
)
399+
return result.resourceId
400+
}
401+
402+
/**
403+
* End file access for a file.
404+
* This is meant to be used when a file was opened using {@linkcode fileAccessMode: 'scoped'}.
405+
*
406+
* @param options The resource ID of the scoped resource, which has been provided by {@linkcode startFileAccess}.
407+
*/
408+
async function endFileAccess(options: { resourceId: string }): Promise<void> {
409+
return await invoke('plugin:dialog|end_file_access', { options })
410+
}
411+
331412
/**
332413
* Open a file/directory save dialog.
333414
*
@@ -471,4 +552,4 @@ export type {
471552
ConfirmDialogOptions
472553
}
473554

474-
export { open, save, message, ask, confirm }
555+
export { open, save, message, ask, confirm, startFileAccess, endFileAccess }

0 commit comments

Comments
 (0)