fix(text_injection): use wtype on compositors that don't bridge IBus to native apps (#486)

* fix(text_injection): use wtype on compositors that don't bridge IBus to native apps

On Wayland compositors that don't bridge IBus to native clients (COSMIC,
Sway, Hyprland, Wayfire, river, niri, ...), the IBus engine's commit_text()
reaches only XWayland and GTK/Qt apps that load the IBus IM module. Native
apps such as cosmic-term receive nothing, yet inject_text() reports success,
so dictation silently does nothing.

- Add a compositor denylist (_wayland_compositor_bridges_ibus) so IBus is
  skipped on those compositors and the virtual-keyboard path (wtype/ydotool)
  is used instead. A denylist (rather than an allowlist) keeps the existing
  IBus-preferred behaviour on unrecognised desktops. Complements the recent
  KDE Plasma Wayland handling, which covers the mirror-image case.
- Replace the unreliable `ydotool type ""` probe (exits 0 even with no daemon)
  with _is_ydotoold_running(), so a daemonless ydotool install correctly falls
  back to wtype instead of selecting a no-op ydotool.
- Add unit tests for the compositor gate, the ydotoold check, and that an
  active-IBus COSMIC session selects wtype.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(text_injection): cover ydotoold socket-env and ydotool direct-mode paths

Add tests for the two new branches codecov/patch flagged as uncovered:
the explicit YDOTOOL_SOCKET path in _is_ydotoold_running(), and the
ydotool direct-mode fallback when no daemon and no wtype are available.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
bbbboother
2026-06-30 19:24:55 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 14972672c5
commit 03b0efb51d
2 changed files with 204 additions and 25 deletions
+87 -25
View File
@@ -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")
+117
View File
@@ -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")