A Postgres URL without an sslmode made lib/pq ask for TLS and die with "SSL is not enabled on the server", which reads like a server fault when it is really a connection-string default. The error now says what to append, and the server retries briefly so a deploy is not marked unhealthy just because Postgres was still starting. The sync server URL also defaults to the hosted instance now, so a fresh install needs no setup.
327 lines
12 KiB
Swift
327 lines
12 KiB
Swift
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"
|
|
|
|
/// Where sync points unless the user overrides it in the sync panel.
|
|
static let defaultServerURL = "https://prompt.tzgyn.dev"
|
|
|
|
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) ?? Self.defaultServerURL
|
|
}
|
|
|
|
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[..<index])-\(digits[index...])"
|
|
}
|
|
|
|
private static func requireOK(_ response: URLResponse) throws {
|
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
|
throw SyncError.server(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum SyncError: LocalizedError {
|
|
case unknownCode
|
|
case badResponse
|
|
case server(statusCode: Int)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .unknownCode: return "Unknown code — generate one first on the other Mac"
|
|
case .badResponse: return "Unexpected response from sync server"
|
|
case .server(let code): return "Sync server returned HTTP \(code)"
|
|
}
|
|
}
|
|
}
|