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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user