Skip to content

Latest commit

 

History

History
320 lines (262 loc) · 10.8 KB

README.md

File metadata and controls

320 lines (262 loc) · 10.8 KB

Tests codebeat badge Swift Platforms

WizardHelper

Open / save prompts for macOS and iOS

WizardHelper is a Swift library that provides open and save prompts for macOS and iOS. It uses NSOpenPanel and NSSavePanel on macOS, and UIDocumentPickerViewController on iOS.

Index

Installation

You can install WizardHelper using Swift Package Manager. Add the following line to your Package.swift file:

.package(url: "https://github.com/eonist/WizardHelper.git", .branch: "master")

Examples:

// macOS: Prompt the user to save a file
WizardHelper.promptSaveFile(fromURL: url, fileName: fileInfo.fileName, view: self)

// macOS: Prompt the user to open a file
WizardHelper.promptOpenFile(view: self)

// iOS: Prompt the user to open a file
WizardHelper.promptOpenFile(from: self) { result in
    switch result {
    case .success(let urls):
        // Do something with the selected URLs
    case .failure(let error):
        // Handle the error
    }
}

// iOS: Prompt the user to open a file with error handling
WizardHelper.promptOpenFile { result in
    switch result {
    case .success(let urls):
        // Do something with the selected URLs
        print("Selected files: \(urls)")
    case .failure(let error):
        // Handle the error
        print("Error: \(error.localizedDescription)")
    }
}

Example (hybrid iOS / macOS)

// open
 WizardHelper.openFile { (url, error) in
     if let url = url {
         print("Opened file at \(url)")
     } else if let error = error {
         print("Failed to open file: \(error)")
     }
 }
 // save
 let url = URL(string: "https://example.com/myfile.txt")! 
 WizardHelper.saveFile(fromURL: url, fileName: "SavedFile.txt") { print("File saved") }

Gotcha iOS

To use WizardHelper on iOS, you need to update your app's Info.plist file with the following:

  • Supports opening documents in place: Set to YES
  • Application supports iTunes file sharing: Set to YES
  • Supports Document Browser: Set to YES
  • App Sandbox (if applicable): Enable User Selected Files - Read/Write

Example Info.plist entries:

<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>UISupportsDocumentBrowser</key>
<true/>

For more details, refer to this StackOverflow answer.

Todo:

  • Add github action
  • Add error handling when moving files
  • Add tests (UITests) 👈
  • Add SwiftUI support 👈
  • Upgrade to swift 6.0 (This might be a bit tricky but doable)
  • Use Result type for better error handling in asynchronous methods:
public typealias SaveFileResult = Result<Void, Error>
public typealias SaveFileCompletion = (SaveFileResult) -> Void

public static func saveFile(fromURL: URL, fileName: String? = nil, onComplete: @escaping SaveFileCompletion) {
    #if os(iOS)
    WizardHelper.promptSaveFile(fromURL: fromURL, view: rootController?.view) { result in
        onComplete(result)
    }
    #elseif os(macOS)
    do {
        try WizardHelper.promptSaveFile(fromURL: fromURL, fileName: fileName ?? fromURL.lastPathComponent)
        onComplete(.success(()))
    } catch {
        onComplete(.failure(error))
    }
    #endif
}
  • Avoid force unwrapping and provide better error messages: In WizardHelper+Import+IOS.swift, replace fatalError with proper error handling:
public static func promptOpenFile(view: UIView? = nil, types: [UTType] = defaultTypes, complete: @escaping OnOpenComplete) {
    guard let view = view ?? UIViewController.topMostController()?.view else {
        complete(.failure(NSError(domain: "ViewControllerNotFound", code: 0, userInfo: [NSLocalizedDescriptionKey: "Unable to find a view controller to present from."])))
        return
    }
    // Rest of the method...
}
  • Enhance the promptSaveFile method on iOS to allow setting a default file name: Since UIActivityViewController doesn't support specifying a default file name, consider using UIDocumentPickerViewController for exporting files:
public static func promptSaveFile(fromURL: URL, suggestedFileName: String? = nil, view: UIView? = nil, onComplete: (() -> Void)?) {
    guard let view = view ?? UIViewController.topMostController()?.view else {
        onComplete?()
        return
    }
    
    let documentPicker = UIDocumentPickerViewController(forExporting: [fromURL], asCopy: true)
    documentPicker.delegate = ExportDelegate(onComplete: onComplete)
    documentPicker.directoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
    if let fileName = suggestedFileName {
        documentPicker.directoryURL = documentPicker.directoryURL?.appendingPathComponent(fileName)
    }
    
    UIViewController.topMostController()?.present(documentPicker, animated: true, completion: nil)
}

private class ExportDelegate: NSObject, UIDocumentPickerDelegate {
    let onComplete: (() -> Void)?
    
    init(onComplete: (() -> Void)?) {
        self.onComplete = onComplete
    }
    
    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
        onComplete?()
    }
    
    func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
        onComplete?()
    }
}

Implement SwiftUI wrappers for your open and save dialogs to make the library more compatible with SwiftUI projects.

import SwiftUI
import UniformTypeIdentifiers

@available(iOS 13.0, macOS 10.15, *)
public struct FileImporter: UIViewControllerRepresentable {
    @Binding var isPresented: Bool
    @Binding var importedURL: URL?
    var allowedContentTypes: [UTType] = [.data]

    public func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
        let picker = UIDocumentPickerViewController(forOpeningContentTypes: allowedContentTypes)
        picker.delegate = context.coordinator
        return picker
    }

    public func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {
        uiViewController.allowsMultipleSelection = false
    }

    public func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    public class Coordinator: NSObject, UIDocumentPickerDelegate {
        var parent: FileImporter

        init(_ parent: FileImporter) {
            self.parent = parent
        }

        public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
            parent.importedURL = urls.first
            parent.isPresented = false
        }

        public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
            parent.isPresented = false
        }
    }
}

@available(iOS 13.0, macOS 10.15, *)
public struct FileExporter: UIViewControllerRepresentable {
    @Binding var isPresented: Bool
    var document: URL

    public func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
        let picker = UIDocumentPickerViewController(forExporting: [document], asCopy: true)
        picker.delegate = context.coordinator
        return picker
    }

    public func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}

    public func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    public class Coordinator: NSObject, UIDocumentPickerDelegate {
        var parent: FileExporter

        init(_ parent: FileExporter) {
            self.parent = parent
        }

        public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
            parent.isPresented = false
        }
    }
}

Usage Example:

import SwiftUI

struct ContentView: View {
    @State private var isImporterPresented = false
    @State private var isExporterPresented = false
    @State private var importedURL: URL?

    var body: some View {
        VStack {
            Button("Import File") {
                isImporterPresented = true
            }
            .fileImporter(isPresented: $isImporterPresented, allowedContentTypes: [.text]) { result in
                switch result {
                case .success(let url):
                    importedURL = url
                case .failure(let error):
                    print("Import error: \(error.localizedDescription)")
                }
            }

            Button("Export File") {
                isExporterPresented = true
            }
            .fileExporter(isPresented: $isExporterPresented, document: URL(fileURLWithPath: "path/to/document.txt"), contentType: .plainText) { result in
                switch result {
                case .success:
                    print("Export successful")
                case .failure(let error):
                    print("Export error: \(error.localizedDescription)")
                }
            }
        }
    }
}

Implement unit tests to ensure your library functions correctly and to prevent regressions.

import XCTest
@testable import WizardHelper

final class WizardHelperUnitTests: XCTestCase {
    func testOpenFile_macOS() throws {
        #if os(macOS)
        let expectation = self.expectation(description: "File open dialog should be presented")
        WizardHelper.openFile { url, error in
            XCTAssertNil(error)
            XCTAssertNotNil(url)
            expectation.fulfill()
        }
        waitForExpectations(timeout: 5, handler: nil)
        #endif
    }

    func testSaveFile_macOS() throws {
        #if os(macOS)
        let expectation = self.expectation(description: "File save dialog should be presented")
        let url = URL(fileURLWithPath: "/path/to/dummy.txt")
        WizardHelper.saveFile(fromURL: url, fileName: "Test.txt") {
            expectation.fulfill()
        }
        waitForExpectations(timeout: 5, handler: nil)
        #endif
    }
}