From 7e9cc5b269fec4dd5507083df8949ebc367508c9 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Wed, 9 Sep 2026 12:08:57 +0800 Subject: [PATCH] Drive capture selection through a CGEventTap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/Kapture/App.swift | 7 + Sources/Kapture/CaptureController.swift | 234 ++++++++++++++++++++++-- Sources/Kapture/OverlayView.swift | 209 ++++----------------- build-app.sh | 4 +- 4 files changed, 265 insertions(+), 189 deletions(-) diff --git a/Sources/Kapture/App.swift b/Sources/Kapture/App.swift index cac7aed..6e43cd6 100644 --- a/Sources/Kapture/App.swift +++ b/Sources/Kapture/App.swift @@ -90,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 { diff --git a/Sources/Kapture/CaptureController.swift b/Sources/Kapture/CaptureController.swift index a7c2703..0dc52fb 100644 --- a/Sources/Kapture/CaptureController.swift +++ b/Sources/Kapture/CaptureController.swift @@ -1,27 +1,47 @@ 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 hoverFrame: NSRect? + 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) { - sessionLog.append(line) - if sessionLog.count > 100 { sessionLog.removeFirst() } + 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 — 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 ---") @@ -53,6 +73,8 @@ final class CaptureController: NSObject { return lines.joined(separator: "\n") } + // MARK: - Session lifecycle + func begin() { guard !isSelecting else { return } guard PermissionManager.isGranted else { @@ -61,25 +83,22 @@ final class CaptureController: NSObject { } isSelecting = true previousApp = NSWorkspace.shared.frontmostApplication - // Activation is ASYNC: isActive only flips on a later runloop tick - // (verified on macOS 26). Show overlays only once active, otherwise - // the first click can pass through to the windows below. + // 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 - 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) } - ) + 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() } } @@ -113,8 +132,12 @@ final class CaptureController: NSObject { } private func hideOverlays() { + removeEventTap() overlays.forEach { $0.orderOut(nil) } overlays = [] + marqueeRect = nil + hoverFrame = nil + marqueeStart = nil } private func restorePreviousApp() { @@ -122,6 +145,193 @@ final class CaptureController: NSObject { previousApp = nil } + 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.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) + // Skip windows filling an entire screen (fullscreen apps) + let coversWholeScreen = NSScreen.screens.contains { screen in + abs(screen.frame.width - rect.width) < 2 && abs(screen.frame.height - rect.height) < 2 + } + guard !coversWholeScreen else { continue } + 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) -> NSRect? { + for (_, frame) in trackedWindows { + if frame.contains(appKitPoint) { + return frame + } + } + 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: + CGEvent.tapEnable(tap: eventTap!, enable: true) + return event + case .tapDisabledByTimeout: + CGEvent.tapEnable(tap: eventTap!, enable: true) + 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 frame = topmostWindowContaining(appKitPoint) + if frame != hoverFrame { + hoverFrame = frame + 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 + hoverFrame = 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 = hoverFrame { + CaptureController.log("tap: window pick \(hover)") + finishWindowPick(hover) + } 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) { ScreenCapture.shared.capture(rect, excludingWindowIDs: ids, roundedCorners: roundedCorners) { [weak self] image in DispatchQueue.main.async { diff --git a/Sources/Kapture/OverlayView.swift b/Sources/Kapture/OverlayView.swift index 1924076..3969558 100644 --- a/Sources/Kapture/OverlayView.swift +++ b/Sources/Kapture/OverlayView.swift @@ -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 - private var dragDistance: CGFloat = 0 - - // 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) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + weak var controller: CaptureController? override var acceptsFirstResponder: Bool { true } @@ -55,178 +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 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 + /// 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 } - // 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 - } - - 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), - width: abs(p.x - start.x), - height: abs(p.y - start.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 ) - needsDisplay = true - } - - 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) { - 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 { - // 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 - ) + 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() @@ -238,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 ) diff --git a/build-app.sh b/build-app.sh index eecc9da..9fd76d0 100755 --- a/build-app.sh +++ b/build-app.sh @@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleIconFile Icon CFBundleShortVersionString - 1.6.5 + 1.7.0 CFBundleVersion - 65 + 70 LSUIElement NSHighResolutionCapable