Files
TZGyn d665d493af Stop the sync poll from rebuilding the panel every 5 s
@Published fires even when the assigned value is unchanged, so the poll's
unconditional lastError = nil invalidated PanelView — which observes
SyncService — every 5 seconds, forever, hidden or visible. Over five days
that was ~185 CPU-minutes and ~190 MB, and clicks started to lag.

- Route poll state through setError, which skips equal values.
- Watch SyncService from a tiny SyncDot view instead of the panel body.
- Skip the URL cache on polls and give them timeouts.
- Only reassign the composer placeholder when it changed.
2026-10-22 23:55:32 +08:00

355 lines
14 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 }
// A cached /version would keep reporting the version the client already
// has, so polls must always hit the network. The responses are a few
// bytes; letting CFNetwork write them to the disk cache every 5 s only
// adds churn.
var versionRequest = URLRequest(url: versionURL)
versionRequest.cachePolicy = .reloadIgnoringLocalCacheData
versionRequest.timeoutInterval = 15
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(for: versionRequest)
if (versionResponse as? HTTPURLResponse)?.statusCode == 404 {
setError("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 {
setError(nil)
return
}
syncLog("poll remote v\(remoteVersion) local v\(lastVersion) — fetching payload")
guard let payloadURL = endpoint("/api/sync/\(code)") else { return }
var payloadRequest = URLRequest(url: payloadURL)
payloadRequest.cachePolicy = .reloadIgnoringLocalCacheData
payloadRequest.timeoutInterval = 30
let (data, response) = try await URLSession.shared.data(for: payloadRequest)
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
}
setError(nil)
} catch {
syncLog("poll failed: \(error.localizedDescription)")
// Polling errors are transient; stay quiet unless nothing ever worked.
if lastSyncedAt == nil { setError("Cannot reach sync server") }
}
}
}
/// `@Published` fires on every assignment, even when the value is
/// unchanged. The 5-second poll would otherwise republish `lastError`
/// forever, and since `PanelView` observes this object, the whole panel
/// (list, rows, overlays) re-rendered every 5 seconds — the app grew to
/// ~190 MB and ~90 ms of layout per poll over five days of uptime.
private func setError(_ message: String?) {
guard lastError != message else { return }
lastError = message
}
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)"
}
}
}