Users can now launch Kapture at login, keep multiple screenshot windows open
at once, and copy a screenshot to the clipboard. Builds sign with a Developer
ID identity when present, falling back to the ad-hoc self-signed key, so
Screen Recording permission survives rebuilds.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4-flash-0731
6.5 KiB
AGENTS.md
Kapture is a native macOS menu-bar screenshot + OCR app written in Swift/SwiftUI. Single executable SwiftPM package, no external dependencies (all Apple frameworks).
Architecture
Control flow for the main path: menu bar → hotkey → overlay → capture → result window → OCR.
Sources/Kapture/App.swift—@mainentry.MenuBarExtramenu,AppDelegate(wires hotkey →CaptureController.begin(); handles--test-ocr),PermissionManager(wrapsCGPreflightScreenCaptureAccess/CGRequestScreenCaptureAccess).Sources/Kapture/HotkeyManager.swift— CarbonRegisterEventHotKey, hardcoded ⌘⇧S (kVK_ANSI_S,cmdKey | shiftKey), signature0x4B505452.Sources/Kapture/CaptureController.swift— selection flow. Creates one borderlessNSPaneloverlay per screen, then captures viaScreenCaptureand hands the image toResultWindow. Coordination happens inbegin()/finishSelection()/cancelSelection().Sources/Kapture/ScreenCapture(same file) — macOS 14+: ScreenCaptureKitSCScreenshotManager+ manual pixel-space crop (display.width / display.frame.widthscale); macOS 13:CGWindowListCreateImagefallback.Sources/Kapture/OverlayView.swift—NSPanel+NSViewdrawing overlay; mouse-driven rect selection (min 2×2 pt), Escape (keyCode 53) cancels; selection committed viawindow.convertToScreen(screen coordinates).Sources/Kapture/OCRService.swift— VisionVNRecognizeTextRequest,.accuratelevel, languages["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"]. Custom line-order sort: observations sorted top→bottom byboundingBox.midY(tolerance0.02), then left→right. Runs on a private serialDispatchQueue(dev.tzgyn.kapture.ocr), completes on that queue — callers must hop back to main.Sources/Kapture/ResultWindow.swift—ResultWindow.show(image:)(single shared floating window, closes previous),CaptureModel(ObservableObject,@Publishedmode= screenshot/text),ResultView(SwiftUI, Screenshot/Text tabs, Get Text / Save PNG / Copy Text).
Singletons everywhere: HotkeyManager.shared, CaptureController.shared,
ScreenCapture.shared, OCRService.shared, ResultWindow.shared.
Note: packaging references dev.tzgyn.kapture as the bundle identifier — match this
in any new code (queue labels, TCC/plist references).
Commands
swift build # debug build (also: swift run Kapture)
swift build -c release # release build (what build-app.sh uses)
swift run Kapture # dev run from terminal (app appears in Dock)
swift run Kapture --test-ocr # OCR smoke test: renders text, OCRs it, prints PASS/FAIL, exit 0/1
./build-app.sh # release build + Kapture.app bundle + icon + ad-hoc codesign
./release.sh <version> # e.g. ./release.sh 1.0.1: builds app, zips to dist/Kapture.zip, prints sha256
scripts/make-icon.sh # regenerates Kapture.app/Contents/Resources/Icon.icns from scripts/gen-icon.swift
There is no test target and no linter/formatter configured. Verification = build +
--test-ocr.
Bundle build & signing
build-app.sh does: release build → handwritten Info.plist (NOT generated from
Package.swift) → make-icon.sh → codesign with self-signed identity kapture-dev
stored in a dedicated keychain at ~/.config/kapture/signing.keychain-db (not the
login keychain). Honest bugs to keep in mind:
- Uses
security create-keychain/unlock-keychainwith a hardcoded password (kapture-dev) — if the cert creation fails midway, the keychain may be left in an undefined state; the script is mostly idempotent but re-run it before assuming failure. - The self-signed cert is created with Homebrew OpenSSL (
/opt/homebrew/bin/openssl, falls back to/usr/local/bin/openssl, then systemopenssl) and imported as a pkcs12 (-export -legacy). The legacy flag is important — modern OpenSSL defaults to formats the macOSsecuritytool rejects. LSUIElement= true → no Dock icon. Info.plist also hardcodesCFBundleShortVersionString1.0 /CFBundleVersion1 — bump here for releases, and match it inrelease.shargs.
macOS may require re-granting Screen Recording permission when the binary identity changes (TCC tracks the binary, not the source).
Release process
./release.sh <version>— builds, zipsKapture.apptodist/Kapture.zip, prints its sha256 (that's all it does, it does not tag or upload).- Tag
git tag v<version> && git push origin v<version>and attach the zip to a GitHub release manually (the old.github/workflows/release.ymlwas removed;release.shoutput still says the workflow will upload the zip, which is stale). - The Homebrew cask lives in
TZGyn/homebrew-tap— bumpversionandsha256there for each release.
Icon note: the cask references the Icon.icns built into the app bundle, so no
separate icon release step needed.
Gotchas
- Coordinate spaces differ: overlay selections are in global screen points
(via
convertToScreen), ScreenCaptureKit images are in pixels and the crop must scale bydisplay.width / display.frame.width. Don't mix them. - Starting on macOS 13 vs 14 changes the capture path: 13 uses the
CGWindowListCreateImagefallback, which only handles the primary screen (frame.origin == .zero) and needs explicit Y-flip (primary.frame.height - rect.maxY). Get selection rect handling wrong on one path and you break only one OS version — test on both. - When selection overlays are active for more than one screen macOS may also be
capturing the overlay panels themselves while the selection is running; the overlay
windows'
windowNumberIDs are collected inbegin()and excluded viaSCContentFilter(excludingWindows:)when capturing. If you add any new window that shows during overlay/selection, add it to the exclusion set. - The OCR completion runs on a background queue — never touch
CaptureModel/UI from it;runOCRhops to@MainActorviaTask { @MainActor in ... }. --test-ocris processed inAppDelegate.applicationDidFinishLaunchingand mustexit()with 0/1 inside the completion; theRunLoop.main.run()after is what keeps the process alive until the async OCR finishes. If OCR returns empty text it doesn't crash — just prints FAIL.- No
@MainActorannotations on AppDelegate / HotkeyManager; keep new code on main thread for AppKit calls. .gitignorecovers.build/,Kapture.app/,dist/— build artifacts should never be committed.