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
174 lines
9.4 KiB
Markdown
174 lines
9.4 KiB
Markdown
# AGENTS.md
|
||
|
||
Prompt is a native macOS app written in Swift/SwiftUI. Single executable
|
||
SwiftPM package, no external dependencies (all Apple frameworks). It is a
|
||
Copper-style (shadcn) capture panel: prompts and photos live in sections,
|
||
clicking an item copies it back to the clipboard.
|
||
|
||
## Architecture
|
||
|
||
- `Sources/Prompt/App.swift` — `@main` entry using a bare
|
||
`Settings { EmptyView() }` scene; the app owns its `NSStatusItem` manually
|
||
(same reason as SimpleTopBar: placement control on notched Macs, seeded via
|
||
`NSStatusItem Preferred Position Prompt`). Left click toggles the panel,
|
||
right click shows the menu (accessibility state, launch at login, open data
|
||
folder, quit). Contains the single-instance guard and `LoginItem`
|
||
(`SMAppService`).
|
||
- `Sources/Prompt/Store.swift` — `@MainActor` `ObservableObject` singleton.
|
||
Data model: `Section` → `[PromptItem]`, item content is
|
||
`.text(String)` or `.image(String)` (filename). Persists to
|
||
`~/Library/Application Support/Prompt/store.json`; images are PNG files in
|
||
`Images/` next to it, cached in an `NSCache`. Owns all mutations (add,
|
||
toggle done, edit, delete, move, merge, clear done) and clipboard writes
|
||
(`copy`, `copyAsList`). Saves on every mutation.
|
||
- `Sources/Prompt/PanelView.swift` — the panel UI. Header (section switcher,
|
||
search, add photos, close), item list, composer. Composer `# Name` creates/
|
||
switches sections. Click = copy; Cmd+click = multi-select; right-click =
|
||
context menu (Copy, Copy as List, Done, Edit, Open, Merge, Move To, Delete).
|
||
Drop target for images/files.
|
||
- `Sources/Prompt/PanelController.swift` — owns the floating `NSPanel`
|
||
(`.nonactivatingPanel`, `.floating`, right-edge docked, frame autosaved as
|
||
`PromptPanel`), the Cmd+V image-paste monitor, and `EditWindowController`
|
||
(separate edit window with a `TextEditor`).
|
||
- `Sources/Prompt/HotkeyManager.swift` — double-Shift detection via a
|
||
CGEvent tap on `flagsChanged` (left Shift ×2 = capture, right Shift ×2 =
|
||
toggle panel), plus Carbon `RegisterEventHotKey` fallbacks (Cmd+Shift+Space
|
||
toggle, Cmd+Shift+C capture) that work without Accessibility.
|
||
- `Sources/Prompt/Capture.swift` — selection capture: backs up every
|
||
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`).
|
||
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
|
||
|
||
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 |
|
||
| `GET /api/sync/{code}/version` | `{"version":N}` — the cheap poll |
|
||
| `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.
|
||
- 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`.
|
||
- **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`, `go build ./...`, and manual run (`--sync-check` for sync).
|
||
|
||
## Bundle build & signing
|
||
|
||
`build-app.sh` does: release build → handwritten `Info.plist` (NOT generated
|
||
from `Package.swift`) → `make-icon.sh` → `codesign`. It resolves a
|
||
`Developer ID Application` identity from the login keychain and falls back to
|
||
ad-hoc signing (`-`) if none is present. `LSUIElement` is true → no Dock icon.
|
||
Bundle id is `dev.tzgyn.prompt`.
|
||
|
||
## Release process
|
||
|
||
Notarization is **required**: an unnotarized build is not a release. Never
|
||
publish one, and never suggest quarantine workarounds (`xattr -dr
|
||
com.apple.quarantine`, `brew install --cask --no-quarantine`). `release.sh`
|
||
enforces this — it fails if it cannot notarize, and refuses to print a sha256
|
||
unless `spctl` reports `source=Notarized Developer ID`. `PROMPT_SKIP_NOTARY=1`
|
||
exists only for a local dev build and prints a "NOT FOR PUBLICATION" warning.
|
||
|
||
1. `./release.sh <version>` — builds, notarizes, staples, zips `Prompt.app` to
|
||
`dist/Prompt.zip`, and prints its sha256 (it does not tag or upload). Uses
|
||
the `PROMPT_NOTARY_*` env vars, defaulting to the shared Kapture App Store
|
||
Connect key at `~/.config/kapture/AuthKey_K5KXMMSB28.p8`.
|
||
2. Commit and push `main`.
|
||
3. `tea releases create --tag v<version> --title "Prompt <version>" --note "<what changed>" --asset dist/Prompt.zip --repo TZGyn/prompt --login gitea.tzgyn.dev`.
|
||
4. Add/update `~/.cache/homebrew-tap/Casks/prompt.rb` (`version` + `sha256`),
|
||
push the tap, then
|
||
`git -C /opt/homebrew/Library/Taps/tzgyn/homebrew-tap pull --ff-only` and
|
||
`brew audit --cask tzgyn/tap/prompt`.
|
||
5. Verify by downloading the release asset back, comparing the sha256 with the
|
||
cask, and unzipping with `ditto -x -k` (not `unzip`, which adds `._*` files
|
||
and breaks the signature check) then `spctl -a -vv Prompt.app`.
|
||
|
||
**Always print the upgrade script after any release or version bump:**
|
||
|
||
```bash
|
||
brew update
|
||
brew upgrade --cask prompt
|
||
```
|
||
|
||
## Gotchas
|
||
|
||
- **Never bind bare keys as `.keyboardShortcut` in the panel** (Space, Delete,
|
||
plain Cmd+C). SwiftUI key equivalents are matched before text input and
|
||
would steal characters from the composer TextField. Only Cmd-modified
|
||
shortcuts that TextFields don't use are bound (Cmd+F/K/N, Shift+Cmd+C).
|
||
This is why Copy has no in-panel shortcut: click-to-copy is the primary
|
||
gesture, matching Copper's intent.
|
||
- The panel is a `.nonactivatingPanel`; its `canBecomeKey` override is what
|
||
lets the composer TextField take focus. Don't replace it with a plain
|
||
NSWindow config without rechecking that typing works without the app
|
||
becoming active.
|
||
- The Cmd+V paste monitor only intercepts when the pasteboard has an image
|
||
AND no string, so pasting text into the composer is never swallowed.
|
||
- CGEvent tap callbacks run off-main; `HotkeyManager` hops to main before
|
||
firing. The tap is re-enabled on `tapDisabledByTimeout`.
|
||
- `Store` is `@MainActor`; anything reaching it from monitors/callbacks must
|
||
go through `Task { @MainActor in }`.
|
||
- `captureSelection()` blocks up to 0.5s polling the pasteboard — keep it off
|
||
the main thread (the hotkey handler already dispatches to a global queue).
|
||
- 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.
|