diff --git a/.gitignore b/.gitignore index e139222..eef7e9e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ # App bundle (build artifact, regenerate with ./build-app.sh) Prompt.app/ +# Go server binary (go build ./... writes it next to the source) +Server/server + # Secrets *.p8 diff --git a/AGENTS.md b/AGENTS.md index ac83de4..937b273 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,19 +38,62 @@ clicking an item copies it back to the clipboard. pasteboard item, simulates Cmd+C, polls `changeCount` for ≤0.5s, reads the string, restores the backup. Runs off the main thread (hotkey dispatches to a global queue); only the store append hops back to main. +- `Sources/Prompt/SyncService.swift` — optional sync client. Holds the group + code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`, + `sync.version`), pushes the whole store as JSON, and polls every 5s. +- `Server/main.go` — the Go sync backend (single file, `net/http` + `lib/pq`). + +## Sync protocol + +No authentication by design: a sync group is named by an 8-digit code +(`1234-5678`) that doubles as the shared secret. The server stores the client +payload as opaque `JSONB`; it never parses note contents. + +| Endpoint | Behaviour | +| --- | --- | +| `POST /api/codes` | Generates a unique code and inserts an empty group | +| `GET /api/sync/{code}` | `{"version":N,"updatedAt":…,"data":{…}\|null}`, 404 if unknown | +| `PUT /api/sync/{code}` | Upserts the payload, returns the incremented version | +| `GET /healthz` | Liveness | + +Client rules: + +- Payload = `{sections, activeSectionID, images}`, where `images` maps a + filename to base64 PNG so photos travel with the notes. +- Version counter is server-owned; the client stores the last version it saw + in `sync.version` and only applies a remote payload when `remote.version > + lastVersion`. +- **Never push a payload that came from the server.** `pushNow()` compares a + sorted-keys encoding against `lastSyncedPayload` and skips identical + payloads. Without this, applying a remote change would immediately push it + back, bump the version, and the two Macs would ping-pong forever. The + `applyingRemote` flag alone is not enough: the save-triggered push is + dispatched asynchronously, so it runs *after* the flag is cleared. +- Conflict resolution is last-writer-wins at whole-store granularity. That is + acceptable for personal use; it is not a merge. +- `apply()` writes incoming images, deletes image files no longer referenced, + and replaces the store in one shot. +- `Prompt --sync-check` runs `SyncService.runDiagnostic()`: pushes any local + change and prints local vs remote versions, then exits. Use it to debug a + second Mac without the UI. ## Commands ```bash swift build # debug build swift run Prompt # dev run (menu bar icon appears) +./Prompt.app/Contents/MacOS/Prompt --sync-check # push + report sync state, then exit ./build-app.sh 0.1.0 # release build + .app bundle + icon + codesign ./release.sh 0.1.0 # build-app.sh + dist/Prompt.zip + notarize + sha256 scripts/make-icon.sh # regenerate Prompt.app/Contents/Resources/Icon.icns + +# sync server (Go, needs Postgres) +createdb prompt_sync +cd Server && go run . # :8080, PROMPT_SYNC_DATABASE_URL / PROMPT_SYNC_ADDR ``` There is no test target and no linter/formatter configured. Verification = -`swift build` and manual run. +`swift build`, `go build ./...`, and manual run (`--sync-check` for sync). ## Bundle build & signing @@ -102,5 +145,11 @@ brew upgrade --cask prompt - The double-Shift tap needs Accessibility; without it the event tap silently fails and only the Carbon fallbacks work. The panel banner and the status menu both surface the permission state. +- `Info.plist` sets `NSAllowsArbitraryLoads` because the sync server URL is + user-supplied and is often plain HTTP (LAN address, self-hosted box). ATS + would otherwise block it with a confusing error. +- The server keeps `data` as opaque `JSONB`, so the payload shape can evolve + without a migration — but both Macs should run the same app version, since a + newer client writing unknown fields will round-trip them to an older one. - `.gitignore` covers `.build/`, `Prompt.app/`, `dist/` — build artifacts should never be committed. diff --git a/README.md b/README.md index edf68b9..6f24ebd 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ to your work. Click anything to copy it back to your clipboard. (or Shift+Cmd+C) for a numbered list ready to paste into a chat. - **Merge Notes, Mark as Done, Edit, Move To** — right-click any item. - **Search** — Cmd+F searches across all sections. +- **Sync (optional)** — generate an 8-digit code (`1234-5678`) on one Mac and + enter it on the other; no account, no login. The code is the only secret. - **Local & private** — everything lives in `~/Library/Application Support/Prompt/` (`store.json` + `Images/`). No account, no sync, no telemetry. @@ -42,6 +44,32 @@ brew trust TZGyn/tap brew install --cask prompt ``` +## Sync server + +Sync is optional and needs the small Go backend in `Server/`. There is no +authentication: a sync group is identified by an 8-digit code, and whoever has +the code can read and write the group. + +```bash +createdb prompt_sync +cd Server +PROMPT_SYNC_DATABASE_URL="postgres://localhost:5432/prompt_sync?sslmode=disable" \ + go run . # listens on :8080 +``` + +Then in the panel: click the sync icon, set the server URL (e.g. +`http://192.168.1.10:8080` or an HTTPS host), and either **Generate Code** on +the first Mac or paste the code into **Join** on the other. + +Notes: + +- Both Macs must be able to reach the server. A LAN address or a Tailscale + hostname works; `localhost` only works if the server runs on the same Mac. +- Joining a group that already has notes replaces what's on that Mac. +- The app polls every 5 seconds and pushes 1.5s after a change; last write + wins. Changes that came from the server are not echoed back. +- Verify sync from a terminal with `Prompt --sync-check`. + ## Build from source ```bash diff --git a/Server/go.mod b/Server/go.mod new file mode 100644 index 0000000..693e489 --- /dev/null +++ b/Server/go.mod @@ -0,0 +1,5 @@ +module dev.tzgyn.prompt/server + +go 1.26 + +require github.com/lib/pq v1.10.9 diff --git a/Server/go.sum b/Server/go.sum new file mode 100644 index 0000000..aeddeae --- /dev/null +++ b/Server/go.sum @@ -0,0 +1,2 @@ +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/Server/main.go b/Server/main.go new file mode 100644 index 0000000..ec778c5 --- /dev/null +++ b/Server/main.go @@ -0,0 +1,186 @@ +// Prompt sync server. No authentication: a sync group is identified by an +// 8-digit code (1234-5678) that acts as the shared secret. Whoever has the +// code can read and overwrite the group's data. +// +// Endpoints: +// +// POST /api/codes -> {"code":"1234-5678"} (create group) +// GET /api/sync/{code} -> {"version":N,"data":{...}} (404 if unknown) +// PUT /api/sync/{code} -> {"version":N+1} (upsert payload) +// GET /healthz -> 200 +// +// Config: +// +// PROMPT_SYNC_DATABASE_URL (default postgres://localhost:5432/prompt_sync?sslmode=disable) +// PROMPT_SYNC_ADDR (default :8080) +package main + +import ( + "crypto/rand" + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "regexp" + "time" + + _ "github.com/lib/pq" +) + +const schema = ` +CREATE TABLE IF NOT EXISTS sync_groups ( + code TEXT PRIMARY KEY, + version BIGINT NOT NULL DEFAULT 0, + data JSONB, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +)` + +var codePattern = regexp.MustCompile(`^\d{4}-\d{4}$`) + +type Server struct { + db *sql.DB +} + +func main() { + dsn := os.Getenv("PROMPT_SYNC_DATABASE_URL") + if dsn == "" { + dsn = "postgres://localhost:5432/prompt_sync?sslmode=disable" + } + addr := os.Getenv("PROMPT_SYNC_ADDR") + if addr == "" { + addr = ":8080" + } + + db, err := sql.Open("postgres", dsn) + if err != nil { + log.Fatalf("open database: %v", err) + } + defer db.Close() + if err := db.Ping(); err != nil { + log.Fatalf("connect to database: %v", err) + } + if _, err := db.Exec(schema); err != nil { + log.Fatalf("ensure schema: %v", err) + } + + s := &Server{db: db} + mux := http.NewServeMux() + mux.HandleFunc("POST /api/codes", s.createCode) + mux.HandleFunc("GET /api/sync/{code}", s.getSync) + mux.HandleFunc("PUT /api/sync/{code}", s.putSync) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + log.Printf("prompt-sync listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +// createCode generates a unique 8-digit code, formats it as 1234-5678, and +// inserts an empty group so the code exists before the first PUT. +func (s *Server) createCode(w http.ResponseWriter, _ *http.Request) { + for range 10 { + code, err := newCode() + if err != nil { + writeError(w, http.StatusInternalServerError, "could not generate code") + return + } + var exists bool + err = s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sync_groups WHERE code = $1)`, code).Scan(&exists) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + if exists { + continue + } + if _, err := s.db.Exec(`INSERT INTO sync_groups (code) VALUES ($1)`, code); err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + writeJSON(w, http.StatusOK, map[string]string{"code": code}) + return + } + writeError(w, http.StatusInternalServerError, "could not find a free code") +} + +func (s *Server) getSync(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + if !codePattern.MatchString(code) { + writeError(w, http.StatusBadRequest, "code must look like 1234-5678") + return + } + var ( + version int64 + data []byte + updatedAt time.Time + ) + err := s.db.QueryRow( + `SELECT version, data, updated_at FROM sync_groups WHERE code = $1`, code, + ).Scan(&version, &data, &updatedAt) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "unknown code") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + if data == nil { + data = []byte("null") + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"version":%d,"updatedAt":%q,"data":%s}`, + version, updatedAt.UTC().Format(time.RFC3339), data) +} + +func (s *Server) putSync(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + if !codePattern.MatchString(code) { + writeError(w, http.StatusBadRequest, "code must look like 1234-5678") + return + } + body := http.MaxBytesReader(w, r.Body, 50<<20) // 50 MB ceiling (images inside) + defer body.Close() + var payload json.RawMessage + if err := json.NewDecoder(body).Decode(&payload); err != nil { + writeError(w, http.StatusBadRequest, "body must be JSON") + return + } + var version int64 + err := s.db.QueryRow(` + INSERT INTO sync_groups (code, version, data, updated_at) + VALUES ($1, 1, $2, now()) + ON CONFLICT (code) DO UPDATE + SET version = sync_groups.version + 1, data = $2, updated_at = now() + RETURNING version`, code, payload).Scan(&version) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + writeJSON(w, http.StatusOK, map[string]int64{"version": version}) +} + +func newCode() (string, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + digits := make([]byte, 8) + for i, v := range b { + digits[i] = '0' + v%10 + } + return fmt.Sprintf("%s-%s", digits[:4], digits[4:]), nil +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/Sources/Prompt/App.swift b/Sources/Prompt/App.swift index 17a54f0..389b133 100644 --- a/Sources/Prompt/App.swift +++ b/Sources/Prompt/App.swift @@ -19,6 +19,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + if CommandLine.arguments.contains("--sync-check") { + Task { @MainActor in + await SyncService.shared.runDiagnostic() + NSApp.terminate(nil) + } + return + } + // Single instance: stale copies keep their hotkeys alive after an // update and eat the events meant for the new instance. let myPid = ProcessInfo.processInfo.processIdentifier @@ -43,6 +51,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Show the panel on launch so the app is discoverable (it has no Dock // icon and the menu bar icon can be hard to spot on a full bar). Task { @MainActor in + SyncService.shared.start() PanelController.shared.show() } } diff --git a/Sources/Prompt/PanelView.swift b/Sources/Prompt/PanelView.swift index 249303b..6a34017 100644 --- a/Sources/Prompt/PanelView.swift +++ b/Sources/Prompt/PanelView.swift @@ -4,11 +4,14 @@ import UniformTypeIdentifiers struct PanelView: View { @ObservedObject var store: Store + @ObservedObject private var sync = SyncService.shared @State private var composer = "" @State private var search = "" @State private var showSearch = false @State private var showSectionSwitcher = false + @State private var showSync = false + @State private var joinCode = "" @State private var selection = Set() @State private var copiedID: UUID? @State private var accessibilityGranted = AXIsProcessTrusted() @@ -33,6 +36,7 @@ struct PanelView: View { .frame(minWidth: 300) .background(shortcutButtons) .overlay(sectionSwitcher) + .overlay(syncOverlay) .onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop) .onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in focusedField = .composer @@ -62,6 +66,11 @@ struct PanelView: View { Spacer() + Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor) + } + .buttonStyle(.plain).help("Sync") Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") } .buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)") Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") } @@ -144,7 +153,7 @@ struct PanelView: View { } .padding(10) } - .onChange(of: store.activeSection.items.count) { _ in + .onChange(of: store.activeSection.items.count) { if let last = store.activeSection.items.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } @@ -342,6 +351,105 @@ struct PanelView: View { } } + // MARK: - Sync + + @ViewBuilder + private var syncOverlay: some View { + if showSync { + VStack(spacing: 0) { + Color.black.opacity(0.001) + .onTapGesture { showSync = false } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Sync").font(.headline) + Spacer() + if sync.isSyncing { ProgressView().controlSize(.small) } + } + TextField("Server URL", text: $sync.serverURL) + .textFieldStyle(.roundedBorder) + .font(.callout) + if let code = sync.code { + Divider() + Text("Your code").font(.caption).foregroundStyle(.secondary) + HStack { + Text(code) + .font(.system(size: 20, weight: .medium, design: .monospaced)) + .textSelection(.enabled) + Spacer() + Button { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(code, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.plain) + .help("Copy code") + } + Text("Enter this code on your other Mac to sync.") + .font(.caption) + .foregroundStyle(.secondary) + statusLine + Divider() + Button("Disconnect", role: .destructive) { sync.disconnect() } + .font(.callout) + } else { + Divider() + Button("Generate Code") { sync.generateCode() } + .disabled(sync.isSyncing) + Text("Creates a new sync group and uploads what's here.") + .font(.caption) + .foregroundStyle(.secondary) + Divider() + Text("Or join an existing group").font(.caption).foregroundStyle(.secondary) + HStack { + TextField("1234-5678", text: $joinCode) + .textFieldStyle(.roundedBorder) + .font(.system(.callout, design: .monospaced)) + .onExitCommand { showSync = false } + Button("Join") { + sync.join(rawCode: joinCode) + joinCode = "" + } + .disabled(sync.isSyncing || joinCode.isEmpty) + } + Text("Joining replaces what's on this Mac with the group's notes.") + .font(.caption) + .foregroundStyle(.secondary) + } + if let error = sync.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(14) + .background(.regularMaterial) + .cornerRadius(10) + .shadow(radius: 12) + .padding(.horizontal, 20) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.top, 44) + .transition(.opacity) + } + } + + @ViewBuilder + private var statusLine: some View { + if sync.isSyncing { + Text("Syncing…").font(.caption).foregroundStyle(.secondary) + } else if let last = sync.lastSyncedAt { + Text("Last synced \(last, style: .relative) ago") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Not synced yet").font(.caption).foregroundStyle(.secondary) + } + } + // MARK: - Composer private var composerField: some View { diff --git a/Sources/Prompt/Store.swift b/Sources/Prompt/Store.swift index e208ea6..bceae4d 100644 --- a/Sources/Prompt/Store.swift +++ b/Sources/Prompt/Store.swift @@ -47,7 +47,7 @@ final class Store: ObservableObject { try? FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true) load() if sections.isEmpty { - let workspace = Section(name: "Workspace") + let workspace = Section(name: "Default") sections = [workspace] activeSectionID = workspace.id save() @@ -77,6 +77,41 @@ final class Store: ObservableObject { let file = StoreFile(sections: sections, activeSectionID: activeSectionID) guard let data = try? JSONEncoder().encode(file) else { return } try? data.write(to: storeFile, options: .atomic) + onDidChange?() + } + + // MARK: - Sync support + + /// Called after every save so `SyncService` can push the change. + var onDidChange: (() -> Void)? + + func replaceAll(sections newSections: [Section], activeSectionID newActiveSectionID: UUID?) { + sections = newSections + if let newActiveSectionID, newSections.contains(where: { $0.id == newActiveSectionID }) { + activeSectionID = newActiveSectionID + } else { + activeSectionID = newSections.first?.id + } + save() + } + + func imageData(for filename: String) -> Data? { + try? Data(contentsOf: imagesDirectory.appendingPathComponent(filename)) + } + + func importImage(filename: String, data: Data) { + let url = imagesDirectory.appendingPathComponent(filename) + if let existing = try? Data(contentsOf: url), existing == data { return } + try? data.write(to: url) + imageCache.removeObject(forKey: filename as NSString) + } + + func cleanupOrphanedImages(referenced: Set) { + let files = (try? FileManager.default.contentsOfDirectory(at: imagesDirectory, includingPropertiesForKeys: nil)) ?? [] + for file in files where !referenced.contains(file.lastPathComponent) { + try? FileManager.default.removeItem(at: file) + imageCache.removeObject(forKey: file.lastPathComponent as NSString) + } } // MARK: - Sections diff --git a/Sources/Prompt/SyncService.swift b/Sources/Prompt/SyncService.swift new file mode 100644 index 0000000..c08adc6 --- /dev/null +++ b/Sources/Prompt/SyncService.swift @@ -0,0 +1,311 @@ +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 url = endpoint("/api/sync/\(code)") else { return } + Task { + do { + let (data, response) = try await URLSession.shared.data(from: url) + if (response as? HTTPURLResponse)?.statusCode == 404 { + lastError = "Sync group no longer exists on the server" + return + } + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + syncLog("poll HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)") + return + } + let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data) + syncLog("poll remote v\(remote.version) local v\(lastVersion)") + if remote.version > lastVersion, let payload = remote.data { + apply(payload, version: 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[.. "$APP/Contents/Info.plist" < NSPrincipalClass NSApplication + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + PLIST