119 lines
4.3 KiB
Swift
119 lines
4.3 KiB
Swift
import ScreenCaptureKit
|
|
import CoreGraphics
|
|
import ImageIO
|
|
import UniformTypeIdentifiers
|
|
import AppKit // NSScreen only, to read backingScaleFactor as a fallback
|
|
import Foundation
|
|
|
|
public struct CapturedImage: Sendable {
|
|
public let pngData: Data
|
|
public let pixelWidth: Int
|
|
public let pixelHeight: Int
|
|
public let scale: CGFloat
|
|
}
|
|
|
|
public struct ScreenCapturer: Sendable {
|
|
public init() {}
|
|
|
|
/// Does NOT prompt. Reports the current Screen Recording grant.
|
|
public static var isScreenRecordingGranted: Bool { CGPreflightScreenCaptureAccess() }
|
|
|
|
/// Captures `region` at the display's true backing scale. Throws
|
|
/// `.screenRecordingNotGranted` when the grant is absent — never requests it.
|
|
public func capture(_ region: CaptureRegion) async throws -> CapturedImage {
|
|
guard Self.isScreenRecordingGranted else {
|
|
throw ShotdeckError.screenRecordingNotGranted
|
|
}
|
|
|
|
let content: SCShareableContent
|
|
do {
|
|
content = try await SCShareableContent.excludingDesktopWindows(
|
|
false,
|
|
onScreenWindowsOnly: true
|
|
)
|
|
} catch {
|
|
throw ShotdeckError.captureFailed(underlying: error.localizedDescription)
|
|
}
|
|
|
|
guard let display = content.displays.first(where: { $0.displayID == region.displayID }) else {
|
|
throw ShotdeckError.displayNoLongerConnected(displayID: region.displayID)
|
|
}
|
|
|
|
// Bounds/clamp: a resolution change often shrinks the display bounds by a few
|
|
// points without the region Ben picked being meaningfully wrong; 80% keeps a real
|
|
// recapture usable while a smaller overlap (e.g. a genuinely different display
|
|
// swapped in) still throws.
|
|
let rect: CGRect
|
|
if display.frame.contains(region.rect) {
|
|
rect = region.rect
|
|
} else {
|
|
let overlap = display.frame.intersection(region.rect)
|
|
let originalArea = region.rect.width * region.rect.height
|
|
let overlapArea = overlap.width * overlap.height
|
|
if originalArea > 0 && overlapArea / originalArea >= 0.8 {
|
|
rect = overlap
|
|
} else {
|
|
throw ShotdeckError.displayNoLongerConnected(displayID: region.displayID)
|
|
}
|
|
}
|
|
|
|
let localRect = CGRect(
|
|
x: rect.minX - display.frame.minX,
|
|
y: rect.minY - display.frame.minY,
|
|
width: rect.width,
|
|
height: rect.height
|
|
)
|
|
|
|
guard let mode = CGDisplayCopyDisplayMode(region.displayID) else {
|
|
throw ShotdeckError.captureFailed(
|
|
underlying: "no display mode for displayID \(region.displayID)"
|
|
)
|
|
}
|
|
guard mode.width > 0 else {
|
|
throw ShotdeckError.captureFailed(
|
|
underlying: "display mode width is 0 for displayID \(region.displayID)"
|
|
)
|
|
}
|
|
let scale = CGFloat(mode.pixelWidth) / CGFloat(mode.width)
|
|
|
|
let filter = SCContentFilter(display: display, excludingWindows: [])
|
|
let configuration = SCStreamConfiguration()
|
|
configuration.sourceRect = localRect
|
|
configuration.width = Int((localRect.width * scale).rounded())
|
|
configuration.height = Int((localRect.height * scale).rounded())
|
|
configuration.scalesToFit = false
|
|
configuration.showsCursor = false
|
|
|
|
let cgImage: CGImage
|
|
do {
|
|
cgImage = try await SCScreenshotManager.captureImage(
|
|
contentFilter: filter,
|
|
configuration: configuration
|
|
)
|
|
} catch {
|
|
throw ShotdeckError.captureFailed(underlying: error.localizedDescription)
|
|
}
|
|
|
|
let data = NSMutableData()
|
|
guard let dest = CGImageDestinationCreateWithData(
|
|
data,
|
|
UTType.png.identifier as CFString,
|
|
1,
|
|
nil
|
|
) else {
|
|
throw ShotdeckError.captureFailed(underlying: "could not create PNG image destination")
|
|
}
|
|
CGImageDestinationAddImage(dest, cgImage, nil)
|
|
guard CGImageDestinationFinalize(dest) else {
|
|
throw ShotdeckError.captureFailed(underlying: "PNG encoding failed to finalize")
|
|
}
|
|
|
|
return CapturedImage(
|
|
pngData: data as Data,
|
|
pixelWidth: cgImage.width,
|
|
pixelHeight: cgImage.height,
|
|
scale: scale
|
|
)
|
|
}
|
|
}
|