ExternalChangeMonitor
A @MainActor poller that watches the storage folder for writes made by other processes — the CLI, or a sync service (Dropbox, iCloud Drive, Google Drive) dropping a new file — and fires a callback so the app can refresh its in-memory state.
Source: Minuta/Sources/Services/ExternalChangeMonitor.swift. App-only; the CLI is short-lived and re-reads on each invocation, so it has no equivalent.
Cadence
The monitor runs a repeating Timer whose interval depends on a Cadence driven by scene phase:
enum Cadence {
case active // 5s
case inactive // 60s
case paused // no timer
var interval: TimeInterval? {
switch self {
case .active: return 5
case .inactive: return 60
case .paused: return nil
}
}
} - active (foreground, visible): polls every 5 seconds.
- inactive (backgrounded or not focused): polls every 60 seconds.
- paused: no timer.
setCadence(_:) swaps the timer only when the cadence actually changes. AppState.updateChangeMonitorCadence(for:) maps ScenePhase.active to .active and both .inactive and .background to .inactive. Becoming active also calls checkNow(), treating focus-gained as an implicit immediate probe.
The check
Each tick runs check(), which coalesces overlapping ticks via an inFlight guard and runs the probe; if the probe reports a change, the change callback fires:
private func check() async {
guard !inFlight else { return } // coalesce overlapping ticks
inFlight = true
defer { inFlight = false }
if await probe() {
await onChange()
}
} The probe is cheap — it delegates to AutomergeStorageService.hasExternalChanges(), which is a handful of stat calls comparing on-disk mtimes against last-observed mtimes (see AutomergeStorageService). Real I/O happens only when a change is detected.
On change
The app wires onChange to AppState.reloadFromExternalChanges(), which runs the full refresh cycle:
func reloadFromExternalChanges() async {
await storage.invalidateCache()
await resolveConflictsIfNeeded()
await loadData()
} invalidateCache()— drop the stale cached Automerge documents.resolveConflictsIfNeeded()— merge any sync-service conflict copies (see Conflict resolution).loadData()— reload tags and records into the published state.
Wiring
AppState.configureChangeMonitor() constructs the monitor with the probe and callback closures, then starts it at .active cadence:
changeMonitor = ExternalChangeMonitor(
probe: { await storageRef.hasExternalChanges() },
onChange: { [weak self] in await self?.reloadFromExternalChanges() }
)
changeMonitor?.setCadence(.active) Related
- Cross-process safety — the app/CLI shared-folder contract this monitor enforces on the app side.
- AutomergeStorageService — provides the
hasExternalChanges()probe andinvalidateCache(). - Conflict resolution — merges conflict copies discovered after an external change.