Files
kapture/Sources/Kapture/CaptureController.swift
T
TZGyn c9a807e8a0 Fix secondary display captures after retina scaling change
The 1.1.2 retina fix overrode the stream width from the display's pixel
dimensions, which made captures on secondary monitors come out at the
wrong scale or offset. Restore requesting the display's native pixel
resolution and keep the crop scale derived from the actual returned image,
plus guard against out-of-bounds crop rects.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:34:18 +08:00

171 lines
6.7 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] = []
func begin() {
guard !isSelecting else { return }
guard PermissionManager.isGranted else {
PermissionManager.request()
return
}
isSelecting = true
previousApp = NSWorkspace.shared.frontmostApplication
if #available(macOS 14.0, *) {
NSApp.activate()
} else {
NSApp.activate(ignoringOtherApps: true)
}
overlays = NSScreen.screens.map { screen in
OverlayWindow(
screen: screen,
onComplete: { [weak self] rect in self?.finishSelection(rect) },
onCancel: { [weak self] in self?.cancelSelection() }
)
}
overlays.forEach { $0.makeKeyAndOrderFront(nil) }
overlayIDs = overlays.map { $0.windowNumber }
}
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]) {
ScreenCapture.shared.capture(rect, excludingWindowIDs: ids) { [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 rect: NSRect, display: SCDisplay) -> CGImage? {
// The captured image may be at any scale (retina 2x, secondary display
// 1x, or trimmed by ScreenCaptureKit). Derive the pixel scale from the
// image we actually got, relative to the display's point frame.
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 cgRect = CGRect(
x: rect.minX,
y: rect.minY,
width: rect.width,
height: rect.height
)
let pxRect = CGRect(
x: (cgRect.minX - display.frame.minX) * scaleX,
y: (cgRect.minY - display.frame.minY) * 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 }
guard let cropCtx = CGContext(
data: nil, width: Int(safeRect.width), height: Int(safeRect.height),
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
cropCtx.interpolationQuality = .high
cropCtx.draw(image, in: CGRect(x: -safeRect.minX, y: -safeRect.minY, width: CGFloat(image.width), height: CGFloat(image.height)))
return cropCtx.makeImage()
}
@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], 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()
// display.width/height are pixels; request the display's full
// native resolution so ScreenCaptureKit gives us every pixel.
config.width = Int(display.width)
config.height = Int(display.height)
config.showsCursor = true
guard let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) else {
completion(nil)
return
}
completion(self.crop(image, to: rect, display: display))
}
} 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]))
}
}
}