What is the difference between a stored path and a bookmark, and how do security-scoped bookmarks work in a sandboxed Mac app?
Predict first
What does the bookmark do after a rename and after a delete?
import Foundation
let fm = FileManager.default
let dir = fm.temporaryDirectory.appendingPathComponent("bm-\(UUID().uuidString)")
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let a = dir.appendingPathComponent("report.txt")
try Data("hello".utf8).write(to: a)
let bookmark = try a.bookmarkData()
let b = dir.appendingPathComponent("renamed.txt")
try fm.moveItem(at: a, to: b)
var stale = false
let resolved = try URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale)
print("old path exists:", fm.fileExists(atPath: a.path))
print("bookmark resolves to:", resolved.lastPathComponent, "| stale:", stale)
print("contents:", String(decoding: try Data(contentsOf: resolved), as: UTF8.self))
try fm.removeItem(at: resolved)
do { _ = try URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale) } catch { print("deleted file: error code", (error as NSError).code) }
let scoped = try dir.bookmarkData(options: .withSecurityScope)
var st2 = false
let r2 = try URL(resolvingBookmarkData: scoped, options: .withSecurityScope, bookmarkDataIsStale: &st2)
print("scoped start accessing (not sandboxed):", r2.startAccessingSecurityScopedResource())
r2.stopAccessingSecurityScopedResource()
- old path exists: false / resolves to renamed.txt, stale: true / contents: hello / deleted: error code 4 / scoped start: true
- resolves to report.txt
- throws immediately
Mental model. A bookmark is a library call number rather than a shelf position: the shelf can be rearranged and the call number still finds the book (and tells you the catalogue entry is out of date).
A path is a string that names a place and breaks when the file moves. A bookmark (URL.bookmarkData()) is an opaque blob that records the file’s identity, so it keeps resolving after a move or rename. The check creates a file, bookmarks it, renames it and resolves the bookmark: the old path no longer exists, the bookmark resolves to renamed.txt with the contents intact, and it reports stale: true, the signal to re-create and re-save the bookmark from the resolved URL. After the file is deleted, resolving throws (Cocoa error 4, “no such file”). A security-scoped bookmark, made with .withSecurityScope (macOS), adds the sandbox’s permission: a sandboxed app that was given a folder through NSOpenPanel stores it as a bookmark and calls startAccessingSecurityScopedResource() after resolving on the next launch to regain access.
Rules to remember: pair every successful start with a stop (the access is counted and leaks otherwise); the bookmark, not the URL, is what you persist; you need the user-selected entitlement (files.user-selected.read-write, or .read-only) and, for a bookmark that outlives launches, com.apple.security.files.bookmarks.app-scope. In an unsandboxed process startAccessing simply returns true, which is what the check printed, so a test outside the sandbox does not prove the sandboxed behaviour; the sandboxed path needs the app bundle from Q840 and a user-selected file, which was not run here. On iOS the same idea appears in document pickers and file providers, where a security-scoped URL is how you keep access to a file outside your container (UIDocumentPickerViewController).
A bookmark follows the file’s identity and marks itself stale when the path changed.
Common traps
- Storing the path string of a user-chosen file. It breaks on rename and carries no sandbox permission.
- Ignoring
isStale. Re-save the bookmark when it is true, or it will eventually fail.
They might follow up with
- Why do I need
stop? Access is reference counted; leaks hold the permission open. - What changes on iOS? Document-picker URLs are security scoped and need the same start and stop calls.
Related questions
- 20.3.2 What does a Mac app need that an iOS app does not: the menu bar, windows, the App Sandbox, Hardened Runtime…
- 20.8.1 What does the App Sandbox actually change in a running Mac process, and how do you test it without a paid…
← What do the Hardened Runtime flag and its exception entitlements… How does a menu item find its target on the responder chain, what do… →