diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 4a5e11bc..a5b18bf8 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -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 = ""; }; BF0C8B841E890CA0D68FFD86 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; BF0C8B94D7994AB9007F6391 /* SkyLight.framework.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SkyLight.framework.swift; sourceTree = ""; }; + 5FC5980000000000000C0001 /* CGSSymbolicHotKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSSymbolicHotKey.swift; sourceTree = ""; }; + 5FC5980000000000000A0001 /* NativeHotkeyResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeHotkeyResolver.swift; sourceTree = ""; }; + 5FC5980000000000000A0101 /* NativeHotkeyResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeHotkeyResolverTests.swift; sourceTree = ""; }; BF0C8BA452332236D972C60E /* ATShortcut.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ATShortcut.swift; sourceTree = ""; }; BF0C8C0FF1B21F5F7CE16B79 /* print_env.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = print_env.sh; sourceTree = ""; }; BF0C8C5B74E5DA2CC31E52F4 /* extract_l10n_strings.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = extract_l10n_strings.sh; sourceTree = ""; }; @@ -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 = ""; @@ -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 */, diff --git a/src/macos/api-wrappers/CGSSymbolicHotKey.swift b/src/macos/api-wrappers/CGSSymbolicHotKey.swift new file mode 100644 index 00000000..134ec1ee --- /dev/null +++ b/src/macos/api-wrappers/CGSSymbolicHotKey.swift @@ -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 +} diff --git a/src/macos/api-wrappers/SkyLight.framework.swift b/src/macos/api-wrappers/SkyLight.framework.swift index 8228f5d9..43f07593 100644 --- a/src/macos/api-wrappers/SkyLight.framework.swift +++ b/src/macos/api-wrappers/SkyLight.framework.swift @@ -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 diff --git a/src/preferences/settings-window/tabs/controls/ControlsTab.swift b/src/preferences/settings-window/tabs/controls/ControlsTab.swift index 09de39b0..41b9dca9 100644 --- a/src/preferences/settings-window/tabs/controls/ControlsTab.swift +++ b/src/preferences/settings-window/tabs/controls/ControlsTab.swift @@ -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.. (disable: Set, enable: Set) { + var disable = Set() + 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)) } + } +} diff --git a/src/preferences/settings-window/tabs/controls/NativeHotkeyResolverSpecs.md b/src/preferences/settings-window/tabs/controls/NativeHotkeyResolverSpecs.md new file mode 100644 index 00000000..278ef9ae --- /dev/null +++ b/src/preferences/settings-window/tabs/controls/NativeHotkeyResolverSpecs.md @@ -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. diff --git a/src/preferences/settings-window/tabs/controls/NativeHotkeyResolverTests.swift b/src/preferences/settings-window/tabs/controls/NativeHotkeyResolverTests.swift new file mode 100644 index 00000000..3eead6b6 --- /dev/null +++ b/src/preferences/settings-window/tabs/controls/NativeHotkeyResolverTests.swift @@ -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]) + } +}