From f3e6fc8fa6ee45fec9659322f6212e90b1534954 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Thu, 8 Oct 2026 10:19:55 +0800 Subject: [PATCH] Poll sync cheaply and make the server deployable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polling re-downloaded the whole store, photos included, every five seconds. The app now checks a tiny version endpoint and only fetches the payload when it actually moved, and the server gained request logging, graceful shutdown, and self-hosting docs so it can run on a VPS instead of only on localhost. 💘 Generated with Crush Assisted-by: Crush:kimi-k3 --- AGENTS.md | 7 ++++ README.md | 6 ++- Server/README.md | 69 ++++++++++++++++++++++++++++++++ Server/main.go | 69 +++++++++++++++++++++++++++++++- Server/prompt-sync.service | 22 ++++++++++ Sources/Prompt/SyncService.swift | 26 ++++++++---- 6 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 Server/README.md create mode 100644 Server/prompt-sync.service diff --git a/AGENTS.md b/AGENTS.md index 9353567..38ec640 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,9 @@ clicking an item copies it back to the clipboard. 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`). + Also ships `Server/README.md` (self-hosting) and `Server/prompt-sync.service` + (systemd unit). Logs one line per request, creates its schema on startup, and + shuts down gracefully on SIGTERM so a redeploy cannot cut off a push. ## Sync protocol @@ -53,6 +56,7 @@ payload as opaque `JSONB`; it never parses note contents. | --- | --- | | `POST /api/codes` | Generates a unique code and inserts an empty group | | `GET /api/sync/{code}` | `{"version":N,"updatedAt":…,"data":{…}\|null}`, 404 if unknown | +| `GET /api/sync/{code}/version` | `{"version":N}` — the cheap poll | | `PUT /api/sync/{code}` | Upserts the payload, returns the incremented version | | `GET /healthz` | Liveness | @@ -60,6 +64,9 @@ Client rules: - Payload = `{sections, activeSectionID, images}`, where `images` maps a filename to base64 PNG so photos travel with the notes. +- Polling is two-step: `GET /version` every 5s (tiny) and the full payload only + when the version moved. Fetching the payload unconditionally would re-download + every base64 photo every 5 seconds. - 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`. diff --git a/README.md b/README.md index 74c2ab5..7d5741b 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,11 @@ 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. + See [Server/README.md](Server/README.md) to deploy it on a VPS or home server. - 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. +- The app polls every 5 seconds (a tiny version check) and pushes 1.5s after a + change; last write wins, and changes that came from the server are not echoed + back. - Verify sync from a terminal with `Prompt --sync-check`. ## Build from source diff --git a/Server/README.md b/Server/README.md new file mode 100644 index 0000000..8553b9e --- /dev/null +++ b/Server/README.md @@ -0,0 +1,69 @@ +# Prompt sync server + +Small Go service that stores one JSON blob per sync group, keyed by an 8-digit +code (`1234-5678`). There is no authentication: the code is the shared secret, +so anyone who knows it can read and overwrite that group. It is meant to be +self-hosted for personal use, not exposed as a public service. + +## Requirements + +- Postgres (any recent version; developed against 18) +- A host both Macs can reach — a VPS, a home server, or a Tailscale node + +## Run it + +```bash +createdb prompt_sync +cd Server +PROMPT_SYNC_DATABASE_URL="postgres://localhost:5432/prompt_sync?sslmode=disable" \ + go run . # or: go build -o prompt-sync . && ./prompt-sync +``` + +| Env var | Default | +| --- | --- | +| `PROMPT_SYNC_ADDR` | `:8080` | +| `PROMPT_SYNC_DATABASE_URL` | `postgres://localhost:5432/prompt_sync?sslmode=disable` | + +The schema is created on startup. Every request is logged as +`METHOD PATH STATUS DURATION`; SIGTERM/SIGINT shut down gracefully so an +in-flight push is not cut off mid-write. + +## Deploy to a Linux host + +```bash +# from the repo root, on the Mac +cd Server +GOOS=linux GOARCH=arm64 go build -o prompt-sync . # amd64 for x86 hosts +scp prompt-sync :/tmp/ && ssh 'sudo mv /tmp/prompt-sync /usr/local/bin/' + +# on the host: create the database and role, then +sudo cp Server/prompt-sync.service /etc/systemd/system/ +sudo systemctl edit --full prompt-sync # set PROMPT_SYNC_DATABASE_URL +sudo systemctl enable --now prompt-sync +journalctl -u prompt-sync -f # verify it is listening +``` + +Put it behind TLS if it is reachable from the internet — the code is the only +secret, and it travels in the URL path. Both plain HTTP and HTTPS work from the +app; an HTTP URL is often fine over a LAN or Tailscale. + +## Endpoints + +| Method | Path | Behaviour | +| --- | --- | --- | +| `POST` | `/api/codes` | Generates a unique code, inserts an empty group | +| `GET` | `/api/sync/{code}` | `{"version":N,"updatedAt":…,"data":{…}\|null}`, 404 if unknown | +| `GET` | `/api/sync/{code}/version` | `{"version":N}` — cheap poll used by the app | +| `PUT` | `/api/sync/{code}` | Upserts the payload, returns the incremented version | +| `GET` | `/healthz` | Liveness (200) | + +Payloads are opaque `JSONB`; the server never parses note contents. Bodies are +capped at 50 MB, which is generous enough for base64 photos. + +## Backups + +The whole state is one table, so `pg_dump prompt_sync` is a complete backup: + +```bash +pg_dump prompt_sync | gzip > prompt_sync-$(date +%F).sql.gz +``` diff --git a/Server/main.go b/Server/main.go index ec778c5..98c9ca2 100644 --- a/Server/main.go +++ b/Server/main.go @@ -6,6 +6,7 @@ // // POST /api/codes -> {"code":"1234-5678"} (create group) // GET /api/sync/{code} -> {"version":N,"data":{...}} (404 if unknown) +// GET /api/sync/{code}/version -> {"version":N} (cheap poll) // PUT /api/sync/{code} -> {"version":N+1} (upsert payload) // GET /healthz -> 200 // @@ -16,6 +17,7 @@ package main import ( + "context" "crypto/rand" "database/sql" "encoding/json" @@ -23,7 +25,9 @@ import ( "log" "net/http" "os" + "os/signal" "regexp" + "syscall" "time" _ "github.com/lib/pq" @@ -69,13 +73,55 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("POST /api/codes", s.createCode) mux.HandleFunc("GET /api/sync/{code}", s.getSync) + mux.HandleFunc("GET /api/sync/{code}/version", s.getVersion) mux.HandleFunc("PUT /api/sync/{code}", s.putSync) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + server := &http.Server{ + Addr: addr, + Handler: withLogging(mux), + ReadHeaderTimeout: 10 * time.Second, + } + + // Shut down cleanly so a redeploy (systemd restart, container stop) never + // cuts off an in-flight push and leaves a client thinking it failed. + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + go func() { + <-stop + log.Println("shutting down") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + }() + log.Printf("prompt-sync listening on %s", addr) - log.Fatal(http.ListenAndServe(addr, mux)) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("serve: %v", err) + } +} + +// withLogging keeps a one-line trail of method, path, status and duration so a +// self-hosted deployment can be debugged from journalctl or docker logs. +func withLogging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(recorder, r) + log.Printf("%s %s %d %s", r.Method, r.URL.Path, recorder.status, time.Since(started).Round(time.Millisecond)) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) } // createCode generates a unique 8-digit code, formats it as 1234-5678, and @@ -136,6 +182,27 @@ func (s *Server) getSync(w http.ResponseWriter, r *http.Request) { version, updatedAt.UTC().Format(time.RFC3339), data) } +// getVersion is the cheap poll: clients ask for the version every few seconds +// and only download the payload (which can carry base64 images) when it moved. +func (s *Server) getVersion(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 + err := s.db.QueryRow(`SELECT version FROM sync_groups WHERE code = $1`, code).Scan(&version) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "unknown code") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + writeJSON(w, http.StatusOK, map[string]int64{"version": version}) +} + func (s *Server) putSync(w http.ResponseWriter, r *http.Request) { code := r.PathValue("code") if !codePattern.MatchString(code) { diff --git a/Server/prompt-sync.service b/Server/prompt-sync.service new file mode 100644 index 0000000..282cee9 --- /dev/null +++ b/Server/prompt-sync.service @@ -0,0 +1,22 @@ +[Unit] +Description=Prompt sync server +Documentation=https://gitea.tzgyn.dev/TZGyn/prompt +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +# The binary is built on a Mac with: +# GOOS=linux GOARCH=arm64 go build -o prompt-sync . +ExecStart=/usr/local/bin/prompt-sync +Environment=PROMPT_SYNC_ADDR=:8080 +Environment=PROMPT_SYNC_DATABASE_URL=postgres://prompt:CHANGE_ME@localhost:5432/prompt_sync?sslmode=disable +Restart=on-failure +RestartSec=2 +# The server only needs to talk to Postgres and bind :8080. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/Sources/Prompt/SyncService.swift b/Sources/Prompt/SyncService.swift index c08adc6..15addd6 100644 --- a/Sources/Prompt/SyncService.swift +++ b/Sources/Prompt/SyncService.swift @@ -181,22 +181,34 @@ final class SyncService: ObservableObject { } private func poll() { - guard !isSyncing, let code, let url = endpoint("/api/sync/\(code)") else { return } + guard !isSyncing, let code, let versionURL = endpoint("/api/sync/\(code)/version") else { return } Task { do { - let (data, response) = try await URLSession.shared.data(from: url) - if (response as? HTTPURLResponse)?.statusCode == 404 { + // 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 http = response as? HTTPURLResponse, http.statusCode == 200 else { - syncLog("poll HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)") + 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) - syncLog("poll remote v\(remote.version) local v\(lastVersion)") - if remote.version > lastVersion, let payload = remote.data { + if let payload = remote.data { apply(payload, version: remote.version) + } else { + lastVersion = remote.version } lastError = nil } catch {