diff --git a/slab/menubar-swift/Sources/SlabMenubar/NavHoldHint.swift b/slab/menubar-swift/Sources/SlabMenubar/NavHoldHint.swift index 4d079d9f1a..3391ecbea7 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/NavHoldHint.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/NavHoldHint.swift @@ -10,110 +10,6 @@ import CoreGraphics /// The arrows are a *hint*, not a mode — the ⌘⌥-arrow hotkeys (WindowNav) work /// exactly the same whether it is visible or not. -/// Global "bare ⌘⌥ is being held" detector. A `CGEvent.tapCreate` listener -/// for the same reason CtrlDoubleTap is one: `NSEvent` global monitors go -/// silent after codesign rebuilds, while tapCreate fails loudly when the -/// Accessibility grant is broken. -final class NavHoldTap { - private let onHoldStart: () -> Void - private let onHoldEnd: () -> Void - private let onChordKey: () -> Void - private let onPointerDown: () -> Void - private var tap: CFMachPort? - private var source: CFRunLoopSource? - private var holding = false - - init(onHoldStart: @escaping () -> Void, - onHoldEnd: @escaping () -> Void, - onChordKey: @escaping () -> Void, - onPointerDown: @escaping () -> Void) { - self.onHoldStart = onHoldStart - self.onHoldEnd = onHoldEnd - self.onChordKey = onChordKey - self.onPointerDown = onPointerDown - } - - @discardableResult - func start() -> Bool { - guard tap == nil else { return true } - guard ProcessInfo.processInfo.environment["SLAB_DISABLE_EVENT_TAPS"] != "1" else { - NSLog("slab nav hint: event tap disabled for this host") - return false - } - guard AXIsProcessTrusted() else { - NSLog("slab nav hint: Accessibility not trusted; skipping event tap") - return false - } - let mask: CGEventMask = - (1 << CGEventType.flagsChanged.rawValue) | - (1 << CGEventType.keyDown.rawValue) | - (1 << CGEventType.leftMouseDown.rawValue) | - (1 << CGEventType.rightMouseDown.rawValue) | - (1 << CGEventType.otherMouseDown.rawValue) - - let callback: CGEventTapCallBack = { _, type, event, refcon in - guard let refcon = refcon else { return Unmanaged.passUnretained(event) } - let me = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - me.handle(type: type, event: event) - return Unmanaged.passUnretained(event) - } - - guard let port = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .listenOnly, - eventsOfInterest: mask, - callback: callback, - userInfo: Unmanaged.passUnretained(self).toOpaque() - ) else { - NSLog("slab nav hint: ⌘⌥ hold tap creation failed — Accessibility not trusted?") - return false - } - tap = port - let src = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, port, 0) - source = src - CFRunLoopAddSource(CFRunLoopGetMain(), src, .commonModes) - CGEvent.tapEnable(tap: port, enable: true) - return true - } - - func stop() { - if let src = source { CFRunLoopRemoveSource(CFRunLoopGetMain(), src, .commonModes) } - if let port = tap { CGEvent.tapEnable(tap: port, enable: false) } - source = nil - tap = nil - } - - private func handle(type: CGEventType, event: CGEvent) { - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - if let port = tap { CGEvent.tapEnable(tap: port, enable: true) } - return - } - switch type { - case .flagsChanged: - // Bare ⌘⌥ only: ⇧ or ⌃ alongside means some other chord family. - // Fn is deliberately NOT a disqualifier — some keyboards raise it - // alongside arrows, and it must not end the hold mid-navigation. - let flags = event.flags - let bare = flags.contains(.maskCommand) && flags.contains(.maskAlternate) - && !flags.contains(.maskShift) && !flags.contains(.maskControl) - if bare != holding { - holding = bare - let cb = bare ? onHoldStart : onHoldEnd - DispatchQueue.main.async(execute: cb) - } - case .keyDown: - if holding { DispatchQueue.main.async(execute: onChordKey) } - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - // A click while holding ⌘⌥ is some other gesture entirely - // (Finder's ⌘⌥-drag makes aliases) — stand down for this hold. - if holding { DispatchQueue.main.async(execute: onPointerDown) } - default: - break - } - } -} - /// Owns the destination panels and the hold/reveal state machine. Main-thread only — /// every entry point is dispatched there by NavHoldTap, and WindowNav.jump /// already runs on main (Carbon hotkey callbacks do). diff --git a/slab/menubar-swift/Sources/SlabMenubar/NavHoldTap.swift b/slab/menubar-swift/Sources/SlabMenubar/NavHoldTap.swift new file mode 100644 index 0000000000..f9f88bd819 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/NavHoldTap.swift @@ -0,0 +1,162 @@ +import AppKit +import ApplicationServices +import CoreGraphics + +/// Global "bare ⌘⌥ is being held" detector. A `CGEvent.tapCreate` listener +/// for the same reason CtrlDoubleTap is one: `NSEvent` global monitors go +/// silent after codesign rebuilds, while tapCreate fails loudly when the +/// Accessibility grant is broken. +final class NavHoldTap { + private let onHoldStart: () -> Void + private let onHoldEnd: () -> Void + private let onChordKey: () -> Void + private let onPointerDown: () -> Void + private var tap: CFMachPort? + private var source: CFRunLoopSource? + private var holding = false + private var releaseTimer: Timer? + private var holdStartedAt: TimeInterval = 0 + private var generation = 0 + private var requiresRelease = false + // A remote keyboard can lose both its release event and its modifier state. + // Require a fresh press after this fail-safe, even if macOS still says held. + private static let maximumHold: TimeInterval = 30 + + init(onHoldStart: @escaping () -> Void, + onHoldEnd: @escaping () -> Void, + onChordKey: @escaping () -> Void, + onPointerDown: @escaping () -> Void) { + self.onHoldStart = onHoldStart + self.onHoldEnd = onHoldEnd + self.onChordKey = onChordKey + self.onPointerDown = onPointerDown + } + + @discardableResult + func start() -> Bool { + guard tap == nil else { return true } + guard ProcessInfo.processInfo.environment["SLAB_DISABLE_EVENT_TAPS"] != "1" else { + NSLog("slab nav hint: event tap disabled for this host") + return false + } + guard AXIsProcessTrusted() else { + NSLog("slab nav hint: Accessibility not trusted; skipping event tap") + return false + } + let mask: CGEventMask = + (1 << CGEventType.flagsChanged.rawValue) | + (1 << CGEventType.keyDown.rawValue) | + (1 << CGEventType.leftMouseDown.rawValue) | + (1 << CGEventType.rightMouseDown.rawValue) | + (1 << CGEventType.otherMouseDown.rawValue) + + let callback: CGEventTapCallBack = { _, type, event, refcon in + guard let refcon = refcon else { return Unmanaged.passUnretained(event) } + let me = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + me.handle(type: type, event: event) + return Unmanaged.passUnretained(event) + } + + guard let port = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: mask, + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { + NSLog("slab nav hint: ⌘⌥ hold tap creation failed — Accessibility not trusted?") + return false + } + tap = port + let src = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, port, 0) + source = src + CFRunLoopAddSource(CFRunLoopGetMain(), src, .commonModes) + CGEvent.tapEnable(tap: port, enable: true) + return true + } + + func stop() { + setHolding(false) + requiresRelease = false + if let src = source { CFRunLoopRemoveSource(CFRunLoopGetMain(), src, .commonModes) } + if let port = tap { CGEvent.tapEnable(tap: port, enable: false) } + source = nil + tap = nil + } + + private func handle(type: CGEventType, event: CGEvent) { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + // Releases may have been dropped while the tap was disabled. + cancelHold() + if let port = tap { CGEvent.tapEnable(tap: port, enable: true) } + return + } + switch type { + case .flagsChanged: + // Bare ⌘⌥ only: ⇧ or ⌃ alongside means some other chord family. + // Fn is deliberately NOT a disqualifier — some keyboards raise it + // alongside arrows, and it must not end the hold mid-navigation. + let bare = Self.isBareChord(event.flags) + if !bare { requiresRelease = false } + setHolding(bare && !requiresRelease) + case .keyDown: + if event.getIntegerValueField(.keyboardEventKeycode) == 53 { + cancelHold() // Escape always dismisses; never consume it. + } else if holding { + DispatchQueue.main.async(execute: onChordKey) + } + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + // A click while holding ⌘⌥ is some other gesture entirely + // (Finder's ⌘⌥-drag makes aliases) — stand down for this hold. + if holding { DispatchQueue.main.async(execute: onPointerDown) } + default: + break + } + } + + private static func isBareChord(_ flags: CGEventFlags) -> Bool { + flags.contains(.maskCommand) && flags.contains(.maskAlternate) + && !flags.contains(.maskShift) && !flags.contains(.maskControl) + } + + private func setHolding(_ value: Bool) { + guard value != holding else { return } + holding = value + generation += 1 + let revision = generation + releaseTimer?.invalidate() + releaseTimer = nil + if value { + holdStartedAt = ProcessInfo.processInfo.systemUptime + let timer = Timer(timeInterval: 0.15, repeats: true) { [weak self] _ in + // Session state includes Deskflow's injected keys; HID state + // only sees the keyboard physically attached to this machine. + self?.reconcile(flags: CGEventSource.flagsState(.combinedSessionState), + now: ProcessInfo.processInfo.systemUptime) + } + timer.tolerance = 0.03 + releaseTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + DispatchQueue.main.async { [weak self] in + guard let self, self.generation == revision else { return } + if self.holding { self.onHoldStart() } else { self.onHoldEnd() } + } + } + + private func cancelHold() { + requiresRelease = true + setHolding(false) + } + + private func reconcile(flags: CGEventFlags, now: TimeInterval) { + guard holding else { return } + if !Self.isBareChord(flags) { + setHolding(false) + } else if now - holdStartedAt >= Self.maximumHold { + cancelHold() + } + } + +} diff --git a/slab/menubar-swift/tests/nav-hold-release.sh b/slab/menubar-swift/tests/nav-hold-release.sh new file mode 100644 index 0000000000..9df7f0ce3b --- /dev/null +++ b/slab/menubar-swift/tests/nav-hold-release.sh @@ -0,0 +1,73 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +probeDir=$(mktemp -d /tmp/slab-nav-hold-check.XXXXXX) +trap 'rm -rf "$probeDir"' EXIT +cat "$root/Sources/SlabMenubar/NavHoldTap.swift" > "$probeDir/check.swift" +cat >> "$probeDir/check.swift" <<'SWIFT' +extension NavHoldTap { + static func checkReleaseRecovery() { + var starts = 0 + var ends = 0 + let detector = NavHoldTap(onHoldStart: { starts += 1 }, + onHoldEnd: { ends += 1 }, + onChordKey: {}, onPointerDown: {}) + let event = CGEvent(keyboardEventSource: nil, virtualKey: 53, keyDown: true)! + let chord: CGEventFlags = [.maskCommand, .maskAlternate] + func flags(_ flags: CGEventFlags) { + event.flags = flags + detector.handle(type: .flagsChanged, event: event) + } + func drain() { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) } + + flags(chord) + drain() + assert(starts == 1 && detector.releaseTimer != nil) + detector.reconcile(flags: chord, now: detector.holdStartedAt + 1) + assert(detector.holding) + // No release event arrives: polling session flags still closes it. + detector.reconcile(flags: [], now: detector.holdStartedAt + 1) + drain() + assert(ends == 1 && !detector.holding && detector.releaseTimer == nil) + for interruption in [CGEventType.tapDisabledByTimeout, .tapDisabledByUserInput] { + flags(chord) + drain() + detector.handle(type: interruption, event: event) + drain() + assert(!detector.holding) + flags([]) + } + flags(chord) + drain() + event.setIntegerValueField(.keyboardEventKeycode, value: 53) + detector.handle(type: .keyDown, event: event) + drain() + flags(chord.union(.maskSecondaryFn)) + assert(!detector.holding) // Escape stays dismissed until release. + flags([]) + flags(chord) + detector.reconcile(flags: chord, now: detector.holdStartedAt + 31) + assert(!detector.holding) // Even stuck session flags expire. + flags(chord) + assert(!detector.holding) + flags([]) + // A press canceled before its queued callback must never reveal. + drain() + let previousStarts = starts + flags(chord) + detector.stop() + drain() + assert(starts == previousStarts && detector.releaseTimer == nil) + // Shift disqualifies the hold; Fn alone must not. + flags(chord.union(.maskSecondaryFn)) + assert(detector.holding) + flags(chord.union(.maskShift)) + assert(!detector.holding) + detector.stop() + drain() + print("nav hold release recovery checks passed") + } +} +NavHoldTap.checkReleaseRecovery() +SWIFT +swift "$probeDir/check.swift"