fix(hotkeys): use the X11 listener for Flatpak on native X11 sessions (#997)
* fix(hotkeys): use the X11 listener for Flatpak on native X11 sessions Use evdev for Flatpak Wayland/XWayland while preserving the GNOME X11 fallback. Fixes #971 * docs(flatpak): note that GNOME X11 still needs /dev/input for hotkeys
This commit is contained in:
@@ -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_<version>_amd64.snap` to the GitHub Release and try Snap Store `edge` and `candidate` when credentials are set. `stable` is promoted from `candidate` after QA.
|
||||
|
||||
+8
-2
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user