fix: skip unbridged IBus on KDE Plasma Wayland (#577)

* fix(injection): skip unbridged IBus on KDE Plasma Wayland

Do not auto-start XIM ibus-daemon on Wayland (circular viability check).
On KDE, require KWin VirtualKeyboard enabled before selecting IBus.

Fixes #574

* refactor(injection): tighten KDE VirtualKeyboard bridge check

Ponytail cleanup: shorter logs and fewer failure branches, same behavior.

* style: black-format ibus daemon Wayland test for CI

* fix(injection): parse KWin VirtualKeyboard gdbus variants strictly

Match <<true>>/<<false>> wrappers instead of bare substrings so ambiguous
gdbus output cannot flip the #574 IBus bridge decision.

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
Jatin K Malik
2026-07-26 23:06:26 -07:00
committed by GitHub
co-authored by Jatin K Malik Cursor Agent
parent 4c070ded46
commit c59fe8b577
5 changed files with 251 additions and 5 deletions
+9 -2
View File
@@ -269,8 +269,10 @@ def start_ibus_daemon():
"""
Start the IBus daemon if it's not already running.
This is useful for desktop environments where IBus doesn't start automatically,
such as some KDE Plasma installations or minimal window managers.
X11 only. On Wayland we must not spawn ``ibus-daemon -x -d -r``: that XIM-mode
daemon is not bridged to the compositor, but still makes
``is_ibus_daemon_running()`` return True and leads to silent no-op injection
(issue #574). The compositor/DE must own the Wayland-bridged IBus instance.
Returns:
True if daemon was started or already running, False on failure
@@ -278,6 +280,11 @@ def start_ibus_daemon():
if is_ibus_daemon_running():
return True
if _is_wayland_session():
# XIM -x is not compositor-bridged; spawning it only fools the pgrep check (#574).
logger.debug("Not starting ibus-daemon on Wayland (see #574)")
return False
if not is_ibus_available():
return False
+60 -1
View File
@@ -233,6 +233,57 @@ class TextInjector:
"weston",
)
def _kde_virtual_keyboard_enabled(self) -> bool:
"""Return True when KWin VirtualKeyboard / input method is enabled.
On KDE Plasma Wayland, IBus only reaches native apps when this is on
(issue #574). Disabled or unqueryable → treat IBus as unbridged.
"""
try:
result = subprocess.run(
[
"gdbus",
"call",
"--session",
"--dest",
"org.kde.KWin",
"--object-path",
"/VirtualKeyboard",
"--method",
"org.freedesktop.DBus.Properties.Get",
"org.kde.kwin.VirtualKeyboard",
"enabled",
],
capture_output=True,
text=True,
timeout=2,
)
except (subprocess.SubprocessError, FileNotFoundError) as e:
logger.info(
"Could not query KWin VirtualKeyboard (%s); treating IBus as unbridged.",
e,
)
return False
out = (result.stdout or "").strip().lower() if result.returncode == 0 else ""
# gdbus prints variant wrappers like: (<<true>>,) or (<<false>>,)
if "<<true>>" in out:
return True
if "<<false>>" in out:
logger.info(
"KWin Virtual Keyboard is disabled; IBus commits will not reach "
"native apps. Falling back to ydotool/wtype. Enable: System "
"Settings → Keyboard → Virtual Keyboard → IBus Wayland."
)
return False
logger.info(
"KWin VirtualKeyboard not confirmed (rc=%s out=%r); treating IBus as unbridged.",
result.returncode,
result.stdout,
)
return False
def _wayland_compositor_bridges_ibus(self) -> bool:
"""Whether native Wayland clients receive IBus commits on this compositor.
@@ -242,6 +293,10 @@ class TextInjector:
those we must use a virtual-keyboard tool (wtype/ydotool) instead. We use
a denylist rather than an allowlist so that unrecognised desktops keep the
previous IBus-preferred behaviour.
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.
"""
if self.environment != DesktopEnvironment.WAYLAND:
# X11 / XWayland: IBus reaches apps through XIM regardless of DE.
@@ -250,7 +305,11 @@ class TextInjector:
os.environ.get(var, "")
for var in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION")
).lower()
return not any(name in desktop for name in self._IBUS_UNBRIDGED_COMPOSITORS)
if any(name in desktop for name in self._IBUS_UNBRIDGED_COMPOSITORS):
return False
if _is_kde_plasma_session():
return self._kde_virtual_keyboard_enabled()
return True
def _ydotool_socket_paths(self) -> list:
"""Return candidate Unix socket paths used by ydotoold."""
+31
View File
@@ -188,6 +188,37 @@ class TestStartIBusDaemon(unittest.TestCase):
result = start_ibus_daemon()
self.assertFalse(result)
@patch("vocalinux.text_injection.ibus_engine.subprocess.Popen")
@patch("vocalinux.text_injection.ibus_engine.is_ibus_available", return_value=True)
@patch("vocalinux.text_injection.ibus_engine.is_ibus_daemon_running", return_value=False)
def test_start_ibus_daemon_noop_on_wayland(self, mock_is_running, mock_available, mock_popen):
"""On Wayland, never spawn XIM ibus-daemon -x -d -r (#574)."""
from vocalinux.text_injection.ibus_engine import start_ibus_daemon
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
result = start_ibus_daemon()
self.assertFalse(result)
mock_popen.assert_not_called()
@patch("vocalinux.text_injection.ibus_engine.time.sleep")
@patch("vocalinux.text_injection.ibus_engine.subprocess.Popen")
@patch("vocalinux.text_injection.ibus_engine.is_ibus_available", return_value=True)
def test_start_ibus_daemon_still_starts_on_x11(self, mock_available, mock_popen, mock_sleep):
"""X11 sessions may still auto-start ibus-daemon with XIM flags."""
from vocalinux.text_injection.ibus_engine import start_ibus_daemon
with patch(
"vocalinux.text_injection.ibus_engine.is_ibus_daemon_running",
side_effect=[False, True],
):
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}, clear=False):
result = start_ibus_daemon()
self.assertTrue(result)
mock_popen.assert_called_once()
self.assertEqual(mock_popen.call_args[0][0], ["ibus-daemon", "-x", "-d", "-r"])
class TestIsEngineActive(unittest.TestCase):
"""Tests for is_engine_active function."""
+149 -2
View File
@@ -1809,16 +1809,21 @@ class TestCompositorIBusBridging(unittest.TestCase):
return injector
def test_bridged_desktops_prefer_ibus(self):
"""GNOME/KDE/Cinnamon and unknown desktops should keep using IBus."""
"""GNOME/Cinnamon and unknown desktops should keep using IBus.
KDE is covered separately: bridging also requires KWin VirtualKeyboard.
"""
injector = self._bare_injector()
for desktop in ("GNOME", "ubuntu:GNOME", "KDE", "X-Cinnamon", ""):
for desktop in ("GNOME", "ubuntu:GNOME", "X-Cinnamon", ""):
with patch.dict(
"os.environ",
{
"XDG_CURRENT_DESKTOP": desktop,
"XDG_SESSION_DESKTOP": desktop,
"DESKTOP_SESSION": desktop,
"KDE_FULL_SESSION": "",
},
clear=False,
):
self.assertTrue(injector._wayland_compositor_bridges_ibus(), desktop)
@@ -1842,6 +1847,148 @@ class TestCompositorIBusBridging(unittest.TestCase):
with patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "COSMIC"}):
self.assertTrue(injector._wayland_compositor_bridges_ibus())
def test_kde_wayland_virtual_keyboard_disabled_skips_ibus(self):
"""KDE Wayland with KWin VirtualKeyboard disabled must not use IBus (#574)."""
injector = self._bare_injector()
with patch.dict(
"os.environ",
{
"XDG_SESSION_TYPE": "wayland",
"XDG_CURRENT_DESKTOP": "KDE",
"XDG_SESSION_DESKTOP": "KDE",
"DESKTOP_SESSION": "plasma",
"KDE_FULL_SESSION": "true",
},
):
with patch.object(injector, "_kde_virtual_keyboard_enabled", return_value=False):
self.assertFalse(injector._wayland_compositor_bridges_ibus())
def test_kde_wayland_virtual_keyboard_enabled_allows_ibus(self):
"""KDE Wayland with KWin VirtualKeyboard enabled may still use IBus (#574)."""
injector = self._bare_injector()
with patch.dict(
"os.environ",
{
"XDG_SESSION_TYPE": "wayland",
"XDG_CURRENT_DESKTOP": "KDE",
"XDG_SESSION_DESKTOP": "KDE",
"DESKTOP_SESSION": "plasma",
"KDE_FULL_SESSION": "true",
},
):
with patch.object(injector, "_kde_virtual_keyboard_enabled", return_value=True):
self.assertTrue(injector._wayland_compositor_bridges_ibus())
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_kde_virtual_keyboard_enabled_parses_gdbus_true(self, mock_run):
"""gdbus (<<true>>,) means VirtualKeyboard is enabled."""
mock_run.return_value = MagicMock(returncode=0, stdout="(<<true>>,)\n", stderr="")
injector = self._bare_injector()
self.assertTrue(injector._kde_virtual_keyboard_enabled())
mock_run.assert_called_once()
args = mock_run.call_args[0][0]
self.assertEqual(args[0], "gdbus")
self.assertIn("org.kde.KWin", args)
self.assertIn("/VirtualKeyboard", args)
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_kde_virtual_keyboard_enabled_parses_gdbus_false(self, mock_run):
"""gdbus (<<false>>,) means VirtualKeyboard is disabled."""
mock_run.return_value = MagicMock(returncode=0, stdout="(<<false>>,)\n", stderr="")
injector = self._bare_injector()
self.assertFalse(injector._kde_virtual_keyboard_enabled())
@patch(
"vocalinux.text_injection.text_injector.subprocess.run",
side_effect=FileNotFoundError("gdbus"),
)
def test_kde_virtual_keyboard_enabled_false_when_gdbus_missing(self, _mock_run):
"""Missing gdbus → treat as unbridged (conservative)."""
injector = self._bare_injector()
self.assertFalse(injector._kde_virtual_keyboard_enabled())
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
@patch("vocalinux.text_injection.text_injector.shutil.which")
def test_kde_wayland_vk_disabled_picks_ydotool_not_ibus(
self,
mock_which,
mock_run,
mock_ibus_class,
mock_ibus_available,
mock_is_active,
mock_daemon,
):
"""Full path: KDE Wayland + VK disabled → ydotool, not IBus (#574)."""
mock_which.side_effect = lambda cmd: {
"ydotool": "/usr/bin/ydotool",
"ydotoold": "/usr/bin/ydotoold",
}.get(cmd)
mock_run.return_value = MagicMock(returncode=0, stdout="(<<false>>,)\n", stderr="")
with patch.dict(
"os.environ",
{
"XDG_SESSION_TYPE": "wayland",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_CURRENT_DESKTOP": "KDE",
"XDG_SESSION_DESKTOP": "KDE",
"DESKTOP_SESSION": "plasma",
"KDE_FULL_SESSION": "true",
},
):
with patch.object(TextInjector, "_is_ydotoold_running", return_value=True):
injector = TextInjector()
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
self.assertEqual(injector.wayland_tool, "ydotool")
mock_ibus_class.assert_not_called()
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
@patch("vocalinux.text_injection.text_injector.shutil.which")
def test_kde_wayland_vk_enabled_allows_ibus(
self,
mock_which,
mock_run,
mock_ibus_class,
mock_ibus_available,
mock_is_active,
mock_daemon,
):
"""Full path: KDE Wayland + VK enabled → IBus still selected when ready (#574)."""
mock_which.side_effect = lambda cmd: {
"ydotool": "/usr/bin/ydotool",
"ydotoold": "/usr/bin/ydotoold",
}.get(cmd)
mock_run.return_value = MagicMock(returncode=0, stdout="(<<true>>,)\n", stderr="")
mock_ibus_class.return_value = MagicMock()
with patch.dict(
"os.environ",
{
"XDG_SESSION_TYPE": "wayland",
"WAYLAND_DISPLAY": "wayland-0",
"XDG_CURRENT_DESKTOP": "KDE",
"XDG_SESSION_DESKTOP": "KDE",
"DESKTOP_SESSION": "plasma",
"KDE_FULL_SESSION": "true",
},
):
with patch.object(TextInjector, "_is_ydotoold_running", return_value=True):
injector = TextInjector()
if injector._ibus_init_thread is not None:
injector._ibus_init_thread.join(timeout=5)
mock_ibus_class.assert_called_once()
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS)
@patch("vocalinux.text_injection.text_injector.socket.socket")
@patch("os.path.exists", return_value=True)
def test_is_ydotoold_running_true_when_socket_connects(self, _mock_exists, mock_socket_cls):
+2
View File
@@ -225,6 +225,8 @@ class TestCheckDependencies(unittest.TestCase):
"vocalinux.text_injection.text_injector.IBusTextInjector",
return_value=mock_ibus,
),
# KDE Wayland also needs KWin VirtualKeyboard enabled (#574).
patch.object(obj, "_kde_virtual_keyboard_enabled", return_value=True),
patch.object(obj, "_start_ibus_initialization") as mock_start,
patch(
"shutil.which",