Copying was a side effect of a click, which made it impossible to build a selection with the mouse and surprising when the intent was to pick something up rather than copy it immediately. A click now selects (or deselects), and copying is an explicit action from the selection bar or the right-click menu.
214 lines
12 KiB
Markdown
214 lines
12 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`). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit
|
||
(Cmd+Q) — an accessory app shows no menu bar, but the main menu still
|
||
services key equivalents, which is the only way to quit from the keyboard
|
||
while the panel has focus. `applicationShouldHandleReopen` shows the panel so
|
||
launching an already-running app does something. Supports `--sync-check` and
|
||
`--diag` (writes the status item's frame and permission state to
|
||
`/tmp/prompt-diag.txt`, then exits).
|
||
- `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` for numbered output, `copyAsLines` for plain newlines;
|
||
both text modes skip image items). Batch mutations (`delete(ids:)`,
|
||
`setDone(_:ids:)`, `move(ids:to:)`) back the selection context menu, so one
|
||
action applies to every selected item and image files are cleaned up once.
|
||
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 = select (Shift+click for a range); the selection
|
||
bar offers Copy (single item), Copy as List and Copy as Lines; right-click =
|
||
context menu acting on the whole selection (Copy, Copy as List, Copy as
|
||
Lines, Done, Edit, Open, Merge, Move To, Delete). Esc clears the selection
|
||
before it hides the panel. 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`). The URL defaults to `SyncService.defaultServerURL`
|
||
(`https://prompt.tzgyn.dev`); nothing is sent anywhere until a code exists,
|
||
because polling only runs when one is set. 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/Dockerfile` (static binary on distroless) and
|
||
`Server/README.md` (self-hosting). Logs one line per request, creates its
|
||
schema on startup, shuts down gracefully on SIGTERM so a redeploy cannot cut
|
||
off a push, and supports `-healthcheck` (pings local `/healthz`) so the
|
||
shell-less image needs no curl for its Docker `HEALTHCHECK`.
|
||
|
||
## 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 assign `NSApp.mainMenu`.** SwiftUI installs a menu whose Edit items
|
||
are how AppKit routes Cmd+C/V/X/A/Z; replacing it with a hand-built menu
|
||
silently breaks pasting, copying and select-all in the composer (this shipped
|
||
once and had to be reverted). `installMenuItems()` only *augments* the
|
||
existing menu: it adds Hide Panel (Cmd+W) and removes SwiftUI's Settings item,
|
||
whose empty scene would otherwise shadow the in-panel Cmd+, sync panel.
|
||
- **Cmd+V is handled by the paste monitor, not the menu.** The panel is a
|
||
`.nonactivatingPanel`, so it can be key while the app is not active, and menu
|
||
key equivalents are not reliably delivered in that state. The monitor
|
||
dispatches `paste:` to the responder chain itself for text, and adds the
|
||
clipboard image to the store when there is no text. `Prompt --diag` dumps the
|
||
menu so the wiring can be checked without a keyboard.
|
||
- **A row is not a `Button`** because it contains a checkbox `Button`, and
|
||
nested buttons never receive clicks. The row uses `contentShape` +
|
||
`onTapGesture` so a click anywhere on the card selects it. Selection is
|
||
driven by the card click, the checkbox, Cmd+click (toggle) and Shift+click
|
||
(range); the context menu acts on the whole selection via `effectiveIDs(for:)`.
|
||
Copying is an explicit action (selection bar or context menu), never a side
|
||
effect of a click — that was changed deliberately.
|
||
- **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,
|
||
Option+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, and `AppDelegate` shows a launch
|
||
alert when it is missing — otherwise a user who hides the panel is locked out
|
||
(no hotkey, no Dock icon) and reports it as "the app won't come back". Check
|
||
with `Prompt --diag` (`accessibility: false` is the tell).
|
||
- `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.
|