Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changes/support-file-access-mode-ios.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"dialog": minor
"dialog-js": minor
---

Add `fileAccessMode` option to file picker.
108 changes: 61 additions & 47 deletions examples/api/src/views/Dialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
let filter = null;
let multiple = false;
let directory = false;
let pickerMode = "";
let pickerMode = "document";
let fileAccessMode = "scoped";

function arrayBufferToBase64(buffer, callback) {
var blob = new Blob([buffer], {
Expand Down Expand Up @@ -52,54 +53,60 @@
.catch(onMessage);
}

function openDialog() {
open({
title: "My wonderful open dialog",
defaultPath,
filters: filter
? [
{
name: "Tauri Example",
extensions: filter.split(",").map((f) => f.trim()),
},
]
: [],
multiple,
directory,
pickerMode: pickerMode === "" ? undefined : pickerMode,
})
.then(function (res) {
if (Array.isArray(res)) {
onMessage(res);
} else {
var pathToRead = res;
var isFile = pathToRead.match(/\S+\.\S+$/g);
readFile(pathToRead)
.then(function (response) {
if (isFile) {
if (
pathToRead.includes(".png") ||
pathToRead.includes(".jpg") ||
pathToRead.includes(".jpeg")
) {
arrayBufferToBase64(
new Uint8Array(response),
function (base64) {
var src = "data:image/png;base64," + base64;
insecureRenderHtml('<img src="' + src + '"></img>');
}
);
} else {
onMessage(res);
}
async function openDialog() {
try {
var result = await open({
title: "My wonderful open dialog",
defaultPath,
filters: filter
? [
{
name: "Tauri Example",
extensions: filter.split(",").map((f) => f.trim()),
},
]
: [],
multiple,
directory,
pickerMode,
fileAccessMode,
})

if (Array.isArray(result)) {
onMessage(result);
} else {
var pathToRead = result;
var isFile = pathToRead.match(/\S+\.\S+$/g);

await readFile(pathToRead)
.then(function (res) {
if (isFile) {
if (
pathToRead.includes(".png") ||
pathToRead.includes(".jpg") ||
pathToRead.includes(".jpeg")
) {
arrayBufferToBase64(
new Uint8Array(res),
function (base64) {
var src = "data:image/png;base64," + base64;
insecureRenderHtml('<img src="' + src + '"></img>');
}
);
} else {
onMessage(res);
// Convert byte array to UTF-8 string
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(new Uint8Array(res));
onMessage(text);
}
})
.catch(onMessage);
}
})
.catch(onMessage);
} else {
onMessage(res);
}
})
}
} catch(exception) {
onMessage(exception)
}
}

function saveDialog() {
Expand Down Expand Up @@ -154,6 +161,13 @@
<option value="document">Document</option>
</select>
</div>
<div>
<label for="dialog-file-access-mode">File Access Mode:</label>
<select id="dialog-file-access-mode" bind:value={fileAccessMode}>
<option value="copy">Copy</option>
<option value="scoped">Scoped</option>
</select>
</div>
<br />

<div class="flex flex-wrap flex-col md:flex-row gap-2 children:flex-shrink-0">
Expand Down
35 changes: 35 additions & 0 deletions plugins/dialog/guest-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,31 @@ interface OpenDialogOptions {
* On desktop, this option is ignored.
*/
pickerMode?: PickerMode
/**
* The file access mode of the dialog.
* If not provided, `copy` is used, which matches the behavior of the {@linkcode open} method before the introduction of this option.
*
* **Usage**
* If a file is opened with {@linkcode fileAccessMode: 'copy'}, it will be copied to the app's sandbox.
* This means the file can be read, edited, deleted, copied, or any other operation without any issues, since the file
* now belongs to the app.
* This also means that the caller has responsibility of deleting the file if this file is not meant to be retained
* in the app sandbox.
*
* If a file is opened with {@linkcode fileAccessMode: 'scoped'}, the file will remain in its original location
* and security-scoped access will be automatically managed by the system.
*
* **Note**
* 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()).
*
* Why only document pickers, and not image or video pickers?
* 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.
* These are meant to be ephemeral (only available within the callback of the picker), and are not accessible outside of the callback.
* 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.
* This means there is no provision for using `scoped` mode with image or video pickers.
* If an image or video picker is used, `copy` is always used.
*/
fileAccessMode?: FileAccessMode
}

/**
Expand Down Expand Up @@ -111,6 +136,16 @@ interface SaveDialogOptions {
*/
export type PickerMode = 'document' | 'media' | 'image' | 'video'

/**
* The file access mode of the dialog.
*
* - `copy`: copy/move the picked file to the app sandbox; no scoped access required.
* - `scoped`: keep file in place; security-scoped access is automatically managed.
*
* **Note:** This option is only supported on iOS 14 and above. This parameter is ignored on iOS 13 and below.
*/
export type FileAccessMode = 'copy' | 'scoped'

/**
* Default buttons for a message dialog.
*
Expand Down
39 changes: 26 additions & 13 deletions plugins/dialog/ios/Sources/DialogPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ struct FilePickerOptions: Decodable {
var filters: [Filter]?
var defaultPath: String?
var pickerMode: PickerMode?
var fileAccessMode: FileAccessMode?
}

struct SaveFileDialogOptions: Decodable {
var fileName: String?
var defaultPath: String?
}

enum FileAccessMode: String, Decodable {
case copy
case scoped
}

enum PickerMode: String, Decodable {
case document
case media
Expand All @@ -56,6 +62,7 @@ class DialogPlugin: Plugin {
override init() {
super.init()
filePickerController = FilePickerController(self)

}

@objc public func showFilePicker(_ invoke: Invoke) throws {
Expand All @@ -70,12 +77,13 @@ class DialogPlugin: Plugin {
case .error(let error):
invoke.reject(error)
}
}
}

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

let mimeKinds = Set(parsedTypes.compactMap { $0.preferredMIMEType?.components(separatedBy: "/")[0] })
let mimeKinds = Set(
parsedTypes.compactMap { $0.preferredMIMEType?.components(separatedBy: "/")[0] })
let filtersIncludeImage = mimeKinds.contains("image")
let filtersIncludeVideo = mimeKinds.contains("video")
let filtersIncludeNonMedia = mimeKinds.contains(where: { $0 != "image" && $0 != "video" })
Expand All @@ -85,7 +93,8 @@ class DialogPlugin: Plugin {
if args.pickerMode == .media
|| args.pickerMode == .image
|| args.pickerMode == .video
|| (!filtersIncludeNonMedia && (filtersIncludeImage || filtersIncludeVideo)) {
|| (!filtersIncludeNonMedia && (filtersIncludeImage || filtersIncludeVideo))
{
DispatchQueue.main.async {
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
configuration.selectionLimit = (args.multiple ?? false) ? 0 : 1
Expand All @@ -107,8 +116,10 @@ class DialogPlugin: Plugin {
DispatchQueue.main.async {
// The UTType.item is the catch-all, allowing for any file type to be selected.
let contentTypes = parsedTypes.isEmpty ? [UTType.item] : parsedTypes
let picker: UIDocumentPickerViewController = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes, asCopy: true)

let picker: UIDocumentPickerViewController = UIDocumentPickerViewController(
forOpeningContentTypes: contentTypes,
asCopy: args.fileAccessMode == .scoped ? false : true)

if let defaultPath = args.defaultPath {
picker.directoryURL = URL(string: defaultPath)
}
Expand Down Expand Up @@ -181,7 +192,7 @@ class DialogPlugin: Plugin {
}
}
}

return parsedTypes
}

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

if filtersIncludeImage && !filtersIncludeVideo {
picker.sourceType = .photoLibrary
}
if filtersIncludeImage && !filtersIncludeVideo {
picker.sourceType = .photoLibrary
}

picker.modalPresentationStyle = .fullScreen
self.presentViewController(picker)
picker.modalPresentationStyle = .fullScreen
self.presentViewController(picker)
}
} else {
let documentTypes = parsedTypes.isEmpty ? ["public.data"] : parsedTypes
Expand All @@ -234,7 +245,8 @@ class DialogPlugin: Plugin {
for filter in filters {
for ext in filter.extensions ?? [] {
guard
let utType: String = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, ext as CFString, nil)?.takeRetainedValue() as String?
let utType: String = UTTypeCreatePreferredIdentifierForTag(
kUTTagClassMIMEType, ext as CFString, nil)?.takeRetainedValue() as String?
else {
continue
}
Expand Down Expand Up @@ -292,6 +304,7 @@ class DialogPlugin: Plugin {
manager.viewController?.present(alert, animated: true, completion: nil)
}
}

}

@_cdecl("init_plugin_dialog")
Expand Down
26 changes: 23 additions & 3 deletions plugins/dialog/ios/Sources/FilePickerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,33 @@ public class FilePickerController: NSObject {
return nil
}
}


/// ## In which cases do we need to save a copy of a file selected by a user to the app sandbox?
/// In short, only when the file is **not** selected using UIDocumentPickerDelegate.
/// For the rest of the cases, we need to write a copy of the file to the app sandbox.
///
/// For PHPicker (used for photos and videos), `NSItemProvider.loadFileRepresentation` returns a temporary file URL that is deleted after the completion handler.
/// The recommendation is to [Persist](https://developer.apple.com/documentation/foundation/nsitemprovider/2888338-loadfilerepresentation) the file by moving/copying
/// it to your app’s directory within the completion handler.
///
/// If available, `loadInPlaceFileRepresentation` can open a file in place; Photos assets typically do not support true in-place access,
/// so fall back to persisting a local file.
/// Ref: https://developer.apple.com/documentation/foundation/nsitemprovider/2888335-loadinplacefilerepresentation
///
/// For UIDocumentPicker, prefer "open in place" and avoid copying when possible.
/// Ref: https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller
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
}
Expand All @@ -119,8 +136,7 @@ public class FilePickerController: NSObject {
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))
self.plugin.onFilePickerEvent(.selected(urls))
} catch {
self.plugin.onFilePickerEvent(.error("Failed to create a temporary copy of the file"))
}
Expand Down Expand Up @@ -191,6 +207,8 @@ extension FilePickerController: PHPickerViewControllerDelegate {
return
}
do {
// 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.
// 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`.
let temporaryUrl = try self.saveTemporaryFile(url)
temporaryUrls.append(temporaryUrl)
} catch {
Expand All @@ -212,6 +230,8 @@ extension FilePickerController: PHPickerViewControllerDelegate {
return
}
do {
// 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.
// 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`.
let temporaryUrl = try self.saveTemporaryFile(url)
temporaryUrls.append(temporaryUrl)
} catch {
Expand Down
12 changes: 10 additions & 2 deletions plugins/dialog/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ use tauri::{command, Manager, Runtime, State, Window};
use tauri_plugin_fs::FsExt;

use crate::{
Dialog, FileDialogBuilder, FilePath, MessageDialogBuilder, MessageDialogButtons,
MessageDialogKind, MessageDialogResult, PickerMode, Result, CANCEL, NO, OK, YES,
Dialog, FileAccessMode, FileDialogBuilder, FilePath, MessageDialogBuilder,
MessageDialogButtons, MessageDialogKind, MessageDialogResult, PickerMode, Result, CANCEL, NO,
OK, YES,
};

#[derive(Serialize)]
Expand Down Expand Up @@ -63,6 +64,10 @@ pub struct OpenDialogOptions {
#[serde(default)]
#[cfg_attr(mobile, allow(dead_code))]
picker_mode: Option<PickerMode>,
/// The file access mode of the dialog.
#[serde(default)]
#[cfg_attr(mobile, allow(dead_code))]
file_access_mode: Option<FileAccessMode>,
}

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

let res = if options.directory {
#[cfg(desktop)]
Expand Down
Loading
Loading