fix(ibus): stop the xkb:us::eng guard flipping GNOME/X11 to a US layout (#827)

The gsettings fallback for the bogus xkb:us::eng default only fired on
GNOME/Wayland; on GNOME/X11 the default is accepted and written back
into the X keymap, permanently switching the user off their real
layout (Hungarian, Brazilian ABNT2, etc).

_get_gnome_current_source() also had no fallback when mru-sources is
empty (a fresh session with a single input source never populates the
MRU list), so even with the guard fixed the recovery path still
returned None. It now falls back to the static sources list.

Fixes #699
This commit is contained in:
Amir Fathi
2026-09-20 11:18:08 -07:00
committed by GitHub
parent 1f121546fb
commit c7b8cd4802
2 changed files with 80 additions and 28 deletions
+38 -26
View File
@@ -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)"
+42 -2
View File
@@ -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):