186 lines
6.9 KiB
Swift
186 lines
6.9 KiB
Swift
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<Stats: Sendable, SyncError: Sendable>: 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.
|
|
public final func syncNow() {
|
|
guard !isSyncing else { return }
|
|
Task { [weak self] in
|
|
await self?.gatedRunCycle()
|
|
}
|
|
}
|
|
|
|
private func gatedRunCycle() async {
|
|
if !(await self.isAuthorized()) {
|
|
let granted = await self.requestAuthorization()
|
|
guard granted else {
|
|
self.onAuthorizationDenied()
|
|
return
|
|
}
|
|
}
|
|
await self.runCycle()
|
|
}
|
|
|
|
private func runCycle() async {
|
|
isSyncing = true
|
|
await performSync()
|
|
let now = Date()
|
|
setLastSync(now)
|
|
lastSyncCompletedAt = now
|
|
UserDefaults.standard.set(now, forKey: Self.lastSyncCompletedKey(persistenceKey))
|
|
isSyncing = false
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
}
|