macosswift3appstore-sandboxnsopenpanelsecurity-scoped-bookmarks

How to save security scoped URL for later use macOS


I've made a Finder extension to add a menu to Finder's Context menu for any file. I'd like to access this file when the user selects this custom menu, obviously this file they select could be anywhere in the file system and outside the allowed sandbox areas.

func accessFile(url: URL, userID: String, completion: @escaping ([String:Any]?, Error?) -> Void){
    var bookmarks = NSKeyedUnarchiver.unarchiveObject(withFile: bookmarksPath) as? [URL: Data]

    print("Testing if we have access to file")
    // 1. Test if I have access to a file
    let directoryURL = url.deletingLastPathComponent()
    let data = bookmarks?[directoryURL]
    if data == nil{
        print("have not asked for access yet or directory is not saved")
        // 2. If I do not, open a open dialog, and get permission
        let openPanel = NSOpenPanel()
        openPanel.allowsMultipleSelection = false
        openPanel.canChooseDirectories = true
        openPanel.canCreateDirectories = false
        openPanel.canChooseFiles = false
        openPanel.prompt = "Grant Access"
        openPanel.directoryURL = directoryURL

        openPanel.begin { result in
            guard result == .OK, let url = openPanel.url else {return}
        
        
            // 3. obtain bookmark data of folder URL and store it to keyed archive
            do{
                let data = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
            }catch{
                print(error)
            }
            bookmarks?[url] = data
            NSKeyedArchiver.archiveRootObject(bookmarks, toFile: bookmarksPath)
        
            // 4. start using the fileURL via:
            url.startAccessingSecurityScopedResource()
            // < do whatever to file >
            url.stopAccessingSecurityScopedResource()
        }
    }else{
        // We have accessed this directory before, get data from bookmarks
        print("we have access already")
        let directoryURL = url.deletingLastPathComponent()
        guard let data = bookmarks?[directoryURL]! else { return }
        var isStale = false
        let newURL = try? URL(resolvingBookmarkData: data, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
    
        // 3. Now again I start using file URL and upload:
        newURL?.startAccessingSecurityScopedResource()
        // < do whatever to file >
        newURL?.stopAccessingSecurityScopedResource()
    
        }
}

Currently it always asks for permission, so the bookmark is not getting saved


Solution

  • I'm not 100% sure if this is the source of your problem, but I don't see where you are using the isStale value. If it it comes back true from URL(resolvingBookmarkData:...), you have to remake/resave the bookmark. So in your else block you need some code like this:

    var isStale = false
    let newURL = try? URL(
        resolvingBookmarkData: data, 
        options: .withSecurityScope, 
        relativeTo: nil, 
        bookmarkDataIsStale: &isStale
    )
    
    if let url = newURL, isStale 
    {
        do
        {
            data = try url.bookmarkData(
                options: .withSecurityScope, 
                includingResourceValuesForKeys: nil, 
                relativeTo: nil
            )
        }
        catch { fatalError("Remaking bookmark failed") }
    
        // Resave the bookmark
        bookmarks?[url] = data
        NSKeyedArchiver.archiveRootObject(bookmarks, toFile: bookmarksPath)
    }
    
    newURL?.startAccessingSecurityScopedResource()
    // < do whatever to file >
    newURL?.stopAccessingSecurityScopedResource()
    

    data will, of course, need to be var instead of let now.

    Also remember that stopAccessingSecurityScopedResource() has to be called on main thread, so if you're not sure accessFile is being called on the main thread, you might want to do that explicitly:

    DispatchQueue.main.async {
        newURL?.stopAccessingSecurityScopedResource()
    }
    

    You'd want to do that in both places you call it.

    I like to write an extension on URL to make it a little nicer:

    extension URL
    {
        func withSecurityScopedAccess<R>(code: (URL) throws -> R) rethrows -> R
        {
            self.startAccessingSecurityScopedResource()
            defer {
                DispatchQueue.main.async {
                    self.stopAccessingSecurityScopedResource()
                }
            }
            return try code(self)
        }
    }
    

    So then I can write:

    url.withSecurityScopedAccess { url in
        // Do whatever with url
    }
    

    Whether you use the extension or not, explicitly calling stopAccessingSecurityScopedResource() on DispatchQueue.main does mean that access won't be stopped until the next main run loop iteration. That's normally not a problem, but if you start and stop the access for the same URL multiple times in a single run loop iteration, it might not work, because it will call startAccessingSecurityScopedResource() multiple time without stopAccessingSecurityScopedResource() in between, and the on the next iteration it would call stopAccessingSecurityScopedResource() multiple times as the queued tasks are executed. I have no idea if URL maintains a security access count that would allow that to be safe, or just a flag, in which case it wouldn't be.