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").