diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 18e93f6..a4f8075 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -158,6 +158,14 @@ flatpak run com.vocalinux.Vocalinux Whisper.cpp + Vulkan. It is **not on Flathub** (submission [flathub#9368](https://github.com/flathub/flathub/pull/9368) closed 2026-07-23 on policy grounds). Details: [packaging/flatpak/README.md](../packaging/flatpak/README.md). +On native X11, hotkeys work without device access (GNOME X11 is the exception: +its X server hides physical keys from the X11 listener, so it also needs +`/dev/input`). On Wayland, the built-in hotkey needs `/dev/input` (`--device=input` on Flatpak 1.15.6 or newer, or +`--device=all` on older versions); otherwise enable External Activation and +bind a desktop shortcut to the D-Bus `Toggle` command in +[Running without device access](../packaging/flatpak/README.md#running-without-device-access). +Desktop shortcuts only toggle dictation, so they cannot support push-to-talk. + ## Snap (Ubuntu Snap Store) Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux) (issue [#48](https://github.com/VocaHQ/vocalinux/issues/48)). Recipe: `snap/snapcraft.yaml`. Tagged `v*` releases attach `vocalinux__amd64.snap` to the GitHub Release and try Snap Store `edge` and `candidate` when credentials are set. `stable` is promoted from `candidate` after QA. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 17d242b..aff782f 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -130,13 +130,19 @@ vocalinux --start # start voice typing vocalinux --stop # stop voice typing ``` -Then bind a compositor shortcut to `vocalinux --toggle`. On KDE Plasma: +Then bind a compositor shortcut to `vocalinux --toggle` on a native install. +For Flatpak, bind the `gdbus` command in +[Running without device access](../packaging/flatpak/README.md#running-without-device-access) +instead, because `vocalinux --toggle` is not on the host PATH. A desktop +shortcut only sends the key press, so it can toggle dictation but cannot +support push-to-talk because it never sees the release. On KDE Plasma: 1. Open **System Settings -> Keyboard -> Shortcuts -> Add New -> Command or Script** (older Plasma: **System Settings -> Shortcuts -> Custom Shortcuts -> Edit -> New -> Global Shortcut -> Command/URL**). -2. Bind a key combination of your choice to the command `vocalinux --toggle`. +2. Bind a key combination of your choice to the command `vocalinux --toggle` + for a native install. For Flatpak, use the D-Bus command linked above. Now your chosen key combination toggles dictation, handled by the compositor rather than by Vocalinux reading the keyboard directly. This works the same way diff --git a/packaging/flatpak/README.md b/packaging/flatpak/README.md index 7f9728f..f826d47 100644 --- a/packaging/flatpak/README.md +++ b/packaging/flatpak/README.md @@ -46,10 +46,13 @@ The manifest ships the default `whisper_cpp` engine only. VOSK is omitted becaus the PyPI package is wheel-only (no sdist), so a Flathub-ready VOSK build would need to compile VOSK and its native deps from source. -Global keyboard shortcuts use **evdev** (`/dev/input`). +Global keyboard shortcuts use **evdev** (`/dev/input`) on Wayland and +XWayland. On native X11, they use the X11 **pynput** listener through +`--socket=x11`. GNOME X11 prefers evdev and falls back to pynput when +`/dev/input` cannot be read. Text injection prefers the **RemoteDesktop portal** -(`org.freedesktop.portal.RemoteDesktop` `NotifyKeyboardKeysym` — keysyms, so -non-US layouts survive) and falls back to **wl-copy** + **ydotool Ctrl+V** +(`org.freedesktop.portal.RemoteDesktop` `NotifyKeyboardKeysym` sends keysyms, +so non-US layouts work) and falls back to **wl-copy** + **ydotool Ctrl+V** (instant paste into native Wayland apps) when the portal is unavailable. Character-by-character `ydotool type` is only a fallback. `xdotool` remains for pure X11/XWayland clients. @@ -58,6 +61,34 @@ Permissions: `--socket=wayland` (clipboard), `--socket=x11` (xdotool fallback), `--device=all` (evdev hotkeys + uinput for the ydotool fallback; Flatpak has no narrower uinput flag). +## Running without device access + +To disable device access while keeping GPU support for Vulkan whisper.cpp, run: + +```bash +flatpak override --user --nodevice=all --device=dri com.vocalinux.Vocalinux +``` + +On native X11, hotkeys still work through the X11 listener, except on GNOME X11, +where mutter never passes physical keys to that listener (#954), so it needs +`/dev/input` like Wayland. On Wayland, the built-in hotkey needs `/dev/input`: grant `--device=input` on Flatpak 1.15.6 or +newer, or `--device=all` on older versions. If `/dev/input` is unavailable, +enable **Settings → Shortcuts → External Activation (Desktop Shortcut)** and +bind a desktop shortcut to: + +```bash +gdbus call --session --dest com.vocalinux.Vocalinux --object-path /com/vocalinux/Vocalinux --method com.vocalinux.Vocalinux.Toggle +``` + +Use toggle mode for this shortcut. A desktop shortcut only sends a key press, +so it cannot support push-to-talk. Running +`flatpak run com.vocalinux.Vocalinux --toggle` also works, but starts a sandbox +on each press. The host `gdbus` command is immediate and does not start another +sandbox. + +Without `/dev/uinput`, the ydotool injection fallback is unavailable. The +RemoteDesktop portal and xdotool fallbacks remain available. + ## Local Build ```bash @@ -146,7 +177,7 @@ Release `.flatpak` assets are the supported paths. - Runtime / SDK: `org.gnome.Platform//50`, `org.gnome.Sdk//50` - Mic: `--socket=pulseaudio` · GPU: `--device=dri` · models: `--share=network` -- Input: `--device=all` (evdev hotkeys + ydotool/`uinput` fallback) +- Input: `--device=all` for evdev hotkeys on Wayland/XWayland and the ydotool/`uinput` fallback; native X11 hotkeys use pynput through `--socket=x11` - Injection: RemoteDesktop portal first; packaged `wl-copy`, `ydotool`/`ydotoold`, plus `xdotool`/`xsel` fallback - Display: `--socket=wayland` (clipboard) and `--socket=x11` (xdotool fallback) - IBus: `--talk-name=org.freedesktop.IBus` diff --git a/packaging/flatpak/com.vocalinux.Vocalinux.yml b/packaging/flatpak/com.vocalinux.Vocalinux.yml index a593cda..f4fdfd5 100644 --- a/packaging/flatpak/com.vocalinux.Vocalinux.yml +++ b/packaging/flatpak/com.vocalinux.Vocalinux.yml @@ -24,7 +24,7 @@ finish-args: - --socket=pulseaudio # GPU render node for the Vulkan-accelerated whisper.cpp build. - --device=dri - # /dev/input for global hotkeys (evdev) and /dev/uinput for the ydotool + # /dev/input for evdev hotkeys on Wayland/XWayland and /dev/uinput for ydotool # injection fallback (Ctrl+V / type) when the RemoteDesktop portal is # unavailable. Flatpak has no narrower flag than "all" for uinput. - --device=all diff --git a/src/vocalinux/ui/keyboard_backends/__init__.py b/src/vocalinux/ui/keyboard_backends/__init__.py index 4585691..81b4016 100644 --- a/src/vocalinux/ui/keyboard_backends/__init__.py +++ b/src/vocalinux/ui/keyboard_backends/__init__.py @@ -87,6 +87,23 @@ def _is_gnome_session() -> bool: return "GNOME" in os.environ.get("XDG_CURRENT_DESKTOP", "").upper() +def _is_native_x11_session() -> bool: + """Return True when raw XDG_SESSION_TYPE is x11 and WAYLAND_DISPLAY is unset or empty.""" + return os.environ.get("XDG_SESSION_TYPE", "").lower() == "x11" and not os.environ.get( + "WAYLAND_DISPLAY" + ) + + +def _log_flatpak_input_hint() -> None: + logger.warning( + "Grant --device=input (Flatpak 1.15.6+) or --device=all, or enable " + "External Activation and bind a desktop shortcut to: %s", + "gdbus call --session --dest com.vocalinux.Vocalinux " + "--object-path /com/vocalinux/Vocalinux " + "--method com.vocalinux.Vocalinux.Toggle", + ) + + def create_backend( preferred_backend: Optional[str] = None, shortcut: str = DEFAULT_SHORTCUT, @@ -130,13 +147,14 @@ def create_backend( else: logger.warning(f"Unknown preferred backend: '{preferred_backend}'") - # Prefer evdev whenever pynput cannot see keys from other apps: - # - native Wayland (no X11 grab path) - # - Flatpak: even with --socket=x11, XWayland only delivers keys to the - # focused X client, so double-tap Ctrl and other globals need /dev/input - need_evdev = env == DesktopEnvironment.WAYLAND or bool(os.environ.get("FLATPAK_ID")) + # Flatpak on Wayland/XWayland still needs evdev because XWayland only + # delivers keys to the focused X client. On native X11, Flatpak reaches + # the host X server through --socket=x11 and uses the host install path (#971). + in_flatpak = bool(os.environ.get("FLATPAK_ID")) + flatpak_needs_evdev = in_flatpak and not _is_native_x11_session() + need_evdev = env == DesktopEnvironment.WAYLAND or flatpak_needs_evdev if need_evdev: - reason = "Flatpak" if os.environ.get("FLATPAK_ID") else "Wayland" + reason = "Flatpak" if in_flatpak else "Wayland" if EVDEV_AVAILABLE: logger.info(f"Using evdev backend for global shortcuts ({reason})") backend = EvdevKeyboardBackend(shortcut=shortcut, mode=mode) # type: ignore @@ -148,6 +166,8 @@ def create_backend( logger.warning( "Keyboard shortcuts will not work without access to keyboard input devices" ) + if in_flatpak: + _log_flatpak_input_hint() return None logger.warning( "evdev backend not available (%s). " @@ -155,6 +175,8 @@ def create_backend( "add your user to the 'input' group (or grant Flatpak --device=input).", reason, ) + if in_flatpak: + _log_flatpak_input_hint() return None # On GNOME's X11 session, mutter does not deliver physical key events @@ -162,6 +184,7 @@ def create_backend( # listener starts cleanly but shortcuts stay dead (#954). evdev reads the # kernel input devices directly, so prefer it here — but fall back to # pynput when /dev/input is unusable rather than leaving shortcuts dead. + # This applies inside Flatpak too. if env == DesktopEnvironment.X11 and _is_gnome_session() and EVDEV_AVAILABLE: backend = EvdevKeyboardBackend(shortcut=shortcut, mode=mode) # type: ignore if backend.is_available(): diff --git a/tests/test_keyboard_init_ext.py b/tests/test_keyboard_init_ext.py index fe78607..3e0ba65 100644 --- a/tests/test_keyboard_init_ext.py +++ b/tests/test_keyboard_init_ext.py @@ -20,6 +20,7 @@ from vocalinux.ui.keyboard_backends import ( EVDEV_AVAILABLE, PYNPUT_AVAILABLE, DesktopEnvironment, + _is_native_x11_session, create_backend, ) from vocalinux.ui.keyboard_backends.evdev_backend import EVDEV_AVAILABLE as EVDEV_BACKEND_AVAILABLE @@ -115,6 +116,164 @@ class TestDesktopEnvironmentDetect: mock_pynput.assert_called_once() mock_evdev.assert_not_called() + def test_create_backend_flatpak_native_x11_prefers_pynput(self) -> None: + """Flatpak on native X11 uses pynput outside GNOME.""" + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "XDG_SESSION_TYPE": "x11", + "XDG_CURRENT_DESKTOP": "X-Cinnamon", + "DISPLAY": ":0", + } + fake = MagicMock() + with patch.dict(os.environ, env, clear=True): + with ( + patch.object(kb_module, "EVDEV_AVAILABLE", True), + patch.object(kb_module, "EvdevKeyboardBackend") as mock_evdev, + patch.object(kb_module, "PYNPUT_AVAILABLE", True), + patch.object(kb_module, "PynputKeyboardBackend", return_value=fake) as mock_pynput, + ): + backend = create_backend() + assert backend is fake + mock_pynput.assert_called_once() + mock_evdev.assert_not_called() + + def test_create_backend_flatpak_gnome_native_x11_prefers_evdev(self) -> None: + """Flatpak on GNOME X11 prefers evdev.""" + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "XDG_SESSION_TYPE": "x11", + "XDG_CURRENT_DESKTOP": "GNOME", + "DISPLAY": ":0", + } + fake = MagicMock() + fake.is_available.return_value = True + with patch.dict(os.environ, env, clear=True): + with ( + patch.object(kb_module, "EVDEV_AVAILABLE", True), + patch.object(kb_module, "EvdevKeyboardBackend", return_value=fake) as mock_evdev, + patch.object(kb_module, "PYNPUT_AVAILABLE", True), + patch.object(kb_module, "PynputKeyboardBackend") as mock_pynput, + ): + backend = create_backend() + assert backend is fake + mock_evdev.assert_called_once() + mock_pynput.assert_not_called() + + def test_create_backend_flatpak_gnome_native_x11_falls_back_to_pynput(self) -> None: + """Flatpak on GNOME X11 falls back when evdev cannot read /dev/input.""" + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "XDG_SESSION_TYPE": "x11", + "XDG_CURRENT_DESKTOP": "GNOME", + "DISPLAY": ":0", + } + fake = MagicMock() + fake.is_available.return_value = False + fake.get_permission_hint.return_value = "not in input group" + pynput_backend = MagicMock() + with patch.dict(os.environ, env, clear=True): + with ( + patch.object(kb_module, "EVDEV_AVAILABLE", True), + patch.object(kb_module, "EvdevKeyboardBackend", return_value=fake), + patch.object(kb_module, "PYNPUT_AVAILABLE", True), + patch.object( + kb_module, "PynputKeyboardBackend", return_value=pynput_backend + ) as mock_pynput, + ): + backend = create_backend() + assert backend is pynput_backend + mock_pynput.assert_called_once() + + def test_create_backend_flatpak_x11_with_wayland_display_prefers_evdev(self) -> None: + """A Wayland socket keeps Flatpak on the evdev path.""" + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "XDG_SESSION_TYPE": "x11", + "WAYLAND_DISPLAY": "wayland-0", + "DISPLAY": ":0", + } + fake = MagicMock() + fake.is_available.return_value = True + with patch.dict(os.environ, env, clear=True): + with ( + patch.object(kb_module, "EVDEV_AVAILABLE", True), + patch.object(kb_module, "EvdevKeyboardBackend", return_value=fake) as mock_evdev, + patch.object(kb_module, "PYNPUT_AVAILABLE", True), + patch.object(kb_module, "PynputKeyboardBackend") as mock_pynput, + ): + backend = create_backend() + assert backend is fake + mock_evdev.assert_called_once() + mock_pynput.assert_not_called() + + def test_create_backend_flatpak_xwayland_without_evdev_returns_none(self) -> None: + """A Flatpak on a Wayland session without input access needs evdev.""" + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "XDG_SESSION_TYPE": "wayland", + "DISPLAY": ":0", + } + fake = MagicMock() + fake.is_available.return_value = False + fake.get_permission_hint.return_value = "not in input group" + with patch.dict(os.environ, env, clear=True): + with ( + patch.object(kb_module, "EVDEV_AVAILABLE", True), + patch.object(kb_module, "EvdevKeyboardBackend", return_value=fake) as mock_evdev, + patch.object(kb_module, "PYNPUT_AVAILABLE", True), + patch.object(kb_module, "PynputKeyboardBackend") as mock_pynput, + ): + backend = create_backend() + assert backend is None + mock_evdev.assert_called_once() + mock_pynput.assert_not_called() + + def test_create_backend_flatpak_unknown_session_prefers_evdev(self) -> None: + """Unknown Flatpak sessions conservatively prefer evdev.""" + env = { + "FLATPAK_ID": "com.vocalinux.Vocalinux", + "DISPLAY": ":0", + } + fake = MagicMock() + fake.is_available.return_value = True + with patch.dict(os.environ, env, clear=True): + with ( + patch.object(kb_module, "EVDEV_AVAILABLE", True), + patch.object(kb_module, "EvdevKeyboardBackend", return_value=fake) as mock_evdev, + patch.object(kb_module, "PYNPUT_AVAILABLE", True), + patch.object(kb_module, "PynputKeyboardBackend") as mock_pynput, + ): + backend = create_backend() + assert backend is fake + mock_evdev.assert_called_once() + mock_pynput.assert_not_called() + + def test_is_native_x11_session(self) -> None: + """Raw x11 session type without a Wayland socket is native X11.""" + with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True): + assert _is_native_x11_session() + + def test_is_native_x11_session_case_insensitive(self) -> None: + """Raw XDG_SESSION_TYPE is case-insensitive.""" + with patch.dict(os.environ, {"XDG_SESSION_TYPE": "X11"}, clear=True): + assert _is_native_x11_session() + + def test_is_native_x11_session_with_wayland_display(self) -> None: + """A Wayland socket means the X11 display may be XWayland.""" + env = {"XDG_SESSION_TYPE": "x11", "WAYLAND_DISPLAY": "wayland-0"} + with patch.dict(os.environ, env, clear=True): + assert not _is_native_x11_session() + + def test_is_native_x11_session_wayland(self) -> None: + """A Wayland session is not native X11.""" + with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True): + assert not _is_native_x11_session() + + def test_is_native_x11_session_unset(self) -> None: + """An unset session type is not treated as native X11.""" + with patch.dict(os.environ, {}, clear=True): + assert not _is_native_x11_session() + def test_create_backend_gnome_x11_prefers_evdev(self) -> None: """GNOME X11 must prefer evdev: mutter's XRecord drops physical key events (#954).""" from vocalinux.ui.keyboard_backends import create_backend