fix(inject): skip leftover IBus on KDE so dictation actually types (#753)

* fix(inject): skip leftover IBus on KDE so dictation actually types

Test Dictation and clipboard paste worked. Inject claimed success and
nothing showed up in Kate, Obsidian, terminals, or browsers.

We started ibus-daemon on X11 even when the session never opted into
IBus, then treated that daemon plus a bare xkb engine as active.
commit_text() returns True. Qt/GTK on KDE never hear it.

Do not spawn the daemon unless GTK_IM_MODULE / QT_IM_MODULE /
XMODIFIERS already name IBus. On KDE, a running daemon without those
env vars is not the session IM. Prefer a live WAYLAND_DISPLAY over a
stale XDG_SESSION_TYPE=x11, and do not take the GNOME scoped-IBus
shortcut on KDE.

See #752

* style: wrap IBus helpers so black is happy

CI lint failed on two lines in the #753 inject fix. Same behavior.

* test(inject): leftover IBus on KDE should be skipped

The CheckDependencies case still expected IBus for a leftover daemon
on KDE. That is the #752 bug. It now expects skip and wtype instead.
This commit is contained in:
Jatin K Malik
2026-09-02 01:02:49 -07:00
committed by GitHub
parent 3c94f021fd
commit 6684f6f584
6 changed files with 202 additions and 33 deletions
+52 -8
View File
@@ -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:
+16 -13
View File
@@ -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)
+37
View File
@@ -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()
+48 -2
View File
@@ -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."""
+43
View File
@@ -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)
+6 -10
View File
@@ -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):