Skip to content

Architecture

Photo Export follows a small SwiftUI + Managers architecture. Views stay relatively thin, stateful workflows live in managers or view models, and the project relies on Apple system frameworks only.

App/photo_exportApp.swift creates the shared app state and injects it into the view tree with @EnvironmentObject.

Long-lived stateful services and pure helpers are split by feature area under photo-export/:

  • App/ — entry point + lifecycle services: photo_exportApp, LoginItemController, AppLifecycleCoordinator, DiagnosticReporter, WhatsNewState
  • Export/ — façade + collaborators: ExportManager (+ ExportManager+AutoSyncConformance), ExportQueueCoordinator, VariantExporter, ImportCoordinator, and the helper-policy types: ExportJobPlanner, ExportFilenamePolicy, ExportPathPolicy, ExportPlacementResolver, ResourceSelection, ProductionAssetResourceWriter, ProductionMediaRenderer, FileIOService, ExportRecordsDirectoryCoordinator, BackupScanner
  • Destination/ExportDestinationManager, DestinationSafetyMonitor, DestinationSnapshotAdapter, FileBackedDestinationSafetyConfirmationStore, ExportDestinationResolver
  • Records/ExportRecordStore, CollectionExportRecordStore, JSONLRecordFile, RecordStoreRouter (single placement-kind dispatch), ExportCompletionPolicy (single edited-fallback/required-variant rule)
  • AutoSync/AutoSyncManager, AutoSyncReducer, AutoSyncEnvironment, with AutoSync/Stores/ for file-backed persistence (dirty state, per-destination tokens, retry state, run summary, scope, global photo-change token)
  • PhotoLibrary/PhotoLibraryManager, ScreenshotPhotoLibraryService, PhotoLibraryPersistentChangeAdapter, CollectionCountCache
  • Protocols/, Models/, ViewModels/, Helpers/, Resources/
  • Views/ regrouped by feature: Timeline/, Collections/, Export/, Settings/, Shared/

The major UI-facing managers retain their original names:

Handles Photos authorization and asset fetching. Uses PHCachingImageManager for thumbnail work.

  • Queries assets grouped by year and month
  • Provides both thumbnail and full-size image loading
  • Manages Photos library authorization flow

Manages the chosen export destination folder using security-scoped bookmarks, and owns the stable logical destination id that keys every per-destination store.

  • Presents the macOS folder picker
  • Persists the selection via security-scoped bookmarks
  • Validates folder accessibility on launch
  • Owns a stable destination id, persisted beside the bookmark, seeded from the destination’s volume/path fingerprint once and then reused for the life of that selection. Reusing it keeps a network-share remount (whose path-derived fingerprint changes) from re-exporting everything; the fingerprint is kept only as a confidence signal for the Auto Export safety check.
  • Publishes identity (stable id + fingerprint) as one value so subscribers never see a stale id paired with a fresh fingerprint

Tracks which assets have been exported per-destination (keyed by the stable destination id) to avoid duplicates and support resume.

  • Stores records by PHAsset.localIdentifier
  • Per-variant state: each record carries a variants dictionary keyed by original / edited (plus originalPairedVideo / editedPairedVideo when the Live Photo paired toggle is on), so each file the asset writes is tracked independently
  • Legacy flat records (single filename + status) decode into a synthesized .original variant so existing users keep their progress after upgrade
  • JSONL-based persistence with compaction
  • Reconfigures automatically when the destination changes
  • Provides selection-aware month summaries for the thumbnail grid and approximate counts for sidebar badges

Sibling store for collection exports (Favorites, user albums, and iCloud shared albums). Lives next to ExportRecordStore on disk under the same per-destination directory but uses its own files (collection-records.json / collection-records.jsonl). The two stores never share a key — a .timeline placement is rejected at every collection-store API entry point — so a corrupt collection store cannot affect timeline progress and vice versa.

  • Records are keyed by (placementId, assetId); the placement itself is keyed by kind/collectionLocalIdentifier/displayPathHash8, so a renamed or moved album resolves to a fresh placement on its next export
  • ExportPlacementResolver decides the on-disk path. User albums land under Collections/Albums/... (with folder hierarchy and _2/_3 suffix disambiguation when two distinct albums sanitize to the same folder name); shared albums land under Collections/Shared Albums/... with the same suffix rules scoped to other shared albums. The disjoint path prefixes mean a user album and a shared album with the same title cannot collide.
  • Shared-album placements set kind.variantPolicy == .singleResource, which the pipeline reads to pin requiredVariants(...) to [.original] — Photos only exposes one downscaled JPEG per shared-album asset, so the edited/original split is meaningless and “Include originals” becomes a no-op for them
  • Same JSONL+snapshot persistence mechanics as the timeline store, including the deferred-rename corruption-recovery flow

