learning_ai_clock/ios/ChronoMind/Shared/Cloud/PlatformSyncManager.swift

450 lines
14 KiB
Swift

// Platform Sync Manager
// Cross-platform sync via platform-service REST API
// Replaces iCloud KV store with proper delta sync + conflict resolution
// Endpoints: /timers, /timers/sync, /timers/batch, /routines/*
import Foundation
import Combine
@MainActor
final class PlatformSyncManager: ObservableObject {
static let shared = PlatformSyncManager()
// MARK: - Published State
@Published var isSyncing = false
@Published var lastSyncDate: Date?
@Published var syncEnabled: Bool {
didSet {
UserDefaults.standard.set(syncEnabled, forKey: Keys.syncEnabled)
if syncEnabled { schedulePeriodicSync() } else { cancelPeriodicSync() }
}
}
@Published var pendingChanges: Int = 0
@Published var lastError: String?
// MARK: - Config
private let baseURL: String
private let session: URLSession
private var authToken: String?
private var syncTask: Task<Void, Never>?
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private struct Keys {
static let syncEnabled = "chronomind-platform-sync-enabled"
static let lastSync = "chronomind-platform-last-sync"
static let offlineQueue = "chronomind-offline-queue"
static let authTokenKeychain = "chronomind-sync-auth-token"
}
// MARK: - Init
private init() {
// Default to localhost for dev; production URL via Info.plist or env
baseURL = Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String
?? "https://api.chronomind.app"
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.waitsForConnectivity = true
session = URLSession(configuration: config)
encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
syncEnabled = UserDefaults.standard.bool(forKey: Keys.syncEnabled)
authToken = KeychainHelper.read(key: Keys.authTokenKeychain)
if let date = UserDefaults.standard.object(forKey: Keys.lastSync) as? Date {
lastSyncDate = date
}
loadOfflineQueue()
if syncEnabled {
schedulePeriodicSync()
}
}
// MARK: - Auth
func setAuthToken(_ token: String?) {
authToken = token
if let token = token {
KeychainHelper.save(key: Keys.authTokenKeychain, value: token)
} else {
KeychainHelper.delete(key: Keys.authTokenKeychain)
}
}
var isAuthenticated: Bool { authToken != nil }
// MARK: - Sync Operations
/// Full delta sync: pull changes since last sync, push local changes
func sync(localTimers: [CMTimer]) async -> SyncResult {
guard syncEnabled, isAuthenticated else {
return SyncResult(pulled: [], conflicts: [], error: "Not authenticated or sync disabled")
}
isSyncing = true
lastError = nil
defer { isSyncing = false }
do {
// 1. Pull remote changes since last sync
let pulled = try await pullDelta()
// 2. Push local offline queue
let batchResult = try await pushOfflineQueue()
// 3. Update last sync timestamp
lastSyncDate = Date()
UserDefaults.standard.set(lastSyncDate, forKey: Keys.lastSync)
return SyncResult(
pulled: pulled,
conflicts: batchResult.conflicts,
error: nil
)
} catch {
lastError = error.localizedDescription
return SyncResult(pulled: [], conflicts: [], error: error.localizedDescription)
}
}
/// Pull timers modified since last sync
func pullDelta() async throws -> [SyncTimerDTO] {
var urlString = "\(baseURL)/timers/sync"
if let lastSync = lastSyncDate {
let iso = ISO8601DateFormatter().string(from: lastSync)
urlString += "?since=\(iso)"
}
guard let url = URL(string: urlString) else {
throw SyncError.invalidURL
}
let (data, response) = try await authenticatedRequest(url: url, method: "GET")
try validateResponse(response)
return try decoder.decode([SyncTimerDTO].self, from: data)
}
/// Push a single timer change to the server
func pushTimer(_ timer: CMTimer) async throws -> SyncTimerDTO {
guard let url = URL(string: "\(baseURL)/timers") else {
throw SyncError.invalidURL
}
let dto = timerToDTO(timer)
let body = try encoder.encode(dto)
let (data, response) = try await authenticatedRequest(url: url, method: "POST", body: body)
try validateResponse(response)
return try decoder.decode(SyncTimerDTO.self, from: data)
}
/// Update an existing timer on the server
func updateTimer(_ timer: CMTimer, syncVersion: Int) async throws -> SyncTimerDTO {
guard let url = URL(string: "\(baseURL)/timers/\(timer.id)") else {
throw SyncError.invalidURL
}
var dto = timerToUpdateDTO(timer)
dto.syncVersion = syncVersion
let body = try encoder.encode(dto)
let (data, response) = try await authenticatedRequest(url: url, method: "PUT", body: body)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 409 {
throw SyncError.conflict
}
try validateResponse(response)
return try decoder.decode(SyncTimerDTO.self, from: data)
}
/// Delete a timer on the server
func deleteTimer(id: String) async throws {
guard let url = URL(string: "\(baseURL)/timers/\(id)") else {
throw SyncError.invalidURL
}
let (_, response) = try await authenticatedRequest(url: url, method: "DELETE")
try validateResponse(response)
}
/// Batch upsert (flush offline queue)
func pushOfflineQueue() async throws -> BatchResult {
let queue = loadOfflineQueueItems()
guard !queue.isEmpty else {
return BatchResult(synced: [], conflicts: [], errors: [])
}
guard let url = URL(string: "\(baseURL)/timers/batch") else {
throw SyncError.invalidURL
}
let body = try encoder.encode(["timers": queue])
let (data, response) = try await authenticatedRequest(url: url, method: "POST", body: body)
try validateResponse(response)
let result = try decoder.decode(BatchResult.self, from: data)
// Clear synced items from queue
clearOfflineQueue(syncedIds: result.synced)
pendingChanges = loadOfflineQueueItems().count
return result
}
// MARK: - Offline Queue
func enqueueChange(_ timer: CMTimer, action: OfflineAction) {
var queue = loadOfflineQueueItems()
// Replace existing entry for same timer ID
queue.removeAll { $0.id == timer.id }
queue.append(OfflineQueueItem(
id: timer.id,
action: action,
timer: timerToDTO(timer),
enqueuedAt: Date()
))
saveOfflineQueue(queue)
pendingChanges = queue.count
}
func enqueueDelete(timerId: String) {
var queue = loadOfflineQueueItems()
queue.removeAll { $0.id == timerId }
queue.append(OfflineQueueItem(
id: timerId,
action: .delete,
timer: nil,
enqueuedAt: Date()
))
saveOfflineQueue(queue)
pendingChanges = queue.count
}
// MARK: - Periodic Sync
private func schedulePeriodicSync() {
cancelPeriodicSync()
syncTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 60 * 1_000_000_000) // 60 seconds
guard let self = self, self.syncEnabled else { break }
_ = await self.sync(localTimers: [])
}
}
}
private func cancelPeriodicSync() {
syncTask?.cancel()
syncTask = nil
}
// MARK: - Network Helpers
private func authenticatedRequest(url: URL, method: String, body: Data? = nil) async throws -> (Data, URLResponse) {
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(UUID().uuidString, forHTTPHeaderField: "x-request-id")
if let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body {
request.httpBody = body
}
return try await session.data(for: request)
}
private func validateResponse(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
guard (200...299).contains(http.statusCode) else {
throw SyncError.httpError(http.statusCode)
}
}
// MARK: - Offline Queue Persistence
private func loadOfflineQueue() {
pendingChanges = loadOfflineQueueItems().count
}
private func loadOfflineQueueItems() -> [OfflineQueueItem] {
guard let data = UserDefaults.standard.data(forKey: Keys.offlineQueue) else { return [] }
return (try? JSONDecoder().decode([OfflineQueueItem].self, from: data)) ?? []
}
private func saveOfflineQueue(_ queue: [OfflineQueueItem]) {
if let data = try? JSONEncoder().encode(queue) {
UserDefaults.standard.set(data, forKey: Keys.offlineQueue)
}
}
private func clearOfflineQueue(syncedIds: [String]) {
var queue = loadOfflineQueueItems()
queue.removeAll { syncedIds.contains($0.id) }
saveOfflineQueue(queue)
}
// MARK: - DTO Conversion
private func timerToDTO(_ timer: CMTimer) -> SyncTimerDTO {
SyncTimerDTO(
id: timer.id,
label: timer.label,
description: timer.description,
type: timer.type.rawValue,
state: timer.state.rawValue,
urgency: timer.urgency.rawValue,
duration: timer.duration ?? 0,
targetTime: timer.targetTime,
createdAt: timer.createdAt,
startedAt: timer.startedAt,
pausedAt: timer.pausedAt,
firedAt: timer.firedAt,
completedAt: timer.completedAt,
cascade: timer.cascade.intervals.isEmpty ? nil : CascadeDTO(
preset: timer.cascade.preset.rawValue,
intervals: timer.cascade.intervals
),
pomodoro: timer.pomodoroConfig.map { config in
PomodoroDTO(
focusMinutes: config.workMinutes,
shortBreakMinutes: config.breakMinutes,
longBreakMinutes: config.longBreakMinutes,
roundsBeforeLong: config.rounds,
currentRound: timer.pomodoroState?.currentRound ?? 1,
isBreak: timer.pomodoroState?.isBreak ?? false,
totalRoundsCompleted: timer.pomodoroState?.completedRounds ?? 0
)
},
isCalendarSync: false,
category: timer.category,
syncVersion: 1
)
}
private func timerToUpdateDTO(_ timer: CMTimer) -> UpdateTimerDTO {
UpdateTimerDTO(
state: timer.state.rawValue,
pausedAt: timer.pausedAt,
firedAt: timer.firedAt,
completedAt: timer.completedAt,
syncVersion: 1
)
}
}
// MARK: - DTOs
struct SyncTimerDTO: Codable {
let id: String
var label: String
var description: String?
var type: String
var state: String
var urgency: String
var duration: TimeInterval
var targetTime: Date
var createdAt: Date
var startedAt: Date?
var pausedAt: Date?
var firedAt: Date?
var completedAt: Date?
var cascade: CascadeDTO?
var pomodoro: PomodoroDTO?
var isCalendarSync: Bool?
var category: String?
var syncVersion: Int
}
struct CascadeDTO: Codable {
let preset: String
let intervals: [Int]
}
struct PomodoroDTO: Codable {
let focusMinutes: Int
let shortBreakMinutes: Int
let longBreakMinutes: Int
let roundsBeforeLong: Int
var currentRound: Int
var isBreak: Bool
var totalRoundsCompleted: Int
}
struct UpdateTimerDTO: Codable {
var state: String?
var pausedAt: Date?
var firedAt: Date?
var completedAt: Date?
var syncVersion: Int
}
// MARK: - Sync Results
struct SyncResult {
let pulled: [SyncTimerDTO]
let conflicts: [SyncConflict]
let error: String?
}
struct SyncConflict: Codable {
let id: String
let serverVersion: Int
}
struct BatchResult: Codable {
let synced: [String]
let conflicts: [SyncConflict]
let errors: [String]
}
// MARK: - Offline Queue
enum OfflineAction: String, Codable {
case create
case update
case delete
}
struct OfflineQueueItem: Codable {
let id: String
let action: OfflineAction
let timer: SyncTimerDTO?
let enqueuedAt: Date
}
// MARK: - Errors
enum SyncError: LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int)
case conflict
case notAuthenticated
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid sync URL"
case .invalidResponse: return "Invalid server response"
case .httpError(let code): return "Server error (\(code))"
case .conflict: return "Sync conflict — server has newer version"
case .notAuthenticated: return "Not signed in"
}
}
}