diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 6b918ff..fff3a00 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -252,12 +252,21 @@ def is_ibus_active_input_method() -> bool: logger.debug(f"IBus detected as active input method on Wayland (engine: {engine})") return True - # X11 / XWayland: IBus reaches apps via XIM. An env-var signal, or a running - # daemon with any engine (KDE Plasma with legacy env vars unset), counts. + # X11 / XWayland: IBus reaches apps via XIM only if those apps actually + # talk to IBus. Env vars are the signal. A leftover or self-started daemon + # with a bare xkb engine on KDE is not the session IM; commit_text() then + # reports success while Kate/Qt/GTK get nothing (issue #752). if ibus_via_env: logger.debug("IBus detected as active input method via environment variables") return True + if _is_kde_session(): + logger.debug( + "KDE session without IBus IM env; not treating a running ibus-daemon " + "as the session IM (see #752)" + ) + return False + engine = get_current_engine() if engine: logger.debug( @@ -274,10 +283,13 @@ def start_ibus_daemon(): """ Start the IBus daemon if it's not already running. - 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. + X11 only, and only when this session already opted into IBus via + GTK_IM_MODULE / QT_IM_MODULE / XMODIFIERS. 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 same happens on KDE + (and other non-IBus X11 sessions) if we start a daemon the apps never + talk to (issue #752). The compositor/DE must own the IBus instance. Returns: True if daemon was started or already running, False on failure @@ -290,6 +302,14 @@ def start_ibus_daemon(): logger.debug("Not starting ibus-daemon on Wayland (see #574)") return False + if not _ibus_im_env_configured(): + logger.debug( + "Not starting ibus-daemon: this session has no IBus IM env " + "(GTK_IM_MODULE / QT_IM_MODULE / XMODIFIERS). A self-started " + "daemon reports inject success while apps ignore it (#752, #574)." + ) + return False + if not is_ibus_available(): return False @@ -514,9 +534,33 @@ def get_current_engine() -> Optional[str]: return None +def _ibus_im_env_configured() -> bool: + """True if the process env says IBus is the session input method.""" + gtk_im = os.environ.get("GTK_IM_MODULE", "").lower() + qt_im = os.environ.get("QT_IM_MODULE", "").lower() + xmodifiers = os.environ.get("XMODIFIERS", "").lower() + return ("ibus" in gtk_im) or ("ibus" in qt_im) or ("@im=ibus" in xmodifiers) + + +def _is_kde_session() -> bool: + """Check if the current session is KDE Plasma.""" + if os.environ.get("KDE_FULL_SESSION", "").lower() == "true": + return True + desktop = " ".join( + os.environ.get(var, "") for var in ("XDG_CURRENT_DESKTOP", "DESKTOP_SESSION", "GDMSESSION") + ).lower() + return "kde" in desktop or "plasma" in desktop + + def _is_wayland_session() -> bool: - """Check if the current session is running under Wayland.""" - return os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland" + """Check if the current session is running under Wayland. + + GTK/Qt apps on Plasma Wayland often keep XDG_SESSION_TYPE=x11. A live + WAYLAND_DISPLAY socket is the reliable hint (issue #752). + """ + if os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland": + return True + return bool(os.environ.get("WAYLAND_DISPLAY")) def get_current_xkb_layout() -> tuple: diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index b27b256..6f80c83 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -213,17 +213,15 @@ class TextInjector: ) return DesktopEnvironment.WAYLAND_XDOTOOL return DesktopEnvironment.WAYLAND - elif session_type == "x11": + # A live Wayland socket beats a stale XDG_SESSION_TYPE=x11. Plasma + # Wayland GTK apps often keep session_type=x11, then we pick IBus/XIM + # and native Kate/Obsidian get nothing (issue #752). + if wayland_display: + return DesktopEnvironment.WAYLAND + if session_type == "x11" or x11_display: return DesktopEnvironment.X11 - else: - # Try to detect based on other methods - if wayland_display: - return DesktopEnvironment.WAYLAND - elif x11_display: - return DesktopEnvironment.X11 - else: - logger.warning("Could not detect desktop environment, defaulting to X11") - return DesktopEnvironment.X11 + logger.warning("Could not detect desktop environment, defaulting to X11") + return DesktopEnvironment.X11 # Wayland compositors that do NOT bridge IBus commits to native Wayland # clients. On these, an IBus engine's commit_text() reaches only XWayland and @@ -508,11 +506,16 @@ class TextInjector: and "@im=ibus" not in xmodifiers ) ) - # Bridging Wayland DEs (GNOME/KDE): inject_text() switches to the + # Bridging Wayland DEs (GNOME): inject_text() switches to the # real vocalinux engine for each commit, so a bare xkb:* baseline is - # fine (#501, #504). Unbridged compositors still bail below. + # 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. wayland_scoped_ibus = ( - self.environment == DesktopEnvironment.WAYLAND and not explicit_non_ibus_im + self.environment == DesktopEnvironment.WAYLAND + and not explicit_non_ibus_im + and not _is_kde_plasma_session() ) # Check if IBus is the active input method (not just installed) diff --git a/tests/test_ibus_active_engine_guard.py b/tests/test_ibus_active_engine_guard.py index a2e90c6..cb9af49 100644 --- a/tests/test_ibus_active_engine_guard.py +++ b/tests/test_ibus_active_engine_guard.py @@ -87,5 +87,42 @@ class TestX11BehaviorUnchanged(unittest.TestCase): self.assertFalse(ibus_engine.is_ibus_active_input_method()) +class TestKdeDoesNotTreatLeftoverDaemonAsIm(unittest.TestCase): + @patch.object(ibus_engine, "get_current_engine", return_value="xkb:us::eng") + @patch.object(ibus_engine, "is_ibus_daemon_running", return_value=True) + @patch.dict( + "os.environ", + {"XDG_SESSION_TYPE": "x11", "XDG_CURRENT_DESKTOP": "KDE"}, + clear=True, + ) + def test_kde_x11_daemon_without_im_env_is_inactive(self, *_): + # CachyOS/KDE: we started (or found) ibus-daemon, but Qt/GTK never use it (#752). + self.assertFalse(ibus_engine.is_ibus_active_input_method()) + + @patch.object(ibus_engine, "get_current_engine", return_value="xkb:us::eng") + @patch.object(ibus_engine, "is_ibus_daemon_running", return_value=True) + @patch.dict( + "os.environ", + { + "XDG_SESSION_TYPE": "x11", + "XDG_CURRENT_DESKTOP": "KDE", + "GTK_IM_MODULE": "ibus", + }, + clear=True, + ) + def test_kde_x11_with_ibus_env_still_active(self, *_): + self.assertTrue(ibus_engine.is_ibus_active_input_method()) + + @patch.object(ibus_engine, "get_current_engine", return_value="xkb:us::eng") + @patch.object(ibus_engine, "is_ibus_daemon_running", return_value=True) + @patch.dict( + "os.environ", + {"XDG_SESSION_TYPE": "x11", "KDE_FULL_SESSION": "true"}, + clear=True, + ) + def test_kde_full_session_without_im_env_is_inactive(self, *_): + self.assertFalse(ibus_engine.is_ibus_active_input_method()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ibus_engine_utils.py b/tests/test_ibus_engine_utils.py index 20c7e43..30ef29a 100644 --- a/tests/test_ibus_engine_utils.py +++ b/tests/test_ibus_engine_utils.py @@ -206,20 +206,66 @@ class TestStartIBusDaemon(unittest.TestCase): @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.""" + """X11 sessions that already opted into IBus may still auto-start the daemon.""" 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): + with patch.dict( + "os.environ", + {"XDG_SESSION_TYPE": "x11", "GTK_IM_MODULE": "ibus"}, + clear=True, + ): 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"]) + @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_without_im_env( + self, mock_is_running, mock_available, mock_popen + ): + """Do not spawn ibus-daemon when the session never opted into IBus (#752).""" + from vocalinux.text_injection.ibus_engine import start_ibus_daemon + + with patch.dict( + "os.environ", + {"XDG_SESSION_TYPE": "x11", "XDG_CURRENT_DESKTOP": "KDE"}, + clear=True, + ): + result = start_ibus_daemon() + + self.assertFalse(result) + mock_popen.assert_not_called() + + @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_when_wayland_socket_present( + self, mock_is_running, mock_available, mock_popen + ): + """WAYLAND_DISPLAY means Wayland even if XDG_SESSION_TYPE says x11 (#752).""" + from vocalinux.text_injection.ibus_engine import start_ibus_daemon + + with patch.dict( + "os.environ", + { + "XDG_SESSION_TYPE": "x11", + "WAYLAND_DISPLAY": "wayland-0", + "GTK_IM_MODULE": "ibus", + }, + clear=True, + ): + result = start_ibus_daemon() + + self.assertFalse(result) + mock_popen.assert_not_called() + 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 b350546..5e20716 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -371,6 +371,19 @@ class TestTextInjector(unittest.TestCase): env = injector._detect_environment() self.assertEqual(env, DesktopEnvironment.WAYLAND) + def test_detect_environment_wayland_socket_beats_x11_session_type(self): + """Plasma Wayland GTK apps often keep XDG_SESSION_TYPE=x11 (#752).""" + with patch.dict( + "os.environ", + {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ":0"}, + clear=True, + ): + with patch.object(TextInjector, "_check_dependencies"): + injector = TextInjector.__new__(TextInjector) + injector._state_lock = threading.Lock() + env = injector._detect_environment() + self.assertEqual(env, DesktopEnvironment.WAYLAND) + def test_detect_environment_display_only(self): """Test environment detection via DISPLAY only.""" with patch.dict("os.environ", {"DISPLAY": ":0"}, clear=True): @@ -2256,3 +2269,33 @@ class TestForcedBackend(unittest.TestCase): def test_missing_variable_means_auto(self): with patch.dict("os.environ", {}, clear=True): self.assertEqual(TextInjector._forced_backend(), "auto") + + +class TestKdeSkipsInactiveIbus(unittest.TestCase): + """KDE without IBus as the session IM must not take the scoped IBus path (#752).""" + + @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_does_not_construct_ibus_injector( + self, + mock_which, + mock_ibus_class, + *_args, + ): + mock_which.side_effect = lambda cmd: "/usr/bin/wtype" if cmd == "wtype" else None + with patch.dict( + "os.environ", + { + "XDG_SESSION_TYPE": "wayland", + "WAYLAND_DISPLAY": "wayland-0", + "XDG_CURRENT_DESKTOP": "KDE", + "KDE_FULL_SESSION": "true", + }, + clear=True, + ): + injector = TextInjector() + mock_ibus_class.assert_not_called() + self.assertIsNone(injector._ibus_injector) diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py index f1d923e..b148d1b 100644 --- a/tests/test_text_injector_ext.py +++ b/tests/test_text_injector_ext.py @@ -194,11 +194,11 @@ class TestCheckDependencies(unittest.TestCase): with self.assertRaises(RuntimeError): obj._check_dependencies() - def test_kde_wayland_uses_ibus_when_daemon_runs_with_xkb_engine(self): + def test_kde_wayland_skips_leftover_ibus_when_daemon_runs_with_xkb_engine(self): + """Leftover IBus on KDE is skipped so scoped inject cannot fake success (#752).""" from vocalinux.text_injection.text_injector import DesktopEnvironment obj = _make_injector(DesktopEnvironment.WAYLAND) - mock_ibus = MagicMock() with ( patch.dict( @@ -222,12 +222,7 @@ class TestCheckDependencies(unittest.TestCase): "vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True, ), - patch( - "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("vocalinux.text_injection.text_injector.IBusTextInjector") as mock_ibus_class, patch.object(obj, "_start_ibus_initialization") as mock_start, patch( "shutil.which", @@ -236,8 +231,9 @@ class TestCheckDependencies(unittest.TestCase): ): obj._check_dependencies() - self.assertIs(obj._ibus_injector, mock_ibus) - mock_start.assert_called_once_with() + mock_ibus_class.assert_not_called() + self.assertIsNone(obj._ibus_injector) + mock_start.assert_not_called() self.assertEqual(obj.wayland_tool, "wtype") def test_gnome_wayland_uses_ibus_when_daemon_runs_with_xkb_engine(self):