Files
prompt/Sources/Prompt/SyncService.swift
T
TZGyn 962c205d1d Make the app reachable and quittable after the panel closes
Closing the panel only hid it, and launching the app again did nothing
because the running instance ignored the reopen, so it looked like the
app refused to exit. Reopening now shows the panel, and the main menu
makes Cmd+Q and Cmd+W work while the panel has focus. Termination also
tears down the event tap, hotkeys and sync timers instead of leaving
them live.
2026-10-08 10:43:42 +08:00

335 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()
}
func stop() {
pollTimer?.invalidate()
pollTimer = nil
pushWork?.cancel()
pushWork = nil
Store.shared.onDidChange = nil
}
// 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)"
}
}
}