Compare commits

..
16 Commits
Author SHA1 Message Date
TZGyn 265571d7e2 Reject window captures that composite extra windows
ScreenCaptureKit's desktopIndependentWindow filter can include owned
child windows (e.g. an app dialog plus its parent), producing a capture
much larger than the picked window. Detect the overflow by comparing the
returned image size against the window's frame and fall back to the
screen crop with rounded mask. Hover changes are logged with the picked
window id and tracked count for diagnosis.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 14:55:40 +08:00
TZGyn 7633e81c52 Capture picked windows natively with real rounded corners
Window picks now capture via ScreenCaptureKit's desktopIndependentWindow
filter, so the image carries the app's actual rounded-corner alpha
instead of a fixed 10pt mask that let the desktop show through on apps
with larger radii. Falls back to crop+mask when the window is not in the
shareable content.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 14:10:03 +08:00
TZGyn 83b127cf44 Detect maximized windows on secondary displays
A leftover heuristic skipped any window matching its screen's dimensions,
which made window detection silently fail on secondary monitors where the
working window is typically maximized.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 14:03:52 +08:00
TZGyn 8332661504 Guard against event-tap teardown crashes; log result-window display
A tap-disabled callback force-unwrapped the tap port, which could crash
the app mid-capture (no result window, dead hotkey). Guard it and log
re-enables. Result window display is now logged with the image size.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 12:29:53 +08:00
TZGyn 832a18b06c Exclude the cursor from captures
💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 12:26:12 +08:00
TZGyn 6e5796e16d Check Accessibility before starting a capture; log capture geometry
The first capture used to start the overlay and the system permission
prompt at once, making both uninteractive. begin() now requires
Accessibility up front, shows an alert with a direct link to the setting,
and only starts capturing once granted. Captures log their display,
backing scale, and rect mapping for diagnosing multi-display offsets.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 12:24:47 +08:00
TZGyn 7e9cc5b269 Drive capture selection through a CGEventTap
Panel event delivery is unreliable on macOS 26 (clicks pass through even
with the app active and the panel key). The selection flow is now driven
by a CGEventTap that sees and consumes mouse events system-wide: hover
highlights windows, clicks capture them (rounded), drags marquee-select,
Esc cancels. Requires Accessibility permission, prompted on first use;
falls back to panel events when unavailable.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 12:08:57 +08:00
TZGyn a7cad2fc2f Terminate stale Kapture instances at launch; log topmost windows
A stale copy surviving an update keeps its own hotkey and overlays on
screen, silently eating every click meant for the new instance (matching
the empty session logs). New copies now force-terminate older instances,
and the first window scan logs the topmost on-screen windows so anything
covering the overlays is visible in diagnostics.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:59:03 +08:00
TZGyn 69e1a7fccb Show overlays only after activation completes
The local probe confirmed activation is asynchronous on macOS 26:
isActive flips on a later runloop tick, so overlays shown in the same
tick as activate() belong to a still-inactive app and misses clicks.
Delay overlay creation by a tick and log the activation state at show
time.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:51:13 +08:00
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
6 changed files with 443 additions and 238 deletions
+5 -2
View File
@@ -25,11 +25,14 @@ No network calls, no API keys — everything runs locally.
## Install with Homebrew
```bash
brew tap TZGyn/tap
brew trust TZGyn/tap # Homebrew 6+ requires trusting third-party taps
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)
+18
View File
@@ -21,6 +21,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
runOCRSelfTest()
return
}
// Single instance: stale copies keep their own hotkey + overlays alive
// after app updates and eat clicks meant for the new instance.
let myPid = ProcessInfo.processInfo.processIdentifier
let others = NSRunningApplication.runningApplications(withBundleIdentifier: "dev.tzgyn.kapture")
.filter { $0.processIdentifier != myPid }
if !others.isEmpty {
CaptureController.log("launch: other Kapture instances pids=\(others.map { String($0.processIdentifier) }.joined(separator: ",")) — terminating them")
for other in others {
other.forceTerminate()
}
}
HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() }
HotkeyManager.shared.register()
}
@@ -79,6 +90,13 @@ enum PermissionManager {
static func request() {
CGRequestScreenCaptureAccess()
}
static var isAccessibilityGranted: Bool { AXIsProcessTrusted() }
static func requestAccessibility() {
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts)
}
}
enum LoginItem {
+285 -23
View File
@@ -1,19 +1,51 @@
import AppKit
import CoreGraphics
import ScreenCaptureKit
import ApplicationServices
final class CaptureController: NSObject {
static let shared = CaptureController()
private var overlays: [NSWindow] = []
private var overlays: [OverlayWindow] = []
private(set) var isSelecting = false
private var previousApp: NSRunningApplication?
private var overlayIDs: [Int] = []
// Selection state (driven by the event tap; AppKit global coordinates)
private var marqueeStart: NSPoint?
private(set) var marqueeRect: NSRect?
private(set) var hoverWindow: (id: CGWindowID, frame: NSRect)?
var hoverFrame: NSRect? { hoverWindow?.frame }
private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = []
private var lastWindowScan = Date.distantPast
private var eventTap: CFMachPort?
/// Session event log for debugging (dumped via Copy Diagnostics).
/// Also appended to /tmp/kapture-debug.log so it survives relaunches.
static var sessionLog: [String] = []
static func log(_ line: String) {
let stamped = String(format: "%@ %@", DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium), line)
sessionLog.append(stamped)
if sessionLog.count > 200 { sessionLog.removeFirst() }
NSLog("[Kapture] \(line)")
let path = "/tmp/kapture-debug.log"
if let handle = FileHandle(forWritingAtPath: path) {
handle.seekToEndOfFile()
handle.write((stamped + "\n").data(using: .utf8)!)
handle.closeFile()
} else {
FileManager.default.createFile(atPath: path, contents: (stamped + "\n").data(using: .utf8))
}
}
/// Diagnostic snapshot for "Copy Diagnostics" menu item.
func diagnostics() -> String {
var lines: [String] = []
lines.append("Kapture diagnostics")
lines.append("Kapture diagnostics — version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "?")")
lines.append("accessibility=\(AXIsProcessTrusted()) eventTap=\(eventTap != nil)")
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
@@ -42,6 +74,8 @@ final class CaptureController: NSObject {
return lines.joined(separator: "\n")
}
// MARK: - Session lifecycle
func begin() {
guard !isSelecting else { return }
guard PermissionManager.isGranted else {
@@ -50,31 +84,34 @@ final class CaptureController: NSObject {
}
isSelecting = true
previousApp = NSWorkspace.shared.frontmostApplication
if #available(macOS 14.0, *) {
NSApp.activate()
} else {
NSApp.activate(ignoringOtherApps: true)
// Activation is async on macOS 26; wait a tick so the app is active
// before the overlays appear (otherwise clicks pass through).
NSApp.activate(ignoringOtherApps: true)
CaptureController.log("begin: activate issued, active=\(NSApp.isActive)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.isSelecting else { return }
CaptureController.log("begin: active=\(NSApp.isActive) — showing overlays")
self.overlays = NSScreen.screens.map { screen in
let panel = OverlayWindow(screen: screen, controller: self)
(panel.contentView as? OverlayView)?.controller = self
return panel
}
self.overlays.forEach { $0.makeKeyAndOrderFront(nil) }
self.overlayIDs = self.overlays.map { $0.windowNumber }
CaptureController.log("begin: \(self.overlays.count) overlays, ids=\(self.overlayIDs), active=\(NSApp.isActive)")
self.installEventTap()
}
overlays = NSScreen.screens.map { screen in
OverlayWindow(
screen: screen,
onComplete: { [weak self] rect in self?.finishSelection(rect) },
onCancel: { [weak self] in self?.cancelSelection() },
onWindowPick: { [weak self] rect in self?.finishWindowPick(rect) }
)
}
overlays.forEach { $0.makeKeyAndOrderFront(nil) }
overlayIDs = overlays.map { $0.windowNumber }
}
/// A window was picked from hover — capture it with rounded corners.
private func finishWindowPick(_ rect: NSRect) {
/// A window was picked from hover — capture it directly (real rounded
/// corners come from the window's own alpha).
private func finishWindowPick(_ windowID: CGWindowID, _ rect: NSRect) {
let ids = overlayIDs
hideOverlays()
isSelecting = false
restorePreviousApp()
DispatchQueue.main.async { [self] in
capture(rect, excludingWindowIDs: ids, roundedCorners: true)
capture(rect, excludingWindowIDs: ids, windowID: windowID)
}
}
@@ -97,8 +134,12 @@ final class CaptureController: NSObject {
}
private func hideOverlays() {
removeEventTap()
overlays.forEach { $0.orderOut(nil) }
overlays = []
marqueeRect = nil
hoverWindow = nil
marqueeStart = nil
}
private func restorePreviousApp() {
@@ -106,8 +147,196 @@ final class CaptureController: NSObject {
previousApp = nil
}
private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int], roundedCorners: Bool = false) {
ScreenCapture.shared.capture(rect, excludingWindowIDs: ids, roundedCorners: roundedCorners) { [weak self] image in
private func renderOverlays() {
for overlay in overlays {
(overlay.contentView as? OverlayView)?.needsDisplay = true
}
}
// MARK: - Event tap
private func installEventTap() {
guard eventTap == nil else { return }
guard AXIsProcessTrusted() else {
CaptureController.log("tap: accessibility not granted — falling back to panel events")
PermissionManager.requestAccessibility()
return
}
let mask: CGEventMask = (1 << CGEventType.mouseMoved.rawValue)
| (1 << CGEventType.leftMouseDown.rawValue)
| (1 << CGEventType.leftMouseDragged.rawValue)
| (1 << CGEventType.leftMouseUp.rawValue)
| (1 << CGEventType.keyDown.rawValue)
guard let tap = CGEvent.tapCreate(
tap: .cghidEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: CGEventMask(mask),
callback: Self.tapCallback,
userInfo: Unmanaged.passUnretained(self).toOpaque()
) else {
CaptureController.log("tap: creation FAILED — falling back to panel events")
return
}
eventTap = tap
if let source = CFMachPortCreateRunLoopSource(nil, tap, 0) {
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
}
CGEvent.tapEnable(tap: tap, enable: true)
CaptureController.log("tap: installed")
}
private func removeEventTap() {
guard let tap = eventTap else { return }
CGEvent.tapEnable(tap: tap, enable: false)
if let source = CFMachPortCreateRunLoopSource(nil, tap, 0) {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
CFMachPortInvalidate(tap)
eventTap = nil
CaptureController.log("tap: removed")
}
private static let tapCallback: CGEventTapCallBack = { _, type, event, refcon in
guard let refcon else { return Unmanaged.passUnretained(event) }
let controller = Unmanaged<CaptureController>.fromOpaque(refcon).takeUnretainedValue()
if let result = controller.handleTap(type, event) {
return Unmanaged.passUnretained(result)
}
return nil
}
/// Convert a CG global point (top-left origin) to AppKit global.
private func cgPointToAppKit(_ p: NSPoint) -> NSPoint {
guard let mainScreen = NSScreen.screens.first else { return p }
return NSPoint(x: p.x, y: mainScreen.frame.height - p.y)
}
/// Scan on-screen windows (AppKit global frames), front to back.
private func refreshTrackedWindows() {
guard let list = CGWindowListCopyWindowInfo(
[ .optionOnScreenOnly, .excludeDesktopElements ], kCGNullWindowID
) as? [[String: Any]] else { trackedWindows = []; return }
let ownIDs = Set(overlayIDs)
var found: [(id: CGWindowID, frame: 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 }
if ownIDs.contains(Int(cid)) { 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 }
let rect = Self.cgToAppKit(rect: cgRect)
found.append((cid, rect))
}
trackedWindows = found
}
/// Convert a CGWindowList (top-left origin) frame into AppKit screen
/// coordinates (bottom-left origin).
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)
}
private func topmostWindowContaining(_ appKitPoint: NSPoint) -> (id: CGWindowID, frame: NSRect)? {
for entry in trackedWindows {
if entry.frame.contains(appKitPoint) {
return entry
}
}
return nil
}
/// Returns the event to pass through, or nil to consume it.
func handleTap(_ type: CGEventType, _ event: CGEvent) -> CGEvent? {
guard isSelecting else { return event }
switch type {
case .tapDisabledByUserInput, .tapDisabledByTimeout:
// A timed-out tap must be re-enabled; guard against a torn-down tap.
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: true)
CaptureController.log("tap: re-enabled after \(type)")
}
return event
case .keyDown:
if event.getIntegerValueField(.keyboardEventKeycode) == 53 {
CaptureController.log("tap: Esc — cancel")
cancelSelection()
return nil
}
return event
case .mouseMoved, .leftMouseDragged:
// Throttle window scans while moving
if Date().timeIntervalSince(lastWindowScan) > 0.15 {
lastWindowScan = Date()
refreshTrackedWindows()
}
let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
if type == .mouseMoved {
let window = topmostWindowContaining(appKitPoint)
if window?.frame != hoverFrame {
hoverWindow = window
if let w = window {
CaptureController.log("hover: picked id=\(w.id) frame=\(w.frame) (tracked=\(trackedWindows.count))")
} else {
CaptureController.log("hover: none (tracked=\(trackedWindows.count))")
}
renderOverlays()
}
return event
}
// Drag: grow the marquee
guard let start = marqueeStart else { return nil }
let rect = NSRect(
x: min(start.x, appKitPoint.x),
y: min(start.y, appKitPoint.y),
width: abs(appKitPoint.x - start.x),
height: abs(appKitPoint.y - start.y)
)
marqueeRect = rect
hoverWindow = nil
renderOverlays()
return nil
case .leftMouseDown:
marqueeStart = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
marqueeRect = nil
CaptureController.log("tap: mouseDown at \(marqueeStart.map { "\($0)" } ?? "?")")
return nil
case .leftMouseUp:
defer { marqueeStart = nil; marqueeRect = nil }
let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
let dragDistance = marqueeStart.map { hypot($0.x - appKitPoint.x, $0.y - appKitPoint.y) } ?? 0
if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 {
CaptureController.log("tap: marquee complete \(rect)")
finishSelection(rect)
} else if let hover = hoverWindow {
CaptureController.log("tap: window pick id=\(hover.id) \(hover.frame)")
finishWindowPick(hover.id, hover.frame)
} else {
CaptureController.log("tap: click with no hover/marquee — cancel")
cancelSelection()
}
return nil
default:
return event
}
}
// MARK: - Capture
private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int], roundedCorners: Bool = false, windowID: CGWindowID? = nil) {
ScreenCapture.shared.capture(rect, excludingWindowIDs: ids, roundedCorners: roundedCorners, windowID: windowID) { [weak self] image in
DispatchQueue.main.async {
self?.isSelecting = false
self?.restorePreviousApp()
@@ -116,6 +345,7 @@ final class CaptureController: NSObject {
ResultWindow.shared.show(image: ResultWindow.failureImage("Capture failed. Check Screen Recording permission."))
return
}
CaptureController.log("capture done: \(image.width)x\(image.height) — showing result window")
ResultWindow.shared.show(image: image)
}
}
@@ -191,7 +421,7 @@ final class ScreenCapture {
return content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first
}
func capture(_ rect: NSRect, excludingWindowIDs excludedIDs: [Int], roundedCorners: Bool = false, completion: @escaping (CGImage?) -> Void) {
func capture(_ rect: NSRect, excludingWindowIDs excludedIDs: [Int], roundedCorners: Bool = false, windowID: CGWindowID? = nil, completion: @escaping (CGImage?) -> Void) {
if #available(macOS 14.0, *) {
Task {
guard let content = try? await SCShareableContent.current,
@@ -214,7 +444,7 @@ final class ScreenCapture {
let backingScale = screen.backingScaleFactor
config.width = Int(CGFloat(display.width) * backingScale)
config.height = Int(CGFloat(display.height) * backingScale)
config.showsCursor = true
config.showsCursor = false
// ScreenCaptureKit uses a different origin than AppKit for
// screens stacked around the main display: per-display,
@@ -226,10 +456,42 @@ final class ScreenCapture {
height: rect.height
)
// Window pick: capture the window DIRECTLY — the image comes
// with the app's real rounded-corner alpha (no mask guessing).
if let windowID,
let scWindow = content.windows.first(where: { $0.windowID == windowID }) {
var windowConfig = SCStreamConfiguration()
windowConfig.width = Int(scWindow.frame.width * backingScale)
windowConfig.height = Int(scWindow.frame.height * backingScale)
windowConfig.showsCursor = false
if let windowImage = try? await SCScreenshotManager.captureImage(
contentFilter: SCContentFilter(desktopIndependentWindow: scWindow),
configuration: windowConfig
) {
let expectedW = CGFloat(scWindow.frame.width) * backingScale
let expectedH = CGFloat(scWindow.frame.height) * backingScale
let overflow = max(
CGFloat(windowImage.width) / max(expectedW, 1),
CGFloat(windowImage.height) / max(expectedH, 1)
)
if overflow > 1.35 {
// SCKit composited extra windows (owned popups etc.)
// — reject and use the screen crop instead.
CaptureController.log("capture: WINDOW id=\(windowID) overflow=\(overflow) (\(windowImage.width)x\(windowImage.height) vs \(Int(expectedW))x\(Int(expectedH))) — rejecting, using crop+mask")
} else {
CaptureController.log("capture: WINDOW id=\(windowID) image=\(windowImage.width)x\(windowImage.height) (native alpha corners)")
completion(windowImage)
return
}
}
CaptureController.log("capture: window filter failed for id=\(windowID) — falling back to crop+mask")
}
guard let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) else {
completion(nil)
return
}
CaptureController.log("capture: rect=\(rect) screen=\(screen.frame) display=\(display.frame) backing=\(backingScale) image=\(image.width)x\(image.height) scRect=\(scRect)")
completion(self.crop(image, to: scRect, display: display, roundedCorners: roundedCorners))
}
} else {
+34 -168
View File
@@ -1,17 +1,20 @@
import AppKit
/// Renders the capture overlay. All interaction state lives in
/// CaptureController (driven by the CGEventTap); this view only draws.
final class OverlayWindow: NSPanel {
init(screen: NSScreen, onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) {
let content = OverlayView(onComplete: onComplete, onCancel: onCancel, onWindowPick: onWindowPick)
init(screen: NSScreen, controller: CaptureController) {
let content = OverlayView()
super.init(
contentRect: screen.frame,
styleMask: [.borderless, .nonactivatingPanel],
styleMask: [.borderless],
backing: .buffered,
defer: false
)
contentView = content
content.controller = controller
level = .screenSaver
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .transient]
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
backgroundColor = .clear
isOpaque = false
hasShadow = false
@@ -23,29 +26,7 @@ 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
// Window detection
private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = []
private var hoveredWindowFrame: NSRect?
private var excludedIDs: [Int] = []
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)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
weak var controller: CaptureController?
override var acceptsFirstResponder: Bool { true }
@@ -55,171 +36,56 @@ 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 rect = NSRect(
x: bounds["X"] ?? 0,
y: bounds["Y"] ?? 0,
width: bounds["Width"] ?? 0,
height: bounds["Height"] ?? 0
)
guard rect.width >= 80, rect.height >= 60 else { continue }
// Skip tiny/utility windows
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
/// The screen frame this overlay covers, in AppKit global space.
private var screenFrame: NSRect {
guard let window else { return .zero }
return window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
}
private func windowFrame(at point: NSPoint) -> NSRect? {
guard let window = window else { return nil }
let screenPoint = window.convertToScreen(NSRect(origin: point, size: .zero))
// CGWindowList bounds use a TOP-LEFT origin; convert the cursor point.
guard let mainScreen = NSScreen.screens.first else { return nil }
let topLeftPoint = NSPoint(
x: screenPoint.origin.x,
y: mainScreen.frame.height - screenPoint.origin.y
/// Convert an AppKit-global rect into this view's coordinates.
private func viewRect(_ global: NSRect) -> NSRect {
let sf = screenFrame
return NSRect(
x: global.origin.x - sf.origin.x,
y: global.origin.y - sf.origin.y,
width: global.width,
height: global.height
)
// Pick the frontmost (list order) window containing the point
for (_, frame) in trackedWindows {
if frame.contains(topLeftPoint) {
return frame
}
}
return nil
}
/// Convert a CGWindowList (top-left origin) 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
selection = NSRect(origin: p, size: .zero)
needsDisplay = true
}
override func mouseDragged(with event: NSEvent) {
guard let start = startPoint else { return }
isDragging = true
hoveredWindowFrame = nil
let p = convert(event.locationInWindow, from: nil)
selection = NSRect(
x: min(start.x, p.x),
y: min(start.y, p.y),
width: abs(p.x - start.x),
height: abs(p.y - start.y)
)
needsDisplay = true
}
override func mouseUp(with event: NSEvent) {
if isDragging {
guard let sel = selection else { return }
if sel.width >= 2, sel.height >= 2 {
onComplete(window!.convertToScreen(sel))
} else {
onCancel()
}
return
}
// No drag: 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(Self.cgToAppKit(rect: frame))
} else {
onCancel()
}
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 {
onCancel()
} else {
super.keyDown(with: event)
}
}
override func draw(_ dirtyRect: NSRect) {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
// Highlight the hovered window (rounded, like the real window corners)
if let frame = hoveredWindowFrame {
// Convert CGWindowList (top-left) frame to view (bottom-left) space
guard let window = window, let mainScreen = NSScreen.screens.first else { return }
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
let viewFrame = NSRect(
x: frame.origin.x - screenFrame.origin.x,
y: mainScreen.frame.height - frame.origin.y - frame.height - screenFrame.origin.y,
width: frame.width,
height: frame.height
)
guard let controller else { return }
// Highlight the hovered window (rounded, like real window corners)
if let frame = controller.hoverFrame {
let vf = viewRect(frame)
NSGraphicsContext.current?.saveGraphicsState()
NSGraphicsContext.current?.compositingOperation = .clear
NSColor.black.setFill()
NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10).fill()
NSBezierPath(roundedRect: vf, xRadius: 10, yRadius: 10).fill()
NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10)
let border = NSBezierPath(roundedRect: vf, 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 }
// Marquee selection
guard let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 else { return }
let vf = viewRect(sel)
NSGraphicsContext.current?.saveGraphicsState()
NSGraphicsContext.current?.compositingOperation = .clear
NSColor.black.setFill()
NSBezierPath(rect: sel).fill()
NSBezierPath(rect: vf).fill()
NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(rect: sel)
let border = NSBezierPath(rect: vf)
border.lineWidth = 1.5
NSColor.controlAccentColor.setStroke()
border.stroke()
@@ -231,8 +97,8 @@ final class OverlayView: NSView {
]
let labelSize = label.size(withAttributes: attrs)
let labelRect = NSRect(
x: sel.maxX - labelSize.width - 8,
y: sel.minY - labelSize.height - 10,
x: vf.maxX - labelSize.width - 8,
y: vf.minY - labelSize.height - 10,
width: labelSize.width + 8,
height: labelSize.height + 6
)
+99 -43
View File
@@ -95,16 +95,23 @@ final class CaptureModel: ObservableObject {
/// Redo stack of removed strokes
@Published var redoStack: [Stroke] = []
/// Whether the pen is active (drag draws instead of selecting)
@Published var isDrawing = false
@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 {
@@ -159,14 +166,29 @@ final class CaptureModel: ObservableObject {
ctx.setLineJoin(.round)
for stroke in strokes {
guard stroke.points.count > 1 else { continue }
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)
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.strokePath()
}
ctx.restoreGState()
NSGraphicsContext.restoreGraphicsState()
@@ -175,7 +197,13 @@ final class CaptureModel: ObservableObject {
func addStroke(_ points: [CGPoint]) {
guard points.count > 1 else { return }
strokes.append(Stroke(points: points, width: strokeWidth, color: drawColor))
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 = []
}
@@ -309,14 +337,24 @@ struct ResultView: View {
Divider().frame(height: 18)
Button {
model.isDrawing.toggle()
if model.isDrawing { model.selection = nil }
model.tool = model.tool == .draw ? .select : .draw
if model.tool != .select { model.selection = nil }
} label: {
Label("Draw", systemImage: model.isDrawing ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle")
Label("Draw", systemImage: model.tool == .draw ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle")
}
.buttonStyle(.bordered)
.foregroundStyle(model.isDrawing ? .primary : .secondary)
.help("Toggle drawing on the screenshot")
.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()
@@ -348,34 +386,40 @@ struct ResultView: View {
Divider()
// Draw toolbar (only when drawing)
if model.isDrawing {
// Tool toolbar (only when a draw tool is active)
if model.tool != .select {
HStack(spacing: 14) {
Label("Draw", systemImage: "pencil.tip")
Label(model.tool == .redact ? "Redact" : "Draw", systemImage: model.tool == .redact ? "rectangle.fill" : "pencil.tip")
.font(.caption)
.foregroundStyle(.secondary)
Divider().frame(height: 18)
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))
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 {
Label("Drag to cover sensitive text", systemImage: "eye.slash")
.font(.caption)
.foregroundStyle(.secondary)
}
.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")
Spacer()
@@ -515,12 +559,24 @@ struct ScreenshotPane: View {
Canvas { context, _ in
for stroke in model.strokes {
guard stroke.points.count > 1 else { continue }
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))
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)
}
context.stroke(path, with: .color(Color(nsColor: stroke.color)), lineWidth: stroke.width)
}
}
.allowsHitTesting(false)
@@ -540,7 +596,7 @@ struct ScreenshotPane: View {
.allowsHitTesting(false)
}
if !model.isDrawing, let sel = model.selection {
if model.tool == .select, let sel = model.selection {
let viewRect = CGRect(
x: origin.x + sel.minX * scale,
y: origin.y + sel.minY * scale,
@@ -559,7 +615,7 @@ struct ScreenshotPane: View {
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if model.isDrawing {
if model.tool != .select {
let p = toImagePoint(value.location, scale: scale, origin: origin)
let imgRect = CGRect(origin: .zero, size: imageSize)
if imgRect.contains(p) {
@@ -578,7 +634,7 @@ struct ScreenshotPane: View {
}
}
.onEnded { _ in
if model.isDrawing {
if model.tool != .select {
if currentStroke.count > 1 {
model.addStroke(currentStroke)
}
+2 -2
View File
@@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key>
<string>Icon</string>
<key>CFBundleShortVersionString</key>
<string>1.5.0</string>
<string>1.8.1</string>
<key>CFBundleVersion</key>
<string>50</string>
<string>81</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>