import Combine import Foundation import LilithLogging private let log = AppLogger.logger(for: "Sync.Base") /// Open base class that captures the cross-module sync lifecycle every module /// previously re-implemented: /// /// * `@Published` state machine (`isSyncing`, `lastSyncCompletedAt`, /// `currentOperation`, `syncError`, `stats`) /// * UserDefaults watermark persistence keyed by a per-module /// `persistenceKey` /// * Periodic `Timer` lifecycle (`startSync`/`stopSync`/`syncNow`) /// * Authorization gate hook (`requestAuthorization` / `isAuthorized` / /// `onAuthorizationDenied`) /// /// Subclasses override `performSync()` to do the actual work and may override /// the authorization hooks when the module needs OS permission (EventKit, /// AppleScript automation, etc.). All lifecycle methods are `final` so the /// contract cannot drift between modules. /// /// Generic parameters are intentionally lightweight: this class deliberately /// avoids constraining `SyncError` to a protocol since each module's error /// enum has its own associated values. Connection-error detection lives in /// the free helper `SyncConnectionErrorHeuristic` so subclasses can map a /// caught `Error` into their own enum. @MainActor open class BaseSyncManager: ObservableObject { // MARK: - Published state (single source of truth across all modules) @Published public var isSyncing: Bool = false @Published public var lastSyncCompletedAt: Date? @Published public var currentOperation: String = "" @Published public var syncError: SyncError @Published public var stats: Stats // MARK: - Watermark (Mac → server delta cursor) /// Last time a sync cycle started successfully. Subclasses read this to /// scope incremental fetches; the base updates it after every cycle. public private(set) var lastSync: Date? // MARK: - Configuration public let persistenceKey: String public let timerInterval: TimeInterval // MARK: - Internals private var syncTimer: Timer? // MARK: - Init public init( initialStats: Stats, noError: SyncError, persistenceKey: String, timerInterval: TimeInterval = 300 ) { precondition(!persistenceKey.isEmpty, "persistenceKey must be non-empty") precondition(timerInterval > 0, "timerInterval must be positive") self.stats = initialStats self.syncError = noError self.persistenceKey = persistenceKey self.timerInterval = timerInterval self.lastSync = UserDefaults.standard.object(forKey: Self.lastSyncKey(persistenceKey)) as? Date self.lastSyncCompletedAt = UserDefaults.standard.object(forKey: Self.lastSyncCompletedKey(persistenceKey)) as? Date log.info("[\(self.persistenceKey)] init lastSync=\(String(describing: self.lastSync))") } deinit { syncTimer?.invalidate() } // MARK: - Lifecycle (final — single source of truth) /// Start periodic syncing. Kicks one cycle immediately, then schedules a /// repeating `Timer` at `timerInterval`. Calls `didStartSync()` once the /// read timer is armed so subclasses can start additional resources /// (e.g. a SendQueueClient for the outbound direction). public final func startSync() { log.info("[\(self.persistenceKey)] startSync") Task { [weak self] in guard let self else { return } await self.gatedRunCycle() } syncTimer?.invalidate() syncTimer = Timer.scheduledTimer(withTimeInterval: timerInterval, repeats: true) { [weak self] _ in Task { @MainActor in self?.syncNow() } } log.info("[\(self.persistenceKey)] scheduled timer every \(self.timerInterval)s") didStartSync() } /// Stop periodic syncing. Calls `willStopSync()` before invalidating the /// read timer so subclasses can tear down additional resources. public final func stopSync() { willStopSync() syncTimer?.invalidate() syncTimer = nil } /// Run a single cycle immediately if one is not already in flight. /// `isSyncing` is flipped synchronously here so rapid back-to-back calls /// from the same actor coalesce into one in-flight cycle. public final func syncNow() { guard !isSyncing else { return } isSyncing = true Task { [weak self] in await self?.gatedRunCycle() } } private func gatedRunCycle() async { defer { isSyncing = false } if !(await self.isAuthorized()) { let granted = await self.requestAuthorization() guard granted else { self.onAuthorizationDenied() return } } await self.runCycle() } private func runCycle() async { await performSync() let now = Date() setLastSync(now) lastSyncCompletedAt = now UserDefaults.standard.set(now, forKey: Self.lastSyncCompletedKey(persistenceKey)) } // MARK: - Watermark helpers (subclasses may need to reset on full re-sync) /// Update `lastSync` and persist it. Pass `nil` to clear the watermark /// (e.g., a "Reset and resync" flow). public func setLastSync(_ date: Date?) { lastSync = date if let date { UserDefaults.standard.set(date, forKey: Self.lastSyncKey(persistenceKey)) } else { UserDefaults.standard.removeObject(forKey: Self.lastSyncKey(persistenceKey)) } } // MARK: - Subclass hooks (override these — not the lifecycle methods) /// Subclasses MUST override. Perform the actual sync work; the base /// handles state transitions before and after the call. open func performSync() async { assertionFailure("BaseSyncManager.performSync must be overridden by \(type(of: self))") } /// Override when the module requires OS permission (EventKit, AppleScript /// automation, Mail.app, etc.). Default returns `true` (no permission /// required). open func requestAuthorization() async -> Bool { true } /// Override to check the current authorization status without prompting. /// Default returns `true`. open func isAuthorized() async -> Bool { true } /// Override to set a module-specific `syncError` when authorization is /// denied. Default is a no-op. open func onAuthorizationDenied() { } /// Called from `startSync()` after the read timer is armed. Subclasses /// with a bidirectional Sender override this to start their /// `SendQueueClient`. Default is a no-op. open func didStartSync() { } /// Called from `stopSync()` before the read timer is torn down. /// Subclasses override to stop additional resources (SendQueueClient /// timers, AppleScript probes, etc.). Default is a no-op. open func willStopSync() { } // MARK: - UserDefaults keys private static func lastSyncKey(_ persistenceKey: String) -> String { "\(persistenceKey).lastSync" } private static func lastSyncCompletedKey(_ persistenceKey: String) -> String { "\(persistenceKey).lastSyncCompletedAt" } }