* 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:
co-authored by
Jatin K Malik
parent
3303d075a6
commit
4e3637dfb5
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user