450 lines
14 KiB
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"
|
|
}
|
|
}
|
|
}
|