Four ways I got security-scoped bookmarks wrong
Divecut is a sandboxed macOS app that points at a folder of footage and keeps pointing at it after you quit and relaunch. The mechanism for that is a security-scoped bookmark: you ask the user for a folder once, store a blob, and resolve it on the next launch.
The API is small. I still got it wrong four times. Each bug had the same shape — it stayed invisible until something unrelated changed, and then it surfaced all at once. That pattern is the useful part, more than the fixes.
1. Holding a bookmark is not the same as having access
On launch I checked that a bookmark existed and moved on. Thumbnails loaded, the grid filled in, everything looked fine.
Then I bumped the thumbnail cache version. Every cell turned into a broken-file badge at once.
The bookmark had never been resolved. resolve() and
startAccessingSecurityScopedResource() were never called, so the app
had no permission to read the originals. It didn't matter while the cache was
warm, because nothing touched the source files. Invalidating the cache removed
the thing that had been hiding the bug.
A cache in front of a permission bug will keep it invisible for as long as the cache stays warm.
The fix is unglamorous: on launch, go all the way through resolve → start accessing, and keep that access for as long as the app is running.
2. /var and /private/var disagree
Importing a folder under the system temp directory produced zero clips. No error, no crash — an empty grid.
FileManager.enumerator returns paths with symlinks already resolved,
so files came back as /private/var/…. The folder path I was comparing
against was /var/…. A prefix match between the two never hits.
What makes this one nasty is that the obvious normalizers don't fix it.
Neither standardizedFileURL nor resolvingSymlinksInPath()
resolves the /var special link — if anything they push in the
opposite direction and strip /private. The one that works is the
canonicalPath resource value:
let canonical = try? standardized
.resourceValues(forKeys: [.canonicalPathKey])
.canonicalPath
The trap after the fix: normalization has to happen on every path that gets compared. I normalized on import but not on the folder-moved check, and the mismatch came straight back.
3. A stale bookmark can't be refreshed from outside
When a user moves the folder in Finder, the bookmark still resolves but comes back
marked stale, and you're expected to write a fresh one. I did exactly that:
detected isStale, called bookmarkData() again, saved it.
That does not actually refresh anything. A bookmark created from a reconstituted URL that you are not currently accessing just goes stale again — this is reported on the Apple Developer Forums, and it matches what I saw.
The refresh has to happen inside an access scope: start accessing, write the new bookmark, stop accessing — all before returning from resolve.
4. Two writes, one crash, wrong folder
To detect "the user moved the folder," I stored the last successfully resolved path next to the bookmark, under its own key. Two values, two writes.
If the app dies between them, you get a bookmark pointing at the new location and a path hint pointing at the old one. On the next launch that combination reads as the folder moved, and the app rewrites clip paths that were never wrong. A crash at the wrong moment corrupts the library.
The fix was to stop having two writes. Both values go into a single blob saved
under one key. A single UserDefaults write isn't torn, so a reader
sees either the complete old value or the complete new one — never a mix.
The pairing that prevents the boring version of this
Every temporary access goes through one helper, so a thrown error can't leak the scope:
public func withAccess<T>(to url: URL, _ body: (URL) throws -> T) throws -> T {
let granted = url.startAccessingSecurityScopedResource()
defer {
if granted { url.stopAccessingSecurityScopedResource() }
}
guard granted else { throw StoreError.accessDenied }
return try body(url)
}
The library folder is the deliberate exception. It stays open for the lifetime of
the app, so it does not go through this helper — defer closing it is
exactly the wrong behaviour there.
What I'd tell myself before starting
| Bug | What hid it |
|---|---|
| Bookmark never resolved | A warm thumbnail cache |
/var vs /private/var | Only temp dirs use that symlink |
| Stale refresh that doesn't refresh | Nobody moves folders during development |
| Two writes, one crash | The app has to die in a specific millisecond |
None of these were caught by writing more careful code the first time. They were caught by changing something else — a cache version, a test folder, a code review — and watching what fell over. If you're building on this API, it's worth invalidating your caches on purpose once, just to see what was standing on nothing.
Divecut is a footage browser for macOS and iPad: Log clips in the right color, searchable in plain words, handed to DaVinci Resolve with the Log intact. See what it does.