From bbef7473eef3a887abb2868481f617d15a6f6053 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 18 Jun 2026 03:12:49 -0400 Subject: [PATCH] Insure that browser keys are not sent when a browser is not focused. --- src/cthulhu/input_event_manager.py | 28 +++++--- ...put_event_manager_x11_focus_regressions.py | 69 ++++++++++++++++--- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index 57b7e9c..b64f420 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -590,6 +590,20 @@ class InputEventManager: return result + def _clear_stale_atspi_context(self, manager: Any) -> None: + """Clears cached input context after X11 positively contradicts AT-SPI.""" + + reason = "active X11 window not found in AT-SPI" + msg = ( + "INPUT EVENT MANAGER: Clearing stale AT-SPI focus context; " + "X11 focus moved to an untracked window." + ) + debug.print_message(debug.LEVEL_INFO, msg, True) + script_manager.get_manager().set_active_script(None, reason) + manager.clear_state(reason) + self._last_input_event = None + self._last_non_modifier_key_event = None + @staticmethod def _get_active_script_app() -> Optional[Atspi.Accessible]: """Returns the app for the active script, if one is available.""" @@ -743,6 +757,11 @@ class InputEventManager: manager.set_active_window(window) else: focus_window = self._get_top_level_window(pendingFocus or manager.get_locus_of_focus()) + staleContext = focus_window or window or self._get_active_script_app() + if self._active_x11_window_differs_from(staleContext): + self._clear_stale_atspi_context(manager) + return False + if focus_window is not None: window = focus_window tokens = [ @@ -755,15 +774,6 @@ class InputEventManager: # One example: Brave's popup menus live in frames which lack the active # state. Failing to revalidate the window on a key press is inconclusive; # do not wipe out the last known window and focus state. - focus = pendingFocus or manager.get_locus_of_focus() - staleContext = window or self._get_active_script_app() - if self._active_x11_window_differs_from(staleContext): - msg = ( - "INPUT EVENT MANAGER: X11 focus moved to an untracked window; " - "preserving Cthulhu key handling." - ) - debug.print_message(debug.LEVEL_INFO, msg, True) - tokens = [ "WARNING:", window, diff --git a/tests/test_input_event_manager_x11_focus_regressions.py b/tests/test_input_event_manager_x11_focus_regressions.py index 75426b3..3b33cb2 100644 --- a/tests/test_input_event_manager_x11_focus_regressions.py +++ b/tests/test_input_event_manager_x11_focus_regressions.py @@ -20,7 +20,7 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase): ): self.assertTrue(manager._active_x11_window_differs_from(cachedWindow)) - def test_keyboard_event_preserves_key_handling_when_unknown_x11_window_is_not_xterm(self): + def test_keyboard_event_clears_stale_context_before_recovering_old_focus_window(self): manager = input_event_manager.InputEventManager() staleWindow = object() staleFocus = object() @@ -30,6 +30,8 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase): scriptManager = mock.Mock() keyboardEvent = mock.Mock() keyboardEvent.is_modifier_key.return_value = False + manager._last_input_event = object() + manager._last_non_modifier_key_event = object() with ( mock.patch.object(input_event_manager.cthulhu_state, "capturingKeys", False), @@ -38,7 +40,8 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase): mock.patch.object(input_event_manager.input_event, "KeyboardEvent", return_value=keyboardEvent), mock.patch.object(input_event_manager.AXUtilities, "can_be_active_window", return_value=False), mock.patch.object(input_event_manager.AXUtilities, "find_active_window", return_value=None), - mock.patch.object(manager, "_get_top_level_window", return_value=None), + mock.patch.object(manager, "_find_active_x11_atspi_window", return_value=None), + mock.patch.object(manager, "_get_top_level_window", return_value=staleWindow), mock.patch.object(manager, "_should_pass_through_for_active_xterm", return_value=False), mock.patch.object(manager, "_active_x11_window_differs_from", return_value=True), mock.patch.object(manager, "last_event_was_keyboard", return_value=False), @@ -53,12 +56,18 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase): "Return", ) - self.assertTrue(result) - scriptManager.set_active_script.assert_not_called() - focusManager.clear_state.assert_not_called() - keyboardEvent.process.assert_called_once_with() + self.assertFalse(result) + scriptManager.set_active_script.assert_called_once_with( + None, + "active X11 window not found in AT-SPI", + ) + focusManager.clear_state.assert_called_once_with("active X11 window not found in AT-SPI") + focusManager.set_active_window.assert_not_called() + keyboardEvent.process.assert_not_called() + self.assertIsNone(manager._last_input_event) + self.assertIsNone(manager._last_non_modifier_key_event) - def test_keyboard_event_uses_active_script_app_when_cached_window_is_missing(self): + def test_keyboard_event_clears_stale_script_when_cached_context_is_missing(self): manager = input_event_manager.InputEventManager() staleApp = object() staleScript = mock.Mock(app=staleApp) @@ -77,6 +86,7 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase): mock.patch.object(input_event_manager.input_event, "KeyboardEvent", return_value=keyboardEvent), mock.patch.object(input_event_manager.AXUtilities, "can_be_active_window", return_value=False), mock.patch.object(input_event_manager.AXUtilities, "find_active_window", return_value=None), + mock.patch.object(manager, "_find_active_x11_atspi_window", return_value=None), mock.patch.object(manager, "_get_top_level_window", return_value=None), mock.patch.object(manager, "_should_pass_through_for_active_xterm", return_value=False), mock.patch.object(manager, "_active_x11_window_differs_from", return_value=True) as differs, @@ -92,8 +102,51 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase): "KP_Page_Up", ) - self.assertTrue(result) + self.assertFalse(result) differs.assert_called_once_with(staleApp) + scriptManager.set_active_script.assert_called_once_with( + None, + "active X11 window not found in AT-SPI", + ) + focusManager.clear_state.assert_called_once_with("active X11 window not found in AT-SPI") + keyboardEvent.process.assert_not_called() + + def test_keyboard_event_recovers_focus_window_when_x11_does_not_contradict_it(self): + manager = input_event_manager.InputEventManager() + cachedWindow = object() + staleFocus = object() + focusManager = mock.Mock() + focusManager.get_active_window.return_value = cachedWindow + focusManager.get_locus_of_focus.return_value = staleFocus + scriptManager = mock.Mock() + keyboardEvent = mock.Mock() + keyboardEvent.is_modifier_key.return_value = False + + with ( + mock.patch.object(input_event_manager.cthulhu_state, "capturingKeys", False), + mock.patch.object(input_event_manager.focus_manager, "get_manager", return_value=focusManager), + mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager), + mock.patch.object(input_event_manager.input_event, "KeyboardEvent", return_value=keyboardEvent), + mock.patch.object(input_event_manager.AXUtilities, "can_be_active_window", return_value=False), + mock.patch.object(input_event_manager.AXUtilities, "find_active_window", return_value=None), + mock.patch.object(manager, "_find_active_x11_atspi_window", return_value=None), + mock.patch.object(manager, "_get_top_level_window", return_value=cachedWindow), + mock.patch.object(manager, "_should_pass_through_for_active_xterm", return_value=False), + mock.patch.object(manager, "_active_x11_window_differs_from", return_value=False), + mock.patch.object(manager, "last_event_was_keyboard", return_value=False), + mock.patch.object(input_event_manager.debug, "print_message"), + ): + result = manager.process_keyboard_event( + mock.Mock(), + True, + 36, + 65293, + 0, + "Return", + ) + + self.assertTrue(result) + focusManager.set_active_window.assert_called_once_with(cachedWindow) scriptManager.set_active_script.assert_not_called() focusManager.clear_state.assert_not_called() keyboardEvent.process.assert_called_once_with()