3

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

mahal tertin
  • 3,239
  • 24
  • 41
Nathan
  • 1,393
  • 1
  • 9
  • 40
  • Does the file at `bookmarksPath ` exist? Is `bookmarks` `nil`? Is the URL in `bookmarks`? Aren't `unarchiveObject(withFile:)` and `archiveRootObject(_:toFile:)` deprecated? – Willeke Dec 17 '22 at 20:43
  • Any build warnings? – Willeke Dec 17 '22 at 20:54
  • @Willeke yeah they are, it’s screaming that it’s archived, I haven’t had a chance to text the answer code but it still lets me compile – Nathan Dec 18 '22 at 21:33
  • Any other warnings like "Initialization of immutable value 'data' was never used"? When do you create the first `bookmarks`? – Willeke Dec 19 '22 at 01:46
  • Off topic: Isn't the file selected by the user? Do you need access to the folder? – Willeke Dec 19 '22 at 12:39
  • @willeke that’s what I thought when first implementing this feature, however I simply pass the local file url (a simple string) via a notification from my ‘Finder Sync Extension Context Menu’ to my main app. Is there some other way which would bypass this access issue, I assume passing a string could be different? I do get those warnings too aswell, if I’m not mistaken. – Nathan Dec 20 '22 at 02:14
  • @willeke I appreciate the help, I’ve tried the answers solution and there’s still access errors, I can provide the full code and workflow if helps, been stuck on this for days – Nathan Dec 20 '22 at 02:19
  • I'm sorry my answer didn't help, though using `isStale` to resave the bookmark is something you need to do anyway. I did some googling, because it sounds like a permissions/sandbox issue. This link might help: [App Sandbox Temporary Exception Entitlements](https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/AppSandboxTemporaryExceptionEntitlements.html) – Chip Jarred Dec 20 '22 at 08:55
  • I think the code in the question doesn't work because: `bookmarks` is `nil` and stays `nil`. `let data = try url.bookmarkData(` is a local `data` variable, the more global `data` doesn't change. `bookmarks?[url] = data` doesn't do anything because both `bookmarks` and `data` are `nil`. – Willeke Dec 20 '22 at 12:00
  • 1
    @ChipJarred I appreciate your help, your answer did help me a lot anyway with other issues etc. I will accept it as it will help someone else, I shall have a look at your link too. – Nathan Dec 21 '22 at 20:01
  • @Willeke will Chip's answer be correct in your assumption the `bookmarks` is always `nil` - it is still not working but – Nathan Dec 21 '22 at 20:02
  • Chip's answer is of no use if you don't fix the build warnings and create a `bookmarks` object. Does your code work if `bookmarks` is `nil`? If not then `bookmarks` shouldn't be an optional. – Willeke Dec 21 '22 at 20:46

2 Answers2

3

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.

Chip Jarred
  • 2,600
  • 7
  • 12
0

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)
    }
}
Willeke
  • 14,578
  • 4
  • 19
  • 47