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
261 lines
11 KiB
Swift
261 lines
11 KiB
Swift
import AppKit
|
|
import CoreGraphics
|
|
import ScreenCaptureKit
|
|
|
|
final class CaptureController: NSObject {
|
|
static let shared = CaptureController()
|
|
|
|
private var overlays: [NSWindow] = []
|
|
private(set) var isSelecting = false
|
|
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 {
|
|
PermissionManager.request()
|
|
return
|
|
}
|
|
isSelecting = true
|
|
previousApp = NSWorkspace.shared.frontmostApplication
|
|
// 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() },
|
|
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) {
|
|
let ids = overlayIDs
|
|
hideOverlays()
|
|
guard rect.width >= 2, rect.height >= 2 else {
|
|
cancelSelection()
|
|
return
|
|
}
|
|
DispatchQueue.main.async { [self] in
|
|
capture(rect, excludingWindowIDs: ids)
|
|
}
|
|
}
|
|
|
|
private func cancelSelection() {
|
|
hideOverlays()
|
|
isSelecting = false
|
|
restorePreviousApp()
|
|
}
|
|
|
|
private func hideOverlays() {
|
|
overlays.forEach { $0.orderOut(nil) }
|
|
overlays = []
|
|
}
|
|
|
|
private func restorePreviousApp() {
|
|
previousApp?.activate(options: [.activateIgnoringOtherApps])
|
|
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
|
|
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
|
|
}
|
|
ResultWindow.shared.show(image: image)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final class ScreenCapture {
|
|
static let shared = ScreenCapture()
|
|
|
|
private init() {}
|
|
|
|
@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 {
|
|
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 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, *)
|
|
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, 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 = 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: 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]))
|
|
}
|
|
}
|
|
}
|