From 98b13d1069b66d14ee3e967b5f2628146f8916be Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 27 Feb 2026 15:22:54 -0300 Subject: [PATCH] Fix X11 key injection modifier release handling --- src/desktop_x11.py | 16 ++++++++++----- tests/test_desktop_x11.py | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/desktop_x11.py b/src/desktop_x11.py index 2fd2969..1699533 100644 --- a/src/desktop_x11.py +++ b/src/desktop_x11.py @@ -437,11 +437,17 @@ class X11Adapter: if keycode == 0: continue keycodes.append(keycode) - for code in keycodes: - xtest.fake_input(dpy, X.KeyPress, code) - for code in reversed(keycodes): - xtest.fake_input(dpy, X.KeyRelease, code) - dpy.flush() + pressed: list[int] = [] + try: + for code in keycodes: + xtest.fake_input(dpy, X.KeyPress, code) + pressed.append(code) + finally: + for code in reversed(pressed): + xtest.fake_input(dpy, X.KeyRelease, code) + # Ensure all synthetic key events are processed before the display + # may be closed by callers. + dpy.sync() def _keysym_for_char(self, ch: str) -> tuple[int | None, bool]: if ch.isupper(): diff --git a/tests/test_desktop_x11.py b/tests/test_desktop_x11.py index 7bf11c0..0bc4b1f 100644 --- a/tests/test_desktop_x11.py +++ b/tests/test_desktop_x11.py @@ -65,6 +65,49 @@ class DesktopX11Tests(unittest.TestCase): display_obj.close.assert_called_once_with() + @patch("desktop_x11.xtest.fake_input") + def test_send_combo_releases_keys_and_syncs(self, fake_input): + adapter = self._adapter() + dpy = MagicMock() + dpy.keysym_to_keycode.side_effect = [10, 20, 30] + + adapter._send_combo(dpy, ["Control_L", "Shift_L", "v"]) + + expected_calls = [ + ((dpy, 2, 10),), + ((dpy, 2, 20),), + ((dpy, 2, 30),), + ((dpy, 3, 30),), + ((dpy, 3, 20),), + ((dpy, 3, 10),), + ] + self.assertEqual(fake_input.call_args_list, expected_calls) + dpy.sync.assert_called_once_with() + + @patch("desktop_x11.xtest.fake_input") + def test_send_combo_releases_pressed_keys_when_press_fails(self, fake_input): + adapter = self._adapter() + dpy = MagicMock() + dpy.keysym_to_keycode.side_effect = [10, 20] + + def _fake_input(_dpy, event_type, code): + if event_type == 2 and code == 20: + raise RuntimeError("press failed") + return None + + fake_input.side_effect = _fake_input + + with self.assertRaisesRegex(RuntimeError, "press failed"): + adapter._send_combo(dpy, ["Control_L", "v"]) + + expected_calls = [ + ((dpy, 2, 10),), + ((dpy, 2, 20),), + ((dpy, 3, 10),), + ] + self.assertEqual(fake_input.call_args_list, expected_calls) + dpy.sync.assert_called_once_with() + if __name__ == "__main__": unittest.main()