macsync/@packages/shared/Sources/MacSyncShared/Sync/BaseSyncManager.swift

187 lines
6.9 KiB
Swift
Raw Normal View History

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"
}
}