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:56:08 +08:00
parent 4934ad916a
commit 15ad9f355c
11 changed files with 747 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
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.