From 15021e9dda32a7e49326e780bae36761f7669956 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 15:08:05 +0400 Subject: [PATCH] WP-3b: ScreenCaptureKit capturer + region picker overlay per SPEC-A2a --- Sources/Shotdeck/RegionPickerController.swift | 181 ++++++++++++++++++ Sources/Shotdeck/RegionPickerWindow.swift | 110 +++++++++++ .../ShotdeckCore/Capture/ScreenCapturer.swift | 118 ++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 Sources/Shotdeck/RegionPickerController.swift create mode 100644 Sources/Shotdeck/RegionPickerWindow.swift create mode 100644 Sources/ShotdeckCore/Capture/ScreenCapturer.swift diff --git a/Sources/Shotdeck/RegionPickerController.swift b/Sources/Shotdeck/RegionPickerController.swift new file mode 100644 index 0000000..88d32bb --- /dev/null +++ b/Sources/Shotdeck/RegionPickerController.swift @@ -0,0 +1,181 @@ +import AppKit +import ShotdeckCore + +@MainActor +public final class RegionPickerController { + private var windows: [RegionPickerWindow] = [] + private var monitor: Any? + private var completion: (@MainActor (CaptureRegion?) -> Void)? + private var dragStart: NSPoint? + private var startScreen: NSScreen? + private var cursorPushed = false + + public init() {} + + /// Presents one overlay per attached screen. A pick already in progress is cancelled + /// (its completion called with nil) before the new one starts — never leaves a caller + /// waiting forever on a dropped re-entrant call. + public func pick(completion: @escaping @MainActor (CaptureRegion?) -> Void) { + if self.completion != nil { + finish(region: nil) + } + self.completion = completion + presentOverlays() + } + + private func presentOverlays() { + let screens = NSScreen.screens + guard !screens.isEmpty else { + finish(region: nil) + return + } + + NSCursor.crosshair.push() + cursorPushed = true + + windows = screens.map { RegionPickerWindow(screen: $0) } + for window in windows { + window.orderFrontRegardless() + } + // Nonactivating panel can become key without activating the app; needed so + // Escape reaches the local monitor immediately, before any mouse click. + let mouse = NSEvent.mouseLocation + let keyWindow = windows.first(where: { $0.coveringScreen.frame.contains(mouse) }) + ?? windows[0] + keyWindow.makeKey() + + monitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp, .keyDown] + ) { [weak self] event in + guard let self else { return event } + // NSEvent is not Sendable; lift the two Sendable fields the handler + // needs. Mouse geometry is read from NSEvent.mouseLocation on the + // main thread inside the isolated methods, per spec. + let type = event.type + let keyCode = event.keyCode + let consume = MainActor.assumeIsolated { + self.handle(type: type, keyCode: keyCode) + } + return consume ? nil : event + } + } + + /// Returns `true` when the event should be swallowed. + private func handle(type: NSEvent.EventType, keyCode: UInt16) -> Bool { + switch type { + case .keyDown: + if keyCode == 53 { // kVK_Escape + finish(region: nil) + return true + } + return false + case .leftMouseDown: + handleMouseDown() + return false + case .leftMouseDragged: + handleMouseDragged() + return false + case .leftMouseUp: + handleMouseUp() + return false + default: + return false + } + } + + private func handleMouseDown() { + let point = NSEvent.mouseLocation + dragStart = point + startScreen = NSScreen.screens.first(where: { $0.frame.contains(point) }) + ?? NSScreen.screens.first + guard let startScreen else { return } + let local = Self.localRect( + from: CGRect(origin: point, size: .zero).intersection(startScreen.frame), + on: startScreen + ) + window(for: startScreen)?.updateSelection(localRect: local, sizeText: "0 x 0") + for window in windows where window.coveringScreen !== startScreen { + window.updateSelection(localRect: nil, sizeText: nil) + } + } + + private func handleMouseDragged() { + guard dragStart != nil, startScreen != nil else { return } + applyLiveSelection() + } + + private func handleMouseUp() { + guard let startScreen else { + dragStart = nil + return + } + guard let rect = currentClampedRect(), rect.width >= 8, rect.height >= 8 else { + // Mis-click: reset drag state, leave every window open and fully dimmed. + dragStart = nil + self.startScreen = nil + for window in windows { + window.updateSelection(localRect: nil, sizeText: nil) + } + return + } + let region = CaptureRegion.fromAppKit(rect: rect, on: startScreen) + finish(region: region) + } + + private func applyLiveSelection() { + guard let startScreen, let rect = currentClampedRect() else { return } + let local = Self.localRect(from: rect, on: startScreen) + let text = "\(Int(rect.width)) x \(Int(rect.height))" + window(for: startScreen)?.updateSelection(localRect: local, sizeText: text) + for window in windows where window.coveringScreen !== startScreen { + window.updateSelection(localRect: nil, sizeText: nil) + } + } + + /// Normalize the drag (so bottom-right → up-left is not misjudged) then clamp + /// to the screen the gesture started on — never selects across displays. + private func currentClampedRect() -> CGRect? { + guard let dragStart, let startScreen else { return nil } + let current = NSEvent.mouseLocation + let normalized = CGRect( + x: min(dragStart.x, current.x), + y: min(dragStart.y, current.y), + width: abs(current.x - dragStart.x), + height: abs(current.y - dragStart.y) + ) + return normalized.intersection(startScreen.frame) + } + + private func window(for screen: NSScreen) -> RegionPickerWindow? { + windows.first { $0.coveringScreen === screen } + } + + private static func localRect(from rect: CGRect, on screen: NSScreen) -> CGRect { + CGRect( + x: rect.minX - screen.frame.minX, + y: rect.minY - screen.frame.minY, + width: rect.width, + height: rect.height + ) + } + + private func finish(region: CaptureRegion?) { + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + if cursorPushed { + NSCursor.pop() + cursorPushed = false + } + for window in windows { + window.orderOut(nil) + } + windows.removeAll() + dragStart = nil + startScreen = nil + let done = completion + completion = nil + done?(region) + } +} diff --git a/Sources/Shotdeck/RegionPickerWindow.swift b/Sources/Shotdeck/RegionPickerWindow.swift new file mode 100644 index 0000000..a5c315d --- /dev/null +++ b/Sources/Shotdeck/RegionPickerWindow.swift @@ -0,0 +1,110 @@ +import AppKit + +/// Full-screen dim overlay for one attached display. Subclasses `NSPanel` with +/// `.nonactivatingPanel` so it can accept mouse and key events without activating +/// Shotdeck or stealing the frontmost app beyond those events. +@MainActor +final class RegionPickerWindow: NSPanel { + let coveringScreen: NSScreen + private let pickerView: RegionPickerView + + init(screen: NSScreen) { + self.coveringScreen = screen + self.pickerView = RegionPickerView(frame: CGRect(origin: .zero, size: screen.frame.size)) + super.init( + contentRect: screen.frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + setFrame(screen.frame, display: false) + level = .screenSaver + isOpaque = false + backgroundColor = .clear + ignoresMouseEvents = false + hasShadow = false + isMovable = false + hidesOnDeactivate = false + isFloatingPanel = true + becomesKeyOnlyIfNeeded = false + animationBehavior = .none + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + contentView = pickerView + } + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + /// `localRect` is in this window's coordinates (screen.frame origin subtracted). + /// Pass `nil` to restore the full dim with no punch and no size chip. + func updateSelection(localRect: CGRect?, sizeText: String?) { + pickerView.selectionLocalRect = localRect + pickerView.sizeChipText = sizeText + pickerView.needsDisplay = true + } +} + +/// Draws ~35% black over the screen with an even-odd punch for the live selection, +/// a thin light border, and the size-readout chip. +@MainActor +private final class RegionPickerView: NSView { + var selectionLocalRect: CGRect? + var sizeChipText: String? + + override var isOpaque: Bool { false } + override var acceptsFirstResponder: Bool { true } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let dim = NSColor.black.withAlphaComponent(0.35) + let path = NSBezierPath(rect: bounds) + if let sel = selectionLocalRect, sel.width > 0, sel.height > 0 { + path.append(NSBezierPath(rect: sel)) + path.windingRule = .evenOdd + } + dim.setFill() + path.fill() + + guard let sel = selectionLocalRect, sel.width > 0, sel.height > 0 else { return } + + NSColor.white.withAlphaComponent(0.85).setStroke() + let border = NSBezierPath(rect: sel) + border.lineWidth = 1 + border.stroke() + + if let text = sizeChipText { + drawSizeChip(text, above: sel) + } + } + + private func drawSizeChip(_ text: String, above sel: CGRect) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), + .foregroundColor: NSColor.white, + ] + let nsText = text as NSString + let textSize = nsText.size(withAttributes: attrs) + let padX: CGFloat = 8 + let padY: CGFloat = 4 + let chipSize = CGSize( + width: ceil(textSize.width) + padX * 2, + height: ceil(textSize.height) + padY * 2 + ) + + var origin = CGPoint(x: sel.minX, y: sel.maxY + 6) + if origin.y + chipSize.height > bounds.maxY { + origin.y = sel.minY - 6 - chipSize.height + } + origin.x = min(max(origin.x, bounds.minX + 4), bounds.maxX - chipSize.width - 4) + origin.y = min(max(origin.y, bounds.minY + 4), bounds.maxY - chipSize.height - 4) + + let chipRect = CGRect(origin: origin, size: chipSize) + NSColor.black.withAlphaComponent(0.75).setFill() + NSBezierPath(roundedRect: chipRect, xRadius: 4, yRadius: 4).fill() + nsText.draw( + at: CGPoint(x: chipRect.minX + padX, y: chipRect.minY + padY), + withAttributes: attrs + ) + } +} diff --git a/Sources/ShotdeckCore/Capture/ScreenCapturer.swift b/Sources/ShotdeckCore/Capture/ScreenCapturer.swift new file mode 100644 index 0000000..b6158fc --- /dev/null +++ b/Sources/ShotdeckCore/Capture/ScreenCapturer.swift @@ -0,0 +1,118 @@ +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 + ) + } +} -- 2.54.0