From 1c581e8eae4bc63fe5a95080f12519a6e7c75196 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 09:44:23 +0200 Subject: [PATCH] feat: add Fn hotkey monitor and probe CLI Co-Authored-By: Claude Sonnet 5.5 --- Package.swift | 1 + Sources/SstCore/FnKeyTracker.swift | 12 ++++ Sources/SstCore/HotkeyMonitor.swift | 67 ++++++++++++++++++++++ Sources/SstProbe/HotkeyProbe.swift | 16 ++++++ Sources/SstProbe/main.swift | 9 +++ Tests/SstCoreTests/FnKeyTrackerTests.swift | 13 +++++ 6 files changed, 118 insertions(+) create mode 100644 Sources/SstCore/FnKeyTracker.swift create mode 100644 Sources/SstCore/HotkeyMonitor.swift create mode 100644 Sources/SstProbe/HotkeyProbe.swift create mode 100644 Sources/SstProbe/main.swift create mode 100644 Tests/SstCoreTests/FnKeyTrackerTests.swift diff --git a/Package.swift b/Package.swift index 85c3f8a..afdba95 100644 --- a/Package.swift +++ b/Package.swift @@ -6,6 +6,7 @@ let package = Package( platforms: [.macOS(.v14)], targets: [ .target(name: "SstCore"), + .executableTarget(name: "SstProbe", dependencies: ["SstCore"]), .testTarget(name: "SstCoreTests", dependencies: ["SstCore"]), ], swiftLanguageModes: [.v5] diff --git a/Sources/SstCore/FnKeyTracker.swift b/Sources/SstCore/FnKeyTracker.swift new file mode 100644 index 0000000..2ba1e10 --- /dev/null +++ b/Sources/SstCore/FnKeyTracker.swift @@ -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 + } +} diff --git a/Sources/SstCore/HotkeyMonitor.swift b/Sources/SstCore/HotkeyMonitor.swift new file mode 100644 index 0000000..3a744dc --- /dev/null +++ b/Sources/SstCore/HotkeyMonitor.swift @@ -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.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 + } + } +} diff --git a/Sources/SstProbe/HotkeyProbe.swift b/Sources/SstProbe/HotkeyProbe.swift new file mode 100644 index 0000000..d052046 --- /dev/null +++ b/Sources/SstProbe/HotkeyProbe.swift @@ -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) +} diff --git a/Sources/SstProbe/main.swift b/Sources/SstProbe/main.swift new file mode 100644 index 0000000..a58363b --- /dev/null +++ b/Sources/SstProbe/main.swift @@ -0,0 +1,9 @@ +import Foundation + +let command = CommandLine.arguments.dropFirst().first ?? "help" +switch command { +case "hotkey": + runHotkeyProbe() +default: + print("usage: sst-probe hotkey") +} diff --git a/Tests/SstCoreTests/FnKeyTrackerTests.swift b/Tests/SstCoreTests/FnKeyTrackerTests.swift new file mode 100644 index 0000000..fdef9eb --- /dev/null +++ b/Tests/SstCoreTests/FnKeyTrackerTests.swift @@ -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) + } +}