feat: add Fn hotkey monitor and probe CLI

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:44:23 +02:00
co-authored by Claude Sonnet 5.5
parent 7d24f3e874
commit 1c581e8eae
6 changed files with 118 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
public struct FnKeyTracker {
private var isDown = false
public init() {}
/// Feed the current Fn flag state; returns an event only on a transition.
public mutating func process(fnDown: Bool) -> HotkeyEvent? {
guard fnDown != isDown else { return nil }
isDown = fnDown
return fnDown ? .pressed : .released
}
}
+67
View File
@@ -0,0 +1,67 @@
import ApplicationServices
import CoreGraphics
public final class HotkeyMonitor {
public enum HotkeyError: Error, LocalizedError {
case tapUnavailable
public var errorDescription: String? {
"Kan de Fn-toets niet volgen: geef Input Monitoring-rechten in Systeeminstellingen en probeer opnieuw (soms is een herstart van de app nodig)."
}
}
public var onEvent: ((HotkeyEvent) -> Void)?
private var tracker = FnKeyTracker()
private var tap: CFMachPort?
private var source: CFRunLoopSource?
public init() {}
public func start() throws {
guard tap == nil else { return }
let mask = CGEventMask(1 << CGEventType.flagsChanged.rawValue)
let callback: CGEventTapCallBack = { _, type, event, refcon in
if let refcon {
let monitor = Unmanaged<HotkeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
monitor.handle(type: type, event: event)
}
return Unmanaged.passUnretained(event)
}
guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .listenOnly,
eventsOfInterest: mask,
callback: callback,
userInfo: Unmanaged.passUnretained(self).toOpaque()
) else { throw HotkeyError.tapUnavailable }
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
self.tap = tap
self.source = source
}
public func stop() {
if let source { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) }
if let tap { CGEvent.tapEnable(tap: tap, enable: false) }
tap = nil
source = nil
}
private func handle(type: CGEventType, event: CGEvent) {
switch type {
case .tapDisabledByTimeout, .tapDisabledByUserInput:
if let tap { CGEvent.tapEnable(tap: tap, enable: true) }
case .flagsChanged:
let fnDown = event.flags.contains(.maskSecondaryFn)
if let hotkeyEvent = tracker.process(fnDown: fnDown) {
onEvent?(hotkeyEvent)
}
default:
break
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import Foundation
import CoreGraphics
import SstCore
func runHotkeyProbe() -> Never {
if !CGPreflightListenEventAccess() { _ = CGRequestListenEventAccess() }
let monitor = HotkeyMonitor()
monitor.onEvent = { print("hotkey:", $0) }
do { try monitor.start() } catch {
print("failed:", error.localizedDescription)
exit(1)
}
print("Houd Fn ingedrukt en laat los. Ctrl-C om te stoppen.")
RunLoop.main.run()
exit(0)
}
+9
View File
@@ -0,0 +1,9 @@
import Foundation
let command = CommandLine.arguments.dropFirst().first ?? "help"
switch command {
case "hotkey":
runHotkeyProbe()
default:
print("usage: sst-probe hotkey")
}