Compare commits

...
2 Commits
Author SHA1 Message Date
TZGyn af15f9a865 Add launch-at-login, multi-window results, and copy-image; auto-detect Developer ID
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
2026-08-11 21:55:04 +08:00
TZGyn 3f8d71b756 Document Homebrew installation in README 2026-08-11 11:26:51 +08:00
6 changed files with 234 additions and 13 deletions
+112
View File
@@ -0,0 +1,112 @@
# 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` — `@main` entry. `MenuBarExtra` menu, `AppDelegate`
(wires hotkey → `CaptureController.begin()`; handles `--test-ocr`), `PermissionManager`
(wraps `CGPreflightScreenCaptureAccess` / `CGRequestScreenCaptureAccess`).
- `Sources/Kapture/HotkeyManager.swift` — Carbon `RegisterEventHotKey`, hardcoded
⌘⇧S (`kVK_ANSI_S`, `cmdKey | shiftKey`), signature `0x4B505452`.
- `Sources/Kapture/CaptureController.swift` — selection flow. Creates one borderless
`NSPanel` overlay per screen, then captures via `ScreenCapture` and hands the image
to `ResultWindow`. Coordination happens in `begin()`/`finishSelection()`/`cancelSelection()`.
- `Sources/Kapture/ScreenCapture` (same file) — macOS 14+: ScreenCaptureKit
`SCScreenshotManager` + manual pixel-space crop (`display.width / display.frame.width`
scale); macOS 13: `CGWindowListCreateImage` fallback.
- `Sources/Kapture/OverlayView.swift` — `NSPanel` + `NSView` drawing overlay; mouse-driven
rect selection (min 2×2 pt), Escape (keyCode 53) cancels; selection committed via
`window.convertToScreen` (screen coordinates).
- `Sources/Kapture/OCRService.swift` — Vision `VNRecognizeTextRequest`, `.accurate`
level, languages `["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"]`. Custom
line-order sort: observations sorted top→bottom by `boundingBox.midY` (tolerance
`0.02`), then left→right. Runs on a private serial `DispatchQueue`
(`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, `@Published`
`mode` = 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
```bash
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-keychain` with 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 system `openssl`) and imported as a
pkcs12 (`-export -legacy`). The legacy flag is important — modern OpenSSL defaults
to formats the macOS `security` tool rejects.
- `LSUIElement` = true → no Dock icon. Info.plist also hardcodes
`CFBundleShortVersionString` 1.0 / `CFBundleVersion` 1 — bump here for releases,
and match it in `release.sh` args.
macOS may require re-granting Screen Recording permission when the binary identity
changes (TCC tracks the binary, not the source).
## Release process
1. `./release.sh <version>` — builds, zips `Kapture.app` to `dist/Kapture.zip`,
prints its sha256 (that's all it does, it does not tag or upload).
2. Tag `git tag v<version> && git push origin v<version>` and attach the zip to a
GitHub release manually (the old `.github/workflows/release.yml` was removed;
`release.sh` output still says the workflow will upload the zip, which is stale).
3. The Homebrew cask lives in `TZGyn/homebrew-tap` — bump `version` and `sha256`
there 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 by `display.width / display.frame.width`. Don't mix them.
- Starting on macOS 13 vs 14 changes the capture path: 13 uses the `CGWindowListCreateImage`
fallback, 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' `windowNumber` IDs are collected in `begin()` and excluded via
`SCContentFilter(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; `runOCR` hops to `@MainActor` via `Task { @MainActor in ... }`.
- `--test-ocr` is processed in `AppDelegate.applicationDidFinishLaunching` and must
`exit()` with 0/1 inside the completion; the `RunLoop.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 `@MainActor` annotations on AppDelegate / HotkeyManager; keep new code on main
thread for AppKit calls.
- `.gitignore` covers `.build/`, `Kapture.app/`, `dist/` — build artifacts should
never be committed.
+19
View File
@@ -13,6 +13,7 @@ No network calls, no API keys — everything runs locally.
- Global hotkey **⌘⇧S** works from any app - Global hotkey **⌘⇧S** works from any app
- Region selection overlay (all screens, Escape cancels) - Region selection overlay (all screens, Escape cancels)
- **Launch at Login** toggle in the menu bar
- The screenshot appears in a window (Screenshot / Text tabs) instead of being - The screenshot appears in a window (Screenshot / Text tabs) instead of being
instantly OCR'd instantly OCR'd
- **Get Text** button runs on-device OCR (English, Chinese simplified/traditional, - **Get Text** button runs on-device OCR (English, Chinese simplified/traditional,
@@ -21,6 +22,24 @@ No network calls, no API keys — everything runs locally.
- Open an existing image file (or drag) via the menu bar icon - Open an existing image file (or drag) via the menu bar icon
- Menu bar icon with permission status - Menu bar icon with permission status
## Install with Homebrew
```bash
brew tap TZGyn/tap
brew trust TZGyn/tap # Homebrew 6+ requires trusting third-party taps
brew install --cask kapture
```
Releases are published as zips on GitHub Releases; `release.sh` packages a
new one (`./release.sh 1.0.1 && git tag v1.0.1 && git push origin v1.0.1`).
The cask lives in the [TZGyn/homebrew-tap](https://github.com/TZGyn/homebrew-tap)
repository — bump the `version` and `sha256` there for each release.
Note: builds are ad-hoc signed (no App Store / Developer ID notarization), so
first launch of a new version may trigger a Gatekeeper warning — users can
right-click → Open, or the warning goes away when the binary identity matches a
previously-granted permission.
## Build & run ## Build & run
```bash ```bash
+34
View File
@@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
import CoreGraphics import CoreGraphics
import ServiceManagement
@main @main
struct KaptureApp: App { struct KaptureApp: App {
@@ -47,6 +48,19 @@ struct MenuView: View {
Divider() Divider()
Button(LoginItem.isEnabled ? "Disable Launch at Login" : "Launch at Login") {
if !LoginItem.toggle() {
let alert = NSAlert()
alert.messageText = "Could not update Launch at Login"
alert.informativeText = "Launch at Login requires a signed app bundle in /Applications. It is not available when running via `swift run`."
alert.alertStyle = .warning
alert.addButton(withTitle: "OK")
alert.runModal()
}
}
Divider()
Button("Quit Kapture") { NSApp.terminate(nil) } Button("Quit Kapture") { NSApp.terminate(nil) }
.keyboardShortcut("q") .keyboardShortcut("q")
} }
@@ -60,6 +74,26 @@ enum PermissionManager {
} }
} }
enum LoginItem {
static var isEnabled: Bool {
SMAppService.mainApp.status == .enabled
}
static func toggle() -> Bool {
let service = SMAppService.mainApp
do {
if service.status == .enabled {
try service.unregister()
} else {
try service.register()
}
return true
} catch {
return false
}
}
}
func runOCRSelfTest() { func runOCRSelfTest() {
let text = "Kapture OCR test 1234, hello world" let text = "Kapture OCR test 1234, hello world"
let size = NSSize(width: 1600, height: 220) let size = NSSize(width: 1600, height: 220)
+36 -8
View File
@@ -5,24 +5,44 @@ import UniformTypeIdentifiers
final class ResultWindow { final class ResultWindow {
static let shared = ResultWindow() static let shared = ResultWindow()
private var window: NSWindow? private var windows: [NSWindow] = []
private var nextOrigin = NSPoint(x: 60, y: 60)
private var openCount = 0
@MainActor @MainActor
func show(image: CGImage) { func show(image: CGImage) {
window?.close()
let model = CaptureModel(image: image) let model = CaptureModel(image: image)
let hosting = NSHostingController(rootView: ResultView(model: model) { [weak self] in let w = NSWindow()
self?.window?.close() w.contentViewController = NSHostingController(rootView: ResultView(model: model) {
w.close()
}) })
let w = NSWindow(contentViewController: hosting) w.title = "Kapture — Screenshot" + (openCount > 0 ? " \(openCount + 1)" : "")
w.title = "Kapture — Screenshot"
w.level = .floating w.level = .floating
w.styleMask = [.titled, .closable, .resizable] w.styleMask = [.titled, .closable, .resizable]
w.setContentSize(NSSize(width: 694, height: 520)) w.setContentSize(NSSize(width: 694, height: 520))
w.isReleasedWhenClosed = false w.isReleasedWhenClosed = false
w.center() w.setFrameOrigin(nextOrigin)
w.makeKeyAndOrderFront(nil) w.makeKeyAndOrderFront(nil)
window = w
windows.append(w)
openCount += 1
nextOrigin = NSPoint(x: nextOrigin.x + 28, y: nextOrigin.y - 28)
windowCleanup(for: w)
}
private func windowCleanup(for window: NSWindow) {
NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
self.windows.removeAll { $0 === window }
self.openCount = max(0, self.openCount - 1)
}
}
} }
@MainActor @MainActor
@@ -115,6 +135,13 @@ final class CaptureModel: ObservableObject {
statusMessage = "Copied to clipboard." statusMessage = "Copied to clipboard."
} }
func copyImage() {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([NSImage(cgImage: image, size: .zero)])
statusMessage = "Image copied to clipboard."
}
func savePNG() { func savePNG() {
let panel = NSSavePanel() let panel = NSSavePanel()
panel.allowedContentTypes = [.png] panel.allowedContentTypes = [.png]
@@ -156,6 +183,7 @@ struct ResultView: View {
.pickerStyle(.segmented) .pickerStyle(.segmented)
.fixedSize() .fixedSize()
Spacer() Spacer()
Button("Copy Image") { model.copyImage() }
Button("Save PNG…") { model.savePNG() } Button("Save PNG…") { model.savePNG() }
Button("Get Text") { model.runOCR() } Button("Get Text") { model.runOCR() }
.disabled(model.isOCRRunning || model.extractedText != nil) .disabled(model.isOCRRunning || model.extractedText != nil)
+32 -4
View File
@@ -12,6 +12,26 @@ KEYCHAIN="$SIGN_DIR/signing.keychain-db"
KCHAIN_PASS="kapture-dev" KCHAIN_PASS="kapture-dev"
mkdir -p "$SIGN_DIR" mkdir -p "$SIGN_DIR"
# Prefer a Developer ID Application identity (login keychain) so the app gets
# clean Gatekeeper behavior; fall back to the self-signed "kapture-dev" identity.
# Resolve by keychain path to avoid ambiguous matches.
DEV_IDENT=""
if command -v security >/dev/null 2>&1; then
DEV_IDENT=$(security find-identity -v -p codesigning 2>/dev/null | awk '/Developer ID Application:/ { print $2; exit }')
fi
SIGN_KEYCHAIN=""
if [ -n "$DEV_IDENT" ]; then
CERT="$DEV_IDENT"
echo "Using Developer ID identity: $CERT"
# Sign from the login keychain where the Dev ID lives.
SIGN_KEYCHAIN=""
else
CERT="kapture-dev"
echo "Using self-signed identity 'kapture-dev'"
SIGN_KEYCHAIN="$KEYCHAIN"
fi
DEV_IDENT="${DEV_IDENT:-}" # ensure set for later use
if [ ! -f "$KEYCHAIN" ]; then if [ ! -f "$KEYCHAIN" ]; then
security create-keychain -p "$KCHAIN_PASS" "$KEYCHAIN" 2>/dev/null security create-keychain -p "$KCHAIN_PASS" "$KEYCHAIN" 2>/dev/null
security set-keychain-settings -lut 86400 "$KEYCHAIN" security set-keychain-settings -lut 86400 "$KEYCHAIN"
@@ -86,9 +106,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Icon</string> <string>Icon</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0</string> <string>1.1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>10</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
@@ -102,6 +122,14 @@ PLIST
chmod +x scripts/make-icon.sh chmod +x scripts/make-icon.sh
./scripts/make-icon.sh ./scripts/make-icon.sh
codesign --force --sign "$CERT" --keychain "$KEYCHAIN" "$APP" if [ -n "$SIGN_KEYCHAIN" ]; then
codesign --force --sign "$CERT" --keychain "$SIGN_KEYCHAIN" "$APP"
else
codesign --force --sign "$CERT" "$APP"
fi
echo "Built $APP (signed with stable identity '$CERT') — drag to /Applications or run: open $APP" if [ -n "$DEV_IDENT" ]; then
echo "Built $APP (signed with Developer ID '$CERT') — drag to /Applications or run: open $APP"
else
echo "Built $APP (signed with stable identity '$CERT') — drag to /Applications or run: open $APP"
fi
+1 -1
View File
@@ -18,4 +18,4 @@ shasum -a 256 dist/Kapture.zip
echo echo
echo "Tag and release with:" echo "Tag and release with:"
echo " git tag v$VERSION && git push origin v$VERSION" echo " git tag v$VERSION && git push origin v$VERSION"
echo "The GitHub Actions release workflow will upload dist/Kapture.zip automatically." echo "Attach dist/Kapture.zip to the GitHub release manually."