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:
co-authored by
Claude Opus 4.8
parent
14972672c5
commit
03b0efb51d
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user