Add optional sync between Macs with a shared 8-digit code

Notes and photos were stuck on one machine. A small Go/Postgres backend
stores each group's payload under a code that doubles as the shared
secret, so a second Mac just needs the code — no account or login.

Applying a remote change must not push it straight back, otherwise the
two Macs would bump the version back and forth forever, so payloads are
fingerprinted and identical ones are skipped.

💘 Generated with Crush

Assisted-by: Crush:kimi-k3
This commit is contained in:
2026-10-08 09:55:34 +08:00
parent 4934ad916a
commit 5db9a3b9f7
11 changed files with 744 additions and 3 deletions
+50 -1
View File
@@ -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 pasteboard item, simulates Cmd+C, polls `changeCount` for ≤0.5s, reads the
string, restores the backup. Runs off the main thread (hotkey dispatches to string, restores the backup. Runs off the main thread (hotkey dispatches to
a global queue); only the store append hops back to main. 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 ## Commands
```bash ```bash
swift build # debug build swift build # debug build
swift run Prompt # dev run (menu bar icon appears) 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 ./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 ./release.sh 0.1.0 # build-app.sh + dist/Prompt.zip + notarize + sha256
scripts/make-icon.sh # regenerate Prompt.app/Contents/Resources/Icon.icns 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 = 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 ## Bundle build & signing
@@ -102,5 +145,11 @@ brew upgrade --cask prompt
- The double-Shift tap needs Accessibility; without it the event tap silently - 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 fails and only the Carbon fallbacks work. The panel banner and the status
menu both surface the permission state. 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 - `.gitignore` covers `.build/`, `Prompt.app/`, `dist/` — build artifacts
should never be committed. should never be committed.
+28
View File
@@ -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. (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. - **Merge Notes, Mark as Done, Edit, Move To** — right-click any item.
- **Search** — Cmd+F searches across all sections. - **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 - **Local & private** — everything lives in
`~/Library/Application Support/Prompt/` (`store.json` + `Images/`). No `~/Library/Application Support/Prompt/` (`store.json` + `Images/`). No
account, no sync, no telemetry. account, no sync, no telemetry.
@@ -42,6 +44,32 @@ brew trust TZGyn/tap
brew install --cask prompt 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 ## Build from source
```bash ```bash
+5
View File
@@ -0,0 +1,5 @@
module dev.tzgyn.prompt/server
go 1.26
require github.com/lib/pq v1.10.9
+2
View File
@@ -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=
+186
View File
@@ -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})
}
Executable
BIN
View File
Binary file not shown.
+9
View File
@@ -19,6 +19,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory) 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 // Single instance: stale copies keep their hotkeys alive after an
// update and eat the events meant for the new instance. // update and eat the events meant for the new instance.
let myPid = ProcessInfo.processInfo.processIdentifier 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 // 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). // icon and the menu bar icon can be hard to spot on a full bar).
Task { @MainActor in Task { @MainActor in
SyncService.shared.start()
PanelController.shared.show() PanelController.shared.show()
} }
} }
+109 -1
View File
@@ -4,11 +4,14 @@ import UniformTypeIdentifiers
struct PanelView: View { struct PanelView: View {
@ObservedObject var store: Store @ObservedObject var store: Store
@ObservedObject private var sync = SyncService.shared
@State private var composer = "" @State private var composer = ""
@State private var search = "" @State private var search = ""
@State private var showSearch = false @State private var showSearch = false
@State private var showSectionSwitcher = false @State private var showSectionSwitcher = false
@State private var showSync = false
@State private var joinCode = ""
@State private var selection = Set<UUID>() @State private var selection = Set<UUID>()
@State private var copiedID: UUID? @State private var copiedID: UUID?
@State private var accessibilityGranted = AXIsProcessTrusted() @State private var accessibilityGranted = AXIsProcessTrusted()
@@ -33,6 +36,7 @@ struct PanelView: View {
.frame(minWidth: 300) .frame(minWidth: 300)
.background(shortcutButtons) .background(shortcutButtons)
.overlay(sectionSwitcher) .overlay(sectionSwitcher)
.overlay(syncOverlay)
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop) .onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in .onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
focusedField = .composer focusedField = .composer
@@ -62,6 +66,11 @@ struct PanelView: View {
Spacer() 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") } Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)") .buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)")
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") } Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
@@ -144,7 +153,7 @@ struct PanelView: View {
} }
.padding(10) .padding(10)
} }
.onChange(of: store.activeSection.items.count) { _ in .onChange(of: store.activeSection.items.count) {
if let last = store.activeSection.items.last { if let last = store.activeSection.items.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } 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 // MARK: - Composer
private var composerField: some View { private var composerField: some View {
+36 -1
View File
@@ -47,7 +47,7 @@ final class Store: ObservableObject {
try? FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true)
load() load()
if sections.isEmpty { if sections.isEmpty {
let workspace = Section(name: "Workspace") let workspace = Section(name: "Default")
sections = [workspace] sections = [workspace]
activeSectionID = workspace.id activeSectionID = workspace.id
save() save()
@@ -77,6 +77,41 @@ final class Store: ObservableObject {
let file = StoreFile(sections: sections, activeSectionID: activeSectionID) let file = StoreFile(sections: sections, activeSectionID: activeSectionID)
guard let data = try? JSONEncoder().encode(file) else { return } guard let data = try? JSONEncoder().encode(file) else { return }
try? data.write(to: storeFile, options: .atomic) 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<String>) {
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 // MARK: - Sections
+311
View File
@@ -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[..<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)"
}
}
}
+8
View File
@@ -49,6 +49,14 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<true/> <true/>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<!-- The sync server URL is user-supplied and may be plain HTTP (a LAN
address or a self-hosted box), which App Transport Security would
otherwise block. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict> </dict>
</plist> </plist>
PLIST PLIST