From 622d0e054d91808d2eedb84439248e49ccf3c642 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:54:56 -0700 Subject: [PATCH] fix(text-injection): detect KWin 6 VirtualKeyboard property (#919) * fix(text-injection): detect KWin 6 VirtualKeyboard property On KDE Plasma 6 the org.kde.kwin.VirtualKeyboard 'enabled' property no longer exists; the setting is reported as 'available'. Query 'available' first, keep 'enabled' as the Plasma 5 fallback, and accept both the real (,) gdbus output and the doubly wrapped (<>,) form. With the bridge check working, KDE sessions with Virtual Keyboard set to "IBus Wayland" can safely use scoped IBus injection again: the leftover-daemon trap of #752 is exactly what a confirmed VK bridge disambiguates. KDE without a confirmed VK stays excluded. KWin's RemoteDesktop portal scrambles letter case, so in auto mode on KDE sessions an installed ydotool is preferred over the portal -- the ordering used before the portal backend existed. Fixes #911 * fix(text-injection): keep KDE portal fallback ahead of daemonless ydotool On KDE, when ydotool is installed but cannot run (no daemon, no /dev/uinput access) and wtype is absent, the daemonless-ydotool branch fired before the portal fallback -- selecting a backend that fails at injection time while a working portal sat unused. The portal fallback now precedes the daemonless branch; working ydotool still outranks the portal on KDE. --------- Co-authored-by: Jatin K Malik --- src/vocalinux/text_injection/text_injector.py | 122 ++++---- tests/test_text_injector.py | 274 +++++++++++++++++- 2 files changed, 331 insertions(+), 65 deletions(-) diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 6ba329e..b9ddc7d 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -314,52 +314,62 @@ class TextInjector: """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. + (issue #574). KWin 6 reports the setting through the ``available`` + property; ``enabled`` existed on Plasma 5 and is kept as a fallback + (issue #911). 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, - env=host_env(), - ) - except (subprocess.SubprocessError, FileNotFoundError) as e: - logger.info( - "Could not query KWin VirtualKeyboard (%s); treating IBus as unbridged.", - e, - ) - return False + for prop in ("available", "enabled"): + try: + result = subprocess.run( + [ + "gdbus", + "call", + "--session", + "--dest", + "org.kde.KWin", + "--object-path", + "/VirtualKeyboard", + "--method", + "org.freedesktop.DBus.Properties.Get", + "org.kde.kwin.VirtualKeyboard", + prop, + ], + capture_output=True, + text=True, + timeout=2, + env=host_env(), + ) + 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." + out = (result.stdout or "").strip().lower() if result.returncode == 0 else "" + # gdbus prints the variant as (,) on KWin 6 and doubly + # wrapped as (<>,) on Plasma 5; the bare "" substring + # matches both forms. + 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 + # The property does not exist on this KWin (Plasma 6 dropped + # 'enabled'; Plasma 5 lacks 'available') or its answer was not a + # readable boolean -- try the other name before giving up. + logger.debug( + "KWin VirtualKeyboard property '%s' inconclusive (rc=%s out=%r).", + prop, + result.returncode, + result.stdout, ) - return False - logger.info( - "KWin VirtualKeyboard not confirmed (rc=%s out=%r); treating IBus as unbridged.", - result.returncode, - result.stdout, - ) + logger.info("KWin VirtualKeyboard not confirmed; treating IBus as unbridged.") return False @staticmethod @@ -879,14 +889,16 @@ class TextInjector: ) # Bridging Wayland DEs (GNOME): inject_text() switches to the # real vocalinux engine for each commit, so a bare xkb:* baseline is - # fine (#501, #504). KDE is not in that set unless IBus is already - # the session IM: a leftover daemon plus scoped activate reports - # success while Kate/Qt get nothing (#752). Unbridged compositors - # still bail below. + # fine (#501, #504). KDE joins that set only when KWin's Virtual + # Keyboard is confirmed on: a confirmed VK bridge is what makes the + # commits reach apps, and it is also what keeps the #752 + # leftover-daemon trap (scoped activate reports success while + # Kate/Qt get nothing) excluded when the VK check fails (#911). + # Unbridged compositors still bail below. wayland_scoped_ibus = ( self.environment == DesktopEnvironment.WAYLAND and not explicit_non_ibus_im - and not _is_kde_plasma_session() + and (not _is_kde_plasma_session() or self._kde_virtual_keyboard_enabled()) ) # Check if IBus is the active input method (not just installed) @@ -970,7 +982,11 @@ class TextInjector: # The RemoteDesktop portal sits ahead of both in autodetection: it # is the only injection path Wayland sanctions, works inside the # Flatpak sandbox without /dev/uinput, and reaches native clients - # on compositors with no input-method-v2 bridge. + # on compositors with no input-method-v2 bridge. KDE is the + # exception: KWin's portal scrambles letter case (#911), so on + # Plasma a present ydotool goes first -- the ordering from before + # the portal backend existed. + kde_ydotool_first = ydotool_available and _is_kde_plasma_session() if forced == "portal" and portal_available: self._select_portal_backend( "%s=portal: using RemoteDesktop portal for Wayland injection", @@ -983,13 +999,21 @@ class TextInjector: self._ensure_ydotoold() self.wayland_tool = "ydotool" logger.info("%s=ydotool: using ydotool for Wayland injection", pin_source) - elif portal_available: + elif portal_available and not kde_ydotool_first: self._select_portal_backend( "Using the RemoteDesktop portal for Wayland text injection" ) elif ydotool_available and self._ensure_ydotoold(): self.wayland_tool = "ydotool" logger.info("Using ydotool for Wayland text injection") + elif portal_available: + # Reached only on KDE after ydotool proved unusable; on other + # desktops the earlier portal branch already fired. A daemonless + # ydotool must not outrank a working portal: it may fail at + # injection time (no /dev/uinput), while the portal delivers. + self._select_portal_backend( + "Using the RemoteDesktop portal for Wayland text injection" + ) elif ydotool_available and not wtype_available: self.wayland_tool = "ydotool" logger.warning( diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index 4267725..2a04cd5 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -2558,9 +2558,9 @@ class TestCompositorIBusBridging(unittest.TestCase): 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="") + def test_kde_virtual_keyboard_enabled_parses_gdbus_true(self, mock_run: MagicMock) -> None: + """gdbus (,) on 'available' means VirtualKeyboard is enabled (#911).""" + 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() @@ -2568,19 +2568,68 @@ class TestCompositorIBusBridging(unittest.TestCase): self.assertEqual(args[0], "gdbus") self.assertIn("org.kde.KWin", args) self.assertIn("/VirtualKeyboard", args) + # KWin 6 exposes the setting as 'available'; it must be queried first. + self.assertEqual(args[-1], "available") @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="") + def test_kde_virtual_keyboard_enabled_parses_gdbus_false(self, mock_run: MagicMock) -> None: + """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()) + mock_run.assert_called_once() + self.assertEqual(mock_run.call_args[0][0][-1], "available") + + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_kde_virtual_keyboard_accepts_legacy_wrapped_true(self, mock_run: MagicMock) -> None: + """Plasma 5's doubly wrapped (<>,) answer form is also accepted.""" + 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() + self.assertEqual(mock_run.call_args[0][0][-1], "available") + + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_kde_virtual_keyboard_falls_back_to_enabled_property(self, mock_run: MagicMock) -> None: + """Plasma 5 has no 'available': 'enabled' is queried next (#911).""" + mock_run.side_effect = [ + MagicMock(returncode=1, stdout="", stderr="Error: UnknownProperty"), + MagicMock(returncode=0, stdout="(<>,)\n", stderr=""), + ] + injector = self._bare_injector() + self.assertTrue(injector._kde_virtual_keyboard_enabled()) + self.assertEqual(mock_run.call_count, 2) + self.assertEqual(mock_run.call_args_list[0][0][0][-1], "available") + self.assertEqual(mock_run.call_args_list[1][0][0][-1], "enabled") + + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_kde_virtual_keyboard_enabled_property_false(self, mock_run: MagicMock) -> None: + """The 'enabled' fallback answering (<>,) still means disabled.""" + mock_run.side_effect = [ + MagicMock(returncode=1, stdout="", stderr="Error: UnknownProperty"), + MagicMock(returncode=0, stdout="(<>,)\n", stderr=""), + ] + injector = self._bare_injector() + self.assertFalse(injector._kde_virtual_keyboard_enabled()) + self.assertEqual(mock_run.call_count, 2) + + @patch("vocalinux.text_injection.text_injector.subprocess.run") + def test_kde_virtual_keyboard_false_when_neither_property_exists( + self, mock_run: MagicMock + ) -> None: + """No readable answer from either property → treat as unbridged.""" + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Error: UnknownProperty") + injector = self._bare_injector() + self.assertFalse(injector._kde_virtual_keyboard_enabled()) + self.assertEqual(mock_run.call_count, 2) @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): + def test_kde_virtual_keyboard_enabled_false_when_gdbus_missing( + self, _mock_run: MagicMock + ) -> None: """Missing gdbus → treat as unbridged (conservative).""" injector = self._bare_injector() self.assertFalse(injector._kde_virtual_keyboard_enabled()) @@ -2611,7 +2660,7 @@ class TestCompositorIBusBridging(unittest.TestCase): "ydotool": "/usr/bin/ydotool", "ydotoold": "/usr/bin/ydotoold", }.get(cmd) - mock_run.return_value = MagicMock(returncode=0, stdout="(<>,)\n", stderr="") + mock_run.return_value = MagicMock(returncode=0, stdout="(,)\n", stderr="") with ( patch.dict( @@ -2659,7 +2708,7 @@ class TestCompositorIBusBridging(unittest.TestCase): "ydotool": "/usr/bin/ydotool", "ydotoold": "/usr/bin/ydotoold", }.get(cmd) - mock_run.return_value = MagicMock(returncode=0, stdout="(<>,)\n", stderr="") + mock_run.return_value = MagicMock(returncode=0, stdout="(,)\n", stderr="") mock_ibus_class.return_value = MagicMock() with patch.dict( @@ -2865,6 +2914,154 @@ class TestCompositorIBusBridging(unittest.TestCase): injector = TextInjector() self.assertEqual(injector.wayland_tool, "ydotool") + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + def test_kde_auto_prefers_ydotool_over_portal( + self, mock_which: MagicMock, _mock_ibus: MagicMock + ) -> None: + """KWin's portal scrambles letter case: on KDE, ydotool goes first (#911).""" + mock_which.side_effect = lambda cmd: { + "ydotool": "/usr/bin/ydotool", + "ydotoold": "/usr/bin/ydotoold", + }.get(cmd) + 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", + }, + clear=True, + ), + patch.object(TextInjector, "_portal_probe", return_value=True), + patch.object(TextInjector, "_is_ydotoold_running", return_value=True), + ): + injector = TextInjector() + self.assertEqual(injector.wayland_tool, "ydotool") + + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + def test_non_kde_auto_still_prefers_portal_over_ydotool( + self, mock_which: MagicMock, _mock_ibus: MagicMock + ) -> None: + """The ydotool-first reorder is KDE-only; other desktops keep the portal.""" + mock_which.side_effect = lambda cmd: { + "ydotool": "/usr/bin/ydotool", + "ydotoold": "/usr/bin/ydotoold", + }.get(cmd) + with ( + patch.dict( + "os.environ", + { + "XDG_SESSION_TYPE": "wayland", + "WAYLAND_DISPLAY": "wayland-0", + "XDG_CURRENT_DESKTOP": "GNOME", + "XDG_SESSION_DESKTOP": "GNOME", + "DESKTOP_SESSION": "gnome", + "KDE_FULL_SESSION": "", + }, + clear=True, + ), + patch.object(TextInjector, "_portal_probe", return_value=True), + patch.object(TextInjector, "_is_ydotoold_running", return_value=True), + ): + injector = TextInjector() + self.assertEqual(injector.wayland_tool, "portal") + + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + def test_kde_auto_uses_portal_when_ydotool_missing( + self, mock_which: MagicMock, _mock_ibus: MagicMock + ) -> None: + """The KDE reorder needs ydotool installed; without it the portal still wins.""" + mock_which.side_effect = lambda cmd: None + 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", + }, + clear=True, + ), + patch.object(TextInjector, "_portal_probe", return_value=True), + ): + injector = TextInjector() + self.assertEqual(injector.wayland_tool, "portal") + + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + def test_kde_auto_uses_portal_when_ydotoold_unusable( + self, mock_which: MagicMock, _mock_ibus: MagicMock + ) -> None: + """On KDE the portal is still the fallback when ydotool cannot run (#911).""" + mock_which.side_effect = lambda cmd: { + "ydotool": "/usr/bin/ydotool", + "ydotoold": "/usr/bin/ydotoold", + "wtype": "/usr/bin/wtype", + }.get(cmd) + 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", + }, + clear=True, + ), + patch.object(TextInjector, "_portal_probe", return_value=True), + patch.object(TextInjector, "_is_ydotoold_running", return_value=False), + patch.object(TextInjector, "_uinput_usable", return_value=False), + ): + injector = TextInjector() + self.assertEqual(injector.wayland_tool, "portal") + + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + def test_kde_auto_prefers_portal_over_daemonless_ydotool( + self, mock_which: MagicMock, _mock_ibus: MagicMock + ) -> None: + """KDE + unusable ydotool + no wtype: portal beats daemonless ydotool (#911). + + The daemonless-ydotool branch would pick a backend that fails at + injection time (no /dev/uinput) while a working portal sits unused. + """ + mock_which.side_effect = lambda cmd: { + "ydotool": "/usr/bin/ydotool", + "ydotoold": "/usr/bin/ydotoold", + }.get(cmd) + 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", + }, + clear=True, + ), + patch.object(TextInjector, "_portal_probe", return_value=True), + patch.object(TextInjector, "_is_ydotoold_running", return_value=False), + patch.object(TextInjector, "_uinput_usable", return_value=False), + ): + injector = TextInjector() + self.assertEqual(injector.wayland_tool, "portal") + @contextlib.contextmanager def _fake_config(config: Any) -> Iterator[None]: @@ -3107,7 +3304,11 @@ class TestForcedBackend(unittest.TestCase): class TestKdeSkipsInactiveIbus(unittest.TestCase): - """KDE without IBus as the session IM must not take the scoped IBus path (#752).""" + """KDE's scoped-IBus admission now hinges on KWin VirtualKeyboard (#752, #911). + + A leftover ibus-daemon on KDE reports success while apps get nothing, so an + inactive IBus is only trusted when the VK bridge is confirmed on. + """ @patch( "vocalinux.text_injection.text_injector.is_ibus_daemon_running", @@ -3122,10 +3323,10 @@ class TestKdeSkipsInactiveIbus(unittest.TestCase): @patch("vocalinux.text_injection.text_injector.shutil.which") def test_kde_wayland_does_not_construct_ibus_injector( self, - mock_which, - mock_ibus_class, - *_args, - ): + mock_which: MagicMock, + mock_ibus_class: MagicMock, + *_args: MagicMock, + ) -> None: mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None) with patch.dict( "os.environ", @@ -3137,6 +3338,47 @@ class TestKdeSkipsInactiveIbus(unittest.TestCase): }, clear=True, ): - injector = TextInjector() + with patch.object(TextInjector, "_kde_virtual_keyboard_enabled", return_value=False): + TextInjector() mock_ibus_class.assert_not_called() - self.assertIsNone(injector._ibus_injector) + + @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=False, + ) + @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.shutil.which") + def test_kde_wayland_with_virtual_keyboard_constructs_ibus_injector( + self, + mock_which: MagicMock, + mock_ibus_class: MagicMock, + *_args: MagicMock, + ) -> None: + """KDE + confirmed VK bridge + inactive IBus → scoped IBus path (#911). + + With KWin VirtualKeyboard set to "IBus Wayland" the bare-xkb baseline is + safe: the bridge is exactly what the #752 exclusion was uncertain about. + """ + mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None) + mock_ibus_class.return_value = MagicMock() + with patch.dict( + "os.environ", + { + "XDG_SESSION_TYPE": "wayland", + "WAYLAND_DISPLAY": "wayland-0", + "XDG_CURRENT_DESKTOP": "KDE", + "KDE_FULL_SESSION": "true", + }, + clear=True, + ): + with patch.object(TextInjector, "_kde_virtual_keyboard_enabled", 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)