diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index ba70d0b..16d04af 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -169,6 +169,72 @@ class TextInjector: logger.warning("Could not detect desktop environment, defaulting to X11") return DesktopEnvironment.X11 + # Wayland compositors that do NOT bridge IBus commits to native Wayland + # clients. On these, an IBus engine's commit_text() reaches only XWayland and + # GTK/Qt apps that load the IBus IM module, while native apps (e.g. + # cosmic-term) receive nothing -- the injection appears to succeed but the + # text is silently dropped. These are the smithay/wlroots-based compositors + # that implement input handling without IBus text-input integration. + _IBUS_UNBRIDGED_COMPOSITORS = ( + "cosmic", + "sway", + "hyprland", + "wayfire", + "river", + "niri", + "labwc", + "weston", + ) + + def _wayland_compositor_bridges_ibus(self) -> bool: + """Whether native Wayland clients receive IBus commits on this compositor. + + GNOME (mutter), KDE (kwin) and most full desktops bridge IBus to native + Wayland clients, so an engine's commit_text() reaches the focused app. A + handful of compositors (see ``_IBUS_UNBRIDGED_COMPOSITORS``) do not, so on + those we must use a virtual-keyboard tool (wtype/ydotool) instead. We use + a denylist rather than an allowlist so that unrecognised desktops keep the + previous IBus-preferred behaviour. + """ + if self.environment != DesktopEnvironment.WAYLAND: + # X11 / XWayland: IBus reaches apps through XIM regardless of DE. + return True + desktop = " ".join( + os.environ.get(var, "") + for var in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION") + ).lower() + return not any(name in desktop for name in self._IBUS_UNBRIDGED_COMPOSITORS) + + def _is_ydotoold_running(self) -> bool: + """Return True if the ydotoold daemon appears to be running. + + ``ydotool type ""`` exits 0 even with no daemon (it prints a notice and + silently does nothing), so the exit code alone is not a reliable probe. + Check for the daemon's socket first, then fall back to scanning for the + process itself. + """ + socket_candidates = [] + env_socket = os.environ.get("YDOTOOL_SOCKET") + if env_socket: + socket_candidates.append(env_socket) + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") + if runtime_dir: + socket_candidates.append(os.path.join(runtime_dir, ".ydotool_socket")) + socket_candidates.append("/tmp/.ydotool_socket") + if any(os.path.exists(path) for path in socket_candidates): + return True + try: + result = subprocess.run( + ["pgrep", "-x", "ydotoold"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + ) + return result.returncode == 0 + except (OSError, subprocess.SubprocessError): + # pgrep missing, timed out, or otherwise unavailable -> assume not running + return False + def _check_dependencies(self): """Check for the required tools for text injection.""" ibus_requested = False @@ -198,6 +264,15 @@ class TextInjector: "(e.g., KDE Plasma). Using alternative text injection method. " "For IBus setup, see: https://github.com/jatinkrmalik/vocalinux/wiki/IBus-Setup" ) + # Some Wayland compositors (COSMIC, Sway, Hyprland, ...) do not deliver + # IBus commits to native Wayland apps, so IBus would silently drop the + # text even though commit_text() reports success. + elif not self._wayland_compositor_bridges_ibus(): + logger.info( + "Compositor '%s' does not bridge IBus to native Wayland apps; " + "using virtual-keyboard injection (wtype/ydotool) instead.", + os.environ.get("XDG_CURRENT_DESKTOP", "unknown"), + ) else: try: self._ibus_injector = IBusTextInjector(auto_activate=False) @@ -218,31 +293,18 @@ class TextInjector: ydotool_available = shutil.which("ydotool") is not None xdotool_available = shutil.which("xdotool") is not None - if ydotool_available: - # Verify ydotoold daemon is running before selecting ydotool - try: - subprocess.run( - ["ydotool", "type", ""], - check=True, - stderr=subprocess.PIPE, - timeout=2, - ) - self.wayland_tool = "ydotool" - logger.info(f"Using {self.wayland_tool} for Wayland text injection") - except ( - subprocess.CalledProcessError, - subprocess.TimeoutExpired, - FileNotFoundError, - ): - if wtype_available: - self.wayland_tool = "wtype" - logger.info( - "Using " - f"{self.wayland_tool} for Wayland text injection " - "(ydotoold not running)" - ) - else: - logger.warning("ydotool found but ydotoold daemon not running") + if ydotool_available and self._is_ydotoold_running(): + # ydotoold is running; ydotool can inject via the daemon. + self.wayland_tool = "ydotool" + logger.info(f"Using {self.wayland_tool} for Wayland text injection") + elif ydotool_available and not wtype_available: + # ydotool present but no daemon and no wtype: try ydotool anyway + # (direct uinput mode, requires access to /dev/uinput). + self.wayland_tool = "ydotool" + logger.warning( + "ydotoold daemon not running; using ydotool in direct mode " + "(may have latency/permission issues)" + ) elif wtype_available: self.wayland_tool = "wtype" logger.info(f"Using {self.wayland_tool} for Wayland text injection") diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index a6f76da..c52f917 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -1669,3 +1669,120 @@ class TestIBusRuntimeFallback(unittest.TestCase): self.assertTrue(result) self.assertEqual(injector.environment, DesktopEnvironment.X11) + + +class TestCompositorIBusBridging(unittest.TestCase): + """Tests for skipping IBus on compositors that don't bridge it to native apps.""" + + def _bare_injector(self, environment=DesktopEnvironment.WAYLAND): + """Build a TextInjector without running __init__ side effects.""" + injector = TextInjector.__new__(TextInjector) + injector.environment = environment + return injector + + def test_bridged_desktops_prefer_ibus(self): + """GNOME/KDE/Cinnamon and unknown desktops should keep using IBus.""" + injector = self._bare_injector() + for desktop in ("GNOME", "ubuntu:GNOME", "KDE", "X-Cinnamon", ""): + with patch.dict( + "os.environ", + { + "XDG_CURRENT_DESKTOP": desktop, + "XDG_SESSION_DESKTOP": desktop, + "DESKTOP_SESSION": desktop, + }, + ): + self.assertTrue(injector._wayland_compositor_bridges_ibus(), desktop) + + def test_unbridged_compositors_skip_ibus(self): + """COSMIC and wlroots compositors do not deliver IBus commits to native apps.""" + injector = self._bare_injector() + for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "niri", "river"): + with patch.dict( + "os.environ", + { + "XDG_CURRENT_DESKTOP": desktop, + "XDG_SESSION_DESKTOP": desktop, + "DESKTOP_SESSION": desktop, + }, + ): + self.assertFalse(injector._wayland_compositor_bridges_ibus(), desktop) + + def test_non_wayland_always_bridges(self): + """On X11/XWayland IBus works via XIM regardless of desktop.""" + injector = self._bare_injector(DesktopEnvironment.X11) + with patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "COSMIC"}): + self.assertTrue(injector._wayland_compositor_bridges_ibus()) + + @patch("os.path.exists", return_value=True) + def test_is_ydotoold_running_true_when_socket_exists(self, _mock_exists): + injector = self._bare_injector() + self.assertTrue(injector._is_ydotoold_running()) + + @patch("vocalinux.text_injection.text_injector.subprocess.run") + @patch("os.path.exists", return_value=False) + def test_is_ydotoold_running_true_when_process_found(self, _mock_exists, mock_run): + injector = self._bare_injector() + mock_run.return_value = MagicMock(returncode=0) + self.assertTrue(injector._is_ydotoold_running()) + + @patch("vocalinux.text_injection.text_injector.subprocess.run") + @patch("os.path.exists", return_value=False) + def test_is_ydotoold_running_false_when_absent(self, _mock_exists, mock_run): + injector = self._bare_injector() + mock_run.return_value = MagicMock(returncode=1) + self.assertFalse(injector._is_ydotoold_running()) + + @patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True) + @patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True) + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True) + @patch("vocalinux.text_injection.text_injector.IBusTextInjector") + @patch("vocalinux.text_injection.text_injector.subprocess.run") + @patch("vocalinux.text_injection.text_injector.shutil.which") + def test_cosmic_skips_ibus_and_uses_wtype( + self, + mock_which, + mock_run, + mock_ibus_class, + mock_ibus_available, + mock_is_active, + mock_daemon, + ): + """On COSMIC, even with IBus active, injection must use wtype, not IBus.""" + mock_which.side_effect = lambda cmd: "/usr/bin/wtype" if cmd == "wtype" else None + mock_run.return_value = MagicMock(returncode=0, stderr="") + + with patch.dict( + "os.environ", + { + "XDG_SESSION_TYPE": "wayland", + "WAYLAND_DISPLAY": "w-1", + "XDG_CURRENT_DESKTOP": "COSMIC", + "XDG_SESSION_DESKTOP": "cosmic", + "DESKTOP_SESSION": "cosmic", + }, + ): + injector = TextInjector() + + self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND) + self.assertEqual(injector.wayland_tool, "wtype") + mock_ibus_class.assert_not_called() + + def test_is_ydotoold_running_true_when_socket_env_set(self): + """An explicit YDOTOOL_SOCKET pointing at an existing socket counts as running.""" + injector = self._bare_injector() + with patch.dict("os.environ", {"YDOTOOL_SOCKET": "/run/user/1000/.ydotool_socket"}): + with patch("os.path.exists", return_value=True): + self.assertTrue(injector._is_ydotoold_running()) + + @patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + def test_ydotool_direct_mode_when_no_daemon_and_no_wtype(self, mock_which, _mock_ibus): + """ydotool present without a daemon and no wtype falls back to ydotool direct mode.""" + mock_which.side_effect = lambda cmd: "/usr/bin/ydotool" if cmd == "ydotool" else None + with patch.object(TextInjector, "_is_ydotoold_running", return_value=False): + with patch.dict( + "os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"} + ): + injector = TextInjector() + self.assertEqual(injector.wayland_tool, "ydotool")