Persistent File Access

persistent_file_access keeps a durable collection of files and directories selected by the user and restores access to them when the application starts again.

Application code uses the same API on macOS and Windows. Open a named collection, add picker results, inspect its recovery state, and perform file I/O inside withAccess. The package selects and manages the appropriate access mechanism for the current platform.

It provides:

  • persistent collections containing both files and directories;
  • automatic recovery when a collection is reopened;
  • available and temporarily unavailable states for each saved item;
  • duplicate prevention and persistent insertion order;
  • whole-collection reloads and targeted retries;
  • removal even when an item is currently unavailable; and
  • immutable snapshots plus a stream for reactive UI.

Choosing between similar packages

persistent_file_access and directory_bookmarks both restore access to user-selected filesystem locations, but they are designed for different application shapes. This comparison reflects the published directory_bookmarks 0.1.2 API.

Need persistent_file_access directory_bookmarks
Supported platforms macOS and Windows macOS; Android is documented as in development, while Windows is planned
Saved selections Named, ordered collections containing both files and directories A directory-centered bookmark workflow
Filesystem operations Any dart:io work inside a scoped withAccess callback Package-provided helpers for reading, writing, listing, and creating items below the bookmarked directory
Recovery model Per-entry available/unavailable state, automatic recovery, retry, reload, and removal Resolve the current bookmark and use permission or operation results
Reactive UI Immutable collection snapshots and a snapshot stream No collection snapshot model in the documented API

Choose persistent_file_access when the application owns a collection of individually selected files or directories, targets macOS and Windows, or needs to keep unavailable entries visible and retryable. Choose directory_bookmarks when a directory-centric API and its built-in relative-path file helpers are a closer match, especially if its developing Android support is relevant.

Getting started

Add the package to a Flutter desktop application:

flutter pub add persistent_file_access

1. Open a collection

Use a stable key for each collection in the application. The package restores its saved entries as part of open.

import 'dart:io';

import 'package:flutter/widgets.dart';
import 'package:persistent_file_access/persistent_file_access.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final recentItems = await PersistentFileAccessSet.open(
    'recent-filesystem-items',
  );

  // Pass recentItems to the part of the application that owns it.
}

No platform check or backend selection is needed for normal macOS and Windows usage.

2. Add files and directories selected by the user

This package accepts File and Directory objects. Use any desktop file picker to obtain the user's selection, then add it to the collection:

final selectedFile = File(filePathFromPicker);
final selectedDirectory = Directory(directoryPathFromPicker);

await recentItems.add(selectedFile);
await recentItems.add(selectedDirectory);

add returns true when the collection or the item's availability changed, and false when the same available item was already present. Use addDetailed when the UI needs to distinguish an insertion, replacement, recovery, or unchanged item:

final result = await recentItems.addDetailed(selectedFile);
print(result.disposition);
print(result.entry.status);

3. Display restored entries

The current snapshot contains every retained item. Temporarily unavailable items remain in the collection so they can recover later, for example when a removable or network volume reconnects.

for (final entry in recentItems.snapshot.entries) {
  print('${entry.lastKnownPath}: ${entry.status}');
}

final ready = recentItems.snapshot.availableEntries;
final unavailable = recentItems.snapshot.unavailableEntries;

Each entry has an opaque id, its file or directory kind, a lastKnownPath suitable for display, and a status. Use the ID as a UI key or to target collection operations; do not parse or persist it separately.

4. Read or write through withAccess

Perform all filesystem work inside a withAccess callback:

final entry = recentItems.snapshot.availableEntries.firstWhere(
  (entry) => entry.kind == PersistentAccessEntityKind.file,
);

final contents = await recentItems.withAccess(entry, (entity) async {
  final file = entity as File;
  return file.readAsString();
});

The callback boundary is part of the API contract. Finish asynchronous reads and writes, consume lazy streams, and close open handles before the callback returns. Do not retain or return the supplied FileSystemEntity for later use.

withAccessById is useful when application state stores the entry ID:

await recentItems.withAccessById(entry.id, (entity) async {
  // Complete filesystem work here.
});

Access is checked again when the callback starts, so an operation may still throw if the item or filesystem changed after the latest snapshot.

5. Retry or remove unavailable entries

Temporary recovery failures do not delete an item. Retry all unavailable entries with reload, or retry just one entry:

await recentItems.reload();

final unavailableEntry = recentItems.snapshot.unavailableEntries.first;
final refreshed = await recentItems.retryEntry(unavailableEntry);

By default, reload retries unavailable entries without probing entries that are already available. To refresh every entry's availability, use:

await recentItems.reload(revalidateAvailable: true);

Revalidation can wait for slow or disconnected volumes. Use it when the UI needs a fresh availability check rather than on every rebuild.

Remove an available item by entity, or remove any retained entry by its public identity:

await recentItems.remove(selectedFile);
await recentItems.removeEntry(unavailableEntry);
// Equivalent when only the ID is available:
await recentItems.removeById(unavailableEntry.id);

Prefer removeEntry or removeById for UI actions because they still work when the file or directory cannot currently be reached.

6. Observe changes and close the collection

Read snapshot for the initial state, then listen to snapshots for accepted adds, removals, reloads, and retries:

final initial = recentItems.snapshot;
final subscription = recentItems.snapshots.listen((snapshot) {
  // Rebuild application state from snapshot.
});

// When the owner is disposed:
await subscription.cancel();
await recentItems.close();

close waits for already admitted withAccess callbacks to finish, closes the snapshot stream, and rejects later operations. Do not call close from inside a withAccess callback because it would wait for that same callback.

Recovery behavior

Opening a collection attempts to recover every saved item. A successful item is available; an item that may recover later is temporarilyUnavailable and remains saved. A stored entry is discarded only when its data is proven to be permanently invalid.

The collection reports discarded entries in snapshot.discardedEntries. snapshot.discardedEntriesRemoved tells whether their removal was successfully saved. Pass onIssue to open when diagnostics are needed for failures that the collection handled safely:

final recentItems = await PersistentFileAccessSet.open(
  'recent-filesystem-items',
  onIssue: (issue) {
    debugPrint('Entry ${issue.id} failed during ${issue.phase}: ${issue.error}');
  },
);

Paths and filesystem errors may contain sensitive user information. Avoid sending lastKnownPath, entry.error, or raw issue details to telemetry unless the user expects it.

The same application flow applies on both supported platforms. Operating-system filesystem behavior can still affect recovery: a saved item may become unavailable after it is deleted, disconnected, or moved somewhere the operating system cannot resolve. In every case, use the snapshot status and retry/remove APIs above rather than adding platform-specific application logic.

macOS application setup

Windows requires no package-specific runner configuration. A sandboxed macOS application must enable user-selected read/write access and app-scoped bookmarks in its entitlement files:

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

Only add a file or directory after the user has authorized it through an operating-system picker, drag and drop, or another user-approved interaction. The application owns these entitlements; a Dart package cannot add them to the macOS runner.

Before releasing a sandboxed macOS application, verify the complete user flow:

  1. Select a file and a directory outside the application container.
  2. Quit and relaunch the application.
  3. Read both restored entries through withAccess.
  4. Make an entry unavailable and confirm the UI offers retry or removal.
  5. Remove an entry and confirm it stays removed after another restart.

Advanced configuration

The default PersistentFileAccessSet.open(key) configuration is recommended for new macOS and Windows collections.

Import the storage library to inject a PersistentSetStorage, usually for tests or an application-specific transactional store:

import 'package:persistent_file_access/persistent_file_access_storage.dart';

final items = await PersistentFileAccessSet.open(
  'recent-filesystem-items',
  storage: customStorage,
);

Import the backend library only when configuring or implementing a backend:

import 'package:persistent_file_access/persistent_file_access_backend.dart';

MacOSPersistentAccessBackend.forEntityKind and legacyHandleKind exist for applications migrating older, untagged macOS bookmark stores. New collections do not need them. WindowsPathPersistentAccessBackend(caseSensitivePaths: true) is available for applications that deliberately store selections on a case-sensitive Windows filesystem.

Implement PersistentAccessBackend only when supporting another platform or a custom access mechanism. A backend owns handle creation, recovery, identity, and the bounded lifecycle used by withAccess; applications using the built-in macOS and Windows support should not interact with handles or backends directly.

Example

The example directory contains a desktop application that selects files and directories, displays their recovery state, reads them through withAccess, retries unavailable entries, and removes saved entries.

Libraries

persistent_file_access
Persistent access to user-selected files and directories across app restarts.
persistent_file_access_backend
Advanced backend APIs for persistent filesystem access.
persistent_file_access_storage
Advanced storage APIs for PersistentFileAccessSet.