From d793eabc1ba6d8d333a1f6ee63e721ce9e9eb242 Mon Sep 17 00:00:00 2001 From: Sergey Nosenko Date: Mon, 30 Mar 2026 19:53:44 +0300 Subject: [PATCH] fix: prevent modifier shortcut freeze during rapid CJKV switching Synthetic CGEvents posted by InputSourceSwitcher during CJKV fix (Cmd key bounce) were feeding back into ShortcutTriggerManager, corrupting pressedModifiers state and polluting lastKeyDownTimestamps. This caused subsequent modifier shortcut triggers to be permanently suppressed when rapidly alternating between left/right modifier keys. Three fixes: - Filter own-process events in CGEvent tap via PID check - During CJKV synthetic window: process flagsChanged normally to keep pressedModifiers in sync, but suppress trigger firing and combo invalidation from phantom modifiers - Evict stale lastKeyDownTimestamps entries to prevent unbounded growth --- .../InputSource/InputSourceSwitcher.swift | 10 +++++++ .../Utilities/ShortcutTrigger.swift | 28 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Input Source Pro/Utilities/InputSource/InputSourceSwitcher.swift b/Input Source Pro/Utilities/InputSource/InputSourceSwitcher.swift index 13dfca2..b92d87e 100644 --- a/Input Source Pro/Utilities/InputSource/InputSourceSwitcher.swift +++ b/Input Source Pro/Utilities/InputSource/InputSourceSwitcher.swift @@ -27,6 +27,11 @@ enum InputSourceSwitcher { private static let logger = ISPLogger(category: String(describing: InputSourceSwitcher.self)) private static var pendingWorkItems: [DispatchWorkItem] = [] + /// End time for the synthetic event suppression window. + /// ShortcutTriggerManager checks this to ignore flagsChanged events generated + /// by synthetic keyboard events during CJKV input source fix. + static var syntheticEventEndTime: TimeInterval = 0 + static func discoverInputSources() -> [Descriptor] { return inputSourceList().map { source in Descriptor( @@ -117,6 +122,10 @@ enum InputSourceSwitcher { let nonCJKVSource = resolveNonCJKVSource(), canPostShortcuts() { + // Suppress modifier event processing in ShortcutTriggerManager for the duration + // of the CJKV fix sequence (~300ms) to prevent synthetic keyboard events from + // corrupting modifier tracking state and blocking subsequent shortcut triggers. + syntheticEventEndTime = ProcessInfo.processInfo.systemUptime + 0.35 logger.debug { "Applying CJKV fix using previous input source shortcut" } selectInputSource(tisTarget, reason: "CJKV target") selectInputSource(nonCJKVSource, reason: "CJKV bounce") @@ -187,6 +196,7 @@ enum InputSourceSwitcher { } private static func cancelPendingWorkItems() { + syntheticEventEndTime = 0 guard !pendingWorkItems.isEmpty else { return } pendingWorkItems.forEach { $0.cancel() } pendingWorkItems.removeAll() diff --git a/Input Source Pro/Utilities/ShortcutTrigger.swift b/Input Source Pro/Utilities/ShortcutTrigger.swift index ec87172..f9634f3 100644 --- a/Input Source Pro/Utilities/ShortcutTrigger.swift +++ b/Input Source Pro/Utilities/ShortcutTrigger.swift @@ -407,6 +407,14 @@ final class ShortcutTriggerManager { return Unmanaged.passUnretained(event) } + // Skip synthetic events posted by our own process (e.g., keyboard events + // from InputSourceSwitcher during CJKV fix). Without this filter, those + // events pollute lastKeyDownTimestamps and invalidate active modifier combos. + let eventPID = event.getIntegerValueField(.eventSourceUnixProcessID) + if eventPID == Int64(ProcessInfo.processInfo.processIdentifier) { + return Unmanaged.passUnretained(event) + } + let manager = Unmanaged.fromOpaque(refcon) .takeUnretainedValue() manager.handleKeyEvent(type: type, event: event) @@ -507,7 +515,12 @@ final class ShortcutTriggerManager { return } - triggerCompletedCombos(at: event.timestamp) + // During CJKV fix synthetic event window: keep pressedModifiers in sync + // but suppress triggers to prevent phantom switches from synthetic Cmd events. + let inSyntheticWindow = ProcessInfo.processInfo.systemUptime < InputSourceSwitcher.syntheticEventEndTime + if !inSyntheticWindow { + triggerCompletedCombos(at: event.timestamp) + } comboInvalidated.removeAll() comboCompleted.removeAll() comboPressTimestamps.removeAll() @@ -515,6 +528,7 @@ final class ShortcutTriggerManager { } private func updateComboState(pressedKeys: Set, timestamp: TimeInterval) { + let inSyntheticWindow = ProcessInfo.processInfo.systemUptime < InputSourceSwitcher.syntheticEventEndTime var didInvalidate = false for combo in currentCombos { @@ -530,6 +544,13 @@ final class ShortcutTriggerManager { } if !pressedKeys.isSubset(of: combo.keys) { + if inSyntheticWindow { + // During CJKV fix: don't invalidate combos for extra modifiers + // caused by synthetic Cmd key events from InputSourceSwitcher. + // The synthetic modifier will be released shortly; normal combo + // tracking resumes once pressedKeys returns to a valid subset. + continue + } comboInvalidated.insert(combo) comboCompleted.remove(combo) comboPressTimestamps.removeValue(forKey: combo) @@ -588,6 +609,11 @@ final class ShortcutTriggerManager { excluding keys: Set ) -> Bool { let excludedKeyCodes = Set(keys.map(\.keyCode)) + + // Evict entries older than the suppress interval to prevent unbounded growth + let cutoff = timestamp - otherKeyPressSuppressInterval + lastKeyDownTimestamps = lastKeyDownTimestamps.filter { $0.value >= cutoff } + let lastOtherKeyTimestamp = lastKeyDownTimestamps .filter { !excludedKeyCodes.contains($0.key) } .map(\.value)