feat(text-injection): use IBus on unbridged compositors when ibus-wayland runs (#614)

* feat(text-injection): use IBus on unbridged compositors when ibus-wayland runs

The `_IBUS_UNBRIDGED_COMPOSITORS` denylist assumes nothing sits between
the compositor and ibus-daemon. Since IBus 1.5.32 that is no longer a
safe assumption: IBus ships `ibus-wayland`, which implements
`zwp_input_method_v2` and relays commits to native Wayland clients
speaking text-input-v3. Every compositor on the denylist exposes
`zwp_input_method_manager_v2`, so with the bridge running they are
bridged, and skipping IBus falls back to a slower path unnecessarily.

Add a live `pgrep -x ibus-wayland` probe and let it override the
denylist. Scoped to the denylist branch only, so GNOME and the KDE
VirtualKeyboard path (#574) are untouched.

Behaviour is unchanged when the bridge is not running, and because the
check is a live probe rather than configuration it degrades back to the
denylist by itself if the bridge dies or was never started. Cost is one
pgrep per TextInjector construction, not per injection.

Verified on Hyprland 0.56.1 with IBus 1.5.34: dictation into Electron
apps (VSCodium, Electron 39/41) and kitty commits through IBus, 303
chars in ~200ms versus 16.8s for 415 chars via `ydotool type`.

Also adds VOCALINUX_FORCE_BACKEND (ibus|wtype|ydotool|auto) as discussed
in #607. Autodetection has to infer whether IBus commits actually reach
the focused app and cannot verify it -- commit_text() reports success
even when the text is dropped -- so an escape hatch is useful when the
inference is wrong, and it makes the two paths A/B-testable without
editing code. Unknown values fall back to auto with a warning rather
than silently pinning a backend. Related to #476 and #402; happy to drop
this from the PR if it should follow their design instead.

Two existing tests asserted the unbridged outcome without pinning the
probe -- one blanket-mocks subprocess.run to returncode=0, the other
shells out for real -- so both now state the "no bridge" precondition
explicitly rather than depending on the host.

Note for users: Chromium only opens a zwp_text_input_v3 seat when
launched with --enable-wayland-ime; without it the bridge runs and the
app still receives nothing.

Closes #607

* test(text-injection): cover bridge override and forced-backend branches

Codecov flagged the new branches in _check_dependencies as uncovered
(74% patch, below the 80% target).

Adds four tests to TestCheckDependencies:

- unbridged compositor + ibus-wayland running selects IBus
- VOCALINUX_FORCE_BACKEND=wtype pins wtype where IBus would be chosen
- VOCALINUX_FORCE_BACKEND=ydotool pins ydotool even with wtype present
- VOCALINUX_FORCE_BACKEND=ibus bypasses the reachability guards, with
  neither a daemon nor a bridge available

Every line added by this PR is now covered.
This commit is contained in:
Benjamin Eisele
2026-07-29 00:36:14 +05:30
committed by GitHub
parent 5505e1040e
commit 161129cf3c
3 changed files with 289 additions and 9 deletions
+79 -5
View File
@@ -284,6 +284,29 @@ class TextInjector:
)
return False
@staticmethod
def _ibus_wayland_bridge_running() -> bool:
"""Whether IBus' zwp_input_method_v2 bridge (``ibus-wayland``) is running.
The ``_IBUS_UNBRIDGED_COMPOSITORS`` denylist assumes nothing sits between
the compositor and ibus-daemon. Since IBus 1.5.32 the ``ibus-wayland``
helper implements ``zwp_input_method_v2``, so on a compositor that
exposes ``zwp_input_method_manager_v2`` (the wlroots/smithay ones on the
denylist all do) it relays commits to native Wayland clients speaking
text-input-v3. When it is running, those compositors are bridged.
Mirrors ``is_ibus_daemon_running()`` in ibus_engine.py.
"""
try:
result = subprocess.run(
["pgrep", "-x", "ibus-wayland"],
capture_output=True,
timeout=2,
)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
def _wayland_compositor_bridges_ibus(self) -> bool:
"""Whether native Wayland clients receive IBus commits on this compositor.
@@ -294,6 +317,12 @@ class TextInjector:
a denylist rather than an allowlist so that unrecognised desktops keep the
previous IBus-preferred behaviour.
A denylisted compositor is still bridged when ``ibus-wayland`` is running,
since that supplies exactly the input-method-v2 relay those compositors
lack (issue #607). The check is a live probe rather than configuration,
so this degrades back to the denylist on its own if the bridge dies or
was never started.
On KDE Plasma Wayland, bridging also requires KWin VirtualKeyboard to be
enabled (issue #574); otherwise commit_text succeeds at the IBus layer
but never reaches apps.
@@ -306,6 +335,14 @@ class TextInjector:
for var in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION")
).lower()
if any(name in desktop for name in self._IBUS_UNBRIDGED_COMPOSITORS):
if self._ibus_wayland_bridge_running():
logger.info(
"Compositor '%s' is on the unbridged list, but the ibus-wayland "
"input-method-v2 bridge is running; IBus can reach native "
"Wayland clients.",
os.environ.get("XDG_CURRENT_DESKTOP", "unknown"),
)
return True
return False
if _is_kde_plasma_session():
return self._kde_virtual_keyboard_enabled()
@@ -415,13 +452,40 @@ class TextInjector:
logger.warning("ydotoold did not become ready in time")
return False
@staticmethod
def _forced_backend() -> str:
"""Backend pinned via ``VOCALINUX_FORCE_BACKEND``, or ``"auto"``.
Autodetection has to infer whether IBus commits actually reach the
focused app, and it cannot verify that: ``commit_text()`` reports
success even when the text is dropped. This gives users an escape hatch
when the inference is wrong, and makes the two paths A/B-testable
without editing code.
Accepts ``ibus``, ``wtype``, ``ydotool`` or ``auto``. Anything else is
ignored with a warning, so a typo cannot silently pin a backend.
"""
value = os.environ.get("VOCALINUX_FORCE_BACKEND", "").strip().lower()
if not value or value == "auto":
return "auto"
if value in ("ibus", "wtype", "ydotool"):
return value
logger.warning(
"Ignoring unknown VOCALINUX_FORCE_BACKEND=%r (expected ibus/wtype/ydotool/auto)",
value,
)
return "auto"
def _check_dependencies(self):
"""Check for the required tools for text injection."""
ibus_requested = False
forced = self._forced_backend()
if forced != "auto":
logger.info("VOCALINUX_FORCE_BACKEND=%s: overriding backend autodetection", forced)
# Prefer IBus on both X11 and Wayland - it sends Unicode directly,
# bypassing keyboard layout issues entirely
if is_ibus_available():
if is_ibus_available() and forced in ("auto", "ibus"):
ibus_active = is_ibus_active_input_method()
gtk_im = os.environ.get("GTK_IM_MODULE", "").lower()
qt_im = os.environ.get("QT_IM_MODULE", "").lower()
@@ -445,13 +509,16 @@ class TextInjector:
# Check if IBus is the active input method (not just installed)
# This is important because IBus may be installed but not being used,
# e.g., when the user has configured ydotool or Fcitx instead.
if not ibus_active and not wayland_scoped_ibus:
# VOCALINUX_FORCE_BACKEND=ibus bypasses the reachability guards below
# and goes straight to setup.
force_ibus = forced == "ibus"
if not force_ibus and not ibus_active and not wayland_scoped_ibus:
logger.info(
"IBus is installed but not the active input method. "
"Falling back to alternative text injection method."
)
# Check if ibus-daemon is running before attempting setup
elif not is_ibus_daemon_running():
elif not force_ibus and not is_ibus_daemon_running():
logger.info(
"IBus daemon not running. This is normal on some desktop environments "
"(e.g., KDE Plasma). Using alternative text injection method. "
@@ -460,7 +527,7 @@ class TextInjector:
# Some Wayland compositors (COSMIC, Sway, Hyprland, ...) do not deliver
# IBus commits to native Wayland apps, so IBus would silently drop the
# text even though commit_text() reports success.
elif not self._wayland_compositor_bridges_ibus():
elif not force_ibus and not self._wayland_compositor_bridges_ibus():
logger.info(
"Compositor '%s' does not bridge IBus to native Wayland apps; "
"using virtual-keyboard injection (wtype/ydotool) instead.",
@@ -496,7 +563,14 @@ class TextInjector:
# Prefer ydotool when the daemon is (or can be) ready. Flatpak ships
# ydotool for native Wayland typing; wtype needs a Wayland socket.
if ydotool_available and self._ensure_ydotoold():
if forced == "wtype" and wtype_available:
self.wayland_tool = "wtype"
logger.info("VOCALINUX_FORCE_BACKEND=wtype: using wtype for Wayland injection")
elif forced == "ydotool" and ydotool_available:
self._ensure_ydotoold()
self.wayland_tool = "ydotool"
logger.info("VOCALINUX_FORCE_BACKEND=ydotool: using ydotool for Wayland injection")
elif ydotool_available and self._ensure_ydotoold():
self.wayland_tool = "ydotool"
logger.info("Using ydotool for Wayland text injection")
elif ydotool_available and not wtype_available:
+84 -3
View File
@@ -1828,7 +1828,13 @@ class TestCompositorIBusBridging(unittest.TestCase):
self.assertTrue(injector._wayland_compositor_bridges_ibus(), desktop)
def test_unbridged_compositors_skip_ibus(self):
"""COSMIC and wlroots compositors do not deliver IBus commits to native apps."""
"""COSMIC and wlroots compositors do not deliver IBus commits to native apps.
The ibus-wayland bridge is explicitly absent here; see
``test_unbridged_compositors_use_ibus_when_bridge_running`` for the
opposite case. Patching it keeps the result independent of whether the
machine running the suite happens to have the bridge up.
"""
injector = self._bare_injector()
for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "niri", "river"):
with patch.dict(
@@ -1839,7 +1845,55 @@ class TestCompositorIBusBridging(unittest.TestCase):
"DESKTOP_SESSION": desktop,
},
):
self.assertFalse(injector._wayland_compositor_bridges_ibus(), desktop)
with patch.object(TextInjector, "_ibus_wayland_bridge_running", return_value=False):
self.assertFalse(injector._wayland_compositor_bridges_ibus(), desktop)
def test_unbridged_compositors_use_ibus_when_bridge_running(self):
"""ibus-wayland supplies the input-method-v2 relay these compositors lack (#607)."""
injector = self._bare_injector()
for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "niri", "river"):
with patch.dict(
"os.environ",
{
"XDG_CURRENT_DESKTOP": desktop,
"XDG_SESSION_DESKTOP": desktop,
"DESKTOP_SESSION": desktop,
},
):
with patch.object(TextInjector, "_ibus_wayland_bridge_running", return_value=True):
self.assertTrue(injector._wayland_compositor_bridges_ibus(), desktop)
def test_bridge_probe_not_consulted_for_bridged_desktops(self):
"""GNOME and friends never reach the probe; behaviour there is unchanged."""
injector = self._bare_injector()
with patch.dict(
"os.environ",
{
"XDG_CURRENT_DESKTOP": "GNOME",
"XDG_SESSION_DESKTOP": "GNOME",
"DESKTOP_SESSION": "GNOME",
"KDE_FULL_SESSION": "",
},
clear=False,
):
with patch.object(TextInjector, "_ibus_wayland_bridge_running") as probe:
self.assertTrue(injector._wayland_compositor_bridges_ibus())
probe.assert_not_called()
def test_bridge_probe_detects_running_process(self):
"""The probe shells out to pgrep -x ibus-wayland."""
with patch("subprocess.run", return_value=MagicMock(returncode=0)) as run:
self.assertTrue(TextInjector._ibus_wayland_bridge_running())
self.assertEqual(run.call_args[0][0], ["pgrep", "-x", "ibus-wayland"])
with patch("subprocess.run", return_value=MagicMock(returncode=1)):
self.assertFalse(TextInjector._ibus_wayland_bridge_running())
def test_bridge_probe_survives_missing_pgrep(self):
"""No pgrep (or a hung one) must not raise -- just report 'no bridge'."""
for boom in (FileNotFoundError(), subprocess.TimeoutExpired("pgrep", 2)):
with patch("subprocess.run", side_effect=boom):
self.assertFalse(TextInjector._ibus_wayland_bridge_running())
def test_non_wayland_always_bridges(self):
"""On X11/XWayland IBus works via XIM regardless of desktop."""
@@ -2116,7 +2170,11 @@ class TestCompositorIBusBridging(unittest.TestCase):
"DESKTOP_SESSION": "cosmic",
},
):
injector = TextInjector()
# No ibus-wayland relay here, so COSMIC stays unbridged. Stated
# explicitly because mock_run returns returncode=0 for every
# subprocess, which the bridge probe would otherwise read as a hit.
with patch.object(TextInjector, "_ibus_wayland_bridge_running", return_value=False):
injector = TextInjector()
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
self.assertEqual(injector.wayland_tool, "wtype")
@@ -2142,3 +2200,26 @@ class TestCompositorIBusBridging(unittest.TestCase):
):
injector = TextInjector()
self.assertEqual(injector.wayland_tool, "ydotool")
class TestForcedBackend(unittest.TestCase):
"""VOCALINUX_FORCE_BACKEND overrides backend autodetection."""
def test_unset_or_auto_means_auto(self):
for value in ("", "auto", " AUTO "):
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value}):
self.assertEqual(TextInjector._forced_backend(), "auto")
def test_recognised_backends(self):
for value in ("ibus", "wtype", "ydotool"):
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": value.upper()}):
self.assertEqual(TextInjector._forced_backend(), value)
def test_unknown_value_falls_back_to_auto(self):
"""A typo must not silently pin the wrong backend."""
with patch.dict("os.environ", {"VOCALINUX_FORCE_BACKEND": "ibsu"}):
self.assertEqual(TextInjector._forced_backend(), "auto")
def test_missing_variable_means_auto(self):
with patch.dict("os.environ", {}, clear=True):
self.assertEqual(TextInjector._forced_backend(), "auto")
+126 -1
View File
@@ -277,7 +277,7 @@ class TestCheckDependencies(unittest.TestCase):
self.assertEqual(obj.wayland_tool, "wtype")
def test_unbridged_wayland_skips_ibus_when_engine_is_xkb(self):
from vocalinux.text_injection.text_injector import DesktopEnvironment
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
obj = _make_injector(DesktopEnvironment.WAYLAND)
@@ -301,12 +301,137 @@ class TestCheckDependencies(unittest.TestCase):
"shutil.which",
side_effect=lambda cmd: "/usr/bin/wtype" if cmd == "wtype" else None,
),
# No ibus-wayland relay, so sway stays unbridged. Stated explicitly so
# the result does not depend on whether the machine running the suite
# happens to have the bridge up.
patch.object(TextInjector, "_ibus_wayland_bridge_running", return_value=False),
):
obj._check_dependencies()
mock_ibus_class.assert_not_called()
self.assertEqual(obj.wayland_tool, "wtype")
def test_unbridged_wayland_uses_ibus_when_bridge_running(self):
"""ibus-wayland makes an otherwise-unbridged compositor usable (#607)."""
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
obj = _make_injector(DesktopEnvironment.WAYLAND)
with (
patch.dict(
os.environ,
{"XDG_SESSION_TYPE": "wayland", "XDG_CURRENT_DESKTOP": "Hyprland"},
clear=True,
),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True),
patch(
"vocalinux.text_injection.text_injector.is_ibus_active_input_method",
return_value=False,
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_daemon_running",
return_value=True,
),
patch("vocalinux.text_injection.text_injector.IBusTextInjector") as mock_ibus_class,
patch.object(obj, "_start_ibus_initialization"),
patch(
"shutil.which",
side_effect=lambda cmd: "/usr/bin/wtype" if cmd == "wtype" else None,
),
patch.object(TextInjector, "_ibus_wayland_bridge_running", return_value=True),
):
obj._check_dependencies()
mock_ibus_class.assert_called_once()
def test_force_backend_wtype_skips_ibus(self):
"""VOCALINUX_FORCE_BACKEND=wtype pins wtype even where IBus would be chosen."""
from vocalinux.text_injection.text_injector import DesktopEnvironment
obj = _make_injector(DesktopEnvironment.WAYLAND)
with (
patch.dict(
os.environ,
{"XDG_SESSION_TYPE": "wayland", "VOCALINUX_FORCE_BACKEND": "wtype"},
clear=True,
),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True),
patch("vocalinux.text_injection.text_injector.IBusTextInjector") as mock_ibus_class,
patch(
"shutil.which",
side_effect=lambda cmd: f"/usr/bin/{cmd}" if cmd in ("wtype", "ydotool") else None,
),
):
obj._check_dependencies()
self.assertEqual(obj.wayland_tool, "wtype")
mock_ibus_class.assert_not_called()
def test_force_backend_ydotool_skips_ibus_and_wtype(self):
"""VOCALINUX_FORCE_BACKEND=ydotool pins ydotool even when wtype is available."""
from vocalinux.text_injection.text_injector import DesktopEnvironment
obj = _make_injector(DesktopEnvironment.WAYLAND)
with (
patch.dict(
os.environ,
{"XDG_SESSION_TYPE": "wayland", "VOCALINUX_FORCE_BACKEND": "ydotool"},
clear=True,
),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True),
patch("vocalinux.text_injection.text_injector.IBusTextInjector") as mock_ibus_class,
patch.object(obj, "_ensure_ydotoold", return_value=True) as mock_ensure,
patch(
"shutil.which",
side_effect=lambda cmd: f"/usr/bin/{cmd}" if cmd in ("wtype", "ydotool") else None,
),
):
obj._check_dependencies()
self.assertEqual(obj.wayland_tool, "ydotool")
mock_ensure.assert_called_once()
mock_ibus_class.assert_not_called()
def test_force_backend_ibus_bypasses_reachability_guards(self):
"""VOCALINUX_FORCE_BACKEND=ibus selects IBus even on an unbridged compositor."""
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
obj = _make_injector(DesktopEnvironment.WAYLAND)
with (
patch.dict(
os.environ,
{
"XDG_SESSION_TYPE": "wayland",
"XDG_CURRENT_DESKTOP": "Hyprland",
"VOCALINUX_FORCE_BACKEND": "ibus",
},
clear=True,
),
patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True),
patch(
"vocalinux.text_injection.text_injector.is_ibus_active_input_method",
return_value=False,
),
patch(
"vocalinux.text_injection.text_injector.is_ibus_daemon_running",
return_value=False,
),
patch("vocalinux.text_injection.text_injector.IBusTextInjector") as mock_ibus_class,
patch.object(obj, "_start_ibus_initialization"),
# Even with no bridge and no daemon, the explicit override wins.
patch.object(TextInjector, "_ibus_wayland_bridge_running", return_value=False),
patch(
"shutil.which",
side_effect=lambda cmd: "/usr/bin/wtype" if cmd == "wtype" else None,
),
):
obj._check_dependencies()
mock_ibus_class.assert_called_once()
def test_kde_wayland_respects_explicit_non_ibus_input_method(self):
from vocalinux.text_injection.text_injector import DesktopEnvironment