Files
prompt/AGENTS.md
T
TZGyn 78d56ca79b Make dark mode black instead of the system window grey
The panel is a full-height strip that sits beside your work, where the
system grey reads as a half-lit window next to a dark editor. Dark mode
is now pure black, with card and row colours in one Theme file so both
the panel background and the strip under the titlebar match.
2026-10-09 18:04:08 +08:00

13 KiB
Raw Permalink Blame History

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/Theme.swift — the panel's appearance-aware colours. Dark mode is pure black rather than the system window grey (the panel is a full-height strip beside your work, where grey reads as a half-lit window); light mode keeps the system window colour. Theme.background is also handed to the NSPanel so the strip under the transparent titlebar matches, and the row colour stays slightly lighter than black so cards remain visible.
  • 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

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:

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.