feat: add Fn hotkey monitor and probe CLI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
let command = CommandLine.arguments.dropFirst().first ?? "help"
|
||||
switch command {
|
||||
case "hotkey":
|
||||
runHotkeyProbe()
|
||||
default:
|
||||
print("usage: sst-probe hotkey")
|
||||
}
|
||||
Reference in New Issue
Block a user