import Cocoa import Carbon // Global shortcuts: // Left Shift x2 — capture the current selection into the active section // Right Shift x2 — show/hide the panel // Cmd+Shift+C — capture (fallback, no Accessibility needed) // Cmd+Shift+Space — show/hide the panel (fallback) // // The double-Shift gestures come from a CGEvent tap on flagsChanged, which // needs Accessibility access. The Carbon hotkeys work without it. final class HotkeyManager { static let shared = HotkeyManager() var onCapture: (() -> Void)? var onTogglePanel: (() -> Void)? private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? private var eventHandlerRef: EventHandlerRef? private var hotKeyRefs: [EventHotKeyRef?] = [] private let signature: OSType = 0x50524D50 // "PRMP" private var lastLeftShiftRelease: TimeInterval = 0 private var lastRightShiftRelease: TimeInterval = 0 private let doubleTapInterval: TimeInterval = 0.35 private init() {} var isListening: Bool { eventTap != nil } func start() { startEventTap() registerFallbackHotkeys() } // MARK: - Double-Shift event tap private func startEventTap() { let mask = CGEventMask(1 << CGEventType.flagsChanged.rawValue) guard let tap = CGEvent.tapCreate( tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: mask, callback: hotkeyEventCallback, userInfo: Unmanaged.passUnretained(self).toOpaque() ) else { return } eventTap = tap let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) runLoopSource = source CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) CGEvent.tapEnable(tap: tap, enable: true) } fileprivate func handle(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged? { if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: true) } return Unmanaged.passUnretained(event) } guard type == .flagsChanged else { return Unmanaged.passUnretained(event) } let keyCode = event.getIntegerValueField(.keyboardEventKeycode) let shiftIsDown = event.flags.contains(.maskShift) guard !shiftIsDown else { return Unmanaged.passUnretained(event) } // only count releases let now = ProcessInfo.processInfo.systemUptime switch keyCode { case 56: // left shift if now - lastLeftShiftRelease < doubleTapInterval { lastLeftShiftRelease = 0 fire { $0.onCapture?() } } else { lastLeftShiftRelease = now } case 60: // right shift if now - lastRightShiftRelease < doubleTapInterval { lastRightShiftRelease = 0 fire { $0.onTogglePanel?() } } else { lastRightShiftRelease = now } default: break } return Unmanaged.passUnretained(event) } private func fire(_ action: @escaping (HotkeyManager) -> Void) { DispatchQueue.main.async { action(self) } } // MARK: - Carbon fallback hotkeys private func registerFallbackHotkeys() { var eventType = EventTypeSpec( eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed) ) InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in var hotKeyID = EventHotKeyID() let status = GetEventParameter( event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &hotKeyID ) guard status == noErr else { return noErr } DispatchQueue.main.async { switch hotKeyID.id { case 1: HotkeyManager.shared.onTogglePanel?() case 2: HotkeyManager.shared.onCapture?() default: break } } return noErr }, 1, &eventType, nil, &eventHandlerRef) registerHotkey(id: 1, keyCode: UInt32(kVK_Space), modifiers: UInt32(cmdKey | shiftKey)) registerHotkey(id: 2, keyCode: UInt32(kVK_ANSI_C), modifiers: UInt32(cmdKey | shiftKey)) } private func registerHotkey(id: UInt32, keyCode: UInt32, modifiers: UInt32) { var ref: EventHotKeyRef? let hotKeyID = EventHotKeyID(signature: signature, id: id) RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref) hotKeyRefs.append(ref) } } private func hotkeyEventCallback( proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer? ) -> Unmanaged? { guard let refcon else { return Unmanaged.passUnretained(event) } let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue() return manager.handle(proxy: proxy, type: type, event: event) }