Fix: IBus engine restore uses wrong fallback on GNOME/Wayland (#497) (#500)

* Fix: IBus engine restore uses wrong fallback on GNOME/Wayland (#497)

On GNOME/Wayland, when IBus has no global engine (common when Mutter
manages input sources), 'ibus engine' can return a misleading
'xkb:us::eng' default that doesn't match the user's actual keyboard
layout. When Vocalinux restores this wrong value after text injection,
Electron apps switch to US layout.

Root cause: get_current_engine() trusted the output of 'ibus engine'
without checking whether IBus actually had a global engine set, and
had no fallback to discover the real keyboard layout.

Fix:
- Add get_current_engine_gnome_fallback() that reads the actual
  keyboard layout from org.gnome.desktop.input-sources via gsettings
- Modify get_current_engine() to detect 'No global engine' in stderr
  and to detect the suspicious 'xkb:us::eng' default on GNOME/Wayland,
  falling back to gsettings in both cases
- Modify stop() to log a message when skipping engine restoration
  because no valid engine was captured (None means don't restore)
- Modify inject_text() to log when skipping restoration after
  injection for the same reason
- Backward compatible: if gsettings is unavailable, falls back to
  current behavior (returning None or the IBus-reported value)

Tests: 12 new tests covering GNOME fallback, 'No global engine'
detection, suspicious default detection, gsettings parsing, and
edge cases.

* ponytail: simplify gsettings uint32 parsing

gsettings prints 'uint32 N' or just 'N' — split()[-1] handles both,
no need for separate branches

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
Jatin K Malik
2026-07-11 05:42:00 +05:30
committed by GitHub
co-authored by Jatin K Malik
parent 3303d075a6
commit 4e3637dfb5
2 changed files with 335 additions and 6 deletions
+148 -6
View File
@@ -309,20 +309,150 @@ def is_engine_active() -> bool:
return False
def get_current_engine() -> Optional[str]:
"""Get the currently active IBus engine name."""
try:
import subprocess
def _is_gnome_session() -> bool:
"""Check if the current session is running under GNOME."""
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "")
return "GNOME" in desktop.upper()
def get_current_engine_gnome_fallback() -> Optional[str]:
"""
Get the current keyboard layout as an IBus engine name via GNOME settings.
On GNOME/Wayland, IBus may have no global engine (input sources are managed
by Mutter, not ibus-daemon). In that case ``ibus engine`` returns nothing
or a misleading ``xkb:us::eng`` default. This function reads the actual
keyboard layout from ``org.gnome.desktop.input-sources`` via gsettings.
Returns:
IBus-format engine name (e.g. ``xkb:br::``) or None if not GNOME
or if gsettings fails.
"""
if not _is_gnome_session():
return None
try:
sources_result = subprocess.run(
["gsettings", "get", "org.gnome.desktop.input-sources", "sources"],
capture_output=True,
text=True,
timeout=5,
)
if sources_result.returncode != 0:
logger.debug("gsettings get sources failed; not using GNOME fallback")
return None
import ast
sources = ast.literal_eval(sources_result.stdout.strip())
if not sources:
logger.debug("GNOME input-sources list is empty")
return None
# Get the current index
current_result = subprocess.run(
["gsettings", "get", "org.gnome.desktop.input-sources", "current"],
capture_output=True,
text=True,
timeout=5,
)
if current_result.returncode != 0:
current_idx = 0
else:
# ponytail: gsettings prints "uint32 N" or just "N" — split()[-1] handles both
current_idx = int(current_result.stdout.strip().split()[-1])
if current_idx < 0 or current_idx >= len(sources):
current_idx = 0
source_type, source_id = sources[current_idx]
# GNOME uses ('xkb', 'br+dyn') or ('ibus', 'anthy') tuples.
# For xkb sources, build an IBus engine name like xkb:<layout>::<variant>
if source_type == "xkb":
layout, _, variant = source_id.partition("+")
if variant:
engine_name = f"xkb:{layout}::{variant}"
else:
engine_name = f"xkb:{layout}::"
logger.debug(
f"GNOME fallback: current source index {current_idx}, "
f"engine name: {engine_name}"
)
return engine_name
elif source_type == "ibus":
# IBus IM engine — return the engine ID directly
logger.debug(f"GNOME fallback: current source is IBus engine '{source_id}'")
return source_id
logger.debug(f"GNOME fallback: unknown source type '{source_type}'")
return None
except (
subprocess.SubprocessError,
FileNotFoundError,
ValueError,
SyntaxError,
) as e:
logger.debug(f"GNOME fallback via gsettings failed: {e}")
return None
def get_current_engine() -> Optional[str]:
"""
Get the currently active IBus engine name.
On GNOME/Wayland, IBus may have no global engine set (Mutter manages input
sources). In that case ``ibus engine`` can return a misleading
``xkb:us::eng`` default that doesn't match the user's actual keyboard
layout. We detect this and fall back to reading the layout from GNOME's
gsettings so the real layout is preserved during engine restore.
Returns:
The IBus engine name, or None if it cannot be determined.
"""
try:
result = subprocess.run(
["ibus", "engine"],
capture_output=True,
text=True,
timeout=5,
)
# Check for "No global engine" in stderr (ibus-daemon has no
# global engine set — common on GNOME/Wayland where Mutter manages
# input sources).
stderr_lower = result.stderr.lower() if result.stderr else ""
if "no global engine" in stderr_lower:
logger.debug(
"ibus engine reports 'No global engine'; " "attempting GNOME gsettings fallback"
)
return get_current_engine_gnome_fallback()
if result.returncode == 0:
engine = result.stdout.strip()
return engine if engine else None
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():
logger.debug(
"ibus engine returned 'xkb:us::eng' on GNOME/Wayland; "
"attempting GNOME gsettings fallback (see #497)"
)
gnome_engine = get_current_engine_gnome_fallback()
if gnome_engine and gnome_engine != engine:
logger.debug(
f"GNOME gsettings reports different engine: "
f"{gnome_engine} (was {engine})"
)
return gnome_engine
return engine
except (subprocess.SubprocessError, FileNotFoundError):
pass
return None
@@ -996,12 +1126,19 @@ class IBusTextInjector:
"""
Stop the IBus text injector and restore previous engine and XKB layout.
Call this when Vocalinux is shutting down.
Call this when Vocalinux is shutting down. If no valid engine was
captured before injection (e.g. on GNOME/Wayland with no global IBus
engine), restoration is skipped to avoid switching the user to a
wrong layout (see issue #497).
"""
if self._previous_engine:
logger.info(f"Restoring previous engine: {self._previous_engine}")
switch_engine(self._previous_engine)
self._previous_engine = None
else:
logger.info(
"Skipping engine restoration — " "no valid engine was captured before injection"
)
# Restore the XKB layout that was captured during setup
# This ensures the user's original keyboard layout is preserved
@@ -1146,6 +1283,11 @@ class IBusTextInjector:
if restore_engine:
logger.debug(f"Restoring IBus engine after injection: {restore_engine}")
switch_engine(restore_engine)
else:
logger.debug(
"Skipping engine restoration after injection — "
"no valid engine was captured (see #497)"
)
return False
+187
View File
@@ -232,6 +232,193 @@ class TestGetCurrentEngine(unittest.TestCase):
result = get_current_engine()
self.assertIsNone(result)
@patch("vocalinux.text_injection.ibus_engine.get_current_engine_gnome_fallback")
@patch("subprocess.run")
def test_get_current_engine_no_global_engine_uses_gnome_fallback(
self, mock_run, mock_gnome_fallback
):
"""Test that 'No global engine' in stderr triggers GNOME fallback."""
mock_run.return_value = MagicMock(stdout="", stderr="No global engine\n", returncode=1)
mock_gnome_fallback.return_value = "xkb:br::"
from vocalinux.text_injection.ibus_engine import get_current_engine
result = get_current_engine()
self.assertEqual(result, "xkb:br::")
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)
@patch("subprocess.run")
def test_get_current_engine_suspicious_us_default_on_gnome_wayland(
self, mock_run, mock_wayland, mock_gnome, mock_gnome_fallback
):
"""Test that xkb:us::eng on GNOME/Wayland triggers gsettings fallback (#497)."""
mock_run.return_value = MagicMock(stdout="xkb:us::eng\n", stderr="", returncode=0)
mock_gnome_fallback.return_value = "xkb:br::"
from vocalinux.text_injection.ibus_engine import get_current_engine
result = get_current_engine()
self.assertEqual(result, "xkb:br::")
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)
@patch("subprocess.run")
def test_get_current_engine_keeps_us_default_when_gnome_fails(
self, mock_run, mock_wayland, mock_gnome, mock_gnome_fallback
):
"""Test that we keep xkb:us::eng when GNOME fallback returns None."""
mock_run.return_value = MagicMock(stdout="xkb:us::eng\n", stderr="", returncode=0)
mock_gnome_fallback.return_value = None
from vocalinux.text_injection.ibus_engine import get_current_engine
result = get_current_engine()
self.assertEqual(result, "xkb:us::eng")
@patch("vocalinux.text_injection.ibus_engine.get_current_engine_gnome_fallback")
@patch("vocalinux.text_injection.ibus_engine._is_gnome_session", return_value=False)
@patch("subprocess.run")
def test_get_current_engine_suspicious_us_not_on_gnome(
self, mock_run, mock_gnome, mock_gnome_fallback
):
"""Test that xkb:us::eng is trusted when not on GNOME."""
mock_run.return_value = MagicMock(stdout="xkb:us::eng\n", stderr="", returncode=0)
from vocalinux.text_injection.ibus_engine import get_current_engine
result = get_current_engine()
self.assertEqual(result, "xkb:us::eng")
mock_gnome_fallback.assert_not_called()
class TestGetCurrentEngineGnomeFallback(unittest.TestCase):
"""Tests for get_current_engine_gnome_fallback function."""
@patch("vocalinux.text_injection.ibus_engine._is_gnome_session", return_value=False)
def test_not_gnome_returns_none(self, mock_gnome):
"""Test that non-GNOME sessions return None."""
from vocalinux.text_injection.ibus_engine import get_current_engine_gnome_fallback
result = get_current_engine_gnome_fallback()
self.assertIsNone(result)
@patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"})
@patch("subprocess.run")
def test_gsettings_sources_failure_returns_none(self, mock_run):
"""Test that gsettings failure returns None."""
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
from vocalinux.text_injection.ibus_engine import get_current_engine_gnome_fallback
result = get_current_engine_gnome_fallback()
self.assertIsNone(result)
@patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"})
@patch("subprocess.run")
def test_empty_sources_returns_none(self, mock_run):
"""Test that empty input-sources list returns None."""
mock_run.return_value = MagicMock(returncode=0, stdout="[]", stderr="")
from vocalinux.text_injection.ibus_engine import get_current_engine_gnome_fallback
result = get_current_engine_gnome_fallback()
self.assertIsNone(result)
@patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"})
@patch("subprocess.run")
def test_xkb_source_returns_ibus_engine_name(self, mock_run):
"""Test that an xkb source is converted to IBus engine format."""
sources_output = "[('xkb', 'br'), ('xkb', 'us+altgr-intl')]"
current_output = "uint32 0"
mock_run.side_effect = [
MagicMock(returncode=0, stdout=sources_output, stderr=""),
MagicMock(returncode=0, stdout=current_output, stderr=""),
]
from vocalinux.text_injection.ibus_engine import get_current_engine_gnome_fallback
result = get_current_engine_gnome_fallback()
self.assertEqual(result, "xkb:br::")
@patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"})
@patch("subprocess.run")
def test_xkb_source_with_variant(self, mock_run):
"""Test that an xkb source with variant is parsed correctly."""
sources_output = "[('xkb', 'us+altgr-intl')]"
current_output = "uint32 0"
mock_run.side_effect = [
MagicMock(returncode=0, stdout=sources_output, stderr=""),
MagicMock(returncode=0, stdout=current_output, stderr=""),
]
from vocalinux.text_injection.ibus_engine import get_current_engine_gnome_fallback
result = get_current_engine_gnome_fallback()
self.assertEqual(result, "xkb:us::altgr-intl")
@patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"})
@patch("subprocess.run")
def test_ibus_source_returns_engine_id(self, mock_run):
"""Test that an ibus source returns the engine ID directly."""
sources_output = "[('ibus', 'anthy')]"
current_output = "uint32 0"
mock_run.side_effect = [
MagicMock(returncode=0, stdout=sources_output, stderr=""),
MagicMock(returncode=0, stdout=current_output, stderr=""),
]
from vocalinux.text_injection.ibus_engine import get_current_engine_gnome_fallback
result = get_current_engine_gnome_fallback()
self.assertEqual(result, "anthy")
@patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"})
@patch("subprocess.run")
def test_current_index_selects_correct_source(self, mock_run):
"""Test that the current index from gsettings selects the right source."""
sources_output = "[('xkb', 'us'), ('xkb', 'br')]"
current_output = "uint32 1"
mock_run.side_effect = [
MagicMock(returncode=0, stdout=sources_output, stderr=""),
MagicMock(returncode=0, stdout=current_output, stderr=""),
]
from vocalinux.text_injection.ibus_engine import get_current_engine_gnome_fallback
result = get_current_engine_gnome_fallback()
self.assertEqual(result, "xkb:br::")
@patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"})
@patch("subprocess.run")
def test_current_failure_defaults_to_index_zero(self, mock_run):
"""Test that if gsettings get current fails, index 0 is used."""
sources_output = "[('xkb', 'br'), ('xkb', 'us')]"
mock_run.side_effect = [
MagicMock(returncode=0, stdout=sources_output, stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
]
from vocalinux.text_injection.ibus_engine import get_current_engine_gnome_fallback
result = get_current_engine_gnome_fallback()
self.assertEqual(result, "xkb:br::")
@patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "GNOME"})
@patch("subprocess.run")
def test_gsettings_not_found_returns_none(self, mock_run):
"""Test that FileNotFoundError (gsettings not installed) returns None."""
mock_run.side_effect = FileNotFoundError("gsettings not found")
from vocalinux.text_injection.ibus_engine import get_current_engine_gnome_fallback
result = get_current_engine_gnome_fallback()
self.assertIsNone(result)
class TestSwitchEngine(unittest.TestCase):
"""Tests for switch_engine function."""