feat: add Fn hotkey monitor and probe CLI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ let package = Package(
|
|||||||
platforms: [.macOS(.v14)],
|
platforms: [.macOS(.v14)],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "SstCore"),
|
.target(name: "SstCore"),
|
||||||
|
.executableTarget(name: "SstProbe", dependencies: ["SstCore"]),
|
||||||
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
|
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
|
||||||
],
|
],
|
||||||
swiftLanguageModes: [.v5]
|
swiftLanguageModes: [.v5]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import SstCore
|
||||||
|
|
||||||
|
struct FnKeyTrackerTests {
|
||||||
|
@Test func emitsPressedThenReleasedOnlyOnTransitions() {
|
||||||
|
var tracker = FnKeyTracker()
|
||||||
|
#expect(tracker.process(fnDown: false) == nil)
|
||||||
|
#expect(tracker.process(fnDown: true) == .pressed)
|
||||||
|
#expect(tracker.process(fnDown: true) == nil)
|
||||||
|
#expect(tracker.process(fnDown: false) == .released)
|
||||||
|
#expect(tracker.process(fnDown: false) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user