Top-level orchestrator over the export collaborators. Depends on the other three injected managers plus the extracted owners listed below.

  • runExport(context:) awaitable API for AutoSync; start* fire-and-forget methods for the toolbar/menu UI (startExportAll, startExportYear, startExportMonth, startExportFavorites, startExportAllAlbums, startExportAllSharedAlbums, startExportTimelineSelection, startExportCollectionsSelection). The two *Selection methods are the bulk dispatchers behind sidebar multi-select — they accept normalized buckets and route each item through the existing single-select helpers so dedup against the record store is shared. The bulk dispatchers share a private runBulkExportTask / runBulkEnqueueLoop helper pair so the outer Task scaffolding and the per-item loop are defined once.
  • Exposes generation: Int as a computed forwarder to ExportQueueCoordinator.generation — the Phase-0 cancellation storage moved into the coordinator. The published mirrors for isRunning, queueCount, totalJobsEnqueued/Completed, isImporting, importStage are sunk from the coordinators via Combine.
  • Persists the user’s version selection (edited / editedWithOriginals) in UserDefaults and snapshots it onto each enqueued job.
  • Delegates per-concern work to extracted owners (see below).

Export-pipeline owners (extracted from ExportManager during the architecture refactor)

Section titled “Export-pipeline owners (extracted from ExportManager during the architecture refactor)”
  • ExportJobPlanner (pure enum, Export/ExportJobPlanner.swift) — turns assets + skip predicates into [ExportJob]. Owns the isExportedshouldSkipForRetry predicate-order discipline.
  • ExportQueueCoordinator (@MainActor final class, Export/ExportQueueCoordinator.swift) — owns pendingJobs, isProcessing, currentTask, queue counters, pause/resume/cancel, the drain loop, and the Phase-0 cancellation seam (generation, isCurrent, throwIfCancelledOrStale, bumpGeneration). Publishes queue state which ExportManager mirrors. VariantExporter and ImportCoordinator hold a direct weak reference for the seam.
  • VariantExporter (@MainActor final class, Export/VariantExporter.swift) — per-variant write path: resource selection, destination temp setup, reuse-source copy, atomic move, timestamps, per-variant exported/failed record write.
  • ImportCoordinator (@MainActor final class, Export/ImportCoordinator.swift) — entire scanner → matcher → bulkImport → reconcile flow for the Import Existing Backup feature.
  • ExportDestinationResolver (Sendable struct, Destination/ExportDestinationResolver.swift) — destination URL + filename allocator: stem allocation, _orig companion naming, inherited group stem from prior records, unique-filename collision suffixing.
  • RecordStoreRouter (@MainActor final class, Records/RecordStoreRouter.swift) — single placement-kind dispatch over the timeline and collection record stores (reads, writes, cancellation cleanup, reuse-source probe).
  • ExportCompletionPolicy (pure enum, Records/ExportCompletionPolicy.swift) — required-variant logic + edited-fallback decisions + asset-complete checks.

ExportFilenamePolicy decides the _orig companion filename shape; ResourceSelection picks between original-side and edited-side PHAssetResources. Atomic writes via per-variant temp file → move to final location, with stale .tmp cleanup at export start. Per-variant records are updated after each successful write; a failed edited variant does not roll back a completed original variant.

Orchestrates Auto Export. Wraps a pure AutoSyncReducer with a Combine-based effect runner so timing-sensitive decisions (debounce, retry backoff, run dispatch) are testable without wall-clock waits.

  • Pure reduce(event, state, now) -> (state, [effect]) reducer. Tests assert effect-list equality without executing them.
  • Subscribes to seven publishers on attach(to:): destination snapshot, scope selection, version selection, import state, export run state, completed-run summaries (manual-run completion hook), and PhotoKit persistent changes.
  • Sequential per-scope fan-out for the .autoExport(scopes) run shape; cancellable on disable / destination clear so a long multi-scope chain stops at the next await boundary.
  • Per-destination persistence under <App Support>/<bundleId>/AutoSync/: dirty-state JSON, retry-state JSON, last run summary, last durably- recorded persistent-change token. Global token at the same parent.
  • Read-modify-write paths for retry state use the manager’s in-memory @Published currentRetryState as the source of truth for the currently- active destination, so successive mutations (failure recording, manual Retry) compose correctly across a multi-scope fan-out.

