Skip to content

Commit feb1e93

Browse files
authored
feat(dialog): implement save API on iOS (#1707)
1 parent ff134a8 commit feb1e93

File tree

4 files changed

+94
-48
lines changed

4 files changed

+94
-48
lines changed

.changes/ios-dialog-save.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"dialog": patch:feat
3+
---
4+
5+
Implement `save` API on iOS.

plugins/dialog/ios/Sources/DialogPlugin.swift

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ enum FilePickerEvent {
1717
}
1818

1919
struct MessageDialogOptions: Decodable {
20-
let title: String?
20+
var title: String?
2121
let message: String
22-
let okButtonLabel: String?
23-
let cancelButtonLabel: String?
22+
var okButtonLabel: String?
23+
var cancelButtonLabel: String?
2424
}
2525

2626
struct Filter: Decodable {
@@ -30,13 +30,18 @@ struct Filter: Decodable {
3030
struct FilePickerOptions: Decodable {
3131
var multiple: Bool?
3232
var filters: [Filter]?
33+
var defaultPath: String?
34+
}
35+
36+
struct SaveFileDialogOptions: Decodable {
37+
var fileName: String?
38+
var defaultPath: String?
3339
}
3440

3541
class DialogPlugin: Plugin {
3642

3743
var filePickerController: FilePickerController!
38-
var pendingInvoke: Invoke? = nil
39-
var pendingInvokeArgs: FilePickerOptions? = nil
44+
var onFilePickerResult: ((FilePickerEvent) -> Void)? = nil
4045

4146
override init() {
4247
super.init()
@@ -66,8 +71,16 @@ class DialogPlugin: Plugin {
6671
}
6772
}
6873

69-
pendingInvoke = invoke
70-
pendingInvokeArgs = args
74+
onFilePickerResult = { (event: FilePickerEvent) -> Void in
75+
switch event {
76+
case .selected(let urls):
77+
invoke.resolve(["files": urls])
78+
case .cancelled:
79+
invoke.resolve(["files": nil])
80+
case .error(let error):
81+
invoke.reject(error)
82+
}
83+
}
7184

7285
if uniqueMimeType == true || isMedia {
7386
DispatchQueue.main.async {
@@ -104,6 +117,9 @@ class DialogPlugin: Plugin {
104117
let documentTypes = parsedTypes.isEmpty ? ["public.data"] : parsedTypes
105118
DispatchQueue.main.async {
106119
let picker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import)
120+
if let defaultPath = args.defaultPath {
121+
picker.directoryURL = URL(string: defaultPath)
122+
}
107123
picker.delegate = self.filePickerController
108124
picker.allowsMultipleSelection = args.multiple ?? false
109125
picker.modalPresentationStyle = .fullScreen
@@ -112,6 +128,46 @@ class DialogPlugin: Plugin {
112128
}
113129
}
114130

131+
@objc public func saveFileDialog(_ invoke: Invoke) throws {
132+
let args = try invoke.parseArgs(SaveFileDialogOptions.self)
133+
134+
// The Tauri save dialog API prompts the user to select a path where a file must be saved
135+
// This behavior maps to the operating system interfaces on all platforms except iOS,
136+
// which only exposes a mechanism to "move file `srcPath` to a location defined by the user"
137+
//
138+
// so we have to work around it by creating an empty file matching the requested `args.fileName`,
139+
// and using it as `srcPath` for the operation - returning the path the user selected
140+
// so the app dev can write to it later - matching cross platform behavior as mentioned above
141+
let fileManager = FileManager.default
142+
let srcFolder = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
143+
let srcPath = srcFolder.appendingPathComponent(args.fileName ?? "file")
144+
if !fileManager.fileExists(atPath: srcPath.path) {
145+
// the file contents must be actually provided by the tauri dev after the path is resolved by the save API
146+
try "".write(to: srcPath, atomically: true, encoding: .utf8)
147+
}
148+
149+
onFilePickerResult = { (event: FilePickerEvent) -> Void in
150+
switch event {
151+
case .selected(let urls):
152+
invoke.resolve(["file": urls.first!])
153+
case .cancelled:
154+
invoke.resolve(["file": nil])
155+
case .error(let error):
156+
invoke.reject(error)
157+
}
158+
}
159+
160+
DispatchQueue.main.async {
161+
let picker = UIDocumentPickerViewController(url: srcPath, in: .exportToService)
162+
if let defaultPath = args.defaultPath {
163+
picker.directoryURL = URL(string: defaultPath)
164+
}
165+
picker.delegate = self.filePickerController
166+
picker.modalPresentationStyle = .fullScreen
167+
self.presentViewController(picker)
168+
}
169+
}
170+
115171
private func presentViewController(_ viewControllerToPresent: UIViewController) {
116172
self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil)
117173
}
@@ -133,14 +189,7 @@ class DialogPlugin: Plugin {
133189
}
134190

135191
public func onFilePickerEvent(_ event: FilePickerEvent) {
136-
switch event {
137-
case .selected(let urls):
138-
pendingInvoke?.resolve(["files": urls])
139-
case .cancelled:
140-
pendingInvoke?.resolve(["files": nil])
141-
case .error(let error):
142-
pendingInvoke?.reject(error)
143-
}
192+
self.onFilePickerResult?(event)
144193
}
145194

146195
@objc public func showMessageDialog(_ invoke: Invoke) throws {

plugins/dialog/src/commands.rs

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -197,41 +197,36 @@ pub(crate) async fn save<R: Runtime>(
197197
dialog: State<'_, Dialog<R>>,
198198
options: SaveDialogOptions,
199199
) -> Result<Option<FilePath>> {
200-
#[cfg(target_os = "ios")]
201-
return Err(crate::Error::FileSaveDialogNotImplemented);
202-
#[cfg(any(desktop, target_os = "android"))]
200+
let mut dialog_builder = dialog.file();
201+
#[cfg(any(windows, target_os = "macos"))]
203202
{
204-
let mut dialog_builder = dialog.file();
205-
#[cfg(any(windows, target_os = "macos"))]
206-
{
207-
dialog_builder = dialog_builder.set_parent(&window);
208-
}
209-
if let Some(title) = options.title {
210-
dialog_builder = dialog_builder.set_title(title);
211-
}
212-
if let Some(default_path) = options.default_path {
213-
dialog_builder = set_default_path(dialog_builder, default_path);
214-
}
215-
if let Some(can) = options.can_create_directories {
216-
dialog_builder = dialog_builder.set_can_create_directories(can);
217-
}
218-
for filter in options.filters {
219-
let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect();
220-
dialog_builder = dialog_builder.add_filter(filter.name, &extensions);
221-
}
203+
dialog_builder = dialog_builder.set_parent(&window);
204+
}
205+
if let Some(title) = options.title {
206+
dialog_builder = dialog_builder.set_title(title);
207+
}
208+
if let Some(default_path) = options.default_path {
209+
dialog_builder = set_default_path(dialog_builder, default_path);
210+
}
211+
if let Some(can) = options.can_create_directories {
212+
dialog_builder = dialog_builder.set_can_create_directories(can);
213+
}
214+
for filter in options.filters {
215+
let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect();
216+
dialog_builder = dialog_builder.add_filter(filter.name, &extensions);
217+
}
222218

223-
let path = dialog_builder.blocking_save_file();
224-
if let Some(p) = &path {
225-
if let Ok(path) = p.path() {
226-
if let Some(s) = window.try_fs_scope() {
227-
s.allow_file(&path);
228-
}
229-
window.state::<tauri::scope::Scopes>().allow_file(&path)?;
219+
let path = dialog_builder.blocking_save_file();
220+
if let Some(p) = &path {
221+
if let Ok(path) = p.path() {
222+
if let Some(s) = window.try_fs_scope() {
223+
s.allow_file(&path);
230224
}
225+
window.state::<tauri::scope::Scopes>().allow_file(&path)?;
231226
}
232-
233-
Ok(path.map(|p| p.simplified()))
234227
}
228+
229+
Ok(path.map(|p| p.simplified()))
235230
}
236231

237232
fn message_dialog<R: Runtime>(

plugins/dialog/src/error.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ pub enum Error {
1818
#[cfg(mobile)]
1919
#[error("Folder picker is not implemented on mobile")]
2020
FolderPickerNotImplemented,
21-
#[cfg(target_os = "ios")]
22-
#[error("File save dialog is not implemented on iOS")]
23-
FileSaveDialogNotImplemented,
2421
#[error(transparent)]
2522
Fs(#[from] tauri_plugin_fs::Error),
2623
#[error("URL is not a valid path")]

0 commit comments

Comments
 (0)