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
19 changes: 14 additions & 5 deletions Sources/MiniWhisper/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,17 +230,26 @@ class AppDelegate: NSObject, NSApplicationDelegate {
manager?.recordingDidEnd()
}

// When permissions are all granted, (re)start the event tap
// Restart once permissions land so anything that needed Accessibility
// gets another attempt.
permissions.onAllGranted = { [weak manager] in
log.info("All permissions granted — restarting hotkey manager")
manager?.stop()
manager?.start()
}

if permissions.accessibilityGranted {
log.info("Starting hotkey manager (accessibility already granted)")
manager.start()
} else {
// Started unconditionally: key chords register as system hot keys, which
// need no Accessibility grant, so they work on first launch. Only a
// bare-modifier shortcut needs the event tap, whose creation keeps being
// retried at watchdog cadence until the grant arrives.
manager.start()

// Asked for unconditionally, even though a Carbon-only shortcut set does
// not need it. Gating the prompt on the current bindings would make the
// permission appear and disappear as the user edits shortcuts; one
// stable requirement is the simpler story. Do not "fix" this to match
// the comment above.
if !permissions.accessibilityGranted {
permissions.openAccessibilitySettings()
permissions.startPolling()
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/MiniWhisper/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,4 +252,12 @@ final class AppState: Sendable {
CustomShortcutMonitor.shared.reloadShortcuts()
}

/// Which shortcuts are registered depends on which features are switched
/// on, so a settings change has to re-derive them. A shortcut for a
/// disabled feature must end up unregistered — that is what lets the chord
/// reach the focused app instead of being swallowed.
func refreshShortcutRegistrations() {
CustomShortcutMonitor.shared.refresh()
}

}
65 changes: 55 additions & 10 deletions Sources/MiniWhisper/Models/CustomShortcut.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,45 @@ enum FnKeyCode {
static func isFnKey(_ keyCode: UInt16) -> Bool { keyCodes.contains(keyCode) }
}

/// Keys that carry `kCGEventFlagMaskSecondaryFn` on their own.
///
/// macOS sets that flag for the whole function-key group whether or not Fn is
/// physically held — pressing ↑ reports it exactly like Fn+↑ does. So the flag
/// cannot be read as "the user held Fn" for these key codes, and anything that
/// does read it that way must exclude them or it rejects every arrow, F-key and
/// navigation key as an unbindable Fn chord.
enum FunctionKeyGroup {
static let keyCodes: Set<UInt16> = {
let codes: [Int] = [
kVK_LeftArrow, kVK_RightArrow, kVK_DownArrow, kVK_UpArrow,
kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6,
kVK_F7, kVK_F8, kVK_F9, kVK_F10, kVK_F11, kVK_F12,
kVK_Home, kVK_End, kVK_PageUp, kVK_PageDown,
kVK_ForwardDelete,
]
return Set(codes.map(UInt16.init))
}()

/// True when the Fn flag on an event for this key says nothing about what
/// the user was holding.
static func setsSecondaryFnIntrinsically(_ keyCode: UInt16) -> Bool {
keyCodes.contains(keyCode)
}

/// Whether an event's Fn flag can be read as "the user was holding Fn".
///
/// `fnKeyIsDown` is the tracked state of the physical key, which the flag
/// alone cannot substitute for. Function-group keys answer false whatever is
/// held: Fn+↑ and a plain ↑ are indistinguishable in the flag, and the
/// useful reading of the pair is the plain key — an Fn chord could not be
/// bound anyway.
static func indicatesHeldFn(
keyCode: UInt16, secondaryFnFlagSet: Bool, fnKeyIsDown: Bool
) -> Bool {
(secondaryFnFlagSet || fnKeyIsDown) && !setsSecondaryFnIntrinsically(keyCode)
}
}

extension CGEventFlags {
var modifierFlags: NSEvent.ModifierFlags {
var m = NSEvent.ModifierFlags()
Expand All @@ -25,6 +64,12 @@ struct CustomShortcut: Codable, Equatable, Hashable {
let option: Bool
let control: Bool
let shift: Bool
/// "Fn was physically held down as a modifier", and nothing else.
///
/// Narrower than it looks, and kept only because persisted shortcuts encode
/// it: it is meaningless on function-group key codes, which report Fn
/// intrinsically, and it is never set when Fn *is* the bound key (see
/// `isFnOnly`). Read it through `usesFnAsModifier` rather than directly.
let fn: Bool

init(
Expand All @@ -43,22 +88,13 @@ struct CustomShortcut: Codable, Equatable, Hashable {
self.fn = fn
}

func matches(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, fnPressed: Bool) -> Bool {
guard self.keyCode == keyCode else { return false }
return self.command == modifiers.contains(.command)
&& self.option == modifiers.contains(.option)
&& self.control == modifiers.contains(.control)
&& self.shift == modifiers.contains(.shift)
&& self.fn == fnPressed
}

var compactDisplayString: String {
var str = ""
if control { str += "Ctrl+" }
if option { str += "Option+" }
if shift { str += "Shift+" }
if command { str += "Cmd+" }
if fn { str += "Fn+" }
if usesFnAsModifier { str += "Fn+" }
str += keyCodeDisplayName
return str
}
Expand All @@ -71,6 +107,15 @@ struct CustomShortcut: Codable, Equatable, Hashable {
FnKeyCode.isFnKey(keyCode) && !command && !option && !control && !shift
}

/// Whether the stored `fn` flag actually records a held Fn key.
///
/// Older builds set `fn` for every function-group key because the system
/// reports the flag for them unconditionally, so those stored flags carry no
/// information and are ignored here — the binding is a plain chord.
var usesFnAsModifier: Bool {
fn && !FunctionKeyGroup.setsSecondaryFnIntrinsically(keyCode)
}

static func keyCodeToDisplayName(_ keyCode: UInt16) -> String {
switch Int(keyCode) {
case kVK_ANSI_A: return "A"
Expand Down
233 changes: 233 additions & 0 deletions Sources/MiniWhisper/Services/Hotkeys/CarbonHotKeyCenter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import Carbon.HIToolbox
import Foundation
import os.log

private let log = Logger(subsystem: Logger.subsystem, category: "CarbonHotKey")

/// Which edge of a hot key fired. Both edges are delivered so a shortcut can
/// drive hold-to-talk (start on press, stop on release), not just toggles.
enum HotKeyPhase: Sendable {
case pressed
case released
}

/// Registers system-wide hot keys through Carbon's `RegisterEventHotKey`.
///
/// Carbon hot keys are matched by the window server and delivered to this
/// process as ordinary application events, so this app is never part of the
/// keystroke-delivery path: no keystroke anywhere waits on this code, and a
/// hang here cannot slow input for other apps. The registration also swallows
/// the chord for us, so the focused app never sees a shortcut we handle.
///
/// The trade-off is that a registration is unconditional — Carbon has no notion
/// of "handle this only when a feature is on". Anything that gates a shortcut
/// must therefore add or remove the registration itself; see `sync(to:)`.
@MainActor
final class CarbonHotKeyCenter {
typealias Callback = @MainActor (CustomShortcutName, HotKeyPhase) -> Void

/// Tags this process's hot keys inside the shared Carbon dispatcher, which
/// carries every registration in the session.
private static let signature: OSType = 0x4D57_484B // 'MWHK'

private var callback: Callback?
private var eventHandler: EventHandlerRef?
private var handlerGate = HotKeyHandlerGate()
private var registrations: [UInt32: (binding: HotKeyBinding, ref: EventHotKeyRef)] = [:]
/// Registrations the OS refused to release. Kept so they can be retried:
/// see `unregister(_:)`.
private var failedUnregistrations: [EventHotKeyRef] = []
private var nextID: UInt32 = 1

func setCallback(_ callback: @escaping Callback) {
self.callback = callback
}

/// Makes the live registrations match `desired`, adding and removing only
/// the difference so unrelated shortcuts keep working across a refresh.
///
/// `desired` is ordered, and registration follows that order: the OS awards
/// a contested chord to whoever asks first, so the caller's precedence is
/// only honoured if this does not reorder.
///
/// Returns the shortcuts that ended up with at least one live registration,
/// which is not always all of them — the system keeps chords it already owns.
@discardableResult
func sync(to desired: [HotKeyBinding]) -> Set<CustomShortcutName> {
let desiredSet = Set(desired)
for (id, entry) in registrations where !desiredSet.contains(entry.binding) {
unregister(entry.ref)
registrations[id] = nil
}
retryFailedUnregistrations()

let current = Set(registrations.values.map(\.binding))
for binding in desired where !current.contains(binding) {
register(binding)
}

return Set(registrations.values.map(\.binding.name))
}

func unregisterAll() {
for entry in registrations.values {
unregister(entry.ref)
}
registrations.removeAll()
retryFailedUnregistrations()
}

private func register(_ binding: HotKeyBinding) {
// Fail closed. A registration swallows its chord system-wide whether or
// not anything is listening, so registering without a live dispatcher
// handler would make the chord dead in every app with no way to notice.
guard handlerGate.ensureInstalled(installEventHandler) else {
log.error(
"Refusing to register \(binding.name.rawValue): no hot-key handler is installed"
)
return
}

let id = nextID
nextID += 1

var ref: EventHotKeyRef?
let status = RegisterEventHotKey(
UInt32(binding.keyCode),
binding.carbonModifiers,
EventHotKeyID(signature: Self.signature, id: id),
GetEventDispatcherTarget(),
0,
&ref
)

guard status == noErr, let ref else {
// Expected for combinations the system already owns (Force Quit and
// friends); those simply stay with the system.
log.info(
"Hot key not registered: \(binding.name.rawValue) keyCode=\(binding.keyCode) modifiers=\(binding.carbonModifiers) status=\(status)"
)
return
}

registrations[id] = (binding, ref)
}

/// Releases a hot key, keeping the reference for a later attempt if the OS
/// refuses.
///
/// Dropping a reference that failed to unregister strands the registration
/// for the lifetime of the process: the chord stays swallowed system-wide
/// and nothing is left that could ever release it.
private func unregister(_ ref: EventHotKeyRef) {
let status = UnregisterEventHotKey(ref)
guard status != noErr else { return }
log.error("UnregisterEventHotKey failed (status \(status)); retaining for retry")
failedUnregistrations.append(ref)
}

private func retryFailedUnregistrations() {
guard !failedUnregistrations.isEmpty else { return }
let remaining = failedUnregistrations.filter { UnregisterEventHotKey($0) != noErr }
if remaining.count != failedUnregistrations.count {
log.info("Released \(self.failedUnregistrations.count - remaining.count) retried hot key(s)")
}
failedUnregistrations = remaining
}

private func installEventHandler() -> Bool {
guard let dispatcher = GetEventDispatcherTarget() else {
log.error("No Carbon event dispatcher available")
return false
}

var eventTypes = [
EventTypeSpec(
eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
EventTypeSpec(
eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)),
]

var handler: EventHandlerRef?
let status = InstallEventHandler(
dispatcher,
carbonHotKeyEventHandler,
eventTypes.count,
&eventTypes,
Unmanaged.passUnretained(self).toOpaque(),
&handler
)

guard status == noErr, let handler else {
log.error("InstallEventHandler failed: \(status)")
return false
}
eventHandler = handler
return true
}

fileprivate func handle(id: UInt32, signature: OSType, phase: HotKeyPhase) -> OSStatus {
guard signature == Self.signature, let entry = registrations[id] else {
return OSStatus(eventNotHandledErr)
}
callback?(entry.binding.name, phase)
return noErr
}
}

/// Enforces "no chord may be registered without a live handler", and installs
/// that handler at most once.
///
/// Split out from the registration path because the failure it guards is silent
/// and permanent: a hot key whose events nothing receives still swallows its
/// chord in every app, so the only safe response to a failed install is to
/// register nothing at all and try installing again later.
struct HotKeyHandlerGate {
private(set) var isInstalled = false

/// Runs `install` only while no handler is live, and reports whether one is.
mutating func ensureInstalled(_ install: () -> Bool) -> Bool {
if isInstalled { return true }
isInstalled = install()
return isInstalled
}
}

/// Carbon dispatches these from the main event loop, so main-actor isolation
/// holds even though the C signature cannot express it.
///
/// Everything needed is read out of the `EventRef` here, before the hop: the
/// event pointer is only valid for the duration of this call and carries no
/// isolation guarantees of its own.
private func carbonHotKeyEventHandler(
_ nextHandler: EventHandlerCallRef?,
_ event: EventRef?,
_ userData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let event, let userData else { return OSStatus(eventNotHandledErr) }

let phase: HotKeyPhase
switch Int(GetEventKind(event)) {
case kEventHotKeyPressed: phase = .pressed
case kEventHotKeyReleased: phase = .released
default: 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 id = hotKeyID.id
let signature = hotKeyID.signature
let center = Unmanaged<CarbonHotKeyCenter>.fromOpaque(userData).takeUnretainedValue()

return MainActor.assumeIsolated { center.handle(id: id, signature: signature, phase: phase) }
}
Loading
Loading