Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions macos/LCTMac/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {

// Setup status bar item (optional)
setupStatusBarItem()


// Register system-wide hotkeys (⌃⌥⌘ S/P/O)
GlobalHotKeyManager.shared.registerDefaults()

// Request necessary permissions
requestPermissions()

Expand Down Expand Up @@ -133,7 +136,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Close overlay window
overlayWindow?.close()
overlayWindow = nil


// Unregister global hotkeys
GlobalHotKeyManager.shared.unregister()

// Remove status item
if let statusItem = statusItem {
NSStatusBar.system.removeStatusItem(statusItem)
Expand Down
127 changes: 127 additions & 0 deletions macos/LCTMac/App/GlobalHotKeyManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import AppKit
import Carbon.HIToolbox

/// Registers system-wide hotkeys via Carbon's RegisterEventHotKey.
///
/// Unlike NSEvent global monitors, this needs no Accessibility/Input-Monitoring
/// permission — the OS notifies us only when our specific chord fires, rather
/// than us observing every keystroke. Hotkey presses post the same
/// Notifications the in-app menu/commands already use, so there's no coupling
/// to the view model.
///
/// Default chords (control-option-command + key, chosen to avoid clashing with
/// system shortcuts like ⌘Space Spotlight):
/// ⌃⌥⌘ S — start/stop capture
/// ⌃⌥⌘ P — pause/resume
/// ⌃⌥⌘ O — toggle overlay
@MainActor
final class GlobalHotKeyManager {
static let shared = GlobalHotKeyManager()

private var hotKeyRefs: [EventHotKeyRef?] = []
private var eventHandler: EventHandlerRef?
private var installed = false

private init() {}

/// Carbon hotkey identifiers; the C handler maps these to Notifications.
private enum Action: UInt32 {
case toggleCapture = 1
case togglePause = 2
case toggleOverlay = 3
}

func registerDefaults() {
guard !installed else { return }
installed = true

installHandler()
register(.toggleCapture, keyCode: UInt32(kVK_ANSI_S))
register(.togglePause, keyCode: UInt32(kVK_ANSI_P))
register(.toggleOverlay, keyCode: UInt32(kVK_ANSI_O))
}

func unregister() {
for ref in hotKeyRefs where ref != nil {
UnregisterEventHotKey(ref)
}
hotKeyRefs.removeAll()
if let eventHandler {
RemoveEventHandler(eventHandler)
}
eventHandler = nil
installed = false
}

// MARK: - Carbon plumbing

private func installHandler() {
var spec = EventTypeSpec(
eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed)
)

// The handler is a C function pointer: it captures nothing and only
// touches thread-safe APIs (GetEventParameter, NotificationCenter.post),
// so it is safe to invoke from Carbon's nonisolated callback context.
InstallEventHandler(
GetApplicationEventTarget(),
{ _, event, _ -> OSStatus in
guard let event else { return OSStatus(eventNotHandledErr) }

var hotKeyID = EventHotKeyID()
let status = GetEventParameter(
event,
UInt32(kEventParamDirectObject),
UInt32(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotKeyID
)
guard status == noErr else { return status }

let name: Notification.Name?
switch hotKeyID.id {
case Action.toggleCapture.rawValue: name = .toggleCapture
case Action.togglePause.rawValue: name = .togglePause
case Action.toggleOverlay.rawValue: name = .toggleOverlay
default: name = nil
}

if let name {
// object: nil → the .toggleCapture observer treats this as a
// toggle (start if stopped, stop if running).
NotificationCenter.default.post(name: name, object: nil)
}
return noErr
},
1,
&spec,
nil,
&eventHandler
)
}

private func register(_ action: Action, keyCode: UInt32) {
let modifiers = UInt32(controlKey | optionKey | cmdKey)
// Signature 'LCT!' scopes our hotkey ids.
let hotKeyID = EventHotKeyID(signature: OSType(0x4C43_5421), id: action.rawValue)

var ref: EventHotKeyRef?
let status = RegisterEventHotKey(
keyCode,
modifiers,
hotKeyID,
GetApplicationEventTarget(),
0,
&ref
)

if status == noErr {
hotKeyRefs.append(ref)
} else {
appLog("[GlobalHotKey] Failed to register \(action) (status \(status)) — chord may be taken")
}
}
}
Loading