fix(snap): type into native Wayland apps with ydotool (#823)
* fix(snap): inject into native Wayland apps via ydotool Edge rev 7 only ships wtype and xdotool. GNOME rejects the virtual keyboard protocol, so the snap falls back to xdotool and types only into XWayland windows. Native GTK/Qt apps get nothing. Stage ydotool, request the uinput plug, and prefer ydotool when wtype is rejected. Distro ydotool 0.1.x still needs a writable /dev/uinput; log `snap connect vocalinux:uinput` when the Snap cannot open it. * docs(snap): v0.16.2 edge has no uinput plug The store snap (0.16.2, latest/edge rev 7) does not declare uinput. `sudo snap connect vocalinux:uinput` fails with "has no plug named uinput" and dictation only reaches XWayland apps. Spell that out in README, INSTALL, UPDATE, the website changelog, and the store copy so people do not run the 0.17 connect command against rev 7. * docs(snap): drop the unconfirmed 0.17 version from uinput notes Greptile P2: the tree is still 0.16.2 and the release process does not name the next tag. Tell people to refresh until `snap info` lists the uinput plug, instead of waiting on a version we have not cut.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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).
|
||||
|
||||
|
||||
+11
-3
@@ -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
|
||||
|
||||
@@ -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"``.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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)",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user