Skip to content

Commit

Permalink
feat(dialog): implement save API on iOS (#1707)
Browse files Browse the repository at this point in the history
  • Loading branch information
lucasfernog authored Sep 3, 2024
1 parent ff134a8 commit feb1e93
Show file tree
Hide file tree
Showing 4 changed files with 94 additions and 48 deletions.
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

0 comments on commit feb1e93

Please sign in to comment.