import AppKit struct SyncPayload: Codable { var sections: [Section] var activeSectionID: UUID? var images: [String: String] // filename -> base64 PNG } private struct SyncGetResponse: Codable { var version: Int64 var data: SyncPayload? } // Syncs the whole store (sections + items + images) to the Go backend. // No authentication: the 8-digit group code (1234-5678) is the secret. // Conflict resolution is last-writer-wins at store granularity, coordinated // by a server-side version counter. @MainActor final class SyncService: ObservableObject { static let shared = SyncService() @Published private(set) var isSyncing = false @Published private(set) var lastSyncedAt: Date? @Published var lastError: String? @Published var code: String? { didSet { defaults.set(code, forKey: Self.codeKey) restartPolling() } } @Published var serverURL: String { didSet { defaults.set(serverURL, forKey: Self.urlKey) } } private let defaults = UserDefaults.standard private static let codeKey = "sync.code" private static let urlKey = "sync.serverURL" private static let versionKey = "sync.version" private var lastVersion: Int64 { get { Int64(defaults.integer(forKey: Self.versionKey)) } set { defaults.set(newValue, forKey: Self.versionKey) } } private var pollTimer: Timer? private var pushWork: DispatchWorkItem? private var applyingRemote = false /// Bytes of the last payload this client pushed or applied. Comparing it /// before pushing stops an applied remote change from echoing straight /// back to the server (which would ping-pong between two Macs forever). private var lastSyncedPayload: Data? private init() { code = defaults.string(forKey: Self.codeKey) serverURL = defaults.string(forKey: Self.urlKey) ?? "http://localhost:8080" } func start() { Store.shared.onDidChange = { [weak self] in Task { @MainActor in self?.schedulePush() } } restartPolling() } // MARK: - Group membership func generateCode() { guard let url = endpoint("/api/codes") else { lastError = "Server URL must start with http:// or https://" return } run { var request = URLRequest(url: url) request.httpMethod = "POST" let (data, response) = try await URLSession.shared.data(for: request) try Self.requireOK(response) let result = try JSONDecoder().decode([String: String].self, from: data) guard let code = result["code"] else { throw SyncError.badResponse } self.code = code self.push() } } // Joining a group with data REPLACES the local store with the remote one. // Joining an empty group uploads the local store instead. func join(rawCode: String) { guard let formatted = Self.normalizeCode(rawCode) else { lastError = "Code must be 8 digits, like 1234-5678" return } guard let url = endpoint("/api/sync/\(formatted)") else { lastError = "Server URL must start with http:// or https://" return } run { let (data, response) = try await URLSession.shared.data(from: url) if (response as? HTTPURLResponse)?.statusCode == 404 { throw SyncError.unknownCode } try Self.requireOK(response) let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data) self.code = formatted if let payload = remote.data { self.apply(payload, version: remote.version) } else { self.push() } } } func disconnect() { code = nil lastVersion = 0 lastError = nil } // MARK: - Push / poll func schedulePush() { guard !applyingRemote, code != nil else { return } pushWork?.cancel() let work = DispatchWorkItem { [weak self] in Task { @MainActor in self?.push() } } pushWork = work DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: work) } private func push() { run { try await self.pushNow() } } private func pushNow() async throws { guard let code, let url = endpoint("/api/sync/\(code)") else { return } let payload = buildPayload() guard let encoded = encode(payload) else { return } guard encoded != lastSyncedPayload else { syncLog("push skipped: unchanged since last sync") return } var request = URLRequest(url: url) request.httpMethod = "PUT" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = encoded let (data, response) = try await URLSession.shared.data(for: request) try Self.requireOK(response) let result = try JSONDecoder().decode([String: Int64].self, from: data) lastVersion = result["version"] ?? lastVersion lastSyncedPayload = encoded lastSyncedAt = Date() syncLog("pushed as v\(lastVersion)") } /// Powers `Prompt --sync-check`: pushes any local change and reports both /// sides so sync can be verified from a terminal on any Mac. func runDiagnostic() async { print("Prompt sync check") print("server: \(serverURL)") guard let code else { print("code: (none — generate one in the panel first)") return } print("code: \(code)") print("local: \(Store.shared.sections.count) sections, last known v\(lastVersion)") do { try await pushNow() print("push: ok (now at v\(lastVersion))") } catch { print("push: failed — \(error.localizedDescription)") } guard let url = endpoint("/api/sync/\(code)") else { return } do { let (data, _) = try await URLSession.shared.data(from: url) let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data) print("remote: v\(remote.version), \(remote.data?.sections.count ?? 0) sections") } catch { print("remote: failed — \(error.localizedDescription)") } } private func poll() { guard !isSyncing, let code, let versionURL = endpoint("/api/sync/\(code)/version") else { return } Task { do { // Cheap poll first: the full payload can be megabytes of // base64 images, so only download it when the version moved. let (versionData, versionResponse) = try await URLSession.shared.data(from: versionURL) if (versionResponse as? HTTPURLResponse)?.statusCode == 404 { lastError = "Sync group no longer exists on the server" return } guard let versionHTTP = versionResponse as? HTTPURLResponse, versionHTTP.statusCode == 200 else { syncLog("poll HTTP \((versionResponse as? HTTPURLResponse)?.statusCode ?? -1)") return } let remoteVersion = try JSONDecoder().decode([String: Int64].self, from: versionData)["version"] ?? 0 guard remoteVersion > lastVersion else { lastError = nil return } syncLog("poll remote v\(remoteVersion) local v\(lastVersion) — fetching payload") guard let payloadURL = endpoint("/api/sync/\(code)") else { return } let (data, response) = try await URLSession.shared.data(from: payloadURL) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return } let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data) if let payload = remote.data { apply(payload, version: remote.version) } else { lastVersion = remote.version } lastError = nil } catch { syncLog("poll failed: \(error.localizedDescription)") // Polling errors are transient; stay quiet unless nothing ever worked. if lastSyncedAt == nil { lastError = "Cannot reach sync server" } } } } private func restartPolling() { pollTimer?.invalidate() pollTimer = nil guard code != nil else { return } pollTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in Task { @MainActor in self?.poll() } } } // MARK: - Payload mapping private func buildPayload() -> SyncPayload { let store = Store.shared var images: [String: String] = [:] for item in store.sections.flatMap(\.items) { if case .image(let filename) = item.content, let data = store.imageData(for: filename) { images[filename] = data.base64EncodedString() } } return SyncPayload(sections: store.sections, activeSectionID: store.activeSectionID, images: images) } private func apply(_ payload: SyncPayload, version: Int64) { applyingRemote = true defer { applyingRemote = false } let store = Store.shared for (filename, base64) in payload.images { if let data = Data(base64Encoded: base64) { store.importImage(filename: filename, data: data) } } store.cleanupOrphanedImages(referenced: Set(payload.images.keys)) store.replaceAll(sections: payload.sections, activeSectionID: payload.activeSectionID) lastVersion = version lastSyncedPayload = encode(payload) lastSyncedAt = Date() syncLog("applied remote v\(version): \(payload.sections.count) sections, \(payload.images.count) images") } // MARK: - Helpers private func run(_ operation: @escaping @MainActor () async throws -> Void) { isSyncing = true lastError = nil Task { do { try await operation() } catch { syncLog("operation failed: \(error.localizedDescription)") lastError = error.localizedDescription } isSyncing = false } } private func endpoint(_ path: String) -> URL? { var base = serverURL.trimmingCharacters(in: .whitespaces) while base.hasSuffix("/") { base.removeLast() } guard base.hasPrefix("http://") || base.hasPrefix("https://") else { return nil } return URL(string: base + path) } private func syncLog(_ message: String) { NSLog("Prompt sync: %@", message) } /// Stable encoding used for change detection: sorted keys keep dictionary /// ordering deterministic so identical payloads compare equal. private func encode(_ payload: SyncPayload) -> Data? { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] return try? encoder.encode(payload) } static func normalizeCode(_ raw: String) -> String? { let digits = raw.filter(\.isNumber) guard digits.count == 8 else { return nil } let index = digits.index(digits.startIndex, offsetBy: 4) return "\(digits[..