From af15f9a865bf0abf907b42897fec4b61e8b04b0b Mon Sep 17 00:00:00 2001 From: TZGyn Date: Tue, 11 Aug 2026 21:55:04 +0800 Subject: [PATCH] Add launch-at-login, multi-window results, and copy-image; auto-detect Developer ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 112 +++++++++++++++++++++++++++++ README.md | 6 ++ Sources/Kapture/App.swift | 34 +++++++++ Sources/Kapture/ResultWindow.swift | 44 +++++++++--- build-app.sh | 36 ++++++++-- release.sh | 2 +- 6 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..39ecda2 --- /dev/null +++ b/AGENTS.md @@ -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 # 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 ` β€” 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 && git push origin v` 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. diff --git a/README.md b/README.md index 1bff49c..fd9a99a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ No network calls, no API keys β€” everything runs locally. - Global hotkey **βŒ˜β‡§S** works from any app - 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 instantly OCR'd - **Get Text** button runs on-device OCR (English, Chinese simplified/traditional, @@ -34,6 +35,11 @@ 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 ```bash diff --git a/Sources/Kapture/App.swift b/Sources/Kapture/App.swift index bfe99b7..0e94f23 100644 --- a/Sources/Kapture/App.swift +++ b/Sources/Kapture/App.swift @@ -1,6 +1,7 @@ import SwiftUI import AppKit import CoreGraphics +import ServiceManagement @main struct KaptureApp: App { @@ -47,6 +48,19 @@ struct MenuView: View { 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) } .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() { let text = "Kapture OCR test 1234, hello world" let size = NSSize(width: 1600, height: 220) diff --git a/Sources/Kapture/ResultWindow.swift b/Sources/Kapture/ResultWindow.swift index e866ae0..9ac39dc 100644 --- a/Sources/Kapture/ResultWindow.swift +++ b/Sources/Kapture/ResultWindow.swift @@ -5,24 +5,44 @@ import UniformTypeIdentifiers final class 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 func show(image: CGImage) { - window?.close() let model = CaptureModel(image: image) - let hosting = NSHostingController(rootView: ResultView(model: model) { [weak self] in - self?.window?.close() + let w = NSWindow() + w.contentViewController = NSHostingController(rootView: ResultView(model: model) { + w.close() }) - let w = NSWindow(contentViewController: hosting) - w.title = "Kapture β€” Screenshot" + w.title = "Kapture β€” Screenshot" + (openCount > 0 ? " \(openCount + 1)" : "") w.level = .floating w.styleMask = [.titled, .closable, .resizable] w.setContentSize(NSSize(width: 694, height: 520)) w.isReleasedWhenClosed = false - w.center() + w.setFrameOrigin(nextOrigin) 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 @@ -115,6 +135,13 @@ final class CaptureModel: ObservableObject { 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() { let panel = NSSavePanel() panel.allowedContentTypes = [.png] @@ -156,6 +183,7 @@ struct ResultView: View { .pickerStyle(.segmented) .fixedSize() Spacer() + Button("Copy Image") { model.copyImage() } Button("Save PNG…") { model.savePNG() } Button("Get Text") { model.runOCR() } .disabled(model.isOCRRunning || model.extractedText != nil) diff --git a/build-app.sh b/build-app.sh index 16cd180..10f8150 100755 --- a/build-app.sh +++ b/build-app.sh @@ -12,6 +12,26 @@ KEYCHAIN="$SIGN_DIR/signing.keychain-db" KCHAIN_PASS="kapture-dev" 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 security create-keychain -p "$KCHAIN_PASS" "$KEYCHAIN" 2>/dev/null security set-keychain-settings -lut 86400 "$KEYCHAIN" @@ -86,9 +106,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleIconFile Icon CFBundleShortVersionString - 1.0 + 1.1.0 CFBundleVersion - 1 + 10 LSUIElement NSHighResolutionCapable @@ -102,6 +122,14 @@ PLIST chmod +x 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 diff --git a/release.sh b/release.sh index 5c16d16..0f780bf 100755 --- a/release.sh +++ b/release.sh @@ -18,4 +18,4 @@ shasum -a 256 dist/Kapture.zip echo echo "Tag and release with:" 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."