fix(text-injection): route XWayland clients without IBus through xdotool (#1003)

* fix(text-injection): route XWayland clients without IBus through xdotool

On KDE Plasma Wayland with the KWin IBus bridge enabled, Vocalinux injects through IBus. An X11 client under XWayland only reaches IBus when it loaded an IBus input method module from GTK_IM_MODULE, QT_IM_MODULE or XMODIFIERS. Without one, the engine commit lands in no input context and still reports OK, so the text was lost with no fallback.

Before each WAYLAND_IBUS injection, check XWayland's active window. A real client whose own environment does not select IBus gets that segment through the WAYLAND_XDOTOOL path, and the backend returns to IBus afterwards so native Wayland apps keep using it.

Fixes #972

* fix(text-injection): honour IBus pins and the client toolkit in the XWayland detour

Keep IBus when the user pinned it, judge a GTK or Qt client only by its own toolkit's IM variable (read from the libraries it maps), and have the clipboard restore after an XWayland paste read the X11 clipboard it wrote even after the backend switched back to IBus.

* fix(text-injection): keep a non-text X11 clipboard on the XWayland paste route

WAYLAND_XDOTOOL pasted through the X11 clipboard even when it held an image or file, which it cannot save and restore. Check the clipboard targets there as the X11 route already does, and type the text instead when they hold no text.
This commit is contained in:
Jatin K Malik
2026-10-21 00:40:22 -07:00
committed by GitHub
parent 4d78246174
commit f013b7ff88
5 changed files with 501 additions and 17 deletions
+152
View File
@@ -2,10 +2,14 @@
import json
import unittest
from typing import Optional
from unittest.mock import MagicMock, mock_open, patch
from vocalinux.text_injection.focused_window import (
FocusedWindow,
_process_toolkits,
environ_uses_ibus_im,
focused_xwayland_client_without_ibus,
get_focused_window,
is_focused_window_terminal,
looks_like_terminal,
@@ -243,3 +247,151 @@ class TestGetFocusedWindow(unittest.TestCase):
return_value=None,
):
self.assertFalse(is_focused_window_terminal())
class TestFocusedXWaylandClientWithoutIBus(unittest.TestCase):
"""Detect XWayland clients that IBus has no input context for (#972)."""
_MODULE = "vocalinux.text_injection.focused_window"
_WAYLAND_ENV = {"WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ":0"}
def _probe(
self,
outputs: dict[str, str],
*,
environ: Optional[bytes] = None,
wm_class: str = "",
toolkits: frozenset[str] = frozenset(),
) -> bool:
def fake_run_text(cmd: list[str], env: Optional[dict[str, str]] = None) -> str:
return outputs.get(cmd[1], "")
open_patch = (
patch(f"{self._MODULE}.open", mock_open(read_data=environ))
if environ is not None
else patch(f"{self._MODULE}.open", side_effect=PermissionError("denied"))
)
with (
patch.dict("os.environ", self._WAYLAND_ENV, clear=False),
patch(f"{self._MODULE}.shutil.which", return_value="/usr/bin/xdotool"),
patch(f"{self._MODULE}._run_text", side_effect=fake_run_text),
patch(f"{self._MODULE}.read_wm_class", return_value=wm_class),
patch(f"{self._MODULE}._process_toolkits", return_value=set(toolkits)),
open_patch,
):
return focused_xwayland_client_without_ibus()
def test_client_without_im_module(self) -> None:
outputs = {"getactivewindow": "4194311", "getwindowpid": "1234"}
self.assertTrue(self._probe(outputs, environ=b"HOME=/home/u\0LANG=C\0"))
def test_client_with_qt_ibus_module(self) -> None:
outputs = {"getactivewindow": "4194311", "getwindowpid": "1234"}
self.assertFalse(self._probe(outputs, environ=b"QT_IM_MODULE=ibus\0"))
def test_client_with_xim_ibus(self) -> None:
outputs = {"getactivewindow": "4194311", "getwindowpid": "1234"}
self.assertFalse(self._probe(outputs, environ=b"XMODIFIERS=@im=ibus\0"))
def test_gtk_client_ignores_qt_ibus_setting(self) -> None:
outputs = {"getactivewindow": "4194311", "getwindowpid": "1234"}
environ = b"GTK_IM_MODULE=fcitx\0QT_IM_MODULE=ibus\0"
self.assertTrue(self._probe(outputs, environ=environ, toolkits=frozenset({"gtk"})))
def test_qt_client_with_qt_ibus_setting(self) -> None:
outputs = {"getactivewindow": "4194311", "getwindowpid": "1234"}
environ = b"GTK_IM_MODULE=fcitx\0QT_IM_MODULE=ibus\0"
self.assertFalse(self._probe(outputs, environ=environ, toolkits=frozenset({"qt"})))
def test_unknown_toolkit_with_mixed_settings(self) -> None:
outputs = {"getactivewindow": "4194311", "getwindowpid": "1234"}
environ = b"GTK_IM_MODULE=fcitx\0QT_IM_MODULE=ibus\0"
self.assertTrue(self._probe(outputs, environ=environ))
def test_unreadable_environment_counts_as_unconnected(self) -> None:
outputs = {"getactivewindow": "4194311", "getwindowpid": "1234"}
self.assertTrue(self._probe(outputs))
def test_kwin_placeholder_window_is_not_a_client(self) -> None:
self.assertFalse(self._probe({"getactivewindow": "2097152"}))
def test_client_without_pid_but_with_class(self) -> None:
self.assertTrue(self._probe({"getactivewindow": "6291472"}, wm_class="tkprobe Tkprobe"))
def test_no_active_x11_window(self) -> None:
self.assertFalse(self._probe({}))
def test_not_a_wayland_session(self) -> None:
with (
patch.dict("os.environ", {"DISPLAY": ":0"}, clear=True),
patch(f"{self._MODULE}._run_text") as run_text,
):
self.assertFalse(focused_xwayland_client_without_ibus())
run_text.assert_not_called()
def test_no_xdotool(self) -> None:
with (
patch.dict("os.environ", self._WAYLAND_ENV, clear=False),
patch(f"{self._MODULE}.shutil.which", return_value=None),
patch(f"{self._MODULE}._run_text") as run_text,
):
self.assertFalse(focused_xwayland_client_without_ibus())
run_text.assert_not_called()
class TestEnvironUsesIBusIm(unittest.TestCase):
"""Decide from a client's IM variables and toolkit whether it reaches IBus."""
def test_variables(self) -> None:
self.assertTrue(environ_uses_ibus_im({"GTK_IM_MODULE": "ibus"}))
self.assertTrue(environ_uses_ibus_im({"QT_IM_MODULE": "IBus"}))
self.assertTrue(environ_uses_ibus_im({"XMODIFIERS": "@im=ibus"}))
self.assertFalse(
environ_uses_ibus_im({"GTK_IM_MODULE": "fcitx", "XMODIFIERS": "@im=fcitx"})
)
self.assertFalse(environ_uses_ibus_im({}))
def test_unknown_toolkit_needs_every_set_variable(self) -> None:
self.assertFalse(environ_uses_ibus_im({"GTK_IM_MODULE": "fcitx", "QT_IM_MODULE": "ibus"}))
self.assertFalse(environ_uses_ibus_im({"QT_IM_MODULE": "ibus", "XMODIFIERS": "@im=none"}))
self.assertTrue(environ_uses_ibus_im({"GTK_IM_MODULE": "ibus", "QT_IM_MODULE": "ibus"}))
def test_known_toolkit_uses_its_own_variable(self) -> None:
mixed = {"GTK_IM_MODULE": "fcitx", "QT_IM_MODULE": "ibus"}
self.assertFalse(environ_uses_ibus_im(mixed, frozenset({"gtk"})))
self.assertTrue(environ_uses_ibus_im(mixed, frozenset({"qt"})))
self.assertFalse(environ_uses_ibus_im(mixed, frozenset({"gtk", "qt"})))
self.assertFalse(environ_uses_ibus_im({"XMODIFIERS": "@im=ibus"}, frozenset({"gtk"})))
def test_xim_module_defers_to_xmodifiers(self) -> None:
gtk = frozenset({"gtk"})
self.assertTrue(
environ_uses_ibus_im({"GTK_IM_MODULE": "xim", "XMODIFIERS": "@im=ibus"}, gtk)
)
self.assertFalse(
environ_uses_ibus_im({"GTK_IM_MODULE": "xim", "XMODIFIERS": "@im=fcitx"}, gtk)
)
class TestProcessToolkits(unittest.TestCase):
"""Read the GUI toolkit of a process from its mapped libraries."""
_MODULE = "vocalinux.text_injection.focused_window"
def test_detects_gtk_and_qt(self) -> None:
maps = (
"7f00 r-xp 0 08:01 1 /usr/lib/x86_64-linux-gnu/libgtk-3.so.0.2404.29\n"
"7f01 r-xp 0 08:01 2 /usr/lib/x86_64-linux-gnu/libQt5Gui.so.5.15.3\n"
)
with patch(f"{self._MODULE}.open", mock_open(read_data=maps)):
self.assertEqual(_process_toolkits("1234"), {"gtk", "qt"})
def test_plain_xlib_client(self) -> None:
maps = "7f00 r-xp 0 08:01 1 /usr/lib/x86_64-linux-gnu/libX11.so.6.4.0\n"
with patch(f"{self._MODULE}.open", mock_open(read_data=maps)):
self.assertEqual(_process_toolkits("1234"), set())
def test_unreadable_maps(self) -> None:
with patch(f"{self._MODULE}.open", side_effect=PermissionError("denied")):
self.assertEqual(_process_toolkits("1234"), set())
self.assertEqual(_process_toolkits("not-a-pid"), set())
+174 -2
View File
@@ -11,7 +11,7 @@ import sys
import threading
import unittest
from collections.abc import Iterator
from typing import Any
from typing import Any, Optional
from unittest import mock
from unittest.mock import MagicMock, mock_open, patch
@@ -1713,7 +1713,10 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
injector = TextInjector()
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
with patch.object(injector, "_should_use_terminal_paste", return_value=False):
with (
patch.object(injector, "_should_use_terminal_paste", return_value=False),
patch.object(injector, "_clipboard_holds_non_text", return_value=False),
):
result = injector.inject_text("привет")
self.assertTrue(result)
@@ -1733,6 +1736,37 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
"must not fall through to the layout-dependent type path when the paste succeeds",
)
def test_xwayland_keeps_a_non_text_clipboard(self) -> None:
"""An image on the X11 clipboard is not overwritten by an XWayland paste."""
def which_side_effect(cmd: str) -> Optional[str]:
if cmd in ("xdotool", "xclip"):
return f"/usr/bin/{cmd}"
return None
self.mock_which.side_effect = which_side_effect
self.mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
injector = TextInjector()
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
with (
patch.object(injector, "_clipboard_holds_non_text", return_value=True),
patch.object(injector, "_inject_via_clipboard_paste") as paste,
):
injector._inject_with_xdotool("hi")
paste.assert_not_called()
calls = [c.args[0] for c in self.mock_subprocess.call_args_list if c.args]
self.assertTrue(any(c[:2] == ["xdotool", "type"] for c in calls))
def test_xwayland_checks_x11_clipboard_targets(self) -> None:
injector = TextInjector()
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
self._mock_x11_clipboard_targets("TARGETS\nimage/png\n")
self.assertTrue(injector._clipboard_holds_non_text())
def test_inject_with_xdotool_xwayland_uses_terminal_paste_chord(self) -> None:
"""XWayland xclip paste must still use Ctrl+Shift+V in terminals (#734)."""
@@ -3683,3 +3717,141 @@ class TestKdeSkipsInactiveIbus(unittest.TestCase):
injector._ibus_init_thread.join(timeout=5)
mock_ibus_class.assert_called_once()
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS)
class TestXWaylandIBusBypass(unittest.TestCase):
"""XWayland clients without an IBus IM module get xdotool instead (#972)."""
def _make_injector(self, environment: DesktopEnvironment) -> TextInjector:
injector = TextInjector.__new__(TextInjector)
injector._state_lock = threading.Lock()
injector._abort_injections = threading.Event()
injector.environment = environment
injector.wayland_tool = None
injector.last_typed_count = 0
injector._backend_pin = ("auto", None)
injector._ibus_injector = MagicMock()
injector._ibus_injector.inject_text.return_value = True
injector._log_current_window_info = MagicMock() # type: ignore[method-assign]
injector._should_copy_to_clipboard = MagicMock( # type: ignore[method-assign]
return_value=False
)
return injector
def test_unconnected_xwayland_client_uses_xdotool(self) -> None:
injector = self._make_injector(DesktopEnvironment.WAYLAND_IBUS)
seen: list[DesktopEnvironment] = []
def fake_xdotool(text: str) -> None:
seen.append(injector.environment)
with (
patch.object(injector, "_should_bypass_ibus", return_value=True),
patch.object(injector, "_inject_with_xdotool", side_effect=fake_xdotool) as xdo,
):
self.assertTrue(injector.inject_text("hello"))
xdo.assert_called_once_with("hello")
injector._ibus_injector.inject_text.assert_not_called()
self.assertEqual(seen, [DesktopEnvironment.WAYLAND_XDOTOOL])
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS)
self.assertEqual(injector.last_typed_count, 5)
def test_ibus_connected_client_keeps_ibus(self) -> None:
injector = self._make_injector(DesktopEnvironment.WAYLAND_IBUS)
with (
patch.object(injector, "_should_bypass_ibus", return_value=False),
patch.object(injector, "_inject_with_xdotool") as xdo,
):
self.assertTrue(injector.inject_text("hello"))
injector._ibus_injector.inject_text.assert_called_once_with("hello")
xdo.assert_not_called()
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS)
def test_environment_restored_when_xdotool_fails(self) -> None:
injector = self._make_injector(DesktopEnvironment.WAYLAND_IBUS)
error = subprocess.CalledProcessError(1, ["xdotool"])
with (
patch.object(injector, "_should_bypass_ibus", return_value=True),
patch.object(injector, "_inject_with_xdotool", side_effect=error),
patch.object(injector, "_copy_to_clipboard", return_value=False),
patch("vocalinux.ui.audio_feedback.play_error_sound"),
):
self.assertFalse(injector.inject_text("hello"))
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS)
def test_x11_ibus_is_not_probed(self) -> None:
injector = self._make_injector(DesktopEnvironment.X11_IBUS)
with patch.object(injector, "_should_bypass_ibus") as probe:
self.assertTrue(injector.inject_text("hello"))
probe.assert_not_called()
injector._ibus_injector.inject_text.assert_called_once_with("hello")
def test_probe_error_keeps_ibus(self) -> None:
injector = self._make_injector(DesktopEnvironment.WAYLAND_IBUS)
with patch(
"vocalinux.text_injection.text_injector.focused_xwayland_client_without_ibus",
side_effect=OSError("no display"),
):
self.assertFalse(injector._should_bypass_ibus())
def test_pinned_ibus_is_not_bypassed(self) -> None:
injector = self._make_injector(DesktopEnvironment.WAYLAND_IBUS)
injector._backend_pin = ("ibus", "text_injection.backend")
with patch(
"vocalinux.text_injection.text_injector.focused_xwayland_client_without_ibus",
return_value=True,
) as probe:
self.assertFalse(injector._should_bypass_ibus())
probe.assert_not_called()
def test_auto_backend_is_bypassed(self) -> None:
injector = self._make_injector(DesktopEnvironment.WAYLAND_IBUS)
with patch(
"vocalinux.text_injection.text_injector.focused_xwayland_client_without_ibus",
return_value=True,
):
self.assertTrue(injector._should_bypass_ibus())
def test_clipboard_restore_reads_the_x11_clipboard_after_detour(self) -> None:
injector = self._make_injector(DesktopEnvironment.WAYLAND_XDOTOOL)
injector._clipboard_restore_target = None
injector._clipboard_restore_generation = 0
reads: list[Any] = []
restore_targets: list[Any] = []
def fake_read(prefer_x11: Any = None) -> str:
reads.append(prefer_x11)
return "original" if len(reads) == 1 else "hello"
def capture_thread(target: Any, daemon: bool = False) -> MagicMock:
restore_targets.append(target)
return MagicMock()
module = "vocalinux.text_injection.text_injector"
with (
patch.object(injector, "_read_clipboard", side_effect=fake_read),
patch.object(injector, "_x11_clipboard_tools", return_value=["xclip"]),
patch.object(injector, "_copy_to_clipboard", return_value=True) as copy,
patch.object(injector, "_should_use_terminal_paste", return_value=False),
patch.object(injector, "_clipboard_paste_command", return_value=["xdotool"]),
patch(f"{module}.subprocess.run"),
patch(f"{module}.threading.Thread", side_effect=capture_thread),
):
self.assertTrue(injector._inject_via_clipboard_paste("hello"))
injector.environment = DesktopEnvironment.WAYLAND_IBUS
with patch(f"{module}.time.sleep"):
restore_targets[0]()
self.assertEqual(reads[-1], True)
copy.assert_called_with("original", tools=["xclip"])
+1
View File
@@ -74,6 +74,7 @@ def _make_injector(env) -> Any:
obj = cast(Any, TextInjector.__new__(TextInjector))
obj._ibus_injector = None
obj.environment = env
obj._backend_pin = ("auto", None)
obj._session_environment = env
obj._ibus_ready = False
obj._ibus_init_failed = False