From 030d9486cd726168d431ae9569876a5be1fbc9e5 Mon Sep 17 00:00:00 2001 From: Benjamin Eisele <163771687+eiseleb47@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:41:28 +0000 Subject: [PATCH] fix(ibus): route keyboard shortcuts to a virtual-keyboard tool (#716) `_inject_keyboard_shortcut` dispatched X11 and WAYLAND_XDOTOOL to xdotool and sent everything else to `_inject_shortcut_with_wayland_tool`, putting both IBus environments on a path that cannot serve them. IBus commits text; it has no way to synthesise a key combination. WAYLAND_IBUS failed worse than it looked. `wayland_tool` is only set when dependency detection picks a Wayland injection tool, so on an IBus session it can be unset, and the attribute access raised inside the enclosing try. The except logs and returns False, so every shortcut reported a clean failure rather than a missing tool. Route X11_IBUS through xdotool, which reaches the same X server the XIM path is already using, and give WAYLAND_IBUS a virtual-keyboard tool, preferring wtype and falling back to ydotool. With neither present the error names the real cause instead of surfacing an AttributeError. X11_IBUS folds into the existing xdotool condition rather than adding a branch, since the call is identical. The `wayland_tool` write is taken under `_state_lock`, matching every other write to it; the injection call stays outside the block, as the lock is not reentrant. Text injection is unchanged; this only affects shortcut delivery, and only in the two IBus environments. Five tests cover the routing: X11_IBUS reaching xdotool, WAYLAND_IBUS preferring wtype, its ydotool fallback, the no-tool-available failure injecting nothing, and an already-chosen tool being left alone. --- src/vocalinux/text_injection/text_injector.py | 46 +++++++++- tests/test_text_injector_ext.py | 89 +++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index af12b88..a8bc71b 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -483,6 +483,39 @@ class TextInjector: ) return "auto" + def _resolve_wayland_key_tool(self) -> Optional[str]: + """Pick (or reuse) the Wayland virtual-keyboard tool for key events. + + Mirrors the preference ``_check_dependencies`` already applies: prefer + ydotool via uinput once its daemon is (or can be made) ready, and only + fall back to wtype. Callers that resolve a tool after startup -- IBus + shortcut routing, BackSpace key events -- use this instead of picking + their own order. + + Returns: + The tool name, or None if neither is installed. + """ + if getattr(self, "wayland_tool", None): + return self.wayland_tool + + ydotool_available = shutil.which("ydotool") is not None + wtype_available = shutil.which("wtype") is not None + + # _ensure_ydotoold can block for ~2s, so it stays outside the lock. + if ydotool_available and self._ensure_ydotoold(): + tool = "ydotool" + elif ydotool_available and not wtype_available: + tool = "ydotool" + elif wtype_available: + tool = "wtype" + else: + return None + + with self._state_lock: + self.wayland_tool = tool + logger.info(f"Using {tool} for Wayland key events") + return tool + def _check_dependencies(self): """Check for the required tools for text injection.""" ibus_requested = False @@ -1685,10 +1718,19 @@ class TextInjector: if ( self.environment == DesktopEnvironment.X11 or self.environment == DesktopEnvironment.WAYLAND_XDOTOOL + # IBus commits text; it cannot synthesise key combinations. Send + # the shortcut through XIM's underlying X server instead. + or self.environment == DesktopEnvironment.X11_IBUS ): return self._inject_shortcut_with_xdotool(shortcut) - else: - return self._inject_shortcut_with_wayland_tool(shortcut) + if self.environment == DesktopEnvironment.WAYLAND_IBUS: + # Same on Wayland: fall through to a virtual-keyboard tool. Without + # this, self.wayland_tool may be unset -> AttributeError -> the + # except below swallows it and every shortcut silently returns False. + if self._resolve_wayland_key_tool() is None: + logger.error("No virtual-keyboard tool available for shortcuts") + return False + return self._inject_shortcut_with_wayland_tool(shortcut) except Exception as e: logger.error(f"Failed to inject keyboard shortcut '{shortcut}': {e}") return False diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py index 1e4068d..085644e 100644 --- a/tests/test_text_injector_ext.py +++ b/tests/test_text_injector_ext.py @@ -842,6 +842,95 @@ class TestParseShortcut(unittest.TestCase): TextInjector._parse_shortcut("") +class TestInjectKeyboardShortcutRouting(unittest.TestCase): + """IBus cannot synthesise key combinations, so shortcuts must be routed + to a tool that can.""" + + @staticmethod + def _env(name): + from vocalinux.text_injection.text_injector import DesktopEnvironment + + return getattr(DesktopEnvironment, name) + + @staticmethod + def _stub_backends(obj): + """Stub both backends -- only for tests asserting *which* one is chosen. + + The two tests that assert IBus shortcuts actually reach a tool call the + real Wayland helper instead, so a regression there cannot pass CI. + """ + obj._inject_shortcut_with_xdotool = MagicMock(return_value=True) + obj._inject_shortcut_with_wayland_tool = MagicMock(return_value=True) + + def test_x11_ibus_routes_to_xdotool(self): + obj = _make_injector(self._env("X11_IBUS")) + self._stub_backends(obj) + + self.assertTrue(obj._inject_keyboard_shortcut("ctrl+z")) + obj._inject_shortcut_with_xdotool.assert_called_once_with("ctrl+z") + obj._inject_shortcut_with_wayland_tool.assert_not_called() + + def test_wayland_ibus_prefers_ydotool(self): + """Reaches the real Wayland helper: a stub would pass while it's broken.""" + obj = _make_injector(self._env("WAYLAND_IBUS")) + obj._inject_shortcut_with_xdotool = MagicMock(return_value=True) + obj._ydotool_legacy_named_keys = False + obj._ensure_ydotoold = MagicMock(return_value=True) + obj._wait_for_modifiers_released = MagicMock() + + with patch("shutil.which", return_value="/usr/bin/x") as which: + with patch("subprocess.run") as mock_run: + self.assertTrue(obj._inject_keyboard_shortcut("ctrl+a")) + + # _check_dependencies prefers the uinput helper once its daemon is ready. + self.assertTrue(which.called) + self.assertEqual(obj.wayland_tool, "ydotool") + self.assertEqual( + mock_run.call_args[0][0], + ["ydotool", "key", "29:1", "30:1", "30:0", "29:0"], + ) + obj._inject_shortcut_with_xdotool.assert_not_called() + + def test_wayland_ibus_falls_back_to_wtype(self): + """Reaches the real Wayland helper and asserts the argv it emits.""" + obj = _make_injector(self._env("WAYLAND_IBUS")) + obj._inject_shortcut_with_xdotool = MagicMock(return_value=True) + obj._wait_for_modifiers_released = MagicMock() + which = lambda c: "/usr/bin/wtype" if c == "wtype" else None # noqa: E731 + + with patch("shutil.which", side_effect=which): + with patch("subprocess.run") as mock_run: + self.assertTrue(obj._inject_keyboard_shortcut("ctrl+a")) + + self.assertEqual(obj.wayland_tool, "wtype") + self.assertEqual( + mock_run.call_args[0][0], + ["wtype", "-M", "ctrl", "-k", "a", "-m", "ctrl"], + ) + obj._inject_shortcut_with_xdotool.assert_not_called() + + def test_wayland_ibus_without_any_tool_fails_without_injecting(self): + obj = _make_injector(self._env("WAYLAND_IBUS")) + self._stub_backends(obj) + + with patch("shutil.which", return_value=None): + self.assertFalse(obj._inject_keyboard_shortcut("ctrl+a")) + + obj._inject_shortcut_with_wayland_tool.assert_not_called() + obj._inject_shortcut_with_xdotool.assert_not_called() + + def test_wayland_ibus_keeps_an_already_chosen_tool(self): + obj = _make_injector(self._env("WAYLAND_IBUS")) + obj.wayland_tool = "ydotool" + self._stub_backends(obj) + + with patch("shutil.which", return_value="/usr/bin/wtype") as which: + self.assertTrue(obj._inject_keyboard_shortcut("ctrl+a")) + + which.assert_not_called() + self.assertEqual(obj.wayland_tool, "ydotool") + + class TestCopyToClipboard(unittest.TestCase): def test_copy_success(self): from vocalinux.text_injection.text_injector import DesktopEnvironment