macsync/@packages/ical/Sources/ICalSync/SyncManager.swift

247 lines
8.4 KiB
Swift

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<ICalSyncStats, ICalSyncError> {
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<CalendarSendTransport> = {
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..<min($0 + eventBatchSize, events.count)])
}
for (idx, batch) in batches.enumerated() {
currentOperation = "Syncing events… (\(idx + 1)/\(batches.count))"
let payloads = batch.map { event in
SyncEventPayload(
eventIdentifier: event.eventIdentifier,
calendarIdentifier: event.calendarIdentifier,
title: event.title,
notes: event.notes,
location: event.location,
url: event.url,
isAllDay: event.isAllDay,
startDate: df.string(from: event.startDate),
endDate: df.string(from: event.endDate),
createdAt: event.creationDate.map { df.string(from: $0) },
modifiedAt: event.lastModifiedDate.map { df.string(from: $0) },
status: eventStatusString(event.status),
availability: eventAvailabilityString(event.availability),
isDetached: event.isDetached,
hasAlarms: event.hasAlarms,
hasRecurrenceRules: event.hasRecurrenceRules,
recurrenceRule: event.recurrenceRule,
organizerEmail: event.organizerEmail,
organizerName: event.organizerName,
attendeeCount: event.attendeeCount
)
}
do {
let synced = try await apiClient.syncEvents(payloads)
log.info("Batch \(idx + 1)/\(batches.count) synced=\(synced)")
if syncError.isConnectionError { syncError = .none }
} catch {
log.warning("Event batch \(idx + 1) failed: \(error.localizedDescription)")
setConnectionError(error)
}
}
}
await updateStats()
currentOperation = "Sync complete"
log.info("performSync complete calendars=\(calendars.count) events=\(events.count)")
}
private func updateStats() async {
do {
let response = try await apiClient.getStats()
stats.calendarCount = response.totalCalendars
stats.eventCount = response.totalEvents
log.info("Stats: calendars=\(response.totalCalendars) events=\(response.totalEvents)")
} catch {
log.warning("updateStats failed: \(error.localizedDescription)")
}
}
private func setConnectionError(_ error: Error) {
if SyncConnectionErrorHeuristic.isConnectionError(error) {
syncError = .backendUnreachable
}
}
// MARK: - String helpers
private func calendarTypeString(_ type: EKCalendarType) -> 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"
}
}
}