235 lines
9.9 KiB
Swift
235 lines
9.9 KiB
Swift
// CaptureEngine.swift
|
|
// GreenshotMac
|
|
//
|
|
// ScreenCaptureKit replacement for Greenshot's WindowCapture / GDI capture code.
|
|
|
|
import AppKit
|
|
import ScreenCaptureKit
|
|
|
|
/// A frozen snapshot of one display, used as the backdrop of the selection overlay.
|
|
struct DisplaySnapshot {
|
|
let screen: NSScreen
|
|
let display: SCDisplay
|
|
let image: CGImage
|
|
/// Display bounds in CoreGraphics global coordinates (top-left origin, points).
|
|
let cgFrame: CGRect
|
|
var scale: CGFloat { CGFloat(image.width) / cgFrame.width }
|
|
}
|
|
|
|
/// A window that can be picked in window capture mode.
|
|
struct WindowInfo {
|
|
let window: SCWindow
|
|
/// Window frame in CoreGraphics global coordinates (top-left origin, points).
|
|
var cgFrame: CGRect { window.frame }
|
|
var title: String { window.title ?? "" }
|
|
var applicationName: String { window.owningApplication?.applicationName ?? "" }
|
|
var bundleID: String? { window.owningApplication?.bundleIdentifier }
|
|
}
|
|
|
|
enum CaptureEngine {
|
|
|
|
// MARK: - Permission
|
|
|
|
@discardableResult
|
|
static func ensurePermission() -> Bool {
|
|
if CGPreflightScreenCaptureAccess() { return true }
|
|
CGRequestScreenCaptureAccess()
|
|
return CGPreflightScreenCaptureAccess()
|
|
}
|
|
|
|
// MARK: - Shareable content
|
|
|
|
static func shareableContent() async throws -> SCShareableContent {
|
|
do {
|
|
return try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
|
|
} catch {
|
|
throw CaptureError.permissionDenied
|
|
}
|
|
}
|
|
|
|
/// Windows worth offering to the user: on screen, reasonably sized, not our own overlay.
|
|
static func selectableWindows(from content: SCShareableContent) -> [WindowInfo] {
|
|
let ownPID = ProcessInfo.processInfo.processIdentifier
|
|
return content.windows
|
|
.filter { window in
|
|
window.isOnScreen &&
|
|
window.frame.width > 40 && window.frame.height > 40 &&
|
|
window.owningApplication?.processID != ownPID
|
|
}
|
|
.map(WindowInfo.init)
|
|
}
|
|
|
|
// MARK: - Snapshots
|
|
|
|
/// Grabs every display so the selection overlay can show a frozen desktop.
|
|
static func snapshotAllDisplays() async throws -> [DisplaySnapshot] {
|
|
let content = try await shareableContent()
|
|
guard !content.displays.isEmpty else { throw CaptureError.noContent }
|
|
|
|
var snapshots: [DisplaySnapshot] = []
|
|
for display in content.displays {
|
|
guard let screen = ScreenGeometry.screen(for: display.displayID) else { continue }
|
|
let image = try await capture(display: display, content: content, sourceRect: nil, screen: screen)
|
|
snapshots.append(DisplaySnapshot(screen: screen,
|
|
display: display,
|
|
image: image,
|
|
cgFrame: CGRect(x: CGFloat(display.frame.origin.x),
|
|
y: CGFloat(display.frame.origin.y),
|
|
width: CGFloat(display.width),
|
|
height: CGFloat(display.height))))
|
|
}
|
|
guard !snapshots.isEmpty else { throw CaptureError.noContent }
|
|
return snapshots
|
|
}
|
|
|
|
private static func capture(display: SCDisplay,
|
|
content: SCShareableContent,
|
|
sourceRect: CGRect?,
|
|
screen: NSScreen) async throws -> CGImage {
|
|
let configuration = SCStreamConfiguration()
|
|
let scale = screen.backingScaleFactor
|
|
let rect = sourceRect ?? CGRect(x: 0, y: 0, width: display.width, height: display.height)
|
|
configuration.sourceRect = rect
|
|
configuration.width = Int((rect.width * scale).rounded())
|
|
configuration.height = Int((rect.height * scale).rounded())
|
|
configuration.scalesToFit = false
|
|
configuration.showsCursor = Configuration.shared.captureMousepointer
|
|
configuration.captureResolution = .best
|
|
if #available(macOS 14.0, *) {
|
|
configuration.ignoreShadowsSingleWindow = true
|
|
}
|
|
|
|
let ownWindows = content.windows.filter {
|
|
$0.owningApplication?.processID == ProcessInfo.processInfo.processIdentifier
|
|
}
|
|
let filter = SCContentFilter(display: display, excludingWindows: ownWindows)
|
|
|
|
do {
|
|
return try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration)
|
|
} catch {
|
|
throw CaptureError.captureFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
// MARK: - Public capture entry points
|
|
|
|
/// Full screen capture of the display the mouse is on, or of a given screen.
|
|
static func captureFullscreen(screen requested: NSScreen? = nil) async throws -> Capture {
|
|
let content = try await shareableContent()
|
|
let mouseLocation = NSEvent.mouseLocation
|
|
let screen = requested ?? ScreenGeometry.screen(containing: mouseLocation) ?? NSScreen.main
|
|
guard let screen,
|
|
let displayID = ScreenGeometry.displayID(for: screen),
|
|
let display = content.displays.first(where: { $0.displayID == displayID })
|
|
else { throw CaptureError.noContent }
|
|
|
|
let image = try await capture(display: display, content: content, sourceRect: nil, screen: screen)
|
|
|
|
var details = CaptureDetails()
|
|
details.captureMode = .fullscreen
|
|
details.title = screen.localizedName
|
|
details.scaleFactor = screen.backingScaleFactor
|
|
details.screenBounds = CGRect(x: CGFloat(display.frame.origin.x),
|
|
y: CGFloat(display.frame.origin.y),
|
|
width: CGFloat(display.width),
|
|
height: CGFloat(display.height))
|
|
return Capture(image: image, details: details)
|
|
}
|
|
|
|
/// Captures one window on its own, without whatever overlaps it.
|
|
static func captureWindow(_ info: WindowInfo) async throws -> Capture {
|
|
let configuration = SCStreamConfiguration()
|
|
let screen = ScreenGeometry.screen(containing: ScreenGeometry.appKitRect(fromCG: info.cgFrame).center)
|
|
?? NSScreen.main
|
|
let scale = screen?.backingScaleFactor ?? 2
|
|
configuration.width = Int((info.cgFrame.width * scale).rounded())
|
|
configuration.height = Int((info.cgFrame.height * scale).rounded())
|
|
configuration.showsCursor = false
|
|
configuration.scalesToFit = false
|
|
configuration.captureResolution = .best
|
|
if #available(macOS 14.0, *) {
|
|
configuration.ignoreShadowsSingleWindow = Configuration.shared.windowCaptureMode != .windowWithShadow
|
|
}
|
|
|
|
let filter = SCContentFilter(desktopIndependentWindow: info.window)
|
|
let image: CGImage
|
|
do {
|
|
image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration)
|
|
} catch {
|
|
throw CaptureError.captureFailed(error.localizedDescription)
|
|
}
|
|
|
|
var details = CaptureDetails()
|
|
details.captureMode = .window
|
|
details.title = info.title.isEmpty ? info.applicationName : info.title
|
|
details.applicationName = info.applicationName
|
|
details.applicationBundleID = info.bundleID
|
|
details.scaleFactor = scale
|
|
details.screenBounds = info.cgFrame
|
|
return Capture(image: image, details: details)
|
|
}
|
|
|
|
/// Crops a region out of an already frozen display snapshot.
|
|
static func crop(snapshot: DisplaySnapshot, cgRect: CGRect, mode: CaptureMode) throws -> Capture {
|
|
let local = CGRect(x: cgRect.origin.x - snapshot.cgFrame.origin.x,
|
|
y: cgRect.origin.y - snapshot.cgFrame.origin.y,
|
|
width: cgRect.width,
|
|
height: cgRect.height)
|
|
guard let cropped = ScreenGeometry.crop(snapshot.image, toPointRect: local, scale: snapshot.scale) else {
|
|
throw CaptureError.captureFailed("The selected region was empty.")
|
|
}
|
|
var details = CaptureDetails()
|
|
details.captureMode = mode
|
|
details.title = snapshot.screen.localizedName
|
|
details.scaleFactor = snapshot.scale
|
|
details.screenBounds = cgRect
|
|
return Capture(image: cropped, details: details)
|
|
}
|
|
|
|
// MARK: - Feedback
|
|
|
|
@MainActor
|
|
static func playFeedback(on rect: CGRect?) {
|
|
if Configuration.shared.playCameraSound {
|
|
NSSound(named: NSSound.Name("Grab"))?.play()
|
|
}
|
|
if Configuration.shared.showFlashAnimation, let rect {
|
|
FlashOverlay.flash(appKitRect: ScreenGeometry.appKitRect(fromCG: rect))
|
|
}
|
|
}
|
|
}
|
|
|
|
extension CGRect {
|
|
var center: CGPoint { CGPoint(x: midX, y: midY) }
|
|
}
|
|
|
|
/// Short white flash over the captured area, the equivalent of Greenshot's capture flash.
|
|
@MainActor
|
|
final class FlashOverlay {
|
|
private static var windows: [NSWindow] = []
|
|
|
|
static func flash(appKitRect rect: CGRect) {
|
|
guard rect.width > 1, rect.height > 1 else { return }
|
|
let window = NSWindow(contentRect: rect, styleMask: .borderless, backing: .buffered, defer: false)
|
|
window.isOpaque = false
|
|
window.backgroundColor = .white
|
|
window.alphaValue = 0.7
|
|
window.level = .screenSaver
|
|
window.ignoresMouseEvents = true
|
|
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
|
window.orderFrontRegardless()
|
|
windows.append(window)
|
|
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = 0.18
|
|
window.animator().alphaValue = 0
|
|
} completionHandler: {
|
|
// Run on the main thread by contract, so main actor isolation can be assumed.
|
|
MainActor.assumeIsolated {
|
|
window.orderOut(nil)
|
|
FlashOverlay.windows.removeAll { $0 === window }
|
|
}
|
|
}
|
|
}
|
|
}
|