Files
prompt/AGENTS.md
T
TZGyn 90890186c0 Tell the user when Accessibility is missing
Without it the Shift-Shift gestures fail silently, so hiding the panel
left no way to bring it back and the app looked broken. The app now says
so on launch and points at Cmd+Shift+Space, which needs no permission,
and --diag reports the permission state alongside the status item's
placement so a lockout can be diagnosed instead of guessed at.
2026-10-08 10:45:50 +08:00

188 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`). 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`). 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 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, 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.