Files
Snaggle/GreenshotMac/Core/HotkeyManager.swift
T
2026-09-17 15:15:45 -05:00

137 lines
6.2 KiB
Swift

// HotkeyManager.swift
// GreenshotMac
//
// System wide hotkeys through the Carbon hotkey API, the macOS counterpart of
// Greenshot's HotkeyControl (RegisterHotKey on Win32).
import AppKit
import Carbon.HIToolbox
final class HotkeyManager {
static let shared = HotkeyManager()
private struct Registration {
let ref: EventHotKeyRef
let handler: () -> Void
}
private var registrations: [UInt32: Registration] = [:]
private var nextID: UInt32 = 1
private var eventHandler: EventHandlerRef?
private init() {}
/// Installs the process wide Carbon event handler. Safe to call more than once.
private func installHandlerIfNeeded() {
guard eventHandler == nil else { return }
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed))
let callback: EventHandlerUPP = { _, event, _ in
var hotkeyID = EventHotKeyID()
let status = GetEventParameter(event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotkeyID)
guard status == noErr else { return status }
DispatchQueue.main.async {
HotkeyManager.shared.fire(id: hotkeyID.id)
}
return noErr
}
InstallEventHandler(GetApplicationEventTarget(), callback, 1, &eventType, nil, &eventHandler)
}
fileprivate func fire(id: UInt32) {
registrations[id]?.handler()
}
/// Registers a hotkey and returns its identifier, or nil when the combination is taken.
@discardableResult
func register(_ spec: HotkeySpec, handler: @escaping () -> Void) -> UInt32? {
guard !spec.isEmpty else { return nil }
installHandlerIfNeeded()
let id = nextID
nextID += 1
var hotkeyRef: EventHotKeyRef?
let hotkeyID = EventHotKeyID(signature: OSType(0x47535354), id: id) // 'GSST'
let status = RegisterEventHotKey(spec.keyCode,
spec.modifiers,
hotkeyID,
GetApplicationEventTarget(),
0,
&hotkeyRef)
guard status == noErr, let hotkeyRef else { return nil }
registrations[id] = Registration(ref: hotkeyRef, handler: handler)
return id
}
func unregister(_ id: UInt32) {
guard let registration = registrations.removeValue(forKey: id) else { return }
UnregisterEventHotKey(registration.ref)
}
func unregisterAll() {
for id in registrations.keys { unregister(id) }
}
// MARK: - Display helpers
static func description(of spec: HotkeySpec) -> String {
guard !spec.isEmpty else { return "None" }
var text = ""
if spec.modifiers & UInt32(controlKey) != 0 { text += "⌃" }
if spec.modifiers & UInt32(optionKey) != 0 { text += "⌥" }
if spec.modifiers & UInt32(shiftKey) != 0 { text += "⇧" }
if spec.modifiers & UInt32(cmdKey) != 0 { text += "⌘" }
text += keyName(for: spec.keyCode)
return text
}
static func keyName(for keyCode: UInt32) -> String {
let names: [UInt32: String] = [
UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B", UInt32(kVK_ANSI_C): "C",
UInt32(kVK_ANSI_D): "D", UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F",
UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H", UInt32(kVK_ANSI_I): "I",
UInt32(kVK_ANSI_J): "J", UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L",
UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N", UInt32(kVK_ANSI_O): "O",
UInt32(kVK_ANSI_P): "P", UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R",
UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T", UInt32(kVK_ANSI_U): "U",
UInt32(kVK_ANSI_V): "V", UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X",
UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z",
UInt32(kVK_ANSI_1): "1", UInt32(kVK_ANSI_2): "2", UInt32(kVK_ANSI_3): "3",
UInt32(kVK_ANSI_4): "4", UInt32(kVK_ANSI_5): "5", UInt32(kVK_ANSI_6): "6",
UInt32(kVK_ANSI_7): "7", UInt32(kVK_ANSI_8): "8", UInt32(kVK_ANSI_9): "9",
UInt32(kVK_ANSI_0): "0",
UInt32(kVK_Space): "Space", UInt32(kVK_Return): "Return", UInt32(kVK_Escape): "Esc",
UInt32(kVK_F1): "F1", UInt32(kVK_F2): "F2", UInt32(kVK_F3): "F3", UInt32(kVK_F4): "F4",
UInt32(kVK_F5): "F5", UInt32(kVK_F6): "F6", UInt32(kVK_F7): "F7", UInt32(kVK_F8): "F8",
UInt32(kVK_F9): "F9", UInt32(kVK_F10): "F10", UInt32(kVK_F11): "F11", UInt32(kVK_F12): "F12"
]
return names[keyCode] ?? "Key \(keyCode)"
}
/// Converts AppKit modifier flags to the Carbon mask used by RegisterEventHotKey.
static func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 {
var modifiers: UInt32 = 0
if flags.contains(.command) { modifiers |= UInt32(cmdKey) }
if flags.contains(.option) { modifiers |= UInt32(optionKey) }
if flags.contains(.control) { modifiers |= UInt32(controlKey) }
if flags.contains(.shift) { modifiers |= UInt32(shiftKey) }
return modifiers
}
}
/// Greenshot's Windows defaults (PrintScreen based) have no direct macOS equivalent,
/// so these mirror the Greenshot key layout using ⌘⇧ combinations that macOS leaves free.
enum DefaultHotkeys {
static let region = HotkeySpec(keyCode: UInt32(kVK_ANSI_4), modifiers: UInt32(cmdKey | shiftKey | optionKey))
static let fullscreen = HotkeySpec(keyCode: UInt32(kVK_ANSI_3), modifiers: UInt32(cmdKey | shiftKey | optionKey))
static let window = HotkeySpec(keyCode: UInt32(kVK_ANSI_5), modifiers: UInt32(cmdKey | shiftKey | optionKey))
static let lastRegion = HotkeySpec(keyCode: UInt32(kVK_ANSI_6), modifiers: UInt32(cmdKey | shiftKey | optionKey))
}