diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 399a73a..ad0f173 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -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 diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index bdd8071..eb3429c 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -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: (<>,) or (<>,) + if "<>" in out: + return True + if "<>" 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.""" diff --git a/tests/test_ibus_engine_utils.py b/tests/test_ibus_engine_utils.py index 50d8869..e7c4e46 100644 --- a/tests/test_ibus_engine_utils.py +++ b/tests/test_ibus_engine_utils.py @@ -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.""" diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index c5a5567..4dfa6b4 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -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 (<>,) means VirtualKeyboard is enabled.""" + mock_run.return_value = MagicMock(returncode=0, stdout="(<>,)\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 (<>,) means VirtualKeyboard is disabled.""" + mock_run.return_value = MagicMock(returncode=0, stdout="(<>,)\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="(<>,)\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="(<>,)\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): diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py index eb04c07..cd92301 100644 --- a/tests/test_text_injector_ext.py +++ b/tests/test_text_injector_ext.py @@ -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",