From cf14c5b27c1659fe761af8126ea9a129724121ad Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 18:17:17 +0530 Subject: [PATCH 01/24] 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. --- src/vocalinux/text_injection/text_injector.py | 53 ++++++--- tests/conftest.py | 3 + tests/test_injection_ladder.py | 105 +++++++++++++++++- tests/test_text_injector.py | 9 +- tests/test_text_injector_ext.py | 23 ++-- 5 files changed, 166 insertions(+), 27 deletions(-) diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index ce534bf..313be68 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index b1f9d9c..e6c4e88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_injection_ladder.py b/tests/test_injection_ladder.py index 2415a3b..aefcead 100644 --- a/tests/test_injection_ladder.py +++ b/tests/test_injection_ladder.py @@ -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.""" diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index 027cee0..6b486f0 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -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", { diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py index d9bf18f..98d5518 100644 --- a/tests/test_text_injector_ext.py +++ b/tests/test_text_injector_ext.py @@ -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.""" -- 2.54.0 From a2736ee6e4d4b3f347b236624edd36a481dcdcae Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 18:22:17 +0530 Subject: [PATCH 02/24] fix(web): label the old launcher name as upstream on the install page The install page spelled the old binary name on a line with no upstream label, which fails the offline naming sweep. The launch note now leads with voculinux and marks the old name as upstream on the same line. --- web/src/app/install/page.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/web/src/app/install/page.tsx b/web/src/app/install/page.tsx index 6795d71..79242a9 100644 --- a/web/src/app/install/page.tsx +++ b/web/src/app/install/page.tsx @@ -12,6 +12,9 @@ const installDisplayCommand = `curl -fsSL \\ -o /tmp/vl.sh && \\ bash /tmp/vl.sh --interactive`; +const codeClass = + "rounded bg-muted px-1.5 py-0.5 font-mono text-sm text-foreground"; + const distroGuides = [ { name: "Ubuntu", @@ -218,15 +221,11 @@ export default function InstallGuidesPage() {

After installation