Files
prompt/Sources/Prompt/HotkeyManager.swift
TZGyn 962c205d1d Make the app reachable and quittable after the panel closes
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.
2026-10-08 10:43:42 +08:00

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)
}