ScanStore Architecture
How ScanStore coordinates SwiftUI state, why it owns sub-stores via closure-injected hooks, and what's planned for v0.5.0+.
1. ScanStore: the coordinator
ScanStore is the root SwiftUI state object for the StorageScope Mac app. It lives in
Sources/StorageScope/Stores/ScanStore.swift and is declared as
@MainActor final class ScanStore: ObservableObject.
It is constructed exactly once in the app delegate
(Sources/StorageScope/App/StorageScopeApp.swift) as
private let store = ScanStore(), then injected into the view tree as an
@ObservedObject on ContentView, SidebarView,
DetailView, InspectorView, TreeExplorerView,
CleanupReviewView, DuplicateCandidatesView,
TypeBreakdownView, OverviewView, and SettingsView.
ScanStore owns the active scan, scan session state, selection state, the duplicate hash
cache, security-scoped bookmark store, and the derived item/cleanup/reclaim caches that power the UI.
It also composes three sub-stores that own narrower responsibility:
RecentsStore— persisted recent-folder URLs.OnDemandVerificationStore— user-triggered "Verify Now" duplicate hashing.FilterStore— search/size/type/sort/cleanup-lane/old-file-age filter state.
The v0.5.0 era is deliberately splitting further responsibility out of ScanStore into
additional sub-stores. Section 5 enumerates what's planned.
Why sub-stores? A single monolithic ObservableObject makes SwiftUI
observation too coarse: any @Published mutation invalidates the whole view tree that observes
the store. Carving selection, filters, and verification into their own
ObservableObjects lets SwiftUI re-render only the views that observe the changed sub-store.
2. Sub-store extraction pattern (v0.5.0 Tier 2)
The canonical pattern for a v0.5.0 sub-store declares it as a lazy var on
ScanStore and wires three closure-injected hooks so the sub-store can read parent state
and ask the parent to coordinate, without holding an owning reference back into ScanStore:
lazy var filters = FilterStore(
scanLookup: { [weak self] in self?.scan },
coordinateInvalidate: { [weak self] in self?.invalidateDerivedCaches() },
reportError: { [weak self] in self?.errorMessage = $0 }
)
The three closure-injected hooks
scanLookup: () -> StorageScan?— the sub-store reads the currentStorageScanwithout holding an owning reference.FilterStoreuses this to walk the retained-items tree when rebuilding its search-subtree match index;OnDemandVerificationStoreuses it to check whether a group is already covered by a verified duplicate group.coordinateInvalidate: () -> Void— the sub-store asks the parent to drop its derived caches whenever filter / verification state changes. Each@Publishedsetter onFilterStorethat affects item ordering or visibility callscoordinateInvalidate().reportError: (String) -> Void— the sub-store surfaces a user-facing error string through the parent's@Published var errorMessage: String?, so a single banner inContentViewcan render errors from any sub-store.
Current state vs. canonical pattern. FilterStore today only
opts into scanLookup and coordinateInvalidate — it has no user-facing
error path of its own. OnDemandVerificationStore uses all three hooks
(scanLookup, coordinateInvalidate, reportError). The three-hook
shape above is the v0.5.0 canonical form new sub-stores should follow.
Why lazy var
Sub-stores capture self in their init via closures. If they were initialized eagerly
during ScanStore.init, the closures would close over not-yet-initialized stored properties
and a later access through self?.scan (a computed property that reads
session.scan) would be undefined behavior. lazy var defers construction until
first access, by which point ScanStore.init has finished and every stored property is
initialized.
Why [weak self]
The closures live for the lifetime of the sub-store, which is owned by ScanStore. A
strong capture would create a retain cycle (ScanStore → sub-store → closure
→ ScanStore) and prevent the coordinator from deallocating when the app tears down the
window. [weak self] lets the closure safely no-op once the store is gone, and never extends
ScanStore's lifetime.
3. The filterBinding bridge
SwiftUI's dynamic member lookup on @ObservedObject ($store.query,
$store.filters.query) does not produce a Binding<String>
when the path crosses an ObservableObject sub-store boundary. Only the
@Observable macro (Swift Observation) traverses that boundary automatically; under the
classic ObservableObject + @Published world StorageScope uses today, a sub-store
property does not project a typed Binding<T> the way $store.query does.
So FilterStore's filter fields — searchText, query,
sizeFilter, sortOption, fileTypeFocus,
cleanupLaneFilter, oldFileAgeDays, includeHiddenFiles
— need a hand-rolled bridge to be consumable from Picker, Stepper,
Toggle, and .searchable(text:):
internal func filterBinding<T>(
_ keyPath: ReferenceWritableKeyPath<FilterStore, T>
) -> Binding<T> {
Binding(
get: { self.filters[keyPath: keyPath] },
set: { self.filters[keyPath: keyPath] = $0 }
)
}
Usage:
.searchable(text: store.filterBinding(\.searchText), prompt: "Filter")
Picker("Sort", selection: store.filterBinding(\.sortOption)) {
Text("Size").tag(ItemSortOption.sizeDescending)
Text("Name").tag(ItemSortOption.nameAscending)
}
Stepper(
value: store.filterBinding(\.oldFileAgeDays),
in: 30...365,
step: 30
) {
Text("Older than \(store.oldFileAgeDays) days")
}
Must be internal, not private. The first cut of this
bridge shipped as private func in v0.4.5 PR #38, which made it invisible to the views
outside the ScanStore file and broke Picker/Stepper bindings in
SidebarView and CleanupReviewView. The access modifier is now explicitly
internal. If you copy this shape into a new sub-store, keep the modifier explicit.
When StorageScope migrates to the @Observable macro (see Section 5, Tier 5), this
bridge becomes unnecessary — Bindable(store.filters).$searchText will produce a typed
Binding<String> without per-keyPath shimming.
4. Derived cache invalidation
ScanStore derives several expensive views of the scan on demand — filtered items,
cleanup candidates, old-large-files, the reclaim plan, and total reclaimable bytes. Each derived view is
cached against an Equatable cache key that folds together the inputs that would change the
output:
private struct ItemsCacheKey: Equatable {
let scanFinishedAt: Date
let view: SmartView
let query: String
let sizeFilter: SizeFilter
let sortOption: ItemSortOption
let cleanupLaneFilter: CleanupLaneFilter
let fileTypeFocus: String?
let ignoredCleanupCandidateIDs: Set<String>
}
private struct DerivedCacheKey: Equatable {
let scanFinishedAt: Date
let query: String
let sizeFilter: SizeFilter
let sortOption: ItemSortOption
let cleanupLaneFilter: CleanupLaneFilter
let ignoredCleanupCandidateIDs: Set<String>
}
Each cache is stored as a paired key/value slot on ScanStore:
cachedItemsKey/cachedItems,
cachedCleanupCandidatesKey/cachedCleanupCandidates,
cachedOldLargeFilesKey/cachedOldLargeFiles,
cachedReclaimPlanKey/cachedReclaimPlan,
cachedPotentialReclaimableBytesKey/cachedPotentialReclaimableBytes.
invalidateDerivedCaches() drops every cache key/value pair in one pass:
private func invalidateDerivedCaches() {
cachedCleanupCandidatesKey = nil
cachedCleanupCandidates = []
cachedPotentialReclaimableBytesKey = nil
cachedPotentialReclaimableBytes = 0
cachedReclaimPlanKey = nil
cachedReclaimPlan = ReclaimPlan(sections: [], primaryAction: nil)
cachedOldLargeFilesKey = nil
cachedOldLargeFiles = []
invalidateItemsCache()
}
private func invalidateItemsCache() {
cachedItemsKey = nil
cachedItems = []
}
Sub-stores must not touch these caches directly.
coordinateInvalidate is the only sanctioned way for a sub-store to drop the parent's caches.
A sub-store that wrote to cachedItemsKey directly would free itself from spelling its own
cache inputs in ItemsCacheKey, and then drift the next time an input was added. Always go
through coordinateInvalidate().
5. What's next (Tiers 3–5)
The v0.5.0 sub-store extraction is sequenced into tiers. FilterStore (Tier 2) and OnDemandVerificationStore (Tier 1b) shipped; the remaining tiers are planned:
- Tier 3 (planned, v0.5.0+). Extract
SelectionState—selectedItemID,selectedFolderID,treeExpandedIDsexpand state, and theselectedCleanupCandidateIDs/ignoredCleanupCandidateIDssets — into aSelectionStorewith the same closure-injected hook shape. - Tier 4 (planned). Extract
TrashReviewState—pendingTrashReviewPlan,isMovingToTrash, and the transactional-batch progress counters — into aTrashReviewStore. This is the most latent risk because the transactional batch interacts with the active scan's hash cache and must coordinate viareportErrorfor rolled-back partial failures. - Tier 5 (planned). Migrate
ScanStoreand its sub-stores from the classicObservableObject+@Publishedpattern to the Swift@Observablemacro. This removes thefilterBindingbridge (Section 3) entirely, because the macro lets dynamic member lookup traverse sub-store boundaries. Gated on raising the deployment target to macOS 14 exclusively (the macro requires macOS 14+, and the project currently still supports a macOS 14 baseline with gradual migration rather than a hard cutoff).
6. Store hierarchy
ASCII rendering of the coordinator ↔ sub-store relationship. Boxes are
ObservableObject types; arrows show the closure direction
(coordinateInvalidate flows up to the parent; weak self capture is what lets
the closure no-op after ScanStore deallocs).
+------------------------------------------+
| ScanStore |
| (@MainActor |
| ObservableObject) |
| |
| +------------+ +-------------+ |
| | Recents | | OnDemand | |
| | Store | | Verification| |
| | | | Store | |
| +------------+ +-------------+ |
| |
| +-----------------+ |
| | FilterStore | |
| | - searchText | |
| | - query | |
| | - fileTypeFocus| |
| | - sizeFilter | |
| | - sortOption | |
| | - cleanupLane | |
| | - ActiveFilter | |
| +--------+--------+ |
| | |
| coordinateInvalidate → drop |
| derived caches |
+------------------------------------------+
^
| weak self capture
|
+------------------------------------------+
| FilterStore delegate closures |
| - scanLookup: () -> StorageScan? |
| - coordinateInvalidate: () -> Void |
| - reportError: (String) -> Void |
+------------------------------------------+
Boxes are not memory ownership boxes; they are responsibility boxes. The
sub-stores are owned by ScanStore (either eagerly via let or lazily via
lazy var), and the closures above are how a sub-store talks back up.
See also: source on GitHub · release history · FAQ · Keyboard Shortcuts