Flutter plugin to create security-scoped bookmarks and keep access to files in sandboxed macOS apps.
- Read the documentation on security-scoped bookmarks.
- Enable the app-sandbox entitlement and the required bookmark entitlement in
both
DebugProfile.entitlementsandRelease.entitlements:com.apple.security.app-sandboxcom.apple.security.files.user-selected.read-write(for the open panel)com.apple.security.files.bookmarks.app-scope(required — without it, resolving a bookmark fails)
Let the user pick a file (picking stays consumer-side, e.g. with file_selector), then mint a bookmark while holding the panel grant and persist the bytes:
final XFile? picked = await openFile(); if (picked == null) { return; } final SecureBookmarks secureBookmarks = SecureBookmarks(); final SecureBookmarkMint mint = await secureBookmarks.mint(File(picked.path)); // Persist mint.bookmark, and show mint.volumeName ("on Red") in the UI. // The volume name is display data, never identity.
Resolve the persisted bytes under a caller id. Resolving enters the security scope; releasing the id leaves it. Releasing an unheld id is a no-op.
try { final SecureBookmarkResolution resolution = await secureBookmarks.resolve( id: 'my-document', bookmarkBytes: storedBytes, ); // Read/write the file at resolution.path, then: await secureBookmarks.release('my-document'); } on UnresolvableBookmark catch (e) { // Detached volume, deleted target, or corrupt bytes. // e.domain and e.code carry the native NSError domain and code. }
Renaming the target yields stale: true plus rewritten bytes. Persist the
refresh in place of the old bytes — without the rewrite a renamed target
stays stale forever:
final SecureBookmarkResolution resolution = await secureBookmarks.resolve( id: 'my-document', bookmarkBytes: storedBytes, ); if (resolution.stale) { storedBytes = resolution.refreshedBookmark!; await persist(storedBytes); }
refreshedBookmark is present exactly when stale is true.
Reachability is resolve-plus-a-real-read. Under the sandbox, existence checks
answer true for files without a grant, then open(2) fails — a path alone
proves nothing until the file is actually opened. Do not treat "resolves to a
path" as "readable"; attempt the read and handle the failure.
The original methods still work unchanged: bookmark mints a base64 string,
resolveBookmark maps it back to a File or Directory, and
startAccessingSecurityScopedResource / stopAccessingSecurityScopedResource
bracket access. New code should prefer mint / resolve / release, which
add stale reporting, per-id scope lifetime, volume names, and typed errors.
The headless test suite mocks the method channel, so it cannot cover the kernel's half. Before release, verify against a real sandboxed build:
- Build and run the example on macOS:
flutter run -d macosfromexample/. - Plug in an external volume. Pick a file on it (Pick & Mint), then quit
the app and relaunch it: Resolve must return the file with
startedAccess: true, proving the bookmark survived the relaunch. - Rename-while-away: quit the app, rename the picked file's parent folder
on the volume, relaunch, and Resolve. Expect
stale: trueplus refreshed bytes; Resolve again with the persisted refresh and expectstale: false. - Detach the volume and Resolve: expect an
UnresolvableBookmarkwithin milliseconds, with no hang. - Open the volume name in the UI and confirm it shows the Finder display name of the external volume (e.g. "on Red").