From 6df571c9de9081c639770951022b2491a28f94f7 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Tue, 28 Apr 2026 22:49:20 +0200 Subject: [PATCH] Add a session event tap as a fallback for cmd-cmd chord detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSEvent global/local monitors can miss flag-change events when other apps consume modifiers, when accessibility permission was just granted, or under certain key-remapping setups. Add a CGEventTap on the session in passive .listenOnly mode (alongside the existing NSEvent monitors) so the chord state machine sees flag changes from a second source. The tap dispatches to main async, never consumes events, and re-uses the same handleFlags state machine. The 'fired' flag dedupes when both sources race the same chord. Falls back silently if the tap can't be created (no Accessibility permission yet). Skips the in-overlay key tap from the original PR — the overlay window becomes key and the existing NSResponder path works there. Co-Authored-By: plyght Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/0350b4af.md | 5 ++++ Sources/cmdcmd/CmdChord.swift | 55 ++++++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 .changeset/0350b4af.md diff --git a/.changeset/0350b4af.md b/.changeset/0350b4af.md new file mode 100644 index 0000000..4c4300e --- /dev/null +++ b/.changeset/0350b4af.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +More reliable cmd-cmd chord detection via a session event tap. diff --git a/Sources/cmdcmd/CmdChord.swift b/Sources/cmdcmd/CmdChord.swift index ae5dafc..80f09ad 100644 --- a/Sources/cmdcmd/CmdChord.swift +++ b/Sources/cmdcmd/CmdChord.swift @@ -5,6 +5,8 @@ import Carbon.HIToolbox /// with no other key pressed during the chord. final class CmdChord { private var monitors: [Any] = [] + private var eventTap: CFMachPort? + private var eventTapRunLoopSource: CFRunLoopSource? private var leftDown = false private var rightDown = false private var contaminated = false @@ -29,10 +31,50 @@ final class CmdChord { return e } monitors = [global, local, globalKey, localKey].compactMap { $0 } + installEventTap() } deinit { for m in monitors { NSEvent.removeMonitor(m) } + if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) } + if let eventTapRunLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), eventTapRunLoopSource, .commonModes) + } + } + + private func installEventTap() { + let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) + let callback: CGEventTapCallBack = { _, type, event, userInfo in + guard let userInfo else { return Unmanaged.passUnretained(event) } + let chord = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode)) + let flags = event.flags + DispatchQueue.main.async { + if type == .keyDown { + chord.markContaminated() + } else if type == .flagsChanged { + chord.handleFlags(keyCode: keyCode, flags: flags) + } + } + return Unmanaged.passUnretained(event) + } + let ref = Unmanaged.passUnretained(self).toOpaque() + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: CGEventMask(mask), + callback: callback, + userInfo: ref + ) else { + Log.write("cmd-cmd event tap unavailable") + return + } + eventTap = tap + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + eventTapRunLoopSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) } private func markContaminated() { @@ -40,12 +82,17 @@ final class CmdChord { } private func handleFlags(_ event: NSEvent) { - let cmd = event.modifierFlags.contains(.command) - switch Int(event.keyCode) { + let raw = event.cgEvent?.flags ?? CGEventFlags(rawValue: UInt64(event.modifierFlags.rawValue)) + handleFlags(keyCode: Int(event.keyCode), flags: raw) + } + + private func handleFlags(keyCode: Int, flags: CGEventFlags) { + let raw = flags.rawValue + switch keyCode { case kVK_Command: - leftDown = cmd && event.modifierFlags.rawValue & 0x8 != 0 + leftDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x8 != 0 case kVK_RightCommand: - rightDown = cmd && event.modifierFlags.rawValue & 0x10 != 0 + rightDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x10 != 0 default: return }