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
8.2 KiB
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—@mainentry using a bareSettings { EmptyView() }scene; the app owns itsNSStatusItemmanually (same reason as SimpleTopBar: placement control on notched Macs, seeded viaNSStatusItem 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 andLoginItem(SMAppService).Sources/Prompt/Store.swift—@MainActorObservableObjectsingleton. 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 inImages/next to it, cached in anNSCache. 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# Namecreates/ 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 floatingNSPanel(.nonactivatingPanel,.floating, right-edge docked, frame autosaved asPromptPanel), the Cmd+V image-paste monitor, andEditWindowController(separate edit window with aTextEditor).Sources/Prompt/HotkeyManager.swift— double-Shift detection via a CGEvent tap onflagsChanged(left Shift ×2 = capture, right Shift ×2 = toggle panel), plus CarbonRegisterEventHotKeyfallbacks (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, pollschangeCountfor ≤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 inUserDefaults(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}, whereimagesmaps 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.versionand only applies a remote payload whenremote.version > lastVersion. - Never push a payload that came from the server.
pushNow()compares a sorted-keys encoding againstlastSyncedPayloadand 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. TheapplyingRemoteflag 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-checkrunsSyncService.runDiagnostic(): pushes any local change and prints local vs remote versions, then exits. Use it to debug a second Mac without the UI.
Commands
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
./release.sh <version>— builds, zipsPrompt.apptodist/Prompt.zip, and prints its sha256 (it does not tag or upload). Notarizes when thePROMPT_NOTARY_*env vars (or the shared Kapture App Store Connect key at~/.config/kapture/AuthKey_K5KXMMSB28.p8) are present; setPROMPT_SKIP_NOTARY=1to skip.- Commit and push
main. tea releases create --tag v<version> --asset dist/Prompt.zip --repo TZGyn/prompt --login gitea.tzgyn.dev.- Bump
version/sha256in~/.cache/homebrew-tap/Casks/prompt.rb, push, thengit -C /opt/homebrew/Library/Taps/tzgyn/homebrew-tap pull --ff-only.
Always print the upgrade script after any release or version bump:
brew update
brew upgrade --cask prompt
Gotchas
- Never bind bare keys as
.keyboardShortcutin 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; itscanBecomeKeyoverride 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;
HotkeyManagerhops to main before firing. The tap is re-enabled ontapDisabledByTimeout. Storeis@MainActor; anything reaching it from monitors/callbacks must go throughTask { @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.plistsetsNSAllowsArbitraryLoadsbecause 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
dataas opaqueJSONB, 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. .gitignorecovers.build/,Prompt.app/,dist/— build artifacts should never be committed.