import AppKit import EventKit import Foundation import LilithLogging import MacSyncShared private let log = AppLogger.logger(for: "ICal.Sync") // MARK: - Stats public struct ICalSyncStats: Equatable, Sendable { public var calendarCount: Int = 0 public var eventCount: Int = 0 public init() {} } // MARK: - Sync Error public enum ICalSyncError: Equatable, Sendable { case none case calendarAccessRequired case backendUnreachable case connectionFailed(String) public var message: String { switch self { case .none: return "" case .calendarAccessRequired: return "Calendar access required" case .backendUnreachable: return "Cannot connect to backend server" case .connectionFailed(let m): return m } } public var isConnectionError: Bool { switch self { case .backendUnreachable, .connectionFailed: return true default: return false } } } // MARK: - SyncManager @MainActor public final class SyncManager: BaseSyncManager { public static let shared = SyncManager() public let reader = CalendarReader.shared private let apiClient = APIClient.shared private let eventBatchSize = 200 // Outbound send queue (server → Mac calendar writes). private lazy var sendQueueClient: SendQueueClient = { let transport = CalendarSendTransport(apiClient: apiClient) let sender = CalendarSender(eventStore: reader.eventStore) return SendQueueClient( label: "ical", transport: transport, interval: 60 ) { item in await sender.apply(item) } }() public override func didStartSync() { sendQueueClient.start() } public override func willStopSync() { sendQueueClient.stop() } private init() { super.init( initialStats: ICalSyncStats(), noError: .none, persistenceKey: "ical", timerInterval: 300 ) // Migrate legacy UserDefaults watermark keys (one-time). if lastSync == nil, let legacy = UserDefaults.standard.object(forKey: "icalLastSync") as? Date { setLastSync(legacy) UserDefaults.standard.removeObject(forKey: "icalLastSync") } if lastSyncCompletedAt == nil, let legacy = UserDefaults.standard.object(forKey: "icalLastSyncCompletedAt") as? Date { lastSyncCompletedAt = legacy UserDefaults.standard.set(legacy, forKey: "ical.lastSyncCompletedAt") UserDefaults.standard.removeObject(forKey: "icalLastSyncCompletedAt") } } // MARK: - Authorization hooks public override func isAuthorized() async -> Bool { reader.isAuthorized } public override func requestAuthorization() async -> Bool { await reader.requestAuthorization() } public override func onAuthorizationDenied() { log.warning("Calendar access denied") syncError = .calendarAccessRequired } public func openCalendarAccessSettings() { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") { NSWorkspace.shared.open(url) } } // MARK: - Sync cycle public override func performSync() async { log.info("performSync starting") // Phase 1: Calendars currentOperation = "Syncing calendars…" let calendars = reader.fetchCalendars() log.info("Fetched \(calendars.count) calendars") let df = ISO8601DateFormatter() if !calendars.isEmpty { let calPayloads = calendars.map { cal in SyncCalendarPayload( calendarIdentifier: cal.calendarIdentifier, title: cal.title, calendarType: calendarTypeString(cal.type), color: cal.color, source: cal.source ) } do { let synced = try await apiClient.syncCalendars(calPayloads) log.info("Synced \(synced) calendars") if syncError.isConnectionError { syncError = .none } } catch { log.warning("Calendar sync failed: \(error.localizedDescription)") setConnectionError(error) } } // Phase 2: Events currentOperation = "Fetching events…" let events = reader.fetchEvents(since: lastSync) log.info("Fetched \(events.count) events") if !events.isEmpty { let batches = stride(from: 0, to: events.count, by: eventBatchSize).map { Array(events[$0.. String { switch type { case .local: return "local" case .calDAV: return "caldav" case .exchange: return "exchange" case .subscription: return "subscription" case .birthday: return "birthday" @unknown default: return "unknown" } } private func eventStatusString(_ status: EKEventStatus) -> String { switch status { case .none: return "none" case .confirmed: return "confirmed" case .tentative: return "tentative" case .canceled: return "canceled" @unknown default: return "none" } } private func eventAvailabilityString(_ av: EKEventAvailability) -> String { switch av { case .notSupported: return "none" case .busy: return "busy" case .free: return "free" case .tentative: return "tentative" case .unavailable: return "unavailable" @unknown default: return "none" } } }