From dc8fa0a2fe5b0ec64518d50aa7a83d818c4deb53 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 14:39:34 +0400 Subject: [PATCH] WP-3a: CaptureRegion geometry + Carbon HotkeyCenter per SPEC-A2a --- .../ShotdeckCore/Capture/CaptureRegion.swift | 58 ++++++ .../ShotdeckCore/Capture/HotkeyCenter.swift | 115 ++++++++++++ .../CaptureRegionTests.swift | 171 ++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 Sources/ShotdeckCore/Capture/CaptureRegion.swift create mode 100644 Sources/ShotdeckCore/Capture/HotkeyCenter.swift create mode 100644 Tests/ShotdeckCoreTests/CaptureRegionTests.swift diff --git a/Sources/ShotdeckCore/Capture/CaptureRegion.swift b/Sources/ShotdeckCore/Capture/CaptureRegion.swift new file mode 100644 index 0000000..d5a7ab2 --- /dev/null +++ b/Sources/ShotdeckCore/Capture/CaptureRegion.swift @@ -0,0 +1,58 @@ +import Foundation +import AppKit // NSScreen, NSDeviceDescriptionKey — macOS-only target, no portability concern + +/// A remembered rectangle, in CoreGraphics global display coordinates (origin top-left, +/// y increasing downward — see the conversion note below). +public struct CaptureRegion: Codable, Sendable, Equatable { + public let displayID: CGDirectDisplayID + public let rect: CGRect + public let capturedScale: CGFloat + + public init(displayID: CGDirectDisplayID, rect: CGRect, capturedScale: CGFloat) { + self.displayID = displayID + self.rect = rect + self.capturedScale = capturedScale + } + + /// Converts an AppKit rect in GLOBAL AppKit coordinates (bottom-left origin, y increasing + /// upward, anchored at the bottom-left of the PRIMARY screen — exactly what + /// `NSWindow.frame`, `NSEvent.mouseLocation` and `NSScreen.frame` already report; no + /// screen-local conversion is needed here) into global CoreGraphics coordinates + /// (top-left origin, y increasing downward, same primary-screen anchor). Both coordinate + /// systems share the SAME horizontal origin and the SAME anchor rectangle (the primary + /// screen's bounds) — only the vertical axis is mirrored around the primary's height. + /// That is why the flip below is correct for every attached screen, including one with a + /// negative AppKit x (to the left of primary) or a y that places it above the primary + /// (whose CG y then comes out negative): x is untouched, and the primary height is the + /// one fixed reference both systems agree on regardless of which physical screen the + /// rect is actually on. + public static func fromAppKit(rect: CGRect, on screen: NSScreen) -> CaptureRegion { + let displayID = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] + as? NSNumber)?.uint32Value ?? CGMainDisplayID() + let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height + return fromAppKit(rect: rect, displayID: displayID, + capturedScale: screen.backingScaleFactor, primaryHeight: primaryHeight) + } + + /// Injectable overload for deterministic geometry tests — no dependency on real attached + /// displays. The public overload above is a thin adapter over this. + static func fromAppKit(rect: CGRect, displayID: CGDirectDisplayID, + capturedScale: CGFloat, primaryHeight: CGFloat) -> CaptureRegion { + let cgY = primaryHeight - rect.origin.y - rect.height + let cgRect = CGRect(x: rect.origin.x, y: cgY, width: rect.width, height: rect.height) + return CaptureRegion(displayID: displayID, rect: cgRect, capturedScale: capturedScale) + } + + /// True when `displayID` is still attached AND `rect` still lies inside its current + /// bounds. Uses `CGDisplayIsActive` (returns 0 safely for an unknown id, never traps) and + /// `CGDisplayBounds` — both real CoreGraphics calls, no injection needed since this is a + /// live-state check by design. + public var isStillValid: Bool { + guard CGDisplayIsActive(displayID) != 0 else { return false } + let bounds = CGDisplayBounds(displayID) + return bounds.contains(rect) + } + + /// Persisted in UserDefaults under this key BY THE APP (WP-4a), never by this type. + public static let defaultsKey = "ai.flowmaster.shotdeck.region" +} diff --git a/Sources/ShotdeckCore/Capture/HotkeyCenter.swift b/Sources/ShotdeckCore/Capture/HotkeyCenter.swift new file mode 100644 index 0000000..ef2e821 --- /dev/null +++ b/Sources/ShotdeckCore/Capture/HotkeyCenter.swift @@ -0,0 +1,115 @@ +import Carbon.HIToolbox +import Foundation + +/// Carbon's C event handler cannot capture Swift closures, so live handlers are kept in one +/// process-wide table keyed by the numeric EventHotKeyID Carbon hands back on fire. Safe +/// because InstallEventHandler on GetApplicationEventTarget() always delivers on the main +/// thread, and every access here happens either from a @MainActor HotkeyCenter method or from +/// the C callback below, which this process only ever invokes on the main run loop. +private final class HotkeyDispatchTable: @unchecked Sendable { + static let shared = HotkeyDispatchTable() + private var handlers: [UInt32: @MainActor () -> Void] = [:] + private init() {} + func set(_ numericID: UInt32, _ handler: @escaping @MainActor () -> Void) { + handlers[numericID] = handler + } + func remove(_ numericID: UInt32) { handlers.removeValue(forKey: numericID) } + func fire(_ numericID: UInt32) { MainActor.assumeIsolated { handlers[numericID]?() } } +} + +private func shotdeckCarbonHotkeyHandler( + _ nextHandler: EventHandlerCallRef?, _ event: EventRef?, _ userData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let event else { return OSStatus(eventNotHandledErr) } + var hotKeyID = EventHotKeyID() + let status = GetEventParameter(event, EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &hotKeyID) + guard status == noErr else { return status } + HotkeyDispatchTable.shared.fire(hotKeyID.id) + return noErr +} + +/// Four-character creator code used for every `EventHotKeyID` this process registers. +private let shotdeckHotKeySignature: OSType = 0x53484F54 // "SHOT" + +@MainActor +public final class HotkeyCenter { + private var bindings: [String: (ref: EventHotKeyRef, numericID: UInt32)] = [:] + private var nextNumericID: UInt32 = 1 + + private static var didInstallGlobalCarbonHandler = false + private static var carbonHandlerRef: EventHandlerRef? + + public init() { HotkeyCenter.installGlobalCarbonHandlerIfNeeded() } + + /// Registers a hotkey. `id` is a caller-chosen stable identifier. Re-registering the same + /// `id` first unregisters its previous binding, then attempts the new one (idempotent). + /// Returns false when Carbon's `RegisterEventHotKey` reports a non-`noErr` status — the + /// most common cause is the same keyCode+modifiers combination already being claimed + /// system-wide by this or another process. + @discardableResult + public func register( + id: String, + keyCode: UInt32, + modifiers: UInt32, + handler: @escaping @MainActor () -> Void + ) -> Bool { + unregister(id: id) + + let hotKeyID = EventHotKeyID(signature: shotdeckHotKeySignature, id: nextNumericID) + var ref: EventHotKeyRef? + let status = RegisterEventHotKey( + keyCode, + modifiers, + hotKeyID, + GetApplicationEventTarget(), + 0, + &ref + ) + guard status == noErr, let ref else { + return false + } + bindings[id] = (ref: ref, numericID: nextNumericID) + HotkeyDispatchTable.shared.set(nextNumericID, handler) + nextNumericID += 1 + return true + } + + public func unregister(id: String) { + guard let binding = bindings.removeValue(forKey: id) else { return } + _ = UnregisterEventHotKey(binding.ref) + HotkeyDispatchTable.shared.remove(binding.numericID) + } + + public func unregisterAll() { + let ids = Array(bindings.keys) + for id in ids { + unregister(id: id) + } + } + + /// Installs the process-wide Carbon hot-key pressed handler exactly once. + private static func installGlobalCarbonHandlerIfNeeded() { + guard !didInstallGlobalCarbonHandler else { return } + didInstallGlobalCarbonHandler = true + + var handlerRef: EventHandlerRef? + var eventTypes = [ + EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: OSType(kEventHotKeyPressed) + ) + ] + let status = InstallEventHandler( + GetApplicationEventTarget(), + shotdeckCarbonHotkeyHandler, + 1, + &eventTypes, + nil, + &handlerRef + ) + if status == noErr { + carbonHandlerRef = handlerRef + } + } +} diff --git a/Tests/ShotdeckCoreTests/CaptureRegionTests.swift b/Tests/ShotdeckCoreTests/CaptureRegionTests.swift new file mode 100644 index 0000000..792b9d1 --- /dev/null +++ b/Tests/ShotdeckCoreTests/CaptureRegionTests.swift @@ -0,0 +1,171 @@ +import Carbon.HIToolbox +import Foundation +import Testing +@testable import ShotdeckCore + +@Test("GEO-1 primary-only 1080-tall screen, AppKit rect (100,100,400,300)") +func geo1_primaryOnlyAppKitRectConvertsToExpectedCGRect() { + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 100, y: 100, width: 400, height: 300), + displayID: 1, + capturedScale: 2, + primaryHeight: 1080 + ) + #expect(region.rect == CGRect(x: 100, y: 680, width: 400, height: 300)) +} + +@Test("GEO-2 rect flush to AppKit bottom (y=0), h=300, primaryHeight=1080") +func geo2_flushToAppKitBottomYieldsCGY780() { + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 50, y: 0, width: 200, height: 300), + displayID: 1, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect.origin.y == 780) +} + +@Test("GEO-3 rect flush to AppKit top (y+h=primaryHeight), primaryHeight=1080, h=300") +func geo3_flushToAppKitTopYieldsCGY0() { + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 50, y: 780, width: 200, height: 300), + displayID: 1, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect.origin.y == 0) +} + +@Test("GEO-4 round-trip AppKit→CG via injectable overload, then invert recovers original") +func geo4_invertRecoverOriginalAppKitRect() { + let original = CGRect(x: 100, y: 100, width: 400, height: 300) + let primaryHeight: CGFloat = 1080 + let region = CaptureRegion.fromAppKit( + rect: original, + displayID: 1, + capturedScale: 2, + primaryHeight: primaryHeight + ) + let appKitY = primaryHeight - region.rect.origin.y - region.rect.height + let recovered = CGRect( + x: region.rect.origin.x, + y: appKitY, + width: region.rect.width, + height: region.rect.height + ) + #expect(recovered == original) +} + +@Test("GEO-5 isStillValid for displayID 999_999 is false and does not trap") +func geo5_unknownDisplayIsNotStillValid() { + let region = CaptureRegion( + displayID: 999_999, + rect: CGRect(x: 0, y: 0, width: 100, height: 100), + capturedScale: 1 + ) + #expect(region.isStillValid == false) +} + +@Test("GEO-6 CaptureRegion JSONEncoder→JSONDecoder round-trip equals original") +func geo6_codableRoundTripEqualsOriginal() throws { + let original = CaptureRegion( + displayID: 42, + rect: CGRect(x: 10, y: 20, width: 300, height: 400), + capturedScale: 2 + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(CaptureRegion.self, from: data) + #expect(decoded == original) +} + +@Test("GEO-7 secondary screen negative x: AppKit (−1800,200,500,400) → CG (−1800,480,500,400)") +func geo7_secondaryNegativeXLeavesXUnchanged() { + // Secondary AppKit frame (−1920, 0, 1920, 1080); primaryHeight=1080. + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: -1800, y: 200, width: 500, height: 400), + displayID: 2, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect == CGRect(x: -1800, y: 480, width: 500, height: 400)) +} + +@Test("GEO-8 secondary screen stacked above primary: AppKit (300,1300,600,350) → CG (300,−570,600,350)") +func geo8_secondaryPositiveYProducesNegativeCGY() { + // Secondary AppKit frame (0, 1080, 1920, 1080); primaryHeight=1080. + let region = CaptureRegion.fromAppKit( + rect: CGRect(x: 300, y: 1300, width: 600, height: 350), + displayID: 2, + capturedScale: 1, + primaryHeight: 1080 + ) + #expect(region.rect == CGRect(x: 300, y: -570, width: 600, height: 350)) +} + +/// Carbon registrations are process-wide, so these cases must not run in parallel. +@Suite(.serialized) +@MainActor +struct HotkeyCenterCarbonTests { + /// kVK_ANSI_2. The app registers this combo once (WP-4a); tests only exercise the registrar. + private let captureKeyCode: UInt32 = 19 + private let captureModifiers = UInt32(optionKey) | UInt32(shiftKey) + + @Test("HK-1 register id a with Option-Shift-2 returns true") + func hk1_registerReturnsTrue() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let ok = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + if !ok { + Issue.record("HK-1: RegisterEventHotKey returned non-noErr inside swift test (no NSApplication). Carbon registration did not function headlessly.") + return + } + #expect(ok) + } + + @Test("HK-2 register same combo under a different id returns false") + func hk2_duplicateComboRejected() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let first = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + if !first { + Issue.record("HK-2: first RegisterEventHotKey failed inside swift test (no NSApplication); cannot evaluate duplicate-combo rejection.") + return + } + let second = center.register(id: "b", keyCode: captureKeyCode, modifiers: captureModifiers) {} + #expect(second == false) + } + + @Test("HK-3 unregister frees the combination so a later register succeeds") + func hk3_unregisterFreesCombination() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let first = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + if !first { + Issue.record("HK-3: first RegisterEventHotKey failed inside swift test (no NSApplication); cannot evaluate unregister.") + return + } + center.unregister(id: "a") + let again = center.register(id: "c", keyCode: captureKeyCode, modifiers: captureModifiers) {} + #expect(again) + } + + @Test("HK-4 unregisterAll frees combinations so a later register succeeds") + func hk4_unregisterAllFreesCombinations() { + let center = HotkeyCenter() + defer { center.unregisterAll() } + let first = center.register(id: "a", keyCode: captureKeyCode, modifiers: captureModifiers) {} + let other = center.register( + id: "other", + keyCode: UInt32(kVK_ANSI_3), + modifiers: captureModifiers + ) {} + if !first { + Issue.record("HK-4: RegisterEventHotKey failed inside swift test (no NSApplication); cannot evaluate unregisterAll.") + return + } + _ = other + center.unregisterAll() + let again = center.register(id: "c", keyCode: captureKeyCode, modifiers: captureModifiers) {} + #expect(again) + } +}