fix: command+tab would sometimes not bind correctly (closes #5653)

This commit is contained in:
lwouis committed 2026-05-28 14:00:57 +02:00
1 parent 60cca89bb8
commit c75aac585a
7 files changed
+238 -17

No files matched your search

+16
View File
@@ -53,6 +53,11 @@
5E0DE2A0000000000000D102 /* WindowOrderResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E0DE2A0000000000000D101 /* WindowOrderResolverTests.swift */; };
5E2A571E000000000000A002 /* WindowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2A571E000000000000A001 /* WindowState.swift */; };
5E2A571E000000000000A003 /* WindowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2A571E000000000000A001 /* WindowState.swift */; };
5FC5980000000000000C0002 /* CGSSymbolicHotKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC5980000000000000C0001 /* CGSSymbolicHotKey.swift */; };
5FC5980000000000000C0003 /* CGSSymbolicHotKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC5980000000000000C0001 /* CGSSymbolicHotKey.swift */; };
5FC5980000000000000A0002 /* NativeHotkeyResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC5980000000000000A0001 /* NativeHotkeyResolver.swift */; };
5FC5980000000000000A0003 /* NativeHotkeyResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC5980000000000000A0001 /* NativeHotkeyResolver.swift */; };
5FC5980000000000000A0102 /* NativeHotkeyResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FC5980000000000000A0101 /* NativeHotkeyResolverTests.swift */; };
5E2A571E000000000000A202 /* ApplicationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2A571E000000000000A201 /* ApplicationState.swift */; };
5E2A571E000000000000A203 /* ApplicationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2A571E000000000000A201 /* ApplicationState.swift */; };
D04BAE2200000000ED004T11 /* SettingsSearchHighlightTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE2200000000ED004T12 /* SettingsSearchHighlightTests.swift */; };
@@ -506,6 +511,9 @@
BF0C8B5D1A9658E64F8ECB56 /* SystemScrollerStyleEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemScrollerStyleEvents.swift; sourceTree = "<group>"; };
BF0C8B841E890CA0D68FFD86 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
BF0C8B94D7994AB9007F6391 /* SkyLight.framework.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SkyLight.framework.swift; sourceTree = "<group>"; };
5FC5980000000000000C0001 /* CGSSymbolicHotKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSSymbolicHotKey.swift; sourceTree = "<group>"; };
5FC5980000000000000A0001 /* NativeHotkeyResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeHotkeyResolver.swift; sourceTree = "<group>"; };
5FC5980000000000000A0101 /* NativeHotkeyResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeHotkeyResolverTests.swift; sourceTree = "<group>"; };
BF0C8BA452332236D972C60E /* ATShortcut.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ATShortcut.swift; sourceTree = "<group>"; };
BF0C8C0FF1B21F5F7CE16B79 /* print_env.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = print_env.sh; sourceTree = "<group>"; };
BF0C8C5B74E5DA2CC31E52F4 /* extract_l10n_strings.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = extract_l10n_strings.sh; sourceTree = "<group>"; };
@@ -829,6 +837,8 @@
D04BAE1F00000000ED001002 /* ShortcutEditor.swift */,
69A2E38AF41E866D3CDE71BF /* OverrideClickResolver.swift */,
4B4409E4B3D98C491503CE5D /* OverrideClickResolverTests.swift */,
5FC5980000000000000A0001 /* NativeHotkeyResolver.swift */,
5FC5980000000000000A0101 /* NativeHotkeyResolverTests.swift */,
5F21C9DF2C6E94240091F72F /* ShortcutsWhenActiveSheet.swift */,
5F21C9E12C6E94410091F72F /* AdditionalControlsSheet.swift */,
);
@@ -1268,6 +1278,7 @@
BF0C83EE9730BCFA41D840A9 /* README.md */,
BF0C8C5C0FF5D567BF190DE5 /* ApplicationServices.HIServices.framework.swift */,
BF0C8B94D7994AB9007F6391 /* SkyLight.framework.swift */,
5FC5980000000000000C0001 /* CGSSymbolicHotKey.swift */,
);
path = "api-wrappers";
sourceTree = "<group>";
@@ -1793,6 +1804,8 @@
A053BAD6681629878E72B1F7 /* ProBadgeViewSegmentTests.swift in Sources */,
DC8CF4F01C999497352736F1 /* OverrideClickResolverTests.swift in Sources */,
DC06402457ECCE6C902FEAE4 /* OverrideClickResolver.swift in Sources */,
5FC5980000000000000A0003 /* NativeHotkeyResolver.swift in Sources */,
5FC5980000000000000A0102 /* NativeHotkeyResolverTests.swift in Sources */,
5E1EC110000000000000C003 /* SelectionResolver.swift in Sources */,
5E1EC110000000000000C102 /* SelectionResolverTests.swift in Sources */,
5E5EA12C0000000000000003 /* SearchModeResolver.swift in Sources */,
@@ -1805,6 +1818,7 @@
5E0DE2A0000000000000D102 /* WindowOrderResolverTests.swift in Sources */,
5E2A571E000000000000A003 /* WindowState.swift in Sources */,
5E2A571E000000000000A203 /* ApplicationState.swift in Sources */,
5FC5980000000000000C0003 /* CGSSymbolicHotKey.swift in Sources */,
D04BAE2200000000ED004T11 /* SettingsSearchHighlightTests.swift in Sources */,
D04BAE2200000000ED004003 /* SettingsSearchHighlight.swift in Sources */,
D04BAE2200000000ED004T01 /* TableGroupView.swift in Sources */,
@@ -1958,6 +1972,7 @@
D04BADA3F2DAB820D6FC6E75 /* AppCenterApplication.m in Sources */,
6E800CF06EE430DCCF9CE47B /* ObjCExceptionCatcher.m in Sources */,
D04BAA16F21AC41A23DDA63D /* ControlsTab.swift in Sources */,
5FC5980000000000000A0002 /* NativeHotkeyResolver.swift in Sources */,
D04BAE1F00000000ED001001 /* ShortcutEditor.swift in Sources */,
DD6771EE8ACE714C2599FEB7 /* OverrideClickResolver.swift in Sources */,
D04BADE4DDF7597749FB13BD /* SettingsWindow.swift in Sources */,
@@ -1996,6 +2011,7 @@
BF0C8A49891371E4027E0641 /* MacroPreferences.swift in Sources */,
BF0C8E366BC25171C0A3881C /* ApplicationServices.HIServices.framework.swift in Sources */,
BF0C803A13C409E7F01FBCAB /* SkyLight.framework.swift in Sources */,
5FC5980000000000000C0002 /* CGSSymbolicHotKey.swift in Sources */,
BF0C80C69EFEF93C18539925 /* PreferencesMigrations.swift in Sources */,
BF0C8A2F6E4C85A7D9B63B12 /* Throttler.swift in Sources */,
AA0C8A100000000000000001 /* AXCallScheduler.swift in Sources */,
@@ -0,0 +1,12 @@
import Foundation
/// Identifiers for the native macOS symbolic hotkeys AltTab cares about. Defined in this small
/// both-targets file (rather than `SkyLight.framework.swift`, which is app-only) so kernels like
/// `NativeHotkeyResolver` that surface this enum can compile in the unit-tests target. See
/// `setNativeCommandTabEnabled` / `CGSSetSymbolicHotKeyEnabled` in `SkyLight.framework.swift` for the
/// runtime side of toggling them.
enum CGSSymbolicHotKey: Int, CaseIterable {
case commandTab = 1
case commandShiftTab = 2
case commandKeyAboveTab = 6 // see keyAboveTabDependingOnInputSource
}
@@ -161,12 +161,6 @@ func CGSGetWindowLevel(_ cid: CGSConnectionID, _ wid: CGWindowID, _ level: Unsaf
@_silgen_name("SLSRequestScreenCaptureAccess") @discardableResult
func SLSRequestScreenCaptureAccess() -> UInt8
enum CGSSymbolicHotKey: Int, CaseIterable {
case commandTab = 1
case commandShiftTab = 2
case commandKeyAboveTab = 6 // see keyAboveTabDependingOnInputSource
}
/// enables/disables a symbolic hotkeys. These are system shortcuts such as command+tab or Spotlight
/// it is possible to find all the existing hotkey IDs by using CGSGetSymbolicHotKeyValue on the first few hundred numbers
/// note: the effect of enabling/disabling persists after the app is quit
@@ -747,19 +747,17 @@ class ControlsTab {
KeyboardEvents.anyShortcutUsesEscape = shortcuts.values.contains { $0.shortcut.carbonKeyCode == kVK_Escape }
}
/// Thin adapter over `NativeHotkeyResolver.resolve` — builds the snapshot inputs from the live
/// shortcut registry and applies the resolver's verdict via the symbolic-hotkey API. See
/// `NativeHotkeyResolverSpecs.md` for the kernel's invariants and #5653's root cause.
static func toggleNativeCommandTabIfNeeded() {
let nativeHotkeys: [CGSSymbolicHotKey: (Shortcut) -> Bool] = [
.commandTab: { shortcut in shortcut.carbonModifierFlags == cmdKey && shortcut.carbonKeyCode == kVK_Tab },
.commandShiftTab: { shortcut in CustomRecorderControlTestable.combinedModifiersMatch(shortcut.carbonModifierFlags, UInt32(cmdKey | shiftKey)) && shortcut.carbonKeyCode == kVK_Tab },
.commandKeyAboveTab: { shortcut in shortcut.carbonModifierFlags == cmdKey && shortcut.carbonKeyCode == kVK_ANSI_Grave },
]
var overlappingHotkeys = shortcuts.values.compactMap { atShortcut in nativeHotkeys.first { $1(atShortcut.shortcut) }?.key }
if overlappingHotkeys.contains(.commandTab) && !overlappingHotkeys.contains(.commandShiftTab) {
overlappingHotkeys.append(.commandShiftTab)
let snapshots = shortcuts.values.map { ShortcutSnapshot(modifiers: $0.shortcut.carbonModifierFlags, keyCode: $0.shortcut.carbonKeyCode) }
let holdShortcutModifiers: [UInt32] = (0..<Preferences.holdShortcut.count).compactMap { i in
shortcuts[Preferences.indexToName("holdShortcut", i)]?.shortcut.carbonModifierFlags
}
let nonOverlappingHotkeys: [CGSSymbolicHotKey] = Array(Set(nativeHotkeys.keys).symmetricDifference(Set(overlappingHotkeys)))
setNativeCommandTabEnabled(false, overlappingHotkeys)
setNativeCommandTabEnabled(true, nonOverlappingHotkeys)
let result = NativeHotkeyResolver.resolve(shortcuts: snapshots, holdShortcutModifiers: holdShortcutModifiers)
setNativeCommandTabEnabled(false, Array(result.disable))
setNativeCommandTabEnabled(true, Array(result.enable))
}
@objc static func shortcutChangedCallback(_ sender: NSControl) {
@@ -0,0 +1,57 @@
import Carbon.HIToolbox.Events
/// Minimal data record for one configured shortcut, in the form the native-hotkey classifier needs.
/// Primitive types so this file compiles in the unit-tests target (same trick as `WindowState`).
struct ShortcutSnapshot: Equatable {
let modifiers: UInt32 // shortcut.carbonModifierFlags
let keyCode: UInt32 // shortcut.carbonKeyCode
}
/// Decides which native macOS symbolic hotkeys (⌘⇥ / ⌘⇧⇥ / ⌘`) AltTab must disable vs (re-)enable
/// to make room for the configured shortcuts. Pure kernel: takes the configured shortcuts plus the
/// set of hold-shortcut modifier flags currently in effect, returns disjoint disable/enable sets.
///
/// Issue #5653: the previous in-place implementation in `ControlsTab.toggleNativeCommandTabIfNeeded`
/// used `nativeHotkeys.first { … }`, which made the classification depend on Swift dictionary
/// iteration order. When a single shortcut matched multiple predicates (e.g. ⌘⇥ matches both
/// `.commandTab` exactly *and* `.commandShiftTab` via `combinedModifiersMatch` when a ⌘⇧
/// hold-shortcut is present), `.first` would pick one and drop the other, intermittently leaving
/// native ⌘⇥ enabled for an entire session. This kernel collects **all** matches per shortcut, so
/// the result is deterministic and independent of map iteration order.
enum NativeHotkeyResolver {
static func resolve(shortcuts: [ShortcutSnapshot], holdShortcutModifiers: [UInt32])
-> (disable: Set<CGSSymbolicHotKey>, enable: Set<CGSSymbolicHotKey>) {
var disable = Set<CGSSymbolicHotKey>()
for s in shortcuts {
if matchesCommandTab(s) { disable.insert(.commandTab) }
if matchesCommandShiftTab(s, holdShortcutModifiers) { disable.insert(.commandShiftTab) }
if matchesCommandKeyAboveTab(s) { disable.insert(.commandKeyAboveTab) }
}
// binding ⌘⇥ should also suppress the native reverse switcher (⌘⇧⇥)
if disable.contains(.commandTab) { disable.insert(.commandShiftTab) }
let enable = Set(CGSSymbolicHotKey.allCases).subtracting(disable)
return (disable, enable)
}
// MARK: - Native-hotkey predicates
private static func matchesCommandTab(_ s: ShortcutSnapshot) -> Bool {
s.modifiers == UInt32(cmdKey) && s.keyCode == UInt32(kVK_Tab)
}
private static func matchesCommandShiftTab(_ s: ShortcutSnapshot, _ holdShortcutModifiers: [UInt32]) -> Bool {
s.keyCode == UInt32(kVK_Tab) && combinedModifiersMatch(s.modifiers, UInt32(cmdKey | shiftKey), holdShortcutModifiers)
}
private static func matchesCommandKeyAboveTab(_ s: ShortcutSnapshot) -> Bool {
s.modifiers == UInt32(cmdKey) && s.keyCode == UInt32(kVK_ANSI_Grave)
}
/// True iff some hold-shortcut modifier set turns `modifiers1` and `modifiers2` into the same
/// effective combo when OR'd in. Mirrors `CustomRecorderControlTestable.combinedModifiersMatch`
/// but takes hold-shortcut modifiers as an explicit parameter rather than reading
/// `ControlsTab.shortcuts` globals — keeps the kernel pure.
private static func combinedModifiersMatch(_ modifiers1: UInt32, _ modifiers2: UInt32, _ holdShortcutModifiers: [UInt32]) -> Bool {
holdShortcutModifiers.contains { (($0 | modifiers1) == ($0 | modifiers2)) }
}
}
@@ -0,0 +1,58 @@
# NativeHotkeyResolver — Specs
## Summary
AltTab triggers its switcher via a Carbon `RegisterEventHotKey`. A native macOS symbolic hotkey
(⌘⇥ / ⌘⇧⇥ / ⌘`), when enabled, is consumed by the Dock/WindowServer **before** any app-level
Carbon hotkey — so binding ⌘⇥ to AltTab requires disabling the corresponding native one.
`NativeHotkeyResolver.resolve` is the pure kernel for that decision: given the configured shortcuts
(as `ShortcutSnapshot` value records) and the modifier flags of the active hold-shortcuts, it
returns disjoint `disable` / `enable` sets over `CGSSymbolicHotKey`. `ControlsTab.toggleNativeCommandTabIfNeeded`
is a thin adapter that builds the inputs from `ControlsTab.shortcuts` and applies the result via
`setNativeCommandTabEnabled`.
The kernel encodes one invariant the previous in-place code got wrong (issue #5653): **a single
shortcut can overlap multiple native predicates and must contribute to all of them.** ⌘⇥ matches
`.commandTab` exactly *and* `.commandShiftTab` via `combinedModifiersMatch` whenever a hold-shortcut
carries shift (the user's second-shortcut scenario). The previous `.first { … }` over a dictionary
of predicates picked one and dropped the others, with the pick varying per process (Swift dictionary
iteration order isn't stable across launches) — leaving native ⌘⇥ enabled for whole sessions.
## Behavior & edge cases
- **Collect, don't pick.** Every native predicate a shortcut matches contributes to `disable`; nothing
is dropped. The `enable` set is the complement over `CGSSymbolicHotKey.allCases`.
- **⌘⇥ pairing.** Disabling `.commandTab` implicitly disables `.commandShiftTab` too, so the native
reverse switcher doesn't fire while AltTab owns ⌘⇥.
- **No globals.** `combinedModifiersMatch` previously read `ControlsTab.shortcuts` to find the hold
modifiers; the kernel takes them as an explicit `holdShortcutModifiers: [UInt32]` parameter, so the
resolver is independent of any global state.
- **Primitive value record.** `ShortcutSnapshot` uses `UInt32` for both modifiers and keycode (no
ShortcutRecorder / `Shortcut` types) so the kernel file compiles in the unit-tests target.
## Test scenarios
Mirrors `NativeHotkeyResolverTests.swift` 1:1.
### A. Issue #5653 — overlapping ⌘⇥ + ⌘⇧⇥ + hold ⌘ + ⌘⇧
- **testCommandTabAndCommandShiftTabBothDisableNativeSwitchers** — the user's stuck-session
config; both `.commandTab` and `.commandShiftTab` must end up disabled, regardless of which
predicate the ⌘⇥ snapshot was visited under first.
- **testResolutionIsDeterministicAcrossRepeatedCalls** — repeated calls on the same inputs always
return the same sets (no dependence on map iteration order).
### B. Single ⌘⇥ — still pairs with ⌘⇧⇥
- **testCommandTabAloneAlsoDisablesReverseSwitcher** — binding ⌘⇥ alone still suppresses native
⌘⇧⇥ via the pairing rule.
### C. ⌘` alone — disables only that hotkey
- **testCommandKeyAboveTabAloneDisablesOnlyThatHotkey** — no cross-talk between Tab and grave-key
predicates; only `.commandKeyAboveTab` is disabled.
### D. Default option config — no native switcher overlap
- **testOptionTabDoesNotOverrideNativeSwitchers** — AltTab's default ⌥⇥ / hold ⌥ doesn't overlap
any native command-tab hotkey, so every native hotkey stays enabled.
### E. Empty config — nothing to override
- **testEmptyConfigReleasesAllNativeHotkeys** — defensive: no shortcuts ⇒ no native hotkey disabled.
@@ -0,0 +1,86 @@
import XCTest
import Carbon.HIToolbox.Events
/// Pins `NativeHotkeyResolver.resolve` against the configurations that historically tripped
/// issue #5653 (intermittent native ⌘⇥ override on launch).
///
/// Groups: A repro case (⌘⇥ + ⌘⇧⇥ with hold ⌘ + ⌘⇧) · B single ⌘⇥ pairing ·
/// C ⌘\` only · D default option · E empty.
final class NativeHotkeyResolverTests: XCTestCase {
private func snap(_ mods: Int, _ key: Int) -> ShortcutSnapshot {
ShortcutSnapshot(modifiers: UInt32(mods), keyCode: UInt32(key))
}
// MARK: - A. Issue #5653 — ⌘⇥ + ⌘⇧⇥ with hold ⌘ + ⌘⇧
/// With both ⌘⇥ and ⌘⇧⇥ bound (and the matching ⌘ + ⌘⇧ hold-shortcuts present), both native
/// switchers must be disabled — even though the ⌘⇥ snapshot matches *two* native predicates
/// (`.commandTab` exact, `.commandShiftTab` via combined hold). The pre-fix `.first { … }`
/// over the predicate dictionary would intermittently drop `.commandTab` here.
func testCommandTabAndCommandShiftTabBothDisableNativeSwitchers() {
let result = NativeHotkeyResolver.resolve(
shortcuts: [snap(cmdKey, kVK_Tab), snap(cmdKey | shiftKey, kVK_Tab)],
holdShortcutModifiers: [UInt32(cmdKey), UInt32(cmdKey | shiftKey)])
XCTAssertEqual(result.disable, [.commandTab, .commandShiftTab])
XCTAssertEqual(result.enable, [.commandKeyAboveTab])
}
/// Within one process the result must be stable across repeated calls on identical inputs.
/// (The original cross-process flakiness — different Swift dictionary hash seeds picking a
/// different `.first` predicate per launch — was the user-visible bug.)
func testResolutionIsDeterministicAcrossRepeatedCalls() {
let shortcuts = [snap(cmdKey, kVK_Tab), snap(cmdKey | shiftKey, kVK_Tab)]
let holds = [UInt32(cmdKey), UInt32(cmdKey | shiftKey)]
let first = NativeHotkeyResolver.resolve(shortcuts: shortcuts, holdShortcutModifiers: holds)
for _ in 0..<50 {
let r = NativeHotkeyResolver.resolve(shortcuts: shortcuts, holdShortcutModifiers: holds)
XCTAssertEqual(r.disable, first.disable)
XCTAssertEqual(r.enable, first.enable)
}
}
// MARK: - B. Single ⌘⇥ — still pairs with ⌘⇧⇥
/// With only ⌘⇥ bound, native ⌘⇧⇥ (reverse switcher) must also be disabled so it doesn't fire
/// when the user presses shift while the switcher is open.
func testCommandTabAloneAlsoDisablesReverseSwitcher() {
let result = NativeHotkeyResolver.resolve(
shortcuts: [snap(cmdKey, kVK_Tab)],
holdShortcutModifiers: [UInt32(cmdKey)])
XCTAssertEqual(result.disable, [.commandTab, .commandShiftTab])
XCTAssertEqual(result.enable, [.commandKeyAboveTab])
}
// MARK: - C. ⌘` alone — disables only that hotkey
/// Binding ⌘\` overrides the native "key above Tab" hotkey but leaves both switcher hotkeys
/// alone. No cross-talk between Tab and grave key predicates.
func testCommandKeyAboveTabAloneDisablesOnlyThatHotkey() {
let result = NativeHotkeyResolver.resolve(
shortcuts: [snap(cmdKey, kVK_ANSI_Grave)],
holdShortcutModifiers: [UInt32(cmdKey)])
XCTAssertEqual(result.disable, [.commandKeyAboveTab])
XCTAssertEqual(result.enable, [.commandTab, .commandShiftTab])
}
// MARK: - D. Default option config — no native switcher overlap
/// AltTab's default trigger (⌥⇥ / hold ⌥) doesn't overlap any native command-tab hotkey, so
/// every native hotkey stays enabled — AltTab coexists with the system's native switcher.
func testOptionTabDoesNotOverrideNativeSwitchers() {
let result = NativeHotkeyResolver.resolve(
shortcuts: [snap(optionKey, kVK_Tab)],
holdShortcutModifiers: [UInt32(optionKey)])
XCTAssertEqual(result.disable, [])
XCTAssertEqual(result.enable, [.commandTab, .commandShiftTab, .commandKeyAboveTab])
}
// MARK: - E. No shortcuts at all — nothing to override
/// Defensive: with no shortcuts configured, no native hotkey is disabled.
func testEmptyConfigReleasesAllNativeHotkeys() {
let result = NativeHotkeyResolver.resolve(shortcuts: [], holdShortcutModifiers: [])
XCTAssertEqual(result.disable, [])
XCTAssertEqual(result.enable, [.commandTab, .commandShiftTab, .commandKeyAboveTab])
}
}