macOS Security scoped URL bookmark for folder
Asked Answered
M

2

6

I'm experiencing problems (on Mojave and Catalina) with "reusing" security scope URL bookmark for a folder between app launches in my app.

It's simple decompressing application using libarchive framework. User selects file to decompress, I want to store URL bookmark for it's parent folder (e.g. ~/Desktop), and reuse it next time user tries to decompress file in the same folder.

First, I added following to my app's entitlements file:

<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>

When accessing file (parent folder respectively) for the first time:

  1. User selects file to decompress
  2. I present NSOpenPanel to obtain access to the file folder:
let directoryURL = fileURL.deletingLastPathComponent()

let openPanel = NSOpenPanel()
openPanel.allowsMultipleSelection = false
openPanel.canChooseDirectories = true
openPanel.canCreateDirectories = false
openPanel.canChooseFiles = false
openPanel.prompt = "Grant Access"
openPanel.directoryURL = directoryURL

openPanel.begin { [weak self] result in
    guard let self = self else { return }
    // WARNING: It's absolutely necessary to access NSOpenPanel.url property to get access
    guard result == .OK, let url = openPanel.url else {
        // HANDLE ERROR HERE ...
        return
    }

    // We got URL and need to store bookmark's data
    // ...
}
  1. I obtain bookmark data of folder URL and store it to keyed archive:
let data = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
bookmarks[url] = data
NSKeyedArchiver.archiveRootObject(bookmarks, toFile: bookmarksPath)
  1. Now I start using file URL and use libarchive to decompress .zip file to it's parent folder:
fileURL.startAccessingSecurityScopedResource()
// Decompressing file with libarchive...
fileURL.stopAccessingSecurityScopedResource()
  1. Everything is working as expected, .zip file gets decompressed

When relaunching app, decompressing file in the same folder, reusing saved bookmark data:

  1. I get bookmarks from keyed archive:
let bookmarks = NSKeyedUnarchiver.unarchiveObject(withFile: bookmarksPath) as? [URL: Data]
  1. I get bookmark data from bookmarks for the file's parent folder and resolve it:
let directoryURL = fileURL.deletingLastPathComponent()
let data = bookmarks[directoryURL]!
var isStale = false
let newURL = try URL(resolvingBookmarkData: data, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
  1. Now again I start using file URL and use libarchive to decompress .zip file to it's parent folder:
fileURL.startAccessingSecurityScopedResource()
// Decompressing file with libarchive...
fileURL.stopAccessingSecurityScopedResource()

But this time libarchive returns error saying Failed to open \'/Users/martin/Desktop/Archive.zip\'

I know I might be doing something terribly wrong or not understanding concept of security scoped URL bookmarks but can't find where's the problem. Any hints?

FINAL SOLUTION Both Rckstr's answer and answer in this Apple developer forum thread pointed me into the right direction. It's absolutely necessary to call startAccessingSecurityScopedResource() on THE SAME INSTANCE of URL returned by try URL(resolvingBookmarkData: data, options: .withSecurityScope ...

Mailbag answered 16/10, 2019 at 13:18 Comment(1)
No need to use sandbox. Hope this answer will help: https://mcmap.net/q/795443/-read-and-write-permission-for-user-selected-folder-in-mac-os-appFaradism
G
8

You're resolving the security-scoped bookmark (for the directory) to let newUrl, but you call startAccessingSecurityScopedResource() on the file's URL fileURL. You need to call it for newURL.

newURL.startAccessingSecurityScopedResource()
// Decompressing fileURL with libarchive...
newURL.stopAccessingSecurityScopedResource()

Two more remarks:

  1. When obtaining access through NSOpenPanel, you don't need to call startAccessingSecurityScopedResource() and stopAccessingSecurityScopedResource(), because the user explicitly granted you access for this session.
  2. I use var isStale: ObjCBool = ObjCBool(false) instead. I'm no Swift expert, so not sure if var isStale = false is ok to use.
Geiss answered 16/10, 2019 at 15:37 Comment(2)
Thanks for the answer. Unfortunately it's not working. I resolve bookmark for the folder (e.g. /Users/martin/Desktop/) and start accessing it instead of the fileURL, but still can't access resources in the folder. FYI when calling folderURL.startAccessingSecurityScopedResource() the return value is false. Any other hints?Mailbag
In fact, you're right. The problem was I wasn't calling startAccessingSecurityScopedResource() for the same URL instance. ThanksMailbag
O
5

Since I cannot comment I create a new answer. Just a gotcha: The NSArchiver doesn't do any magic and is not strictly necessary. You can store the URL how ever you want for example in user defaults:

I do like this:

private func handleURLReceivedFromOpenPanel(_ url: URL) throws -> Void {
    let data = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
        
    UserDefaults.standard.set(data, forKey: UserDefaultsKeys.writableUrl)
        
    guard url.startAccessingSecurityScopedResource() else {
            fatalError("Failed starting to access security scoped resource for: \(url.path)")
    }
}

func getStoredUrl() throws -> URL {
    guard let data = UserDefaults.standard.data(forKey: UserDefaultsKeys.writableUrl) else {
        // no url stored so return a url that can be accessed
        return try FileManager.default
            .url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
            .appendingPathComponent("someSubfolderOrWhatever")
    }
        
    var isStale = false
    let newUrl = try URL(resolvingBookmarkData: data,
                         options: .withSecurityScope,
                         relativeTo: nil,
                         bookmarkDataIsStale: &isStale)
      
    guard newUrl.startAccessingSecurityScopedResource() else {
        throw Error("Could not start accessing security scoped resource: \(newUrl.path)")
    }

    return newUrl
}

If you are storing the URL in memory remember to release the resource with

oldUrl.stopAccessingSecurityScopedResource()
Oread answered 3/8, 2020 at 17:13 Comment(1)
I found this example very helpful.Nagle

© 2022 - 2024 — McMap. All rights reserved.