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:
co-authored by
Jatin K Malik
Cursor Agent
parent
4c070ded46
commit
c59fe8b577
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user