How to save security scoped URL for later use macOS

532 views Asked by At

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

2

There are 2 answers

0
Chip Jarred On BEST ANSWER

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.

0
Willeke On

Let's make some issues visible by removing the bookmark and NSOPenPanel code:

var bookmarks = NSKeyedUnarchiver.unarchiveObject(withFile: bookmarksPath) as? [URL: Data]
// bookmarks is an optional and can be nil (when the file doesn't exist)

let data = bookmarks?[directoryURL]
if data == nil {
    // NSOpenPanel
    
    do {
        let data = try openPanelUrl.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
        // this data is a local data, the other data didn't change
    } catch {
        print(error)
    }
    
    // bookmarks and data are still nil
    bookmarks?[openPanelUrl] = data
    NSKeyedArchiver.archiveRootObject(bookmarks, toFile: bookmarksPath)
    
    // use url
} else {

    // get the bookmark data again
    guard let data = bookmarks?[directoryURL]! else { return }
    
    // get url from data and use it
}

I would do something like:

var bookmarks: [URL: Data]
if let savedBookmarks = NSKeyedUnarchiver.unarchiveObject(withFile: bookmarksPath) as? [URL: Data] {
    bookmarks = savedBookmarks
}
else {
    bookmarks = [:]
}
// bookmarks is a dictionary and can be saved

if let data = bookmarks[directoryURL] {
    // get url from data and use it
}
else {
    // NSOpenPanel
    
    do {
        if let newData = try openPanelUrl.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) {
            // bookmarks and newData are not nil
            bookmarks[openPanelUrl] = newData
            NSKeyedArchiver.archiveRootObject(bookmarks, toFile: bookmarksPath)
            
            // use url
        }
    } catch {
        print(error)
    }
}