A second hotkey snapshots every display the moment it is pressed and
shows that still image as the overlay, so the pointer and hover states
(tooltips, open menus) are captured. Selection is marquee-only — no
window detection — and the result is cropped from the snapshot rather
than recaptured, keeping the frame exactly as it was.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4-flash-0731
726 lines
34 KiB
Swift
726 lines
34 KiB
Swift
import AppKit
|
|
import CoreGraphics
|
|
import ScreenCaptureKit
|
|
import ApplicationServices
|
|
|
|
final class CaptureController: NSObject {
|
|
static let shared = CaptureController()
|
|
|
|
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, pid: Int)?
|
|
var hoverFrame: NSRect? { hoverWindow?.frame }
|
|
private var trackedWindows: [(id: CGWindowID, frame: NSRect, pid: Int)] = []
|
|
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 — 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
|
|
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")
|
|
}
|
|
|
|
// MARK: - Session lifecycle
|
|
|
|
/// Frozen mode: the screen is snapshotted at hotkey press, so the pointer
|
|
/// and any hover state (tooltips, open menus) end up in the capture.
|
|
private(set) var isFrozenMode = false
|
|
private var frozenSnapshots: [ScreenCapture.DisplaySnapshot] = []
|
|
|
|
func begin(frozen: Bool = false) {
|
|
guard !isSelecting else { return }
|
|
guard PermissionManager.isGranted else {
|
|
PermissionManager.request()
|
|
return
|
|
}
|
|
isSelecting = true
|
|
isFrozenMode = frozen
|
|
previousApp = NSWorkspace.shared.frontmostApplication
|
|
// With the event tap driving input we must NOT activate: stealing
|
|
// focus turns the target app into background mode (dimmed chrome),
|
|
// and that is what would end up in the screenshot. Activate only on
|
|
// the fallback path, where panel events are the only input.
|
|
let usesTap = AXIsProcessTrusted()
|
|
if !usesTap {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
CaptureController.log("begin: frozen=\(frozen) usesTap=\(usesTap) active=\(NSApp.isActive)")
|
|
|
|
if frozen {
|
|
ScreenCapture.shared.snapshotAllDisplays(includeCursor: true) { [weak self] snapshots in
|
|
DispatchQueue.main.async {
|
|
guard let self, self.isSelecting else { return }
|
|
self.frozenSnapshots = snapshots
|
|
CaptureController.log("frozen: captured \(snapshots.count) displays")
|
|
self.showOverlays()
|
|
}
|
|
}
|
|
} else {
|
|
showOverlays()
|
|
}
|
|
}
|
|
|
|
private func showOverlays() {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
guard let self, self.isSelecting else { return }
|
|
self.overlays = NSScreen.screens.map { screen in
|
|
let panel = OverlayWindow(screen: screen, controller: self)
|
|
if let view = panel.contentView as? OverlayView {
|
|
view.controller = self
|
|
view.frozenImage = self.frozenSnapshots.first(where: { $0.frame == screen.frame })?.image
|
|
}
|
|
return panel
|
|
}
|
|
// orderFrontRegardless: show without taking key/focus.
|
|
self.overlays.forEach { $0.orderFrontRegardless() }
|
|
self.overlayIDs = self.overlays.map { $0.windowNumber }
|
|
CaptureController.log("begin: \(self.overlays.count) overlays, ids=\(self.overlayIDs) — prior app stays focused")
|
|
self.installEventTap()
|
|
}
|
|
}
|
|
|
|
/// 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, ownedRisk: Bool) {
|
|
let ids = overlayIDs
|
|
hideOverlays()
|
|
isSelecting = false
|
|
restorePreviousApp()
|
|
DispatchQueue.main.async { [self] in
|
|
if ownedRisk {
|
|
capture(rect, excludingWindowIDs: ids, roundedCorners: true)
|
|
} else {
|
|
capture(rect, excludingWindowIDs: ids, windowID: windowID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func finishSelection(_ rect: NSRect) {
|
|
let ids = overlayIDs
|
|
let frozen = frozenSnapshots
|
|
let wasFrozen = isFrozenMode
|
|
hideOverlays()
|
|
guard rect.width >= 2, rect.height >= 2 else {
|
|
cancelSelection()
|
|
return
|
|
}
|
|
|
|
if wasFrozen {
|
|
let midpoint = NSPoint(x: rect.midX, y: rect.midY)
|
|
let snapshot = frozen.first(where: { $0.frame.contains(midpoint) }) ?? frozen.first
|
|
isSelecting = false
|
|
restorePreviousApp()
|
|
DispatchQueue.main.async { [self] in
|
|
if let snapshot, let image = ScreenCapture.cropFrozen(snapshot, to: rect) {
|
|
CaptureController.log("frozen: cropped \(Int(rect.width))x\(Int(rect.height)) -> \(image.width)x\(image.height)")
|
|
ResultWindow.shared.show(image: image)
|
|
} else {
|
|
ResultWindow.shared.show(image: ResultWindow.failureImage("Could not crop the frozen screen."))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.async { [self] in
|
|
capture(rect, excludingWindowIDs: ids)
|
|
}
|
|
}
|
|
|
|
private func cancelSelection() {
|
|
hideOverlays()
|
|
isSelecting = false
|
|
restorePreviousApp()
|
|
}
|
|
|
|
private func hideOverlays() {
|
|
removeEventTap()
|
|
overlays.forEach { $0.orderOut(nil) }
|
|
overlays = []
|
|
marqueeRect = nil
|
|
hoverWindow = nil
|
|
marqueeStart = nil
|
|
frozenSnapshots = []
|
|
isFrozenMode = false
|
|
}
|
|
|
|
private func restorePreviousApp() {
|
|
previousApp?.activate(options: [.activateIgnoringOtherApps])
|
|
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<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, pid: Int)] = []
|
|
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,
|
|
let pid = info[kCGWindowOwnerPID 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((id: cid, frame: rect, pid: pid))
|
|
}
|
|
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, pid: Int)? {
|
|
for entry in trackedWindows {
|
|
if entry.frame.contains(appKitPoint) {
|
|
return entry
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// System dialogs (permission prompts, System Settings) must always
|
|
/// receive their clicks even while we're selecting.
|
|
private func isSystemDialogFrontmost() -> Bool {
|
|
guard let front = NSWorkspace.shared.frontmostApplication else { return false }
|
|
let id = front.bundleIdentifier ?? ""
|
|
return id.hasPrefix("com.apple.security") || id.hasPrefix("com.apple.SystemSettings") || id == "com.apple.systempreferences"
|
|
}
|
|
|
|
/// Returns the event to pass through, or nil to consume it.
|
|
func handleTap(_ type: CGEventType, _ event: CGEvent) -> CGEvent? {
|
|
guard isSelecting else { return event }
|
|
if isSystemDialogFrontmost() {
|
|
CaptureController.log("tap: system dialog frontmost — passing through \(type)")
|
|
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 {
|
|
// Frozen mode is marquee-only: no window hover detection.
|
|
if isFrozenMode { return event }
|
|
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 — but only after real movement, so a
|
|
// click's micro-jitter keeps the hovered window eligible.
|
|
guard let start = marqueeStart else { return nil }
|
|
let distance = hypot(appKitPoint.x - start.x, appKitPoint.y - start.y)
|
|
guard distance >= 4 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 isFrozenMode {
|
|
CaptureController.log("tap: frozen click without drag — cancel")
|
|
cancelSelection()
|
|
} else if let hover = hoverWindow {
|
|
// An overlapping window from the same app means this is an
|
|
// owned popup — desktopIndependentWindow would composite the
|
|
// parent too, so capture the screen crop instead.
|
|
// Owned popups composite their parent even when the windows
|
|
// don't overlap, so ANY other same-app window on screen means
|
|
// crop (correct region) instead of the group composite.
|
|
let ownedRisk = trackedWindows.contains {
|
|
$0.id != hover.id && $0.pid == hover.pid
|
|
}
|
|
CaptureController.log("tap: window pick id=\(hover.id) pid=\(hover.pid) ownedRisk=\(ownedRisk) \(hover.frame)")
|
|
finishWindowPick(hover.id, hover.frame, ownedRisk: ownedRisk)
|
|
} 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()
|
|
Task { @MainActor in
|
|
guard let image else {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final class ScreenCapture {
|
|
static let shared = ScreenCapture()
|
|
|
|
private init() {}
|
|
|
|
/// Estimate the window's corner radius (in image pixels) by scanning the
|
|
/// top-left corner: the crop's corner pixels are the desktop background;
|
|
/// app content starts where the color departs from it.
|
|
static func detectCornerRadius(of image: CGImage) -> CGFloat? {
|
|
let w = image.width, h = image.height
|
|
guard w > 30, h > 30 else { return nil }
|
|
var px = [UInt8](repeating: 0, count: w * h * 4)
|
|
guard let c = CGContext(data: &px, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4,
|
|
space: CGColorSpaceCreateDeviceRGB(),
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
|
|
c.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))
|
|
|
|
func pixel(x: Int, yTop: Int) -> (r: Int, g: Int, b: Int) {
|
|
let row = yTop * w * 4 + x * 4
|
|
return (Int(px[row]), Int(px[row + 1]), Int(px[row + 2]))
|
|
}
|
|
let bg = pixel(x: 0, yTop: 0)
|
|
func diffFromBg(_ p: (r: Int, g: Int, b: Int)) -> Int {
|
|
abs(p.r - bg.r) + abs(p.g - bg.g) + abs(p.b - bg.b)
|
|
}
|
|
var radii: [CGFloat] = []
|
|
for (limit, axis) in [(h / 3, 0), (w / 3, 1)] {
|
|
for i in 0..<limit {
|
|
let p = axis == 0 ? pixel(x: 0, yTop: i) : pixel(x: i, yTop: 0)
|
|
if diffFromBg(p) > 24 {
|
|
radii.append(CGFloat(i))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
guard radii.count == 2 else { return nil }
|
|
let r = (radii[0] + radii[1]) / 2
|
|
return (r >= 3 && r <= 40) ? r : nil
|
|
}
|
|
|
|
@available(macOS 14.0, *)
|
|
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: localMinX * scaleX,
|
|
y: flippedY * scaleY,
|
|
width: cgRect.width * scaleX,
|
|
height: cgRect.height * scaleY
|
|
)
|
|
// 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 {
|
|
// The screen crop contains the app's real rounded corners against
|
|
// the desktop — measure the radius from the corner pixels, then
|
|
// mask with it (supersampled for smooth edges).
|
|
let detected = Self.detectCornerRadius(of: result)
|
|
let radius = min(max((detected.map { $0 * 1.23 + 2.6 }) ?? (10 * scaleX), 4), 40)
|
|
CaptureController.log("corners: detected=\(detected.map { String(format: "%.1f", $0) } ?? "nil") using=\(Int(radius))px")
|
|
// Anti-aliased gray mask: supersample 3x, downsample, then draw
|
|
// the image through it ONCE at 1x (no content resampling blur).
|
|
let ss = 3
|
|
let W = result.width * ss, H = result.height * ss
|
|
guard let hiMask = CGContext(
|
|
data: nil, width: W, height: H,
|
|
bitsPerComponent: 8, bytesPerRow: 0,
|
|
space: CGColorSpaceCreateDeviceGray(),
|
|
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
|
) else { return result }
|
|
hiMask.setFillColor(CGColor(gray: 1, alpha: 1))
|
|
hiMask.fill(CGRect(x: 0, y: 0, width: W, height: H))
|
|
hiMask.setFillColor(CGColor(gray: 0, alpha: 1))
|
|
let cornerPath = CGMutablePath()
|
|
cornerPath.addRect(CGRect(x: 0, y: 0, width: W, height: H))
|
|
cornerPath.addRoundedRect(in: CGRect(x: 0, y: 0, width: W, height: H), cornerWidth: radius * CGFloat(ss), cornerHeight: radius * CGFloat(ss), transform: .identity)
|
|
hiMask.addPath(cornerPath)
|
|
hiMask.fillPath(using: .evenOdd)
|
|
guard let hiMaskImg = hiMask.makeImage(),
|
|
let downMask = CGContext(
|
|
data: nil, width: result.width, height: result.height,
|
|
bitsPerComponent: 8, bytesPerRow: 0,
|
|
space: CGColorSpaceCreateDeviceGray(),
|
|
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
|
) else { return result }
|
|
downMask.interpolationQuality = .high
|
|
downMask.draw(hiMaskImg, in: CGRect(x: 0, y: 0, width: result.width, height: result.height))
|
|
guard let mask1x = downMask.makeImage(),
|
|
let ctx = CGContext(
|
|
data: nil, width: result.width, height: result.height,
|
|
bitsPerComponent: 8, bytesPerRow: 0,
|
|
space: CGColorSpaceCreateDeviceRGB(),
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
) else { return result }
|
|
ctx.clip(to: CGRect(x: 0, y: 0, width: result.width, height: result.height), mask: mask1x)
|
|
ctx.draw(result, in: CGRect(x: 0, y: 0, width: result.width, height: result.height))
|
|
if let masked = ctx.makeImage() {
|
|
result = masked
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Screen frame (AppKit global points) + full-resolution image per display.
|
|
struct DisplaySnapshot {
|
|
let frame: NSRect
|
|
let image: CGImage
|
|
}
|
|
|
|
/// Snapshot every display right now. Used by frozen mode: the images are
|
|
/// taken before the overlay appears, so the pointer and hover states
|
|
/// (tooltips, menus) are baked in.
|
|
func snapshotAllDisplays(includeCursor: Bool, completion: @escaping ([DisplaySnapshot]) -> Void) {
|
|
if #available(macOS 14.0, *) {
|
|
Task {
|
|
guard let content = try? await SCShareableContent.current else {
|
|
completion([])
|
|
return
|
|
}
|
|
var out: [DisplaySnapshot] = []
|
|
for display in content.displays {
|
|
guard let screen = NSScreen.screens.first(where: {
|
|
(($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0) == display.displayID
|
|
}) else { continue }
|
|
let config = SCStreamConfiguration()
|
|
let scale = screen.backingScaleFactor
|
|
config.width = Int(CGFloat(display.width) * scale)
|
|
config.height = Int(CGFloat(display.height) * scale)
|
|
config.showsCursor = includeCursor
|
|
let filter = SCContentFilter(display: display, excludingWindows: [])
|
|
if let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) {
|
|
out.append(DisplaySnapshot(frame: screen.frame, image: image))
|
|
}
|
|
}
|
|
completion(out)
|
|
}
|
|
} else {
|
|
var out: [DisplaySnapshot] = []
|
|
for screen in NSScreen.screens {
|
|
let cgRect = CGRect(
|
|
x: screen.frame.minX,
|
|
y: (NSScreen.screens.first(where: { $0.frame.origin == .zero })?.frame.height ?? screen.frame.height) - screen.frame.maxY,
|
|
width: screen.frame.width,
|
|
height: screen.frame.height
|
|
)
|
|
if let image = CGWindowListCreateImage(cgRect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution, .boundsIgnoreFraming]) {
|
|
out.append(DisplaySnapshot(frame: screen.frame, image: image))
|
|
}
|
|
}
|
|
completion(out)
|
|
}
|
|
}
|
|
|
|
/// Crop a selection out of a frozen snapshot (top-left pixel space).
|
|
static func cropFrozen(_ snapshot: DisplaySnapshot, to rect: NSRect) -> CGImage? {
|
|
let scaleX = CGFloat(snapshot.image.width) / snapshot.frame.width
|
|
let scaleY = CGFloat(snapshot.image.height) / snapshot.frame.height
|
|
guard scaleX > 0, scaleY > 0 else { return nil }
|
|
let localX = rect.minX - snapshot.frame.minX
|
|
let localTopY = snapshot.frame.maxY - rect.maxY
|
|
let px = CGRect(
|
|
x: localX * scaleX,
|
|
y: localTopY * scaleY,
|
|
width: rect.width * scaleX,
|
|
height: rect.height * scaleY
|
|
)
|
|
let safe = px.intersection(CGRect(x: 0, y: 0, width: CGFloat(snapshot.image.width), height: CGFloat(snapshot.image.height))).integral
|
|
guard safe.width >= 1, safe.height >= 1 else { return nil }
|
|
return snapshot.image.cropping(to: safe)
|
|
}
|
|
|
|
@available(macOS 14.0, *)
|
|
private func displayContaining(_ rect: NSRect, in content: SCShareableContent) -> SCDisplay? {
|
|
let mid = NSPoint(x: rect.midX, y: rect.midY)
|
|
guard let screen = NSScreen.screens.first(where: { $0.frame.contains(mid) }) ?? NSScreen.screens.first else {
|
|
return nil
|
|
}
|
|
let cgID = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
|
|
return content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first
|
|
}
|
|
|
|
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,
|
|
let display = displayContaining(rect, in: content) else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
let excludedSet = Set(excludedIDs.map { CGWindowID($0) })
|
|
let excluded = content.windows.filter { excludedSet.contains($0.windowID) }
|
|
let filter = SCContentFilter(display: display, excludingWindows: excluded)
|
|
let config = SCStreamConfiguration()
|
|
// 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 = false
|
|
|
|
// 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
|
|
)
|
|
|
|
// 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 {
|
|
guard let primary = NSScreen.screens.first(where: { $0.frame.origin == .zero }) ?? NSScreen.screens.first else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
let cgRect = CGRect(
|
|
x: rect.minX,
|
|
y: primary.frame.height - rect.maxY,
|
|
width: rect.width,
|
|
height: rect.height
|
|
)
|
|
completion(CGWindowListCreateImage(cgRect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution, .boundsIgnoreFraming]))
|
|
}
|
|
}
|
|
}
|