diff --git a/README.md b/README.md index 96cf341..2821273 100644 --- a/README.md +++ b/README.md @@ -181,8 +181,11 @@ Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux). `stable` is s sudo snap install vocalinux --edge sudo snap connect vocalinux:audio-record # if mic is not auto-connected sudo snap connect vocalinux:raw-input # global keyboard shortcuts (evdev) +sudo snap connect vocalinux:uinput # native Wayland typing (ydotool) ``` +The **v0.16.2** store snap (`latest/edge` rev 7) has no `uinput` plug. `snap connect vocalinux:uinput` fails with `snap "vocalinux" has no plug named "uinput"`, and dictation only reaches XWayland apps. After `snap info vocalinux` shows a revision that lists `uinput` under plugs, `sudo snap refresh vocalinux` then connect `uinput` for native GNOME/GTK/Qt windows. + ### From source ```bash diff --git a/docs/INSTALL.md b/docs/INSTALL.md index fd3b551..8bff2de 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -118,8 +118,11 @@ Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux) (issue [#48](h sudo snap install vocalinux --edge sudo snap connect vocalinux:audio-record # if mic is not auto-connected sudo snap connect vocalinux:raw-input # global keyboard shortcuts (evdev) +sudo snap connect vocalinux:uinput # native Wayland typing (ydotool) ``` +**v0.16.2 edge (rev 7) has no `uinput` plug.** `sudo snap connect vocalinux:uinput` fails with `snap "vocalinux" has no plug named "uinput"`. That build only types into XWayland apps (Firefox/Chrome often work; native GNOME/GTK/Qt do not). A later store revision ships `ydotool` and the plug. After `snap info vocalinux` lists `uinput`, `sudo snap refresh vocalinux`, connect `uinput`, and restart Vocalinux. + ## From source ```bash diff --git a/docs/UPDATE.md b/docs/UPDATE.md index fc1ba26..bfbc60e 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -38,7 +38,7 @@ git pull origin main |--------|---------| | AUR | `yay -S vocalinux` (or your AUR helper) | | AppImage | Download the new file from [Releases](https://github.com/VocaHQ/vocalinux/releases) | -| Snap | `sudo snap refresh vocalinux` (channel `--edge` until stable is promoted) | +| Snap | `sudo snap refresh vocalinux` (`--edge` until stable is promoted). **v0.16.2** has no `uinput` plug, so `snap connect vocalinux:uinput` fails and typing stays XWayland-only. After `snap info vocalinux` lists an `uinput` plug, refresh, then `sudo snap connect vocalinux:uinput` for native Wayland apps. | | Flatpak (release bundle) | Install the new `.flatpak` from Releases; bundles do not auto-update | | PyPI | Reinstall in the same venv after system packages are current | @@ -102,6 +102,7 @@ Missing system packages: see [INSTALL.md](INSTALL.md) or [DISTRO_COMPATIBILITY.m - **CI**: gate AUR PKGBUILD builds on every PR (#772 by @sesav) - **CI / docs**: test the distros the docs promise and fix docs drift (#773 by @sesav) - **Site**: VocaGateway family card is Beta; README logo, badges, and privacy copy (#765 by @jatinkrmalik, #764) +- **Snap**: v0.16.2 `latest/edge` (rev 7) has no `uinput` plug. `sudo snap connect vocalinux:uinput` errors with `snap "vocalinux" has no plug named "uinput"`. Dictation only reaches XWayland apps. A later store snap ships ydotool and the plug; then `snap refresh` and `snap connect vocalinux:uinput` (#823) See the [full changelog](https://github.com/VocaHQ/vocalinux/releases/tag/v0.16.2). diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 549cb9e..2b5d09d 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,7 +1,7 @@ # Vocalinux Snap packaging # # First ship: strict confinement, default whisper.cpp engine path, GTK tray UI, -# mic capture, model download, and X11/XWayland text injection (xdotool/xsel). +# mic capture, model download, and text injection (ydotool/uinput, wtype, xdotool). # Informed by issue #48 and packaging/flatpak lessons on feat/flatpak-packaging. # # Build (from repo root, with snapcraft + LXD or Multipass): @@ -26,10 +26,12 @@ description: | - System tray control and GTK settings - Offline dictation (models download on first use) - Microphone capture and short feedback tones - - Text injection via xdotool/xsel (X11/XWayland) and IBus when available + - Text injection via ydotool (native Wayland/X11), wtype, or xdotool Sandbox notes: - Global hotkeys need: sudo snap connect vocalinux:raw-input + - Native Wayland typing needs: sudo snap connect vocalinux:uinput + (v0.16.2 store snaps have no uinput plug; refresh to a revision that lists it) - Microphone may need: sudo snap connect vocalinux:audio-record - Config and models live under ~/snap/vocalinux/ @@ -59,6 +61,10 @@ apps: # Super-privileged: not auto-connected; user must: # sudo snap connect vocalinux:raw-input - raw-input + # ydotool writes to /dev/uinput so dictation reaches native Wayland apps. + # Super-privileged: not auto-connected; user must: + # sudo snap connect vocalinux:uinput + - uinput common-id: com.vocalinux.Vocalinux desktop: usr/share/applications/vocalinux.desktop environment: @@ -111,7 +117,9 @@ parts: - libasound2-plugins # pcm/ctl pulse → host PulseAudio/PipeWire - pulseaudio-utils # paplay for start/stop UI sounds - xdotool - - wtype # native Wayland text injection (preferred over xdotool) + - wtype # virtual-keyboard protocol (GNOME often rejects this) + - ydotool # uinput; reaches native Wayland apps (needs vocalinux:uinput) + - x11-utils # xprop for XWayland WM_CLASS - xsel - xclip - wl-clipboard diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 61256e5..0cc0be0 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -149,9 +149,18 @@ class TextInjector: "KDE Plasma Wayland detected. wtype is not a reliable " f"text injection path on this compositor. {_kde_wayland_ibus_hint()}" ) - if shutil.which("xdotool"): + # xdotool only types into XWayland windows. Prefer ydotool + # (uinput) so native Wayland apps still receive text. + if shutil.which("ydotool") and self._ensure_ydotoold(): + self.wayland_tool = "ydotool" + logger.info( + "wtype unsupported on this compositor; " + "using ydotool (uinput) for native Wayland apps" + ) + elif shutil.which("xdotool"): logger.info("Automatically switching to XWayland fallback with xdotool") self.environment = DesktopEnvironment.WAYLAND_XDOTOOL + self._warn_if_snap_xwayland_only() else: logger.error("No fallback text injection method available") except Exception as e: @@ -426,15 +435,22 @@ class TextInjector: ydotool 1.x talks to a daemon that owns /dev/uinput. Inside Flatpak we start the daemon on demand when the app is granted device access. Host ydotool 0.1.x often works without a daemon; starting one is still - safe when ydotoold is installed. + safe when ydotoold is installed. The CLI still needs a writable + /dev/uinput (Snap: ``snap connect vocalinux:uinput``). """ if self._is_ydotoold_running(): return True ydotoold = shutil.which("ydotoold") if not ydotoold: - # Distro ydotool 0.1.x may not ship a daemon; treat as ready. - return shutil.which("ydotool") is not None - if not os.path.exists("/dev/uinput"): + # Distro ydotool 0.1.x may not ship a daemon; treat as ready + # only when /dev/uinput is actually writable. + if shutil.which("ydotool") is None: + return False + if not self._uinput_usable(): + logger.warning(self._uinput_permission_hint()) + return False + return True + if not self._uinput_usable(): logger.warning( "ydotoold needs /dev/uinput (Flatpak: grant --device=all). " "Text injection into native Wayland apps will fail." @@ -459,6 +475,40 @@ class TextInjector: logger.warning("ydotoold did not become ready in time") return False + @staticmethod + def _uinput_usable() -> bool: + """Return True when this process can open ``/dev/uinput`` for write.""" + try: + fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK) + except OSError: + return False + os.close(fd) + return True + + @staticmethod + def _uinput_permission_hint() -> str: + """How to grant ydotool access to ``/dev/uinput`` in this install.""" + if os.environ.get("SNAP"): + return ( + "ydotool cannot open /dev/uinput. For the Snap, run: " + "sudo snap connect vocalinux:uinput" + ) + return ( + "ydotool cannot open /dev/uinput. " + "Add your user to the input group and log out, or start ydotoold. " + f"{_ydotool_install_guidance()}" + ) + + @staticmethod + def _warn_if_snap_xwayland_only() -> None: + """Log that Snap XWayland fallback will miss native Wayland apps.""" + if not os.environ.get("SNAP"): + return + logger.warning( + "Snap XWayland fallback only types into X11/XWayland apps. " + "Native Wayland apps need: sudo snap connect vocalinux:uinput" + ) + @staticmethod def _forced_backend() -> str: """Backend pinned via ``VOCALINUX_FORCE_BACKEND``, or ``"auto"``. diff --git a/tests/test_snap_packaging.py b/tests/test_snap_packaging.py index b6d97ed..93e0533 100644 --- a/tests/test_snap_packaging.py +++ b/tests/test_snap_packaging.py @@ -25,6 +25,9 @@ def test_snapcraft_recipe_and_gui_assets() -> None: plugs = set((doc.get("apps") or {}).get("vocalinux", {}).get("plugs") or []) assert "raw-input" in plugs assert "audio-record" in plugs + assert "uinput" in plugs + stage = doc["parts"]["vocalinux"].get("stage-packages") or [] + assert "ydotool" in stage assert DESKTOP_FILE.is_file() assert SNAP_PNG.is_file() @@ -47,6 +50,23 @@ def test_snap_puts_gnome_platform_first_on_ld_library_path() -> None: assert ld_path.endswith(":$LD_LIBRARY_PATH") +def test_snap_docs_warn_that_0162_has_no_uinput_plug() -> None: + """v0.16.2 edge has no uinput plug; the connect command must not stand alone.""" + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + install = (REPO_ROOT / "docs" / "INSTALL.md").read_text(encoding="utf-8") + update = (REPO_ROOT / "docs" / "UPDATE.md").read_text(encoding="utf-8") + changelog = (REPO_ROOT / "web" / "src" / "app" / "changelog" / "page.tsx").read_text( + encoding="utf-8" + ) + snapcraft = SNAPCRAFT_YAML.read_text(encoding="utf-8") + for text in (readme, install, update, changelog, snapcraft): + uinput_lines = "\n".join(line for line in text.splitlines() if "uinput" in line.lower()) + lowered = uinput_lines.lower() + assert "uinput" in lowered + assert "plug" in lowered and "no" in lowered + assert "0.17" not in uinput_lines + + def test_snap_strips_pygobject_and_uses_gnome_gi() -> None: """Pip must not build PyGObject; GI comes from the gnome extension.""" text = SNAPCRAFT_YAML.read_text(encoding="utf-8") diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index d28ef50..3c13d36 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -166,6 +166,47 @@ class TestTextInjector(unittest.TestCase): # Should fall back to XWayland self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL) + def test_wayland_wtype_fail_prefers_ydotool_over_xdotool(self) -> None: + """wtype rejection must not skip ydotool; xdotool is XWayland-only.""" + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}): + self.mock_which.side_effect = lambda cmd: { + "wtype": "/usr/bin/wtype", + "ydotool": "/usr/bin/ydotool", + "xdotool": "/usr/bin/xdotool", + }.get(cmd) + + mock_process = MagicMock() + mock_process.returncode = 1 + mock_process.stderr = "compositor does not support virtual keyboard protocol" + self.mock_subprocess.return_value = mock_process + + with patch.object(TextInjector, "_uinput_usable", return_value=True): + injector = TextInjector() + + self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND) + self.assertEqual(injector.wayland_tool, "ydotool") + + def test_wayland_wtype_fail_xdotool_when_uinput_blocked(self) -> None: + """ydotool in PATH without /dev/uinput still cannot rescue a wtype miss.""" + with patch.dict( + "os.environ", {"XDG_SESSION_TYPE": "wayland", "SNAP": "/snap/vocalinux/x1"} + ): + self.mock_which.side_effect = lambda cmd: { + "wtype": "/usr/bin/wtype", + "ydotool": "/usr/bin/ydotool", + "xdotool": "/usr/bin/xdotool", + }.get(cmd) + + mock_process = MagicMock() + mock_process.returncode = 1 + mock_process.stderr = "compositor does not support virtual keyboard protocol" + self.mock_subprocess.return_value = mock_process + + with patch.object(TextInjector, "_uinput_usable", return_value=False): + injector = TextInjector() + + self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL) + def test_x11_text_injection(self): """Test text injection in X11 environment.""" # Setup X11 environment @@ -2355,24 +2396,38 @@ class TestCompositorIBusBridging(unittest.TestCase): self.assertFalse(injector._is_ydotoold_running()) @patch("vocalinux.text_injection.text_injector.shutil.which") - def test_ensure_ydotoold_ready_when_only_ydotool_cli(self, mock_which): + def test_ensure_ydotoold_ready_when_only_ydotool_cli(self, mock_which: MagicMock) -> None: """Host ydotool 0.1.x without ydotoold is still considered ready.""" mock_which.side_effect = lambda cmd: "/usr/bin/ydotool" if cmd == "ydotool" else None injector = self._bare_injector() with patch.object(injector, "_is_ydotoold_running", return_value=False): - self.assertTrue(injector._ensure_ydotoold()) + with patch.object(injector, "_uinput_usable", return_value=True): + self.assertTrue(injector._ensure_ydotoold()) - @patch("os.path.exists", return_value=False) + @patch("vocalinux.text_injection.text_injector.shutil.which") + def test_ensure_ydotoold_false_when_uinput_not_writable(self, mock_which: MagicMock) -> None: + """0.1.x ydotool without /dev/uinput write access is not ready (Snap).""" + mock_which.side_effect = lambda cmd: "/usr/bin/ydotool" if cmd == "ydotool" else None + injector = self._bare_injector() + with patch.object(injector, "_is_ydotoold_running", return_value=False): + with patch.object(injector, "_uinput_usable", return_value=False): + self.assertFalse(injector._ensure_ydotoold()) + + @patch.object(TextInjector, "_uinput_usable", return_value=False) @patch("vocalinux.text_injection.text_injector.shutil.which", return_value="/app/bin/ydotoold") - def test_ensure_ydotoold_false_without_uinput(self, _mock_which, _mock_exists): + def test_ensure_ydotoold_false_without_uinput( + self, _mock_which: MagicMock, _mock_uinput: MagicMock + ) -> None: injector = self._bare_injector() with patch.object(injector, "_is_ydotoold_running", return_value=False): self.assertFalse(injector._ensure_ydotoold()) + @patch.object(TextInjector, "_uinput_usable", return_value=True) @patch("vocalinux.text_injection.text_injector.subprocess.Popen") - @patch("os.path.exists", return_value=True) @patch("vocalinux.text_injection.text_injector.shutil.which", return_value="/app/bin/ydotoold") - def test_ensure_ydotoold_starts_daemon(self, _mock_which, _mock_exists, mock_popen): + def test_ensure_ydotoold_starts_daemon( + self, _mock_which: MagicMock, mock_popen: MagicMock, _mock_uinput: MagicMock + ) -> None: injector = self._bare_injector() # First probe: not running; after start: running with patch.object(injector, "_is_ydotoold_running", side_effect=[False, False, True]): diff --git a/web/src/app/changelog/page.tsx b/web/src/app/changelog/page.tsx index 2dae965..172d60e 100644 --- a/web/src/app/changelog/page.tsx +++ b/web/src/app/changelog/page.tsx @@ -29,6 +29,7 @@ const releases = [ "CI: gate AUR PKGBUILD builds on every PR; test the distros the docs promise and fix docs drift (PR #772, #773)", "Site: VocaGateway family card is Beta; README logo, badges, and privacy copy (PR #765, #764)", "Deps: bump the github-actions group (PR #766)", + "Snap 0.16.2 edge (rev 7) has no uinput plug: snap connect vocalinux:uinput fails, and dictation only reaches XWayland apps. A later store snap ships ydotool; refresh, then connect uinput (PR #823)", ], }, {