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:
Jatin K Malik
2026-10-20 23:41:07 -07:00
committed by GitHub
parent 3abc106fdb
commit d59395b5c9
6 changed files with 240 additions and 13 deletions
+8
View File
@@ -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
View File
@@ -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
+35 -4
View File
@@ -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
+29 -6
View File
@@ -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():
+159
View File
@@ -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