fix(inject): add explicit niri guard and wtype-first key tool order

Autodetection on niri now never probes or selects the RemoteDesktop
portal, and the ibus-wayland bridge exception no longer reopens IBus
there. Key events after startup resolve wtype before ydotool, matching
_check_dependencies and the decided ladder.

conftest scrubs NIRI_SOCKET so tests modelling other desktops are not
affected when run inside a niri session.
This commit is contained in:
2026-11-06 18:17:17 +05:30
parent 5075933677
commit cf14c5b27c
5 changed files with 166 additions and 27 deletions
+40 -13
View File
@@ -100,6 +100,23 @@ def _is_kde_plasma_session() -> bool:
return "kde" in desktop or "plasma" in desktop
def _is_niri_session() -> bool:
"""Return True when the session runs niri (spec #16, niri guard).
niri exports ``NIRI_SOCKET`` to its children and sets
``XDG_CURRENT_DESKTOP=niri``. Either signal counts, so the niri guard
cannot lapse just because a launcher dropped the desktop variables.
"""
if os.environ.get("NIRI_SOCKET"):
return True
desktop_values = (
os.environ.get("XDG_CURRENT_DESKTOP", ""),
os.environ.get("XDG_SESSION_DESKTOP", ""),
os.environ.get("DESKTOP_SESSION", ""),
)
return any("niri" in value.lower() for value in desktop_values)
def _kde_wayland_ibus_hint() -> str:
"""Return the KDE Plasma Wayland IBus setup hint."""
return (
@@ -442,6 +459,11 @@ class TextInjector:
if self.environment != DesktopEnvironment.WAYLAND:
# X11 / XWayland: IBus reaches apps through XIM regardless of DE.
return True
if _is_niri_session():
# Explicit niri guard (spec #16): IBus reports success while niri's
# native clients receive nothing, so the ibus-wayland bridge
# exception below never reopens IBus on niri.
return False
desktop = " ".join(
os.environ.get(var, "")
for var in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION")
@@ -667,11 +689,11 @@ class TextInjector:
def _resolve_wayland_key_tool(self) -> Optional[str]:
"""Pick (or reuse) the Wayland virtual-keyboard tool for key events.
Mirrors the preference ``_check_dependencies`` already applies: prefer
ydotool via uinput once its daemon is (or can be made) ready, and only
fall back to wtype. Callers that resolve a tool after startup -- IBus
shortcut routing, BackSpace key events -- use this instead of picking
their own order.
Uses the same rung order as ``_check_dependencies`` (spec #16, ticket
#31): direct keysym typing (wtype) first, then the virtual-input tool
(ydotool, whose daemon is started when it is the tool in use). Callers
that resolve a tool after startup -- IBus shortcut routing, BackSpace
key events -- use this instead of picking their own order.
Returns:
The tool name, or None if neither is installed.
@@ -679,16 +701,17 @@ class TextInjector:
if getattr(self, "wayland_tool", None):
return self.wayland_tool
ydotool_available = shutil.which("ydotool") is not None
wtype_available = shutil.which("wtype") is not None
ydotool_available = shutil.which("ydotool") is not None
# _ensure_ydotoold can block for ~2s, so it stays outside the lock.
if ydotool_available and self._ensure_ydotoold():
tool = "ydotool"
elif ydotool_available and not wtype_available:
tool = "ydotool"
elif wtype_available:
if wtype_available:
tool = "wtype"
elif ydotool_available:
# _ensure_ydotoold can block for ~2s, so it stays outside the lock.
# A daemonless ydotool is still the tool in use, as in
# _check_dependencies; _ensure_ydotoold logs why it is not ready.
self._ensure_ydotoold()
tool = "ydotool"
else:
return None
@@ -1002,7 +1025,11 @@ class TextInjector:
pinned_tool_present = (forced == "wtype" and wtype_available) or (
forced == "ydotool" and ydotool_available
)
portal_available = not pinned_tool_present and self._portal_probe()
# niri ships no RemoteDesktop backend, so the portal is never probed
# there: no probe, no permission prompt, no portal rung (spec #16).
portal_available = (
not pinned_tool_present and not _is_niri_session() and self._portal_probe()
)
if ydotool_available:
_warn_if_ydotool_globally_enabled()
+3
View File
@@ -41,6 +41,9 @@ for _var in (
"XDG_SESSION_DESKTOP",
"DESKTOP_SESSION",
"WAYLAND_DISPLAY",
# niri exports this to every child; it would turn on the niri guard
# (text_injector._is_niri_session) in tests that model another desktop.
"NIRI_SOCKET",
):
os.environ.pop(_var, None)
atexit.register(shutil.rmtree, _XDG_ISOLATION_DIR, ignore_errors=True)
+104 -1
View File
@@ -36,7 +36,11 @@ from unittest.mock import MagicMock, patch
import pytest
from vocalinux.text_injection.remote_desktop_portal import RemoteDesktopPortalError
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
from vocalinux.text_injection.text_injector import (
DesktopEnvironment,
TextInjector,
_is_niri_session,
)
_TI = "vocalinux.text_injection.text_injector"
@@ -187,6 +191,105 @@ class TestNiriLadder:
assert runs == [["wtype", "--", "hi"]]
class TestNiriGuard:
"""Autodetection on niri never selects the portal or IBus (spec #16, c4).
The guard is explicit, so it does not depend on the portal probe failing
or on the ibus-wayland bridge being absent. Explicit user pins stay
honoured as escape hatches and are covered elsewhere.
"""
def test_niri_never_selects_portal_even_when_a_backend_answers(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A RemoteDesktop backend that answers on niri must still be skipped."""
injector = _selecting_injector(
monkeypatch,
desktop="niri",
tools={"wtype": "/usr/bin/wtype"},
portal_available=True,
)
assert injector.wayland_tool == "wtype"
assert injector._portal is None
def test_niri_never_selects_ibus_even_with_ibus_wayland_bridge_running(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The ibus-wayland bridge exception does not reopen IBus on niri."""
with patch(f"{_TI}.IBusTextInjector") as mock_ibus_class:
injector = _selecting_injector(
monkeypatch,
desktop="niri",
tools={"wtype": "/usr/bin/wtype"},
portal_available=False,
ibus_available=True,
ibus_bridge_running=True,
)
mock_ibus_class.assert_not_called()
assert injector._ibus_injector is None
assert injector.wayland_tool == "wtype"
def test_bridge_check_is_false_on_niri_even_with_bridge_running(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The compositor-bridge predicate itself answers no on niri."""
monkeypatch.setenv("XDG_CURRENT_DESKTOP", "niri")
monkeypatch.setattr(TextInjector, "_ibus_wayland_bridge_running", staticmethod(lambda: True))
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._wayland_compositor_bridges_ibus() is False
def test_niri_is_recognised_from_its_session_socket(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""NIRI_SOCKET identifies niri even when the desktop variable is unset."""
for var in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("NIRI_SOCKET", "/run/user/1000/niri.wayland-1.1.sock")
assert _is_niri_session() is True
def test_other_desktops_are_not_niri(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A GNOME session keeps the portal and IBus eligible."""
monkeypatch.delenv("NIRI_SOCKET", raising=False)
monkeypatch.setenv("XDG_CURRENT_DESKTOP", "GNOME")
assert _is_niri_session() is False
class TestKeyEventToolOrder:
"""Key events after startup follow the same rung order as autodetection.
Direct typing (wtype) outranks the virtual-input tool (ydotool) here too,
so BackSpace and IBus shortcut routing never silently switch tools.
"""
@staticmethod
def _tools(monkeypatch: pytest.MonkeyPatch, names: tuple) -> None:
monkeypatch.setattr(
"shutil.which", lambda cmd: f"/usr/bin/{cmd}" if cmd in names else None
)
def test_wtype_outranks_a_ready_ydotool(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._tools(monkeypatch, ("wtype", "ydotool"))
ensure = MagicMock(return_value=True)
monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: ensure())
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._resolve_wayland_key_tool() == "wtype"
assert injector.wayland_tool == "wtype"
ensure.assert_not_called()
def test_ydotool_is_used_only_when_wtype_is_absent(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
self._tools(monkeypatch, ("ydotool",))
monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: True)
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._resolve_wayland_key_tool() == "ydotool"
def test_no_key_tool_when_neither_is_installed(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._tools(monkeypatch, ())
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._resolve_wayland_key_tool() is None
class TestPortalFirstWhereImplemented:
"""GNOME/KDE implement RemoteDesktop, so the portal leads there."""
+7 -2
View File
@@ -2804,9 +2804,14 @@ class TestCompositorIBusBridging(unittest.TestCase):
self.assertFalse(injector._wayland_compositor_bridges_ibus(), desktop)
def test_unbridged_compositors_use_ibus_when_bridge_running(self):
"""ibus-wayland supplies the input-method-v2 relay these compositors lack (#607)."""
"""ibus-wayland supplies the input-method-v2 relay these compositors lack (#607).
niri is deliberately absent: the explicit niri guard (spec #16) keeps
IBus off there whatever the bridge probe says (see TestNiriGuard in
tests/test_injection_ladder.py).
"""
injector = self._bare_injector()
for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "niri", "river"):
for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "river"):
with patch.dict(
"os.environ",
{
+12 -11
View File
@@ -1505,25 +1505,25 @@ class TestInjectKeyboardShortcutRouting(unittest.TestCase):
obj._inject_shortcut_with_xdotool.assert_called_once_with("ctrl+z")
obj._inject_shortcut_with_wayland_tool.assert_not_called()
def test_wayland_ibus_prefers_ydotool(self):
"""Reaches the real Wayland helper: a stub would pass while it's broken."""
def test_wayland_ibus_prefers_wtype_over_ready_ydotool(self):
"""Direct typing outranks the virtual-input tool (spec #16, ticket #31)."""
obj = _make_injector(self._env("WAYLAND_IBUS"))
obj._inject_shortcut_with_xdotool = MagicMock(return_value=True)
obj._ydotool_legacy_named_keys = False
obj._ensure_ydotoold = MagicMock(return_value=True)
obj._wait_for_modifiers_released = MagicMock()
which = lambda c: f"/usr/bin/{c}" if c in ("wtype", "ydotool") else None # noqa: E731
with patch("shutil.which", return_value="/usr/bin/x") as which:
with patch("shutil.which", side_effect=which):
with patch("subprocess.run") as mock_run:
self.assertTrue(obj._inject_keyboard_shortcut("ctrl+a"))
# _check_dependencies prefers the uinput helper once its daemon is ready.
self.assertTrue(which.called)
self.assertEqual(obj.wayland_tool, "ydotool")
self.assertEqual(obj.wayland_tool, "wtype")
self.assertEqual(
mock_run.call_args[0][0],
["ydotool", "key", "29:1", "30:1", "30:0", "29:0"],
["wtype", "-M", "ctrl", "-k", "a", "-m", "ctrl"],
)
obj._ensure_ydotoold.assert_not_called()
obj._inject_shortcut_with_xdotool.assert_not_called()
def test_wayland_ibus_falls_back_to_wtype(self):
@@ -1638,14 +1638,15 @@ class TestPressBackspace(unittest.TestCase):
self.assertTrue(obj.press_backspace(1))
self.assertEqual(mock_run.call_args[0][0], ["wtype", "-k", "BackSpace"])
def test_prefers_ydotool_when_both_tools_are_present(self):
"""_check_dependencies prefers the uinput helper; match that order."""
def test_prefers_wtype_when_both_tools_are_present(self):
"""Key events use the same rung order as startup: wtype before ydotool."""
obj = _make_backspace_injector("WAYLAND_IBUS", legacy=False)
with patch("subprocess.run") as mock_run:
with patch("shutil.which", return_value="/usr/bin/x"):
self.assertTrue(obj.press_backspace(1))
self.assertEqual(obj.wayland_tool, "ydotool")
self.assertEqual(mock_run.call_args[0][0][:2], ["ydotool", "key"])
self.assertEqual(obj.wayland_tool, "wtype")
self.assertEqual(mock_run.call_args[0][0][0], "wtype")
obj._ensure_ydotoold.assert_not_called()
def test_waits_for_modifiers_before_resolving_the_tool(self):
"""A held PTT modifier turns BackSpace into Ctrl+BackSpace."""