Adapts PhotoKit’s PHPhotoLibraryChangeObserver + fetchPersistentChanges into the Result-typed event stream AutoSync consumes.

  • Establishes a baseline on first launch (silent — no event emitted), runs an immediate catch-up fetch on start() to surface any changes that landed while the app was quit, then emits one PhotoLibraryPersistentChangeEvent per subsequent photoLibraryDidChange callback carrying inserted / updated / deleted local identifiers
  • Maps the three documented PhotoKit failure modes (token expired, token invalid, details unavailable) onto the corresponding PhotoLibraryPersistentChangeFetchError cases so AutoSync can route each to a bounded full-reconciliation fallback
  • Subscribes to PhotoLibraryManager.$authorizationStatus so the observer registers on the first sufficient status, not just at app launch
  • Skipped under XCTest / swift-testing so the test process doesn’t trip the Photos-library permission prompt at DerivedData paths

Detects the Phase 0b “non-empty destination with no matching records” state and surfaces it as @Published needsSafetyConfirmation, which DestinationSnapshotAdapter folds into the DestinationSnapshot.safety field as .unsafeNeedsConfirmation.

  • Closure-based directory scan injection so unit tests can drive the monitor without a real ExportDestinationManager
  • Stale-generation guard discards late-arriving scan results when the user has already switched destinations
  • Confirmation persists per-destination at AutoSync/destinations/<id>/safetyRecord.json so previously-confirmed destinations never re-prompt

Owns destination-change handling and the migration-conflict detection that surfaces as .unsafeMigrationConflict. gcLegacyState closure-based dependency keeps filesystem layout decisions out of the coordinator and in PhotoExportApp where the record-store directory roots are known.

  • BackupScanner scans an existing backup folder and matches files back to Photos assets. Its fingerprints split original-side and edited-side resource filenames so each scanned file is classified per variant before being merged into the record store.
  • ExportFilenamePolicy is the shared source of truth for _orig companion filename rules and used by both the export pipeline and the backup scanner.
  • ResourceSelection picks the right PHAssetResource for a variant and is the shared classifier for “original-side” vs “edited-side” resources.
  • FileIOService centralizes atomic file moves and timestamp handling.

The main UI lives under photo-export/Views/ and photo-export/ViewModels/.

TypeResponsibility
ContentViewTop-level router (auth → onboarding → library)
LibraryRootViewNavigationSplitView shell with the Timeline / Collections segmented selector
TimelineSidebarViewYear/month tree with Cmd/Shift-click multi-select; reports selection via TimelineSelectionBuckets
CollectionsSidebarViewFavorites, user albums and folders (with Cmd/Shift-click multi-select), and a separate Shared Albums section, lazy-counted via cachedCountAssets(in:)
YearContentViewContent pane shown when a year row is the focused selection (year-level summary + primary action, no asset grid)
MonthContentViewThumbnail grid for the selected month
CollectionContentViewThumbnail grid for Favorites, a user album, or a shared album, sharing MonthViewModel via a scope-based loader
AssetDetailViewFull-size image or video preview
ExportToolbarViewExport destination and queue controls
RecordStoreAlertHostSurfaces a corruption-recovery alert when either record store transitions to .failed
OnboardingViewFirst-run flow for permissions and destination setup
ImportViewProgress and results for importing an existing backup
MonthViewModelCancellation-aware asset loading for any PhotoFetchScope (timeline / favorites / album / shared album)

The export record store keeps per-destination state under Application Support. A detailed format description lives in persistence-store.md.

  • Logging: os.Logger with subsystem com.valtteriluoma.photo-export — visible in Console.app for diagnostics.
  • Concurrency: UI-facing managers run on the main actor. Export is sequential rather than parallel, by design.
  • Asset identity: Tracked by PHAsset.localIdentifier. Existing files in the destination are never overwritten — re-running an export resumes where it left off.

Contributor-facing conventions (linting rules, formatting, the SwiftUI + Managers code style) live in the contributor guide and AGENTS.md.