Skip to content
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

feat(dialog): implement save API on iOS #1707

Merged
merged 2 commits into from
Sep 3, 2024
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
5 changes: 5 additions & 0 deletions .changes/ios-dialog-save.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"dialog": patch:feat
---

Implement `save` API on iOS.
79 changes: 64 additions & 15 deletions plugins/dialog/ios/Sources/DialogPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ enum FilePickerEvent {
}

struct MessageDialogOptions: Decodable {
let title: String?
var title: String?
let message: String
let okButtonLabel: String?
let cancelButtonLabel: String?
var okButtonLabel: String?
var cancelButtonLabel: String?
}

struct Filter: Decodable {
Expand All @@ -30,13 +30,18 @@ struct Filter: Decodable {
struct FilePickerOptions: Decodable {
var multiple: Bool?
var filters: [Filter]?
var defaultPath: String?
}

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

class DialogPlugin: Plugin {

var filePickerController: FilePickerController!
var pendingInvoke: Invoke? = nil
var pendingInvokeArgs: FilePickerOptions? = nil
var onFilePickerResult: ((FilePickerEvent) -> Void)? = nil

override init() {
super.init()
Expand Down Expand Up @@ -66,8 +71,16 @@ class DialogPlugin: Plugin {
}
}

pendingInvoke = invoke
pendingInvokeArgs = args
onFilePickerResult = { (event: FilePickerEvent) -> Void in
switch event {
case .selected(let urls):
invoke.resolve(["files": urls])
case .cancelled:
invoke.resolve(["files": nil])
case .error(let error):
invoke.reject(error)
}
}

if uniqueMimeType == true || isMedia {
DispatchQueue.main.async {
Expand Down Expand Up @@ -104,6 +117,9 @@ class DialogPlugin: Plugin {
let documentTypes = parsedTypes.isEmpty ? ["public.data"] : parsedTypes
DispatchQueue.main.async {
let picker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import)
if let defaultPath = args.defaultPath {
picker.directoryURL = URL(string: defaultPath)
}
picker.delegate = self.filePickerController
picker.allowsMultipleSelection = args.multiple ?? false
picker.modalPresentationStyle = .fullScreen
Expand All @@ -112,6 +128,46 @@ class DialogPlugin: Plugin {
}
}

@objc public func saveFileDialog(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(SaveFileDialogOptions.self)

// The Tauri save dialog API prompts the user to select a path where a file must be saved
// This behavior maps to the operating system interfaces on all platforms except iOS,
// which only exposes a mechanism to "move file `srcPath` to a location defined by the user"
//
// so we have to work around it by creating an empty file matching the requested `args.fileName`,
// and using it as `srcPath` for the operation - returning the path the user selected
// so the app dev can write to it later - matching cross platform behavior as mentioned above
let fileManager = FileManager.default
let srcFolder = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let srcPath = srcFolder.appendingPathComponent(args.fileName ?? "file")
if !fileManager.fileExists(atPath: srcPath.path) {
// the file contents must be actually provided by the tauri dev after the path is resolved by the save API
try "".write(to: srcPath, atomically: true, encoding: .utf8)
}

onFilePickerResult = { (event: FilePickerEvent) -> Void in
switch event {
case .selected(let urls):
invoke.resolve(["file": urls.first!])
case .cancelled:
invoke.resolve(["file": nil])
case .error(let error):
invoke.reject(error)
}
}

DispatchQueue.main.async {
let picker = UIDocumentPickerViewController(url: srcPath, in: .exportToService)
if let defaultPath = args.defaultPath {
picker.directoryURL = URL(string: defaultPath)
}
picker.delegate = self.filePickerController
picker.modalPresentationStyle = .fullScreen
self.presentViewController(picker)
}
}

private func presentViewController(_ viewControllerToPresent: UIViewController) {
self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil)
}
Expand All @@ -133,14 +189,7 @@ class DialogPlugin: Plugin {
}

public func onFilePickerEvent(_ event: FilePickerEvent) {
switch event {
case .selected(let urls):
pendingInvoke?.resolve(["files": urls])
case .cancelled:
pendingInvoke?.resolve(["files": nil])
case .error(let error):
pendingInvoke?.reject(error)
}
self.onFilePickerResult?(event)
}

@objc public func showMessageDialog(_ invoke: Invoke) throws {
Expand Down
55 changes: 25 additions & 30 deletions plugins/dialog/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,41 +197,36 @@ pub(crate) async fn save<R: Runtime>(
dialog: State<'_, Dialog<R>>,
options: SaveDialogOptions,
) -> Result<Option<FilePath>> {
#[cfg(target_os = "ios")]
return Err(crate::Error::FileSaveDialogNotImplemented);
#[cfg(any(desktop, target_os = "android"))]
let mut dialog_builder = dialog.file();
#[cfg(any(windows, target_os = "macos"))]
{
let mut dialog_builder = dialog.file();
#[cfg(any(windows, target_os = "macos"))]
{
dialog_builder = dialog_builder.set_parent(&window);
}
if let Some(title) = options.title {
dialog_builder = dialog_builder.set_title(title);
}
if let Some(default_path) = options.default_path {
dialog_builder = set_default_path(dialog_builder, default_path);
}
if let Some(can) = options.can_create_directories {
dialog_builder = dialog_builder.set_can_create_directories(can);
}
for filter in options.filters {
let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect();
dialog_builder = dialog_builder.add_filter(filter.name, &extensions);
}
dialog_builder = dialog_builder.set_parent(&window);
}
if let Some(title) = options.title {
dialog_builder = dialog_builder.set_title(title);
}
if let Some(default_path) = options.default_path {
dialog_builder = set_default_path(dialog_builder, default_path);
}
if let Some(can) = options.can_create_directories {
dialog_builder = dialog_builder.set_can_create_directories(can);
}
for filter in options.filters {
let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect();
dialog_builder = dialog_builder.add_filter(filter.name, &extensions);
}

let path = dialog_builder.blocking_save_file();
if let Some(p) = &path {
if let Ok(path) = p.path() {
if let Some(s) = window.try_fs_scope() {
s.allow_file(&path);
}
window.state::<tauri::scope::Scopes>().allow_file(&path)?;
let path = dialog_builder.blocking_save_file();
if let Some(p) = &path {
if let Ok(path) = p.path() {
if let Some(s) = window.try_fs_scope() {
s.allow_file(&path);
}
window.state::<tauri::scope::Scopes>().allow_file(&path)?;
}

Ok(path.map(|p| p.simplified()))
}

Ok(path.map(|p| p.simplified()))
}

fn message_dialog<R: Runtime>(
Expand Down
3 changes: 0 additions & 3 deletions plugins/dialog/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@ pub enum Error {
#[cfg(mobile)]
#[error("Folder picker is not implemented on mobile")]
FolderPickerNotImplemented,
#[cfg(target_os = "ios")]
#[error("File save dialog is not implemented on iOS")]
FileSaveDialogNotImplemented,
#[error(transparent)]
Fs(#[from] tauri_plugin_fs::Error),
#[error("URL is not a valid path")]
Expand Down
Loading