Search code examples
iosswiftxcodeiclouduidocument

Saving UIDocument fails with permissions error - `NSCocoaErrorDomain` code `513`


I am trying to build and iOS app with similar behaviour to Pages / Numbers / Keynote. Each of these apps is a Document Based App, where the user is first presented with a UIDocumentBrowserViewController where the user choses a document to open in the app. In Numbers for example a user can select a .numbers file and it will open, or a user can select a .csv and it will import this csv file into a numbers file which is saved along side the original csv in the same location.

In my app I want the user to select a .csv file, and then I'll import it into my own document format (called .pivot) and save this alongside the csv file (just like numbers.) This works fine in the simulator but when I run my code on a device I get an error when calling save(to:for:completionHandler:) on my custom Pivot document.

My document browser code is as follows.

class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocumentBrowserViewControllerDelegate {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        delegate = self
        
        allowsDocumentCreation = false
        allowsPickingMultipleItems = false
    }
    
    func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentsAt documentURLs: [URL]) {
        guard let sourceURL = documentURLs.first else { return }
        
        if sourceURL.pathExtension == "csv" {
            
            // Create a CSV document so we can read the CSV data
            let csvDocument = CSVDocument(fileURL: sourceURL)
            csvDocument.open { _ in
                
                guard let csv = csvDocument.csvData else {
                    fatalError("CSV is nil upon open")
                }
                
                // Create the file at the same location as the csv, with the same name just a different extension
                var pivotURL = sourceURL.deletingLastPathComponent()
                let pivotFilename = sourceURL.lastPathComponent .replacingOccurrences(of: "csv", with: "pivot")
                pivotURL.appendPathComponent(pivotFilename, isDirectory: false)
                
                let model = PivotModel()
                model.csv = csv
                let document = PivotDocument(fileURL: pivotURL)
                document.model = model
                
                document.save(to: pivotURL, for: .forCreating, completionHandler: { success in
                    
                    // `success` is false here
                    
                    DispatchQueue.main.async {
                        self.performSegue(withIdentifier: "presentPivot", sender: self)
                    }
                })
            }
        }
    }
    
}

My first UIDocument subclass to load a csv file is as follows.

import SwiftCSV // This is pulled in using SPM and works as I expect, so is unlikely causing this problem 

class CSVDocument: UIDocument {
    
    var csvData: CSV?
    
    override func contents(forType typeName: String) throws -> Any {
        return Data()
    }
    
    override func load(fromContents contents: Any, ofType typeName: String?) throws {
        guard let data = contents as? Data else {
            fatalError("No file data")
        }
        
        guard let string = String(data: data, encoding: .utf8) else {
            fatalError("Cannot load data into string")
        }
        
        csvData = try CSV(string: string)
    }
}

My second UIDocument subclass for my custom Pivot document is as follows. By overriding the handleError() function I can see the save fails with an error in the NSCocoaErrorDomain, with code of 513.

class PivotDocument: UIDocument {
    
    var model: PivotModel!
    var url: URL!
    
    override func contents(forType typeName: String) throws -> Any {
        let encoder = JSONEncoder()
        return try encoder.encode(model)
    }
    
    override func load(fromContents contents: Any, ofType typeName: String?) throws {        
        guard let data = contents as? Data else {
            fatalError("File contents are not Data")
        }
        
        let decoder = JSONDecoder()
        model = try decoder.decode(PivotModel.self, from: data)
    }
    
    override func handleError(_ error: Error, userInteractionPermitted: Bool) {
        let theError = error as NSError
        
        print("\(theError.code)") // 513
        print("\(theError.domain)") // NSCocoaErrorDomain
        print("\(theError.localizedDescription)") // “example.pivot” couldn’t be moved because you don’t have permission to access “CSVs”.
        
        super.handleError(error, userInteractionPermitted: userInteractionPermitted)
    }
}

The fact that this works in the simulator (where my user has access to all the file system) but doesn't on iOS (where user and app permissions are different) makes me think I have a permission problem. Do I need to declare some entitlements in my Xcode project for example?

Or am I just misusing the UIDocument API and do I need to find a different implementation?


Solution

  • I found the function I was looking for that replicates the functionality of the iWork apps!

    UIDocumentBrowserViewController has this function importDocument(at:nextToDocumentAt:mode:completionHandler:). From the docs:

    Use this method to import a document into the same file provider and directory as an existing document. For example, to duplicate a document that's already managed by a file provider: Create a duplicate of the original file in the user's temporary directory. Be sure to give it a unique name. Call importDocument(at:nextToDocumentAt:mode:completionHandler:), passing in the temporary file's URL as the documentURL parameter and the original file's URL as the neighborURL parameter.

    So documentBrowser(_:didPickDocumentsAt:) is now:

    let pivotFilename = sourceURL.lastPathComponent .replacingOccurrences(of: "csv", with: "pivot")
    
    let path = FileManager.default.temporaryDirectory.appendingPathComponent(pivotFilename)
    if FileManager.default.createFile(atPath: path.path, contents: nil, attributes: nil) {
        
        self.importDocument(at: path, nextToDocumentAt: sourceURL, mode: .copy) { (importedURL, errorOrNil) in
            guard let pivotURL = importedURL else {
                fatalError("No URL for imported document. Error: \n \(errorOrNil?.localizedDescription ?? "NO ERROR")")
            }
        
            
            let model = PivotModel()
            model.csv = csv
            let document = PivotDocument(fileURL: pivotURL)
            document.model = model
            
            DispatchQueue.main.async {
                self.performSegue(withIdentifier: "presentPivot", sender: self)
            }
        }
    }
    else {
        fatalError("Could not create local pivot file in temp dir")
    }
    

    No more permissions errors. Hope this helps someone else in the future.