1 Commits
Author SHA1 Message Date
TZGyn f3e6fc8fa6 Poll sync cheaply and make the server deployable
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
2026-10-08 10:19:55 +08:00
6 changed files with 189 additions and 10 deletions
+7
View File
@@ -42,6 +42,9 @@ clicking an item copies it back to the clipboard.
code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`, code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`,
`sync.version`), pushes the whole store as JSON, and polls every 5s. `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`). - `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 ## 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 | | `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":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 | | `PUT /api/sync/{code}` | Upserts the payload, returns the incremented version |
| `GET /healthz` | Liveness | | `GET /healthz` | Liveness |
@@ -60,6 +64,9 @@ Client rules:
- Payload = `{sections, activeSectionID, images}`, where `images` maps a - Payload = `{sections, activeSectionID, images}`, where `images` maps a
filename to base64 PNG so photos travel with the notes. 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 - 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 > in `sync.version` and only applies a remote payload when `remote.version >
lastVersion`. lastVersion`.
+4 -2
View File
@@ -66,9 +66,11 @@ Notes:
- Both Macs must be able to reach the server. A LAN address or a Tailscale - 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. 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. - 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 - The app polls every 5 seconds (a tiny version check) and pushes 1.5s after a
wins. Changes that came from the server are not echoed back. change; last write wins, and changes that came from the server are not echoed
back.
- Verify sync from a terminal with `Prompt --sync-check`. - Verify sync from a terminal with `Prompt --sync-check`.
## Build from source ## Build from source
+69
View File
@@ -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 <host>:/tmp/ && ssh <host> '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
```
+68 -1
View File
@@ -6,6 +6,7 @@
// //
// POST /api/codes -> {"code":"1234-5678"} (create group) // POST /api/codes -> {"code":"1234-5678"} (create group)
// GET /api/sync/{code} -> {"version":N,"data":{...}} (404 if unknown) // 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) // PUT /api/sync/{code} -> {"version":N+1} (upsert payload)
// GET /healthz -> 200 // GET /healthz -> 200
// //
@@ -16,6 +17,7 @@
package main package main
import ( import (
"context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
@@ -23,7 +25,9 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal"
"regexp" "regexp"
"syscall"
"time" "time"
_ "github.com/lib/pq" _ "github.com/lib/pq"
@@ -69,13 +73,55 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /api/codes", s.createCode) mux.HandleFunc("POST /api/codes", s.createCode)
mux.HandleFunc("GET /api/sync/{code}", s.getSync) 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("PUT /api/sync/{code}", s.putSync)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) 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.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 // 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) 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) { func (s *Server) putSync(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code") code := r.PathValue("code")
if !codePattern.MatchString(code) { if !codePattern.MatchString(code) {
+22
View File
@@ -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
+19 -7
View File
@@ -181,22 +181,34 @@ final class SyncService: ObservableObject {
} }
private func poll() { 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 { Task {
do { do {
let (data, response) = try await URLSession.shared.data(from: url) // Cheap poll first: the full payload can be megabytes of
if (response as? HTTPURLResponse)?.statusCode == 404 { // 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" lastError = "Sync group no longer exists on the server"
return return
} }
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { guard let versionHTTP = versionResponse as? HTTPURLResponse, versionHTTP.statusCode == 200 else {
syncLog("poll HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)") syncLog("poll HTTP \((versionResponse as? HTTPURLResponse)?.statusCode ?? -1)")
return 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) let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data)
syncLog("poll remote v\(remote.version) local v\(lastVersion)") if let payload = remote.data {
if remote.version > lastVersion, let payload = remote.data {
apply(payload, version: remote.version) apply(payload, version: remote.version)
} else {
lastVersion = remote.version
} }
lastError = nil lastError = nil
} catch { } catch {