From e846e172db524a21e59c433036c7bd5b884aa811 Mon Sep 17 00:00:00 2001 From: Seth Moore Date: Mon, 21 Sep 2026 11:25:04 -0400 Subject: [PATCH] fix(macOS): make Mission Control and Spaces hotkeys work Control+Up, Control+Down, and Control+Left/Right did nothing on a macOS host, so a streaming client could not open Mission Control, show the app's windows, or switch desktops (Spaces), while every other shortcut worked - macOS reports physical arrow, navigation, and function keys with the secondary-Fn flag, and arrow and keypad keys with the numeric-pad flag. The built-in hotkeys are registered as Control+Fn+Arrow and are matched against those flags - The backend posted key events with only the tracked modifier flags, so its Control+Up never matched the Mission Control hotkey - Applications accept an arrow key either way, so cursor movement and Control+letter shortcuts worked and hid the bug The backend now adds the flags a physical key would carry, per event - Arrows get secondary-Fn and numeric-pad, Home/End/Page Up/Page Down/ Forward Delete/Help and F1-F20 get secondary-Fn, keypad keys get numeric-pad - The flags are not stored in the shared modifier state, so they do not leak onto later key or mouse events - Event construction moves out of submit() into create_keyboard_event() so tests can inspect the CGEvent without posting it - Unit tests build the events for a Control+Up, Control+A, and keypad sequence and check the flags and the tracked modifier state; both fail against the previous code Co-Authored-By: Claude Fable 5.1 --- src/platform/macos/macos_backend.cpp | 139 +++++++++++++++--- .../fixtures/macos_backend_test_hooks.hpp | 28 ++++ tests/fixtures/macos_backend_test_hooks.cpp | 37 +++++ tests/unit/test_macos_backend.cpp | 85 +++++++++++ 4 files changed, 269 insertions(+), 20 deletions(-) diff --git a/src/platform/macos/macos_backend.cpp b/src/platform/macos/macos_backend.cpp index c67ca893..f3a7549c 100644 --- a/src/platform/macos/macos_backend.cpp +++ b/src/platform/macos/macos_backend.cpp @@ -284,6 +284,86 @@ namespace lvh::detail { } } + /// Keys a physical keyboard reports with the secondary-Fn flag: arrows, navigation keys, and F1 to F20. + constexpr std::array secondary_fn_keys { + kVK_LeftArrow, + kVK_RightArrow, + kVK_UpArrow, + kVK_DownArrow, + kVK_Home, + kVK_End, + kVK_PageUp, + kVK_PageDown, + kVK_ForwardDelete, + kVK_Help, + kVK_F1, + kVK_F2, + kVK_F3, + kVK_F4, + kVK_F5, + kVK_F6, + kVK_F7, + kVK_F8, + kVK_F9, + kVK_F10, + kVK_F11, + kVK_F12, + kVK_F13, + kVK_F14, + kVK_F15, + kVK_F16, + kVK_F17, + kVK_F18, + kVK_F19, + kVK_F20, + }; + + /// Keys a physical keyboard reports with the numeric-pad flag: arrows and the keypad. + constexpr std::array numeric_pad_keys { + kVK_LeftArrow, + kVK_RightArrow, + kVK_UpArrow, + kVK_DownArrow, + kVK_ANSI_Keypad0, + kVK_ANSI_Keypad1, + kVK_ANSI_Keypad2, + kVK_ANSI_Keypad3, + kVK_ANSI_Keypad4, + kVK_ANSI_Keypad5, + kVK_ANSI_Keypad6, + kVK_ANSI_Keypad7, + kVK_ANSI_Keypad8, + kVK_ANSI_Keypad9, + kVK_ANSI_KeypadDecimal, + kVK_ANSI_KeypadMultiply, + kVK_ANSI_KeypadPlus, + kVK_ANSI_KeypadClear, + kVK_ANSI_KeypadDivide, + kVK_ANSI_KeypadEnter, + kVK_ANSI_KeypadMinus, + kVK_ANSI_KeypadEquals, + }; + + /** + * @brief Resolve the flags macOS attaches to a non-modifier key by itself. + * + * The system hotkey layer matches on these flags, so a synthetic Control+Up posted without + * them never reaches Mission Control even though the same event reaches ordinary applications. + * + * @param key macOS virtual key code. + * @return Flags to add to the posted event for this key only. + */ + inline CGEventFlags implicit_key_flags(CGKeyCode key) { + CGEventFlags flags {}; + if (std::ranges::find(secondary_fn_keys, key) != secondary_fn_keys.end()) { + flags |= kCGEventFlagMaskSecondaryFn; + } + if (std::ranges::find(numeric_pad_keys, key) != numeric_pad_keys.end()) { + flags |= kCGEventFlagMaskNumericPad; + } + return flags; + } + /** * @brief Convert a scroll-wheel slider value to logical lines per detent. * @@ -442,6 +522,43 @@ namespace lvh::detail { std::mutex keyboard_mutex; ///< Guards shared keyboard modifier state. }; + /** + * @brief Build the CoreGraphics event for one key transition and update the shared modifier state. + * + * The caller must hold `state.keyboard_mutex`. + * + * @param state Shared backend state whose modifier flags are read and updated. + * @param key macOS virtual key code. + * @param pressed Whether the key is going down. + * @return Event ready to post, or `nullptr` when CoreGraphics cannot create one. The caller releases it. + */ + inline CGEventRef create_keyboard_event(MacosInputState &state, CGKeyCode key, bool pressed) { + const auto keyboard_event = CGEventCreateKeyboardEvent(state.keyboard_source, key, pressed); + if (!keyboard_event) { + return nullptr; + } + + CGEventSetIntegerValueField(keyboard_event, kCGKeyboardEventKeycode, key); + + ModifierFlags modifier_flags; + if (modifier_flags_for_key(key, modifier_flags)) { + if (pressed) { + state.keyboard_flags |= modifier_flags.generic | modifier_flags.device; + } else { + state.keyboard_flags &= ~modifier_flags.device; + if ((state.keyboard_flags & modifier_flags.all_devices) == 0) { + state.keyboard_flags &= ~modifier_flags.generic; + } + } + CGEventSetType(keyboard_event, kCGEventFlagsChanged); + } else { + CGEventSetType(keyboard_event, pressed ? kCGEventKeyDown : kCGEventKeyUp); + } + + CGEventSetFlags(keyboard_event, state.keyboard_flags | implicit_key_flags(key)); + return keyboard_event; + } + /** * @brief Backend keyboard backed by CoreGraphics keyboard events. */ @@ -470,30 +587,12 @@ namespace lvh::detail { return OperationStatus::failure(backend_failure, "macOS keyboard event source is unavailable"); } - const auto keyboard_event = CGEventCreateKeyboardEvent(state_->keyboard_source, *key, event.pressed); + std::lock_guard lock {state_->keyboard_mutex}; + const auto keyboard_event = create_keyboard_event(*state_, *key, event.pressed); if (!keyboard_event) { return OperationStatus::failure(backend_failure, "create macOS keyboard event"); } - std::lock_guard lock {state_->keyboard_mutex}; - CGEventSetIntegerValueField(keyboard_event, kCGKeyboardEventKeycode, *key); - - ModifierFlags modifier_flags; - if (modifier_flags_for_key(*key, modifier_flags)) { - if (event.pressed) { - state_->keyboard_flags |= modifier_flags.generic | modifier_flags.device; - } else { - state_->keyboard_flags &= ~modifier_flags.device; - if ((state_->keyboard_flags & modifier_flags.all_devices) == 0) { - state_->keyboard_flags &= ~modifier_flags.generic; - } - } - CGEventSetType(keyboard_event, kCGEventFlagsChanged); - } else { - CGEventSetType(keyboard_event, event.pressed ? kCGEventKeyDown : kCGEventKeyUp); - } - - CGEventSetFlags(keyboard_event, state_->keyboard_flags); CGEventPost(kCGSessionEventTap, keyboard_event); CFRelease(keyboard_event); return OperationStatus::success(); diff --git a/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp index a67966be..528bc54b 100644 --- a/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp @@ -7,6 +7,8 @@ // standard includes #include #include +#include +#include // lib includes #include @@ -29,6 +31,16 @@ namespace lvh::detail::test { std::uint32_t event_type {}; ///< CoreGraphics mouse event type value. }; + /** + * @brief Portable view of one CoreGraphics keyboard event the macOS backend built. + */ + struct MacosKeyEventResult { + std::uint32_t event_type {}; ///< CoreGraphics event type value. + std::int64_t key_code {}; ///< macOS virtual key code stored on the event. + std::uint64_t flags {}; ///< Flags stored on the event. + std::uint64_t tracked_flags {}; ///< Shared modifier state after the event was built. + }; + /** * @brief Result set for macOS backend lifecycle utility coverage. */ @@ -68,6 +80,22 @@ namespace lvh::detail::test { */ bool macos_backend_is_modifier_key(KeyboardKeyCode key_code); + /** + * @brief Resolve the flags the macOS backend adds to a key event for the key itself. + * + * @param key_code Portable key code. + * @return CoreGraphics event flags, or zero when the key is unmapped or carries none. + */ + std::uint64_t macos_backend_implicit_key_flags(KeyboardKeyCode key_code); + + /** + * @brief Build, without posting, the events the macOS backend would send for a key sequence. + * + * @param transitions Portable key codes paired with `true` for press and `false` for release. + * @return One result per transition whose key code the backend maps. + */ + std::vector macos_backend_key_events(const std::vector> &transitions); + /** * @brief Convert a macOS scroll-wheel scaling value to lines per detent. * diff --git a/tests/fixtures/macos_backend_test_hooks.cpp b/tests/fixtures/macos_backend_test_hooks.cpp index 709e3460..97f04953 100644 --- a/tests/fixtures/macos_backend_test_hooks.cpp +++ b/tests/fixtures/macos_backend_test_hooks.cpp @@ -31,6 +31,43 @@ namespace lvh::detail::test { return macos::modifier_flags_for_key(*mapped, flags); } + std::uint64_t macos_backend_implicit_key_flags(KeyboardKeyCode key_code) { + const auto mapped = macos::macos_key_code(key_code); + if (!mapped) { + return 0; + } + + return static_cast(macos::implicit_key_flags(*mapped)); + } + + std::vector macos_backend_key_events(const std::vector> &transitions) { + macos::MacosInputState state; + std::lock_guard lock {state.keyboard_mutex}; + + std::vector results; + for (const auto &[key_code, pressed] : transitions) { + const auto mapped = macos::macos_key_code(key_code); + if (!mapped) { + continue; + } + + const auto event = macos::create_keyboard_event(state, *mapped, pressed); + if (!event) { + continue; + } + + results.push_back({ + .event_type = static_cast(CGEventGetType(event)), + .key_code = CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode), + .flags = static_cast(CGEventGetFlags(event)), + .tracked_flags = static_cast(state.keyboard_flags), + }); + CFRelease(event); + } + + return results; + } + int macos_backend_scroll_lines_per_detent(double scale) { return macos::scroll_lines_per_detent(scale); } diff --git a/tests/unit/test_macos_backend.cpp b/tests/unit/test_macos_backend.cpp index 19a37acc..4730535d 100644 --- a/tests/unit/test_macos_backend.cpp +++ b/tests/unit/test_macos_backend.cpp @@ -63,6 +63,91 @@ TEST_F(MacosBackendTest, IdentifiesModifierKeys) { EXPECT_FALSE(lvh::detail::test::macos_backend_is_modifier_key(0x13)); } +TEST_F(MacosBackendTest, AddsImplicitFlagsForFunctionAndKeypadKeys) { + using lvh::detail::test::macos_backend_implicit_key_flags; + constexpr std::uint64_t fn = kCGEventFlagMaskSecondaryFn; + constexpr std::uint64_t numeric_pad = kCGEventFlagMaskNumericPad; + + EXPECT_EQ(macos_backend_implicit_key_flags(0x25), fn | numeric_pad); // VKEY_LEFT + EXPECT_EQ(macos_backend_implicit_key_flags(0x26), fn | numeric_pad); // VKEY_UP + EXPECT_EQ(macos_backend_implicit_key_flags(0x27), fn | numeric_pad); // VKEY_RIGHT + EXPECT_EQ(macos_backend_implicit_key_flags(0x28), fn | numeric_pad); // VKEY_DOWN + EXPECT_EQ(macos_backend_implicit_key_flags(0x21), fn); // VKEY_PRIOR + EXPECT_EQ(macos_backend_implicit_key_flags(0x22), fn); // VKEY_NEXT + EXPECT_EQ(macos_backend_implicit_key_flags(0x23), fn); // VKEY_END + EXPECT_EQ(macos_backend_implicit_key_flags(0x24), fn); // VKEY_HOME + EXPECT_EQ(macos_backend_implicit_key_flags(0x2D), fn); // VKEY_INSERT + EXPECT_EQ(macos_backend_implicit_key_flags(0x2E), fn); // VKEY_DELETE + EXPECT_EQ(macos_backend_implicit_key_flags(0x70), fn); // VKEY_F1 + EXPECT_EQ(macos_backend_implicit_key_flags(0x83), fn); // VKEY_F20 + EXPECT_EQ(macos_backend_implicit_key_flags(0x60), numeric_pad); // VKEY_NUMPAD0 + EXPECT_EQ(macos_backend_implicit_key_flags(0x6B), numeric_pad); // VKEY_ADD + EXPECT_EQ(macos_backend_implicit_key_flags(0x6E), numeric_pad); // VKEY_DECIMAL + EXPECT_EQ(macos_backend_implicit_key_flags(0x0D), 0U); // VKEY_RETURN + EXPECT_EQ(macos_backend_implicit_key_flags(0x41), 0U); // VKEY_A + EXPECT_EQ(macos_backend_implicit_key_flags(0xA2), 0U); // VKEY_LCONTROL + EXPECT_EQ(macos_backend_implicit_key_flags(0x13), 0U); // unmapped VKEY_PAUSE + EXPECT_EQ(macos_backend_implicit_key_flags(0xFFFF), 0U); +} + +TEST_F(MacosBackendTest, BuildsControlArrowEventsWithFnFlags) { + using lvh::detail::test::macos_backend_key_events; + constexpr std::uint64_t control = kCGEventFlagMaskControl; + constexpr std::uint64_t fn = kCGEventFlagMaskSecondaryFn; + constexpr std::uint64_t numeric_pad = kCGEventFlagMaskNumericPad; + constexpr std::uint64_t checked = kCGEventFlagMaskShift | kCGEventFlagMaskControl | kCGEventFlagMaskAlternate | + kCGEventFlagMaskCommand | fn | numeric_pad; + + // Control+Up as a client sends it, then Control+A for contrast. + const auto events = macos_backend_key_events({ + {0xA2, true}, // VKEY_LCONTROL down + {0x26, true}, // VKEY_UP down + {0x26, false}, // VKEY_UP up + {0x41, true}, // VKEY_A down + {0x41, false}, // VKEY_A up + {0xA2, false}, // VKEY_LCONTROL up + }); + ASSERT_EQ(events.size(), 6U); + + EXPECT_EQ(events[0].event_type, kCGEventFlagsChanged); + EXPECT_EQ(events[0].flags & checked, control); + + // The arrow event carries the flags a physical arrow key reports, on top of the held Control. + EXPECT_EQ(events[1].event_type, kCGEventKeyDown); + EXPECT_EQ(events[1].key_code, kVK_UpArrow); + EXPECT_EQ(events[1].flags & checked, control | fn | numeric_pad); + EXPECT_EQ(events[2].event_type, kCGEventKeyUp); + EXPECT_EQ(events[2].flags & checked, control | fn | numeric_pad); + + // The per-key flags never enter the shared modifier state, so Control+A is unchanged. + EXPECT_EQ(events[1].tracked_flags & checked, control); + EXPECT_EQ(events[2].tracked_flags & checked, control); + EXPECT_EQ(events[3].event_type, kCGEventKeyDown); + EXPECT_EQ(events[3].key_code, kVK_ANSI_A); + EXPECT_EQ(events[3].flags & checked, control); + EXPECT_EQ(events[4].flags & checked, control); + + EXPECT_EQ(events[5].event_type, kCGEventFlagsChanged); + EXPECT_EQ(events[5].flags & checked, 0U); + EXPECT_EQ(events[5].tracked_flags & checked, 0U); +} + +TEST_F(MacosBackendTest, BuildsKeypadEventsWithNumericPadFlag) { + using lvh::detail::test::macos_backend_key_events; + constexpr std::uint64_t fn = kCGEventFlagMaskSecondaryFn; + constexpr std::uint64_t numeric_pad = kCGEventFlagMaskNumericPad; + + const auto events = macos_backend_key_events({ + {0x67, true}, // VKEY_NUMPAD7 down + {0x0D, true}, // VKEY_RETURN down + }); + ASSERT_EQ(events.size(), 2U); + EXPECT_EQ(events[0].key_code, kVK_ANSI_Keypad7); + EXPECT_EQ(events[0].flags & (fn | numeric_pad), numeric_pad); + EXPECT_EQ(events[1].key_code, kVK_Return); + EXPECT_EQ(events[1].flags & (fn | numeric_pad), 0U); +} + TEST_F(MacosBackendTest, ConvertsScrollSettings) { EXPECT_EQ(lvh::detail::test::macos_backend_scroll_lines_per_detent(0.0), 1); EXPECT_EQ(lvh::detail::test::macos_backend_scroll_lines_per_detent(0.3125), 5);