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()
}
  1. invalidateCache() — drop the stale cached Automerge documents.
  2. resolveConflictsIfNeeded() — merge any sync-service conflict copies (see Conflict resolution).
  3. loadData() — reload tags and records into the published state; it bumps historyRevision, so History reloads its date range as well. UI test: ExternalChangeTests.testHistoryFollowsExternalChanges adds, edits and deletes a record of an earlier day of the current month from the test process while the app runs.

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)