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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user