Compare commits

..
29 Commits
Author SHA1 Message Date
TZGyn e22bcb99c4 Make overlay panels activating so clicks are received
The session log showed active=false after every activation attempt on
macOS 26: cooperative activation is denied for LSUIElement apps, and an
inactive app's panels let clicks fall through to the windows below.
Remove .nonactivatingPanel so showing the overlay activates the app
(previous app focus is restored after capture as before).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:46:45 +08:00
TZGyn 457e303152 Force app activation on capture; log to /tmp for durable diagnostics
Cooperative NSApp.activate() can silently fail for LSUIElement apps,
leaving the app inactive so overlay clicks pass through to windows below.
Force activation with ignoringOtherApps on all OS versions. Session logs
now also append to /tmp/kapture-debug.log so they survive relaunches, and
the first window-scan result is always logged.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:00:44 +08:00
TZGyn 345f306edf Add session event logging to capture flow for remote debugging
OverlayView now logs window tracking counts, hover changes, and
mouseDown/Up branch decisions into a session log that Copy Diagnostics
includes along with the app version, so capture failures on machines we
cannot access directly can be diagnosed from a pasted report.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:52:16 +08:00
TZGyn 45e4962ec3 Fix multi-display window detection and click-to-capture; separate Redact tool
Window frames are now converted to AppKit space once at detection time, so
hover highlighting and picking work on every display. A click is only
treated as a drag after 4pt of movement, so clicking a detected window
captures it instead of passing a stray marquee. Redact is now a standalone
tool (Select/Draw/Redact) with its own toolbar hints instead of a toggle
inside the draw bar.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:46:32 +08:00
TZGyn 5f137f89bf Fix window hover detection; add redaction tool
Window detection was broken by wrong CG-to-AppKit coordinate conversion
of the cursor and highlight rects — both now go through the same
cgToAppKit mapping. The draw toolbar gains a Pen/Redact toggle: redact
drags cover content with a solid black bar (participates in undo/redo
and export like pen strokes).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:39:26 +08:00
TZGyn b998ea210e Document brew trust step for the Gitea tap 2026-09-09 10:36:20 +08:00
TZGyn 86826f7ecc Point Homebrew install at the Gitea-hosted tap
brew tap without a URL resolves to GitHub, where the tap no longer exists;
the full Gitea URL is required.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:32:47 +08:00
TZGyn 969cda6f14 Add window detection capture with rounded corners
Hovering during selection now highlights the window under the cursor;
clicking captures it with a rounded-corner alpha mask matching macOS
window chrome. Dragging still switches to manual marquee selection.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:29:24 +08:00
TZGyn de642ad692 Fix flipped drawing when copying/saving annotated images
The strokes were stored in top-left image coordinates but drawn into an
AppKit CGContext with a bottom-left origin, so exports came out mirrored
and offset. Flip Y in the context before stroking so annotations land
where they were drawn.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-24 09:08:37 +08:00
TZGyn 8b27263962 Make stroke thickness per-stroke; restore Clear button
Each drawn stroke now stores its own width and color, so changing the
thickness slider no longer retroactively resizes existing strokes. The
Clear button is back in the draw toolbar.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 14:00:00 +08:00
TZGyn 1961d0dc9c Move draw controls into the draw toolbar
The color picker, undo/redo, and clear eraser now live in the dedicated
draw top bar instead of the main toolbar, keeping the primary actions
(Copy/Save/Get Text) uncluttered. Keyboard shortcuts (⌘Z/⌘⇧Z) remain.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:55:00 +08:00
TZGyn 41cb06dcb5 Add draw toolbar with thickness, undo/redo, and color picker
When drawing is enabled a dedicated top bar shows a thickness slider,
color picker, and undo/redo buttons (⌘Z / ⌘⇧Z). Strokes render and export
with the chosen width and color.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:39:06 +08:00
TZGyn 52e36b5c5a Add drawing color picker to the result window
A compact ColorPicker appears next to the Draw button when drawing is
enabled; strokes render and export in the chosen color (defaults to
system red).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:33:32 +08:00
TZGyn a74a86d001 Fix vertical offset in ScreenCaptureKit crops
The captured CGImage has a top-left origin, while the crop rect was
computed in SCDisplay space (bottom-left). Flip Y within the display's
bounds when converting to pixel coordinates so the crop lands exactly
where the user selected.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:27:41 +08:00
TZGyn f50057d951 Fix screenshot offset after pixel-exact crop change
The new CGImage.cropping path expects a top-left pixel rect; the previous
CGContext redraw had implicitly flipped Y. Keep the existing SCDisplay-space
conversion so the crop matches the selected region.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:23:37 +08:00
TZGyn 16b876807d Add freehand drawing and fix residual capture pixelation
The screenshot window now supports toggling a pen to draw annotations
that are flattened into copied and saved images. Captures are also
cropped with a pixel-exact CGImage.cropping instead of a CGContext
redraw, removing residual blur on retina displays.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:21:22 +08:00
TZGyn 6a2c8df180 Modernize the result window UI
Restyles the window with a cleaner layout: icon-based toolbar buttons
(Copy/Save/Get All Text, highlighted OCR Selection action), a bottom status
bar with progress indicator and contextual hints, monospaced text tab, and
a divider separating chrome from content.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-13 09:40:51 +08:00
TZGyn c9447d2a8b Make Kapture screenshot-first with marquee text selection
The result window now works like a screenshot tool: drag on the image to
select a region, then click Get Selection Text to OCR only that area
(full-image OCR stays as Get All Text). Selection is rendered in image
coordinates so it stays accurate at any window size.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 21:06:09 +08:00
TZGyn 3fdc2fca95 Fix vertical offset when capturing the secondary display
The first ScreenCaptureKit Y-flip used the wrong reference edges, shifting
cropped selections on displays below the main screen. Convert each selection
per-display as scY = appKitY - NSScreen.maxY + SCDisplay.maxY, which
matches both the main and secondary displays.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:51:18 +08:00
TZGyn 590be2356f Fix captures on displays below the main screen
ScreenCaptureKit uses a flipped Y axis relative to AppKit NSScreen frames,
so selections on a display below the main screen produced out-of-bounds
crop rects and "Capture failed". Convert the selection into SCDisplay space
using the matching NSScreen's top edge before cropping.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:47:48 +08:00
TZGyn af32a36a91 Add Copy Diagnostics menu item for multi-display debugging
Prints each NSScreen's frame/backing scale/CG display ID and each
SCDisplay's frame/point size, and copies it to the clipboard, so
secondary-display capture failures can be debugged from the menu bar.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:44:56 +08:00
TZGyn 1327b61e2b Request capture pixels from the selection screen's backing scale
SCDisplay.width is in points, not pixels: the previous fix sent point
counts to SCStreamConfiguration, producing blurry main-screen captures and
failed secondary-screen captures. Multiply by the backing scale factor of
the screen containing the selection (defaulting to 1x, never forcing 2x).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:37:53 +08:00
TZGyn c9a807e8a0 Fix secondary display captures after retina scaling change
The 1.1.2 retina fix overrode the stream width from the display's pixel
dimensions, which made captures on secondary monitors come out at the
wrong scale or offset. Restore requesting the display's native pixel
resolution and keep the crop scale derived from the actual returned image,
plus guard against out-of-bounds crop rects.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:34:18 +08:00
TZGyn 7bc93c2014 Anti-alias downscaled screenshots in the result window
Full-res retina captures are downscaled to fit the window; nearest-neighbor
made them look chunky. Use high-quality interpolation when shrinking, keep
pixel-crisp rendering only when upscaling small selections.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:25:23 +08:00
TZGyn 374af50acc Capture at full retina resolution
The ScreenCaptureKit stream was sized in points at 1x scale, so retina
displays produced half-resolution captures that appeared pixelated.
Request backingScale x pixels and derive the crop scale from the actual
captured image dimensions.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:14:39 +08:00
TZGyn 1a5771ec27 Render screenshot pixels crisply in the result window
The result image was upscaled with interpolated smoothing, making small
captures look low-res. Disable interpolation so pixels stay 1:1 sharp.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:07:42 +08:00
TZGyn 234a4226c5 Add notarization to release pipeline; sign with hardened runtime
release.sh now submits the zip to Apple's notary via notarytool, staples
the ticket, and repackages, so users no longer hit Gatekeeper warnings.
build-app.sh signs Developer ID builds with the hardened runtime and
unlocks the login keychain to avoid transient signing errors. The private
API key (.p8) is gitignored.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 11:31:14 +08:00
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
9 changed files with 1047 additions and 98 deletions
+3
View File
@@ -5,6 +5,9 @@
# App bundle (build artifact, regenerate with ./build-app.sh)
Kapture.app/
# Secrets
*.p8
# macOS
.DS_Store
+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.
+22
View File
@@ -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,
@@ -21,6 +22,27 @@ No network calls, no API keys — everything runs locally.
- Open an existing image file (or drag) via the menu bar icon
- Menu bar icon with permission status
## Install with Homebrew
```bash
brew tap TZGyn/tap https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git
brew trust TZGyn/tap
brew install --cask kapture
```
The tap is hosted on Gitea, so the full URL is required. Homebrew may print
an "Unsupported tap" warning — it's harmless.
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
```bash
+41
View File
@@ -1,6 +1,7 @@
import SwiftUI
import AppKit
import CoreGraphics
import ServiceManagement
@main
struct KaptureApp: App {
@@ -47,6 +48,26 @@ struct MenuView: View {
Divider()
Button("Copy Diagnostics") {
let diag = CaptureController.shared.diagnostics()
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(diag, forType: .string)
}
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 +81,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)
+129 -29
View File
@@ -10,6 +10,49 @@ final class CaptureController: NSObject {
private var previousApp: NSRunningApplication?
private var overlayIDs: [Int] = []
/// Session event log for debugging (dumped via Copy Diagnostics).
static var sessionLog: [String] = []
static func log(_ line: String) {
sessionLog.append(line)
if sessionLog.count > 100 { sessionLog.removeFirst() }
NSLog("[Kapture] \(line)")
}
/// Diagnostic snapshot for "Copy Diagnostics" menu item.
func diagnostics() -> String {
var lines: [String] = []
lines.append("Kapture diagnostics — version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "?")")
lines.append("--- session log (newest last) ---")
lines.append(contentsOf: Self.sessionLog)
lines.append("--- screens ---")
for (i, s) in NSScreen.screens.enumerated() {
let scale = s.backingScaleFactor
let cgID = (s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
lines.append("screen[\(i)]: frame=\(s.frame) scale=\(scale) backingSize=\(s.frame.width*scale)x\(s.frame.height*scale) cgID=\(cgID)")
}
if #available(macOS 14.0, *) {
Task {
if let content = try? await SCShareableContent.current {
for (i, d) in content.displays.enumerated() {
lines.append("SCDisplay[\(i)]: frame=\(d.frame) width=\(d.width)x\(d.height) id=\(d.displayID)")
}
} else {
lines.append("SCShareableContent: unavailable (no Screen Recording permission?)")
}
let text = lines.joined(separator: "\n")
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
}
} else {
let text = lines.joined(separator: "\n")
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
}
return lines.joined(separator: "\n")
}
func begin() {
guard !isSelecting else { return }
guard PermissionManager.isGranted else {
@@ -18,20 +61,32 @@ final class CaptureController: NSObject {
}
isSelecting = true
previousApp = NSWorkspace.shared.frontmostApplication
if #available(macOS 14.0, *) {
NSApp.activate()
} else {
NSApp.activate(ignoringOtherApps: true)
}
// Force activation and log whether it stuck — inactive apps don't
// receive overlay clicks.
let activated = NSApp.activate(ignoringOtherApps: true)
CaptureController.log("begin: activate(ignoringOtherApps)=\(activated) active=\(NSApp.isActive)")
overlays = NSScreen.screens.map { screen in
OverlayWindow(
screen: screen,
onComplete: { [weak self] rect in self?.finishSelection(rect) },
onCancel: { [weak self] in self?.cancelSelection() }
onCancel: { [weak self] in self?.cancelSelection() },
onWindowPick: { [weak self] rect in self?.finishWindowPick(rect) }
)
}
overlays.forEach { $0.makeKeyAndOrderFront(nil) }
overlayIDs = overlays.map { $0.windowNumber }
CaptureController.log("begin: \(overlays.count) overlays, ids=\(overlayIDs), active=\(NSApp.isActive)")
}
/// A window was picked from hover — capture it with rounded corners.
private func finishWindowPick(_ rect: NSRect) {
let ids = overlayIDs
hideOverlays()
isSelecting = false
restorePreviousApp()
DispatchQueue.main.async { [self] in
capture(rect, excludingWindowIDs: ids, roundedCorners: true)
}
}
private func finishSelection(_ rect: NSRect) {
@@ -62,8 +117,8 @@ final class CaptureController: NSObject {
previousApp = nil
}
private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int]) {
ScreenCapture.shared.capture(rect, excludingWindowIDs: ids) { [weak self] image in
private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int], roundedCorners: Bool = false) {
ScreenCapture.shared.capture(rect, excludingWindowIDs: ids, roundedCorners: roundedCorners) { [weak self] image in
DispatchQueue.main.async {
self?.isSelecting = false
self?.restorePreviousApp()
@@ -85,30 +140,56 @@ final class ScreenCapture {
private init() {}
@available(macOS 14.0, *)
private func crop(_ image: CGImage, to rect: NSRect, display: SCDisplay) -> CGImage? {
let scaleX = CGFloat(display.width) / display.frame.width
let scaleY = CGFloat(display.height) / display.frame.height
let cgRect = CGRect(
x: rect.minX,
y: rect.minY,
width: rect.width,
height: rect.height
)
private func crop(_ image: CGImage, to cgRect: CGRect, display: SCDisplay, roundedCorners: Bool = false) -> CGImage? {
// cgRect is in SCDisplay space (bottom-left origin, Y flipped vs AppKit).
// The captured image has a TOP-LEFT origin. So within the display,
// we must flip Y: pixelY = displayHeight - (cgY - displayMinY) - cgHeight.
let scaleX = CGFloat(image.width) / display.frame.width
let scaleY = CGFloat(image.height) / display.frame.height
guard scaleX > 0, scaleY > 0 else { return nil }
let localMinX = cgRect.minX - display.frame.minX
let localMaxY = cgRect.maxY - display.frame.minY
let flippedY = display.frame.height - localMaxY
let pxRect = CGRect(
x: (cgRect.minX - display.frame.minX) * scaleX,
y: (cgRect.minY - display.frame.minY) * scaleY,
x: localMinX * scaleX,
y: flippedY * scaleY,
width: cgRect.width * scaleX,
height: cgRect.height * scaleY
)
guard let cropCtx = CGContext(
data: nil, width: Int(pxRect.width), height: Int(pxRect.height),
// Guard against negative offsets when the selection reaches beyond the
// display's frame (e.g. secondary monitor coordinate quirks).
let safeRect = pxRect.intersection(CGRect(x: 0, y: 0, width: CGFloat(image.width), height: CGFloat(image.height)))
guard safeRect.width >= 1, safeRect.height >= 1 else { return nil }
// Use a pixel-exact subimage crop instead of redrawing through a
// CGContext, which resamples and loses quality on retina captures.
let rounded = safeRect.integral
guard rounded.width >= 1, rounded.height >= 1 else { return nil }
guard var result = image.cropping(to: rounded) else { return nil }
// Apply a rounded-corner alpha mask for window captures, matching
// macOS window chrome (≈10pt radius, scaled to image pixels).
if roundedCorners {
let radius = 10 * scaleX
let maskRect = CGRect(x: 0, y: 0, width: result.width, height: result.height)
guard let maskCtx = CGContext(
data: nil, width: result.width, height: result.height,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
cropCtx.interpolationQuality = .high
cropCtx.draw(image, in: CGRect(x: -pxRect.minX, y: -pxRect.minY, width: CGFloat(image.width), height: CGFloat(image.height)))
return cropCtx.makeImage()
) else { return result }
maskCtx.setFillColor(CGColor(gray: 1, alpha: 1))
maskCtx.fill(maskRect)
maskCtx.setBlendMode(.clear)
maskCtx.setFillColor(CGColor(gray: 0, alpha: 1))
maskCtx.addPath(CGPath(roundedRect: maskRect, cornerWidth: radius, cornerHeight: radius, transform: nil))
maskCtx.fillPath()
maskCtx.setBlendMode(.normal)
if let mask = maskCtx.makeImage(),
let masked = result.masking(mask) {
result = masked
}
}
return result
}
@available(macOS 14.0, *)
@@ -121,7 +202,7 @@ final class ScreenCapture {
return content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first
}
func capture(_ rect: NSRect, excludingWindowIDs excludedIDs: [Int], completion: @escaping (CGImage?) -> Void) {
func capture(_ rect: NSRect, excludingWindowIDs excludedIDs: [Int], roundedCorners: Bool = false, completion: @escaping (CGImage?) -> Void) {
if #available(macOS 14.0, *) {
Task {
guard let content = try? await SCShareableContent.current,
@@ -133,15 +214,34 @@ final class ScreenCapture {
let excluded = content.windows.filter { excludedSet.contains($0.windowID) }
let filter = SCContentFilter(display: display, excludingWindows: excluded)
let config = SCStreamConfiguration()
config.width = Int(display.width)
config.height = Int(display.height)
// SCDisplay.width/height are in POINTS; SCStreamConfiguration
// width/height are output PIXELS. Multiply by the backing scale
// of the screen containing the selection so retina displays are
// captured at full resolution and 1x displays stay 1x.
guard let screen = NSScreen.screens.first(where: { $0.frame.contains(NSPoint(x: rect.midX, y: rect.midY)) }) else {
completion(nil)
return
}
let backingScale = screen.backingScaleFactor
config.width = Int(CGFloat(display.width) * backingScale)
config.height = Int(CGFloat(display.height) * backingScale)
config.showsCursor = true
// ScreenCaptureKit uses a different origin than AppKit for
// screens stacked around the main display: per-display,
// SCDisplay y = appKitY - NSScreen.maxY + SCDisplay.maxY.
let scRect = CGRect(
x: rect.minX,
y: rect.minY - screen.frame.maxY + display.frame.maxY,
width: rect.width,
height: rect.height
)
guard let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) else {
completion(nil)
return
}
completion(self.crop(image, to: rect, display: display))
completion(self.crop(image, to: scRect, display: display, roundedCorners: roundedCorners))
}
} else {
guard let primary = NSScreen.screens.first(where: { $0.frame.origin == .zero }) ?? NSScreen.screens.first else {
+134 -3
View File
@@ -1,8 +1,8 @@
import AppKit
final class OverlayWindow: NSPanel {
init(screen: NSScreen, onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void) {
let content = OverlayView(onComplete: onComplete, onCancel: onCancel)
init(screen: NSScreen, onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) {
let content = OverlayView(onComplete: onComplete, onCancel: onCancel, onWindowPick: onWindowPick)
super.init(
contentRect: screen.frame,
styleMask: [.borderless, .nonactivatingPanel],
@@ -25,13 +25,21 @@ final class OverlayWindow: NSPanel {
final class OverlayView: NSView {
private let onComplete: (NSRect) -> Void
private let onCancel: () -> Void
private let onWindowPick: (NSRect) -> Void
private var startPoint: NSPoint?
private(set) var selection: NSRect?
private var isDragging = false
private var dragDistance: CGFloat = 0
init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void) {
// Window detection (frames stored in AppKit global space)
private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = []
private var hoveredWindowFrame: NSRect?
init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) {
self.onComplete = onComplete
self.onCancel = onCancel
self.onWindowPick = onWindowPick
super.init(frame: .zero)
}
@@ -47,9 +55,87 @@ final class OverlayView: NSView {
addCursorRect(bounds, cursor: .crosshair)
}
// MARK: - Window detection
/// Rebuild the list of capturable windows intersecting this screen.
/// Called lazily on mouse moves; cheap enough for interactive hover.
private func refreshTrackedWindows() {
guard let window = window else { return }
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
// Exclude this overlay's own window (the other overlays don't intersect
// this screen, so the on-screen filter handles them).
let ownID = window.windowNumber
guard let list = CGWindowListCopyWindowInfo(
[ .optionOnScreenOnly, .excludeDesktopElements ], kCGNullWindowID
) as? [[String: Any]] else { trackedWindows = []; return }
var found: [(CGWindowID, NSRect)] = []
for info in list {
guard let cid = info[kCGWindowNumber as String] as? CGWindowID,
let bounds = info[kCGWindowBounds as String] as? [String: CGFloat],
let layer = info[kCGWindowLayer as String] as? Int,
layer == 0
else { continue }
// Skip our own overlays
if Int(cid) == ownID { continue }
let cgRect = NSRect(
x: bounds["X"] ?? 0,
y: bounds["Y"] ?? 0,
width: bounds["Width"] ?? 0,
height: bounds["Height"] ?? 0
)
guard cgRect.width >= 80, cgRect.height >= 60 else { continue }
// Convert to AppKit space FIRST — CG and AppKit Y axes disagree on
// multi-desktop layouts, so intersect tests must use one space.
let rect = Self.cgToAppKit(rect: cgRect)
// Skip windows filling the whole screen (fullscreen apps)
guard rect.width < screenFrame.width || rect.height < screenFrame.height else { continue }
// Only windows intersecting this screen
guard rect.intersects(screenFrame) else { continue }
found.append((cid, rect))
}
trackedWindows = found
}
private func windowFrame(at point: NSPoint) -> NSRect? {
guard let window = window else { return nil }
// convertToScreen gives AppKit-global coordinates; tracked frames are
// already in that space.
let screenPoint = window.convertToScreen(NSRect(origin: point, size: .zero)).origin
for (_, frame) in trackedWindows {
if frame.contains(screenPoint) {
return frame
}
}
return nil
}
/// Convert a CGWindowList (top-left origin, CG global space) frame into
/// AppKit screen coordinates (bottom-left origin) for capture.
private static func cgToAppKit(rect: NSRect) -> NSRect {
guard let mainScreen = NSScreen.screens.first else { return rect }
let y = mainScreen.frame.height - rect.origin.y - rect.height
return NSRect(x: rect.origin.x, y: y, width: rect.width, height: rect.height)
}
// MARK: - Events
override func mouseMoved(with event: NSEvent) {
guard !isDragging else { return }
refreshTrackedWindows()
let p = convert(event.locationInWindow, from: nil)
let frame = windowFrame(at: p)
if frame != hoveredWindowFrame {
hoveredWindowFrame = frame
needsDisplay = true
}
}
override func mouseDown(with event: NSEvent) {
let p = convert(event.locationInWindow, from: nil)
startPoint = p
dragDistance = 0
selection = NSRect(origin: p, size: .zero)
needsDisplay = true
}
@@ -57,6 +143,12 @@ final class OverlayView: NSView {
override func mouseDragged(with event: NSEvent) {
guard let start = startPoint else { return }
let p = convert(event.locationInWindow, from: nil)
dragDistance = hypot(p.x - start.x, p.y - start.y)
// Only become a marquee after real movement; micro-jitter from a
// click must not cancel window-pick mode.
guard dragDistance >= 4 else { return }
isDragging = true
hoveredWindowFrame = nil
selection = NSRect(
x: min(start.x, p.x),
y: min(start.y, p.y),
@@ -67,12 +159,27 @@ final class OverlayView: NSView {
}
override func mouseUp(with event: NSEvent) {
defer {
isDragging = false
dragDistance = 0
}
if isDragging {
guard let sel = selection else { return }
if sel.width >= 2, sel.height >= 2 {
onComplete(window!.convertToScreen(sel))
} else {
onCancel()
}
return
}
// It was a click. If a window is hovered, capture it with rounded
// corners; otherwise treat as an empty marquee (cancel).
if let frame = hoveredWindowFrame {
onWindowPick(frame)
} else {
onCancel()
}
}
override func keyDown(with event: NSEvent) {
@@ -87,6 +194,30 @@ final class OverlayView: NSView {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
// Highlight the hovered window (rounded, like the real window corners)
if let frame = hoveredWindowFrame {
// Tracked frames are AppKit-global; view space = global - window origin.
guard let window = window else { return }
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
let viewFrame = NSRect(
x: frame.origin.x - screenFrame.origin.x,
y: frame.origin.y - screenFrame.origin.y,
width: frame.width,
height: frame.height
)
NSGraphicsContext.current?.saveGraphicsState()
NSGraphicsContext.current?.compositingOperation = .clear
NSColor.black.setFill()
NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10).fill()
NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10)
border.lineWidth = 2
NSColor.controlAccentColor.setStroke()
border.stroke()
return
}
guard let sel = selection, sel.width > 0, sel.height > 0 else { return }
NSGraphicsContext.current?.saveGraphicsState()
+493 -52
View File
@@ -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
@@ -68,6 +88,31 @@ final class CaptureModel: ObservableObject {
@Published var extractedText: String?
@Published var isOCRRunning = false
@Published var statusMessage: String?
/// Drag selection rect in IMAGE point space (nil = none)
@Published var selection: CGRect?
/// Freehand drawing strokes in IMAGE point space
@Published var strokes: [Stroke] = []
/// Redo stack of removed strokes
@Published var redoStack: [Stroke] = []
/// Whether the pen is active (drag draws instead of selecting)
@Published var tool: Tool = .select
/// Current drawing color
@Published var drawColor: NSColor = .systemRed
/// Stroke thickness in points (image space)
@Published var strokeWidth: CGFloat = 3.0
enum Tool {
case select
case draw
case redact
}
struct Stroke {
let points: [CGPoint]
let width: CGFloat
let color: NSColor
var isRedaction: Bool = false
}
enum CaptureMode {
case screenshot
@@ -78,24 +123,125 @@ final class CaptureModel: ObservableObject {
self.image = image
}
var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 }
var canUndo: Bool { !strokes.isEmpty }
var canRedo: Bool { !redoStack.isEmpty }
func undoStroke() {
guard let last = strokes.popLast() else { return }
redoStack.append(last)
}
func redoStroke() {
guard let last = redoStack.popLast() else { return }
strokes.append(last)
}
func clearAnnotations() {
strokes = []
redoStack = []
}
/// Image with drawn strokes flattened in, for copy/save/export.
var annotatedImage: CGImage {
guard !strokes.isEmpty else { return image }
let size = NSSize(width: image.width, height: image.height)
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
) else { return image }
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
let ctx = NSGraphicsContext.current!.cgContext
// CGImage draws with a flipped Y in NSGraphicsContext
ctx.draw(image, in: NSRect(origin: .zero, size: size))
// Strokes are stored in TOP-LEFT image coordinates (from the SwiftUI
// canvas), but the CGContext uses a BOTTOM-LEFT origin. Flip Y so the
// strokes land where they were drawn.
ctx.saveGState()
ctx.translateBy(x: 0, y: size.height)
ctx.scaleBy(x: 1, y: -1)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
for stroke in strokes {
guard stroke.points.count > 1 else { continue }
if stroke.isRedaction {
// Redaction bar: filled black rect covering the stroke bounds,
// padded so it fully covers text.
let xs = stroke.points.map { $0.x }
let ys = stroke.points.map { $0.y }
let bar = NSRect(
x: xs.min()! - 2,
y: ys.min()! - 2,
width: (xs.max()! - xs.min()!) + 4,
height: max(stroke.width, ys.max()! - ys.min()!) + 4
)
NSColor.black.setFill()
NSBezierPath(rect: bar).fill()
} else {
stroke.color.setStroke()
ctx.setLineWidth(stroke.width)
ctx.beginPath()
ctx.move(to: stroke.points[0])
for point in stroke.points.dropFirst() {
ctx.addLine(to: point)
}
ctx.strokePath()
}
}
ctx.restoreGState()
NSGraphicsContext.restoreGraphicsState()
return rep.cgImage ?? image
}
func addStroke(_ points: [CGPoint]) {
guard points.count > 1 else { return }
if tool == .redact {
// Redaction: a filled black bar from start to end, as thick as the
// current stroke width (drawn as a rect for clean edges).
strokes.append(Stroke(points: points, width: max(strokeWidth, 12), color: .black, isRedaction: true))
} else {
strokes.append(Stroke(points: points, width: strokeWidth, color: drawColor))
}
redoStack = []
}
var displayText: String {
if isOCRRunning {
return "Recognizing text…"
}
return extractedText ?? "Text appears here — click “Get Text”."
return extractedText ?? "Select text in the screenshot, then click “Get Text”."
}
func runOCR() {
func cropImage(to rectInImage: CGRect) -> CGImage? {
return image.cropping(to: rectInImage)
}
func runOCR(over selectionOnly: CGRect? = nil) {
guard !isOCRRunning else { return }
isOCRRunning = true
statusMessage = nil
OCRService.shared.recognize(image) { [weak self] text in
let targetImage: CGImage
if let sel = selectionOnly {
guard let cropped = image.cropping(to: sel) else {
self.isOCRRunning = false
self.statusMessage = "Selection too small."
return
}
targetImage = cropped
} else {
targetImage = image
}
OCRService.shared.recognize(targetImage) { [weak self] text in
Task { @MainActor in
guard let self else { return }
self.isOCRRunning = false
self.mode = .text
if text.isEmpty {
self.extractedText = "No text found in the screenshot."
self.extractedText = "No text found in the selection."
} else {
self.extractedText = text
let pasteboard = NSPasteboard.general
@@ -107,6 +253,11 @@ final class CaptureModel: ObservableObject {
}
}
func runOCRSelection() {
guard let sel = selection, hasSelection else { return }
runOCR(over: sel)
}
func copyText() {
guard let text = extractedText else { return }
let pasteboard = NSPasteboard.general
@@ -115,6 +266,13 @@ final class CaptureModel: ObservableObject {
statusMessage = "Copied to clipboard."
}
func copyImage() {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([NSImage(cgImage: annotatedImage, size: .zero)])
statusMessage = "Image copied to clipboard."
}
func savePNG() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.png]
@@ -124,7 +282,7 @@ final class CaptureModel: ObservableObject {
statusMessage = "Could not create PNG file."
return
}
CGImageDestinationAddImage(dest, image, nil)
CGImageDestinationAddImage(dest, annotatedImage, nil)
if CGImageDestinationFinalize(dest) {
statusMessage = "Saved PNG."
} else {
@@ -144,67 +302,350 @@ struct ResultView: View {
let onClose: () -> Void
var body: some View {
VStack(spacing: 10) {
HStack {
VStack(spacing: 0) {
// Toolbar
HStack(spacing: 10) {
Picker("", selection: $model.mode) {
Text("Screenshot").tag(CaptureModel.CaptureMode.screenshot)
Text("Text").tag(CaptureModel.CaptureMode.text)
if model.isOCRRunning {
Text("…").tag(CaptureModel.CaptureMode.text)
}
}
.pickerStyle(.segmented)
.fixedSize()
Spacer()
Button("Save PNG…") { model.savePNG() }
Button("Get Text") { model.runOCR() }
.disabled(model.isOCRRunning || model.extractedText != nil)
Button("Close") { onClose() }
}
.padding(.horizontal, 12)
.padding(.top, 10)
if model.mode == .screenshot {
Image(nsImage: NSImage(cgImage: model.image, size: .zero))
.resizable()
.aspectRatio(contentMode: .fit)
.border(Color.secondary.opacity(0.4))
.padding(.horizontal, 12)
Spacer()
if model.hasSelection {
Button {
model.runOCRSelection()
} label: {
Label("OCR Selection", systemImage: "text.magnifyingglass")
}
.buttonStyle(.borderedProminent)
.disabled(model.isOCRRunning)
.help("Recognize text inside the selected region")
}
Button {
model.runOCR()
} label: {
Label("Get All Text", systemImage: "text.viewfinder")
}
.buttonStyle(.bordered)
.disabled(model.isOCRRunning)
.help("Recognize all text in the screenshot")
Divider().frame(height: 18)
Button {
model.tool = model.tool == .draw ? .select : .draw
if model.tool != .select { model.selection = nil }
} label: {
Label("Draw", systemImage: model.tool == .draw ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle")
}
.buttonStyle(.bordered)
.foregroundStyle(model.tool == .draw ? .primary : .secondary)
.help("Draw on the screenshot")
Button {
model.tool = model.tool == .redact ? .select : .redact
if model.tool != .select { model.selection = nil }
} label: {
Label("Redact", systemImage: model.tool == .redact ? "rectangle.fill.on.rectangle.fill" : "rectangle.dashed")
}
.buttonStyle(.bordered)
.foregroundStyle(model.tool == .redact ? .primary : .secondary)
.help("Cover sensitive areas with black bars")
Button {
model.copyImage()
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
.buttonStyle(.bordered)
.help("Copy image to clipboard")
Button {
model.savePNG()
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
.buttonStyle(.bordered)
.help("Save screenshot as PNG");
Button {
onClose()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Close")
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
Divider()
// Tool toolbar (only when a draw tool is active)
if model.tool != .select {
HStack(spacing: 14) {
Label(model.tool == .redact ? "Redact" : "Draw", systemImage: model.tool == .redact ? "rectangle.fill" : "pencil.tip")
.font(.caption)
.foregroundStyle(.secondary)
Divider().frame(height: 18)
if model.tool == .draw {
HStack(spacing: 6) {
Image(systemName: "circle.fill").font(.system(size: 4))
Image(systemName: "circle.fill").font(.system(size: 7))
Image(systemName: "circle.fill").font(.system(size: 10))
}
.foregroundStyle(.secondary)
Slider(value: $model.strokeWidth, in: 1...12, step: 0.5)
.frame(width: 120)
.help("Stroke thickness")
Divider().frame(height: 18)
ColorPicker("", selection: Binding(
get: { Color(nsColor: model.drawColor) },
set: { model.drawColor = NSColor($0) }
), supportsOpacity: false)
.labelsHidden()
.frame(width: 28)
.help("Drawing color")
} else {
ZStack(alignment: .bottomTrailing) {
Label("Drag to cover sensitive text", systemImage: "eye.slash")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
model.undoStroke()
} label: {
Label("Undo", systemImage: "arrow.uturn.backward")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!model.canUndo)
.keyboardShortcut("z", modifiers: .command)
.help("Undo last stroke (⌘Z)")
Button {
model.redoStroke()
} label: {
Label("Redo", systemImage: "arrow.uturn.forward")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!model.canRedo)
.keyboardShortcut("z", modifiers: [.command, .shift])
.help("Redo stroke (⌘⇧Z)")
Button {
model.clearAnnotations()
} label: {
Label("Clear", systemImage: "eraser")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(model.strokes.isEmpty)
.help("Remove all drawn annotations")
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.bar)
Divider()
}
// Content
if model.mode == .screenshot {
ScreenshotPane(model: model)
.padding(12)
} else {
ZStack {
ScrollView {
Text(model.displayText)
.font(.system(.body))
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.padding(12)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.4))
)
// Status bar
HStack(spacing: 8) {
if let msg = model.statusMessage {
Text(msg)
if model.isOCRRunning {
ProgressView()
.controlSize(.small)
}
Text(model.statusMessage ?? (model.mode == .screenshot
? (model.hasSelection ? "Region selected — click OCR Selection." : "Drag to select a region, or Get All Text.")
: "Selectable text"))
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
if model.mode == .text, model.extractedText != nil {
Button {
model.copyText()
} label: {
Label("Copy Text", systemImage: "doc.on.doc")
}
Button("Copy Text") { model.copyText() }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(8)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.bar)
}
.frame(minWidth: 520, minHeight: 420)
}
}
if model.statusMessage != nil && model.mode == .screenshot {
Text(model.statusMessage!)
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
struct ScreenshotPane: View {
@ObservedObject var model: CaptureModel
private var imageSize: CGSize {
CGSize(width: model.image.width, height: model.image.height)
}
private func geometry(for geo: GeometryProxy) -> (scale: CGFloat, drawSize: CGSize, origin: CGPoint) {
let size = imageSize
let scale = min(geo.size.width / size.width, geo.size.height / size.height)
let drawSize = CGSize(width: size.width * scale, height: size.height * scale)
let origin = CGPoint(
x: (geo.size.width - drawSize.width) / 2,
y: (geo.size.height - drawSize.height) / 2
)
return (scale, drawSize, origin)
}
private func toImagePoint(_ viewPoint: CGPoint, scale: CGFloat, origin: CGPoint) -> CGPoint {
CGPoint(
x: (viewPoint.x - origin.x) / scale,
y: (viewPoint.y - origin.y) / scale
)
}
private func clampSelection(_ rect: CGRect) -> CGRect {
rect.intersection(CGRect(origin: .zero, size: imageSize))
}
@State private var currentStroke: [CGPoint] = []
var body: some View {
GeometryReader { geo in
let (scale, drawSize, origin) = geometry(for: geo)
ZStack {
Image(nsImage: NSImage(cgImage: model.image, size: .zero))
.resizable()
.interpolation(imageSize.width >= geo.size.width ? .high : .none)
.aspectRatio(contentMode: .fit)
.frame(width: drawSize.width, height: drawSize.height)
.position(x: origin.x + drawSize.width / 2, y: origin.y + drawSize.height / 2)
// Completed strokes
Canvas { context, _ in
for stroke in model.strokes {
guard stroke.points.count > 1 else { continue }
if stroke.isRedaction {
let xs = stroke.points.map { $0.x }
let ys = stroke.points.map { $0.y }
let bar = CGRect(
x: origin.x + (xs.min()! - 2) * scale,
y: origin.y + (ys.min()! - 2) * scale,
width: ((xs.max()! - xs.min()!) + 4) * scale,
height: (max(stroke.width, ys.max()! - ys.min()!) + 4) * scale
)
context.fill(Path(bar), with: .color(.black))
} else {
var path = Path()
path.move(to: CGPoint(x: origin.x + stroke.points[0].x * scale, y: origin.y + stroke.points[0].y * scale))
for point in stroke.points.dropFirst() {
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
}
context.stroke(path, with: .color(Color(nsColor: stroke.color)), lineWidth: stroke.width)
}
}
.padding(.bottom, 10)
.frame(minWidth: 480, minHeight: 380)
}
.allowsHitTesting(false)
// In-progress stroke
if !currentStroke.isEmpty {
Canvas { context, _ in
var path = Path()
if currentStroke.count > 1 {
path.move(to: CGPoint(x: origin.x + currentStroke[0].x * scale, y: origin.y + currentStroke[0].y * scale))
for point in currentStroke.dropFirst() {
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
}
}
context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth)
}
.allowsHitTesting(false)
}
if model.tool == .select, let sel = model.selection {
let viewRect = CGRect(
x: origin.x + sel.minX * scale,
y: origin.y + sel.minY * scale,
width: sel.width * scale,
height: sel.height * scale
)
Rectangle()
.fill(Color.accentColor.opacity(0.15))
.border(Color.accentColor, width: 1.5)
.frame(width: viewRect.width, height: viewRect.height)
.position(x: viewRect.midX, y: viewRect.midY)
.allowsHitTesting(false)
}
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if model.tool != .select {
let p = toImagePoint(value.location, scale: scale, origin: origin)
let imgRect = CGRect(origin: .zero, size: imageSize)
if imgRect.contains(p) {
currentStroke.append(p)
}
} else {
let start = toImagePoint(value.startLocation, scale: scale, origin: origin)
let current = toImagePoint(value.location, scale: scale, origin: origin)
let rect = CGRect(
x: min(start.x, current.x),
y: min(start.y, current.y),
width: abs(current.x - start.x),
height: abs(current.y - start.y)
)
model.selection = clampSelection(rect)
}
}
.onEnded { _ in
if model.tool != .select {
if currentStroke.count > 1 {
model.addStroke(currentStroke)
}
currentStroke = []
} else {
if let s = model.selection, (s.width < 2 || s.height < 2) {
model.selection = nil
}
}
}
)
}
}
}
+33 -3
View File
@@ -12,6 +12,28 @@ 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"
# Ensure the login keychain holding the Dev ID is unlocked & accessible.
security unlock-keychain "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null || true
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "" "$HOME/Library/Keychains/login.keychain-db" >/dev/null 2>&1 || true
SIGN_KEYCHAIN="$HOME/Library/Keychains/login.keychain-db"
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 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key>
<string>Icon</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>1.6.3</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>63</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
@@ -102,6 +124,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 --options runtime --sign "$CERT" --keychain "$SIGN_KEYCHAIN" "$APP"
else
codesign --force --sign "$CERT" "$APP"
fi
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
+71 -2
View File
@@ -8,14 +8,83 @@ if [ "$#" -ne 1 ]; then
fi
VERSION="$1"
# Notarization credentials. Configure once, e.g. in ~/.zshrc:
# export KAPTURE_NOTARY_ASC_PROVIDER="<TeamID>"
# export KAPTURE_NOTARY_API_KEY_PATH="$HOME/.config/kapture/AuthKey_XXXX.p8"
# export KAPTURE_NOTARY_API_KEY_ID="<KeyID>"
# export KAPTURE_NOTARY_API_ISSUER="<IssuerID>"
# If these are unset (or KAPTURE_SKIP_NOTARY=1), release.sh builds, zips, and stops
# there — the zip is signed with the Developer ID identity but NOT notarized.
./build-app.sh
mkdir -p dist
rm -f dist/Kapture.zip
ditto -c -k --keepParent Kapture.app dist/Kapture.zip
shasum -a 256 dist/Kapture.zip
if [ -n "${KAPTURE_SKIP_NOTARY:-}" ]; then
echo
echo "Skipping notarization (KAPTURE_SKIP_NOTARY set)."
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."
exit 0
fi
REQUIRED_VARS="KAPTURE_NOTARY_ASC_PROVIDER KAPTURE_NOTARY_API_KEY_PATH KAPTURE_NOTARY_API_KEY_ID KAPTURE_NOTARY_API_ISSUER"
MISSING=""
for v in $REQUIRED_VARS; do
if [ -z "${!v:-}" ]; then
MISSING="$MISSING $v"
fi
done
if [ -n "$MISSING" ]; then
echo "Notarization skipped — missing env var(s):$MISSING (see header comments)." >&2
shasum -a 256 dist/Kapture.zip
echo
echo "The zip above is signed but NOT notarized; Gatekeeper will warn users until it is."
echo "Set the KAPTURE_NOTARY_* variables and re-run ./release.sh $VERSION."
exit 0
fi
if ! command -v xcrun >/dev/null 2>&1 || ! xcrun --find notarytool >/dev/null 2>&1; then
echo "Notarization skipped — xcrun notarytool not found (requires Xcode 13+)." >&2
shasum -a 256 dist/Kapture.zip
exit 0
fi
echo "Submitting to Apple for notarization (first run can take minutes)..."
xcrun notarytool submit dist/Kapture.zip \
--key "$KAPTURE_NOTARY_API_KEY_PATH" \
--key-id "$KAPTURE_NOTARY_API_KEY_ID" \
--issuer "$KAPTURE_NOTARY_API_ISSUER" \
--wait \
--output-format json > notary-result.json
STATUS=$(jq -r '.status' notary-result.json 2>/dev/null || echo "unknown")
if [ "$STATUS" != "Accepted" ]; then
echo "Notarization failed with status: $STATUS" >&2
if [ -f notary-result.json ]; then
cat notary-result.json >&2
rm -f notary-result.json
fi
exit 1
fi
rm -f notary-result.json
echo "Notarization accepted — stapling ticket onto the app..."
xcrun stapler staple Kapture.app
xcrun stapler validate Kapture.app
rm -f dist/Kapture.zip
ditto -c -k --keepParent Kapture.app dist/Kapture.zip
echo
echo "Done — notarized."
shasum -a 256 dist/Kapture.zip
echo
echo "Tag and release with:"
echo " git tag v$VERSION && git push origin v$VERSION"
echo "Attach dist/Kapture.zip to the GitHub release manually; the Homebrew cask is ready."