Closing the panel only hid it, and launching the app again did nothing because the running instance ignored the reopen, so it looked like the app refused to exit. Reopening now shows the panel, and the main menu makes Cmd+Q and Cmd+W work while the panel has focus. Termination also tears down the event tap, hotkeys and sync timers instead of leaving them live.
160 lines
5.6 KiB
Swift
160 lines
5.6 KiB
Swift
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()
|
|
}
|
|
|
|
func stop() {
|
|
if let tap = eventTap {
|
|
CGEvent.tapEnable(tap: tap, enable: false)
|
|
CFMachPortInvalidate(tap)
|
|
}
|
|
if let source = runLoopSource {
|
|
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
|
|
}
|
|
for ref in hotKeyRefs {
|
|
if let ref { UnregisterEventHotKey(ref) }
|
|
}
|
|
hotKeyRefs.removeAll()
|
|
eventTap = nil
|
|
runLoopSource = nil
|
|
}
|
|
|
|
// 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<CGEvent>? {
|
|
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<EventHotKeyID>.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<CGEvent>? {
|
|
guard let refcon else { return Unmanaged.passUnretained(event) }
|
|
let manager = Unmanaged<HotkeyManager>.fromOpaque(refcon).takeUnretainedValue()
|
|
return manager.handle(proxy: proxy, type: type, event: event)
|
|
}
|