From 4e3637dfb59131aeb2c3ae50df4dd883189c6674 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Fri, 10 Jul 2026 17:12:00 -0700 Subject: [PATCH] Fix: IBus engine restore uses wrong fallback on GNOME/Wayland (#497) (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src/vocalinux/text_injection/ibus_engine.py | 154 +++++++++++++++- tests/test_ibus_engine.py | 187 ++++++++++++++++++++ 2 files changed, 335 insertions(+), 6 deletions(-) diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 786c366..12a3f5e 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -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::: + 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 diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index 9444e7a..d03b3f7 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -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."""