Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Input Source Pro/Utilities/InputSource/InputSourceSwitcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -187,6 +196,7 @@ enum InputSourceSwitcher {
}

private static func cancelPendingWorkItems() {
syntheticEventEndTime = 0
guard !pendingWorkItems.isEmpty else { return }
pendingWorkItems.forEach { $0.cancel() }
pendingWorkItems.removeAll()
Expand Down
28 changes: 27 additions & 1 deletion Input Source Pro/Utilities/ShortcutTrigger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ShortcutTriggerManager>.fromOpaque(refcon)
.takeUnretainedValue()
manager.handleKeyEvent(type: type, event: event)
Expand Down Expand Up @@ -507,14 +515,20 @@ 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()
}
}

private func updateComboState(pressedKeys: Set<SingleModifierKey>, timestamp: TimeInterval) {
let inSyntheticWindow = ProcessInfo.processInfo.systemUptime < InputSourceSwitcher.syntheticEventEndTime
var didInvalidate = false

for combo in currentCombos {
Expand All @@ -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)
Expand Down Expand Up @@ -588,6 +609,11 @@ final class ShortcutTriggerManager {
excluding keys: Set<SingleModifierKey>
) -> 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)
Expand Down