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.

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