diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index fff3a00..55b1d8a 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -360,21 +360,18 @@ def _is_gnome_session() -> bool: return "GNOME" in desktop.upper() -def _get_gnome_current_source() -> Optional[tuple[str, str]]: - """Return GNOME's current input source from the MRU list.""" - if not _is_gnome_session(): - return None - +def _read_gnome_input_sources_key(key: str) -> Optional[list]: + """Return the parsed list for a gsettings org.gnome.desktop.input-sources key, or None.""" try: result = subprocess.run( - ["gsettings", "get", "org.gnome.desktop.input-sources", "mru-sources"], + ["gsettings", "get", "org.gnome.desktop.input-sources", key], capture_output=True, text=True, timeout=5, env=host_env(), ) if result.returncode != 0: - logger.debug("gsettings get mru-sources failed; not using GNOME fallback") + logger.debug(f"gsettings get {key} failed; not using GNOME fallback") return None import ast @@ -384,24 +381,39 @@ def _get_gnome_current_source() -> Optional[tuple[str, str]]: sources_text = sources_text[len("@a(ss) ") :] sources = ast.literal_eval(sources_text) if not isinstance(sources, (list, tuple)) or not sources: - logger.debug("GNOME mru-sources list is empty or invalid") return None - - source = sources[0] - if not isinstance(source, (list, tuple)) or len(source) != 2: - logger.debug("GNOME current input source has an invalid shape") - return None - - source_type, source_id = source - if not isinstance(source_type, str) or not isinstance(source_id, str) or not source_id: - logger.debug("GNOME current input source has invalid values") - return None - return source_type, source_id + return list(sources) except (subprocess.SubprocessError, OSError, TypeError, ValueError, SyntaxError) as e: logger.debug(f"GNOME fallback via gsettings failed: {e}") return None +def _get_gnome_current_source() -> Optional[tuple[str, str]]: + """Return GNOME's current input source from the MRU list, or from the + configured source list when GNOME has not recorded an MRU entry yet.""" + if not _is_gnome_session(): + return None + + sources = _read_gnome_input_sources_key("mru-sources") + if not sources: + logger.debug("GNOME mru-sources is empty; falling back to the configured sources list") + sources = _read_gnome_input_sources_key("sources") + if not sources: + logger.debug("GNOME mru-sources and sources are both empty or invalid") + return None + + source = sources[0] + if not isinstance(source, (list, tuple)) or len(source) != 2: + logger.debug("GNOME current input source has an invalid shape") + return None + + source_type, source_id = source + if not isinstance(source_type, str) or not isinstance(source_id, str) or not source_id: + logger.debug("GNOME current input source has invalid values") + return None + return source_type, source_id + + def _resolve_registered_xkb_engine(source_id: str) -> Optional[str]: """Resolve a GNOME XKB source to a registered IBus engine ID.""" layout, _, variant = source_id.partition("+") @@ -446,7 +458,9 @@ def get_current_engine_gnome_fallback() -> Optional[str]: On GNOME/Wayland, IBus may have no global engine (input sources are managed by Mutter, not ibus-daemon). GNOME's ``current`` key is deprecated and - ignored, so the first entry in ``mru-sources`` is used as the current source. + ignored, so the first entry in ``mru-sources`` is used as the current + source, falling back to ``sources`` (GNOME's static configured list) when + no MRU entry has been recorded yet. Explicit IBus source IDs are returned directly. XKB source IDs are matched against registered IBus engines; names such as ``xkb:cn::`` are never @@ -504,12 +518,10 @@ def get_current_engine() -> Optional[str]: if not engine: return None - # On GNOME/Wayland, ``ibus engine`` can return ``xkb:us::eng`` - # as a default fallback even when the user's actual layout is - # different (e.g. Brazilian ABNT2). Detect this suspicious - # default and try the GNOME gsettings fallback for the real - # layout before trusting the IBus output (see issue #497). - if engine == "xkb:us::eng" and _is_gnome_session() and _is_wayland_session(): + # On GNOME, ``ibus engine`` can return ``xkb:us::eng`` as a + # default fallback even when the actual layout differs. Detect + # it and try the gsettings fallback first (see #497, #699). + if engine == "xkb:us::eng" and _is_gnome_session(): logger.debug( "ibus engine returned 'xkb:us::eng' on GNOME/Wayland; " "attempting GNOME gsettings fallback (see #497)" diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index 13c5837..baf770d 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -264,6 +264,23 @@ class TestGetCurrentEngine(unittest.TestCase): self.assertEqual(result, "libpinyin") mock_gnome_fallback.assert_called_once() + @patch("vocalinux.text_injection.ibus_engine.get_current_engine_gnome_fallback") + @patch("vocalinux.text_injection.ibus_engine._is_gnome_session", return_value=True) + @patch("vocalinux.text_injection.ibus_engine._is_wayland_session", return_value=False) + @patch("subprocess.run") + def test_get_current_engine_suspicious_us_default_on_gnome_x11( + self, mock_run, mock_wayland, mock_gnome, mock_gnome_fallback + ): + """Test that xkb:us::eng on GNOME/X11 also triggers gsettings fallback (#699).""" + mock_run.return_value = MagicMock(stdout="xkb:us::eng\n", stderr="", returncode=0) + mock_gnome_fallback.return_value = "xkb:hu::hun" + + from vocalinux.text_injection.ibus_engine import get_current_engine + + result = get_current_engine() + self.assertEqual(result, "xkb:hu::hun") + mock_gnome_fallback.assert_called_once() + @patch("vocalinux.text_injection.ibus_engine.get_current_engine_gnome_fallback") @patch("vocalinux.text_injection.ibus_engine._is_gnome_session", return_value=True) @patch("vocalinux.text_injection.ibus_engine._is_wayland_session", return_value=True) @@ -320,8 +337,8 @@ class TestGetCurrentEngineGnomeFallback(unittest.TestCase): @patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"}) @patch("subprocess.run") - def test_empty_mru_sources_returns_none(self, mock_run): - """Test that an empty mru-sources list returns None.""" + def test_empty_mru_and_sources_returns_none(self, mock_run): + """Test that an empty mru-sources AND an empty sources both return None.""" mock_run.return_value = MagicMock(returncode=0, stdout="[]", stderr="") from vocalinux.text_injection.ibus_engine import get_current_engine_gnome_fallback @@ -329,6 +346,29 @@ class TestGetCurrentEngineGnomeFallback(unittest.TestCase): result = get_current_engine_gnome_fallback() self.assertIsNone(result) + @patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"}) + @patch("subprocess.run") + def test_empty_mru_sources_falls_back_to_configured_sources(self, mock_run): + """Test that an empty mru-sources falls back to sources[0] (#699).""" + + def run_side_effect(cmd, **kwargs): + if cmd[:2] == ["ibus", "list-engine"]: + return MagicMock(returncode=0, stdout="xkb:us::eng\nxkb:hu::hun\n", stderr="") + key = cmd[-1] + if key == "mru-sources": + return MagicMock(returncode=0, stdout="@a(ss) []", stderr="") + if key == "sources": + return MagicMock(returncode=0, stdout="[('xkb', 'hu')]", stderr="") + raise AssertionError(f"unexpected command: {cmd}") + + mock_run.side_effect = run_side_effect + + from vocalinux.text_injection.ibus_engine import get_current_engine_gnome_fallback + + result = get_current_engine_gnome_fallback() + self.assertEqual(result, "xkb:hu::hun") + self.assertEqual(mock_run.call_count, 3) # mru-sources, sources, ibus list-engine + @patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"}) @patch("subprocess.run") def test_invalid_current_source_shape_returns_none(self, mock_run):