Files
voculinux/tests/test_keyboard_init_ext.py
T
Jatin K MalikandGitHub 3deb523efd feat(flatpak): add Flatpak packaging for universal distribution (#484)
* feat(flatpak): add Flatpak packaging for universal distribution

Adds a Flathub-ready Flatpak for the whisper.cpp engine, plus the minimal
runtime adaptations Flatpak actually needs — deliberately scoped down from the
earlier attempts (#416, #428), which bundled unrelated whisper.cpp GPU, audio,
and clipboard changes.

Packaging (packaging/flatpak/):
- app manifest, AppStream metainfo, and desktop entry (com.vocalinux.Vocalinux)
- generated offline Python dependency modules (flatpak-pip-generator)
- bundled shared modules: PortAudio, libayatana-appindicator, xdotool, xsel

Runtime changes (Flatpak-only, behaviour unchanged outside a sandbox):
- utils/paths.py: honour XDG_CONFIG_HOME/XDG_DATA_HOME so config, models and
  runtime data live under ~/.var/app/<app-id> in the sandbox
- autostart uses `flatpak run <app-id>` (the host launcher is always on PATH)
- text injection and keyboard backend detect a Wayland host reached via XWayland
  (no Wayland socket granted) and route through xdotool / pynput-on-X11
- tray indicator references the exported `<app-id>-*` host icon theme names

Also: a CI workflow building x86_64 + aarch64 bundles, and README / packaging
README / distro-compatibility doc updates.

Verified with a full flatpak-builder build + bundle install on aarch64:
pywhispercpp loads its native libraries without LD_LIBRARY_PATH, XDG paths
redirect correctly, bundled tools are present, and the entrypoint runs.
Manifest and builddir lint are clean and AppStream metadata validates.

Closes #167

* chore(flatpak): sync metainfo version to 0.14.0-beta

Also ignore flatpak-build-dir/ so local builder output stays untracked.

* refactor(flatpak): trim docs and comments after ponytail review

Drop redundant packaging README command blocks, shrink path/tray/autostart
comments, and stop ignoring a non-canonical build-dir name.

* fix(flatpak): close readiness gaps for v0.14.0-beta

Route log files through XDG data dirs so Flatpak keeps them under
~/.var/app, refresh AppStream screenshots to vocalinux.com, and use
the SPDX GPL-3.0-only license id.

* fix(flatpak): enable global hotkeys via evdev and --device=input

pynput over XWayland only receives keys for the focused X client. Prefer
evdev inside Flatpak and grant /dev/input so shortcuts work app-wide.

* fix(flatpak): inject text with ydotool/uinput on Wayland

xdotool only reaches XWayland clients, so typing appeared to succeed but
never hit native apps. Ship ydotool, grant --device=all for uinput, start
ydotoold on demand, and type via uinput inside the Flatpak.

* fix(flatpak): paste via wl-copy + Ctrl+V instead of typing

ydotool type injects character-by-character. Ship wl-clipboard, grant
--socket=wayland, and prefer clipboard paste for instant insertion.

* test(text-injection): expect ydotool default key-delay of 2

Matches the shipped VOCALINUX_YDOTOOL_KEY_DELAY default used when
clipboard paste falls back to character typing.
2026-07-16 00:03:30 -07:00

450 lines
21 KiB
Python

"""
Tests for keyboard backends __init__.py module.
This module tests:
- DesktopEnvironment.detect() for all cases (wayland, x11, unknown, fallback env vars)
- create_backend() for all branches (preferred evdev, preferred pynput, unknown preferred,
wayland auto, x11 auto, no backends)
- Mocking EVDEV_AVAILABLE, PYNPUT_AVAILABLE, and backend classes
"""
import os
import sys
from unittest.mock import MagicMock, Mock, patch
import pytest
# Import the module under test
import vocalinux.ui.keyboard_backends as kb_module
from vocalinux.ui.keyboard_backends import (
EVDEV_AVAILABLE,
PYNPUT_AVAILABLE,
DesktopEnvironment,
create_backend,
)
from vocalinux.ui.keyboard_backends.evdev_backend import EVDEV_AVAILABLE as EVDEV_BACKEND_AVAILABLE
from vocalinux.ui.keyboard_backends.pynput_backend import (
PYNPUT_AVAILABLE as PYNPUT_BACKEND_AVAILABLE,
)
class TestDesktopEnvironmentDetect:
"""Test DesktopEnvironment.detect() method."""
def test_package_availability_flags_match_backend_modules(self):
"""Test package-level availability flags mirror backend import checks."""
assert EVDEV_AVAILABLE == EVDEV_BACKEND_AVAILABLE
assert PYNPUT_AVAILABLE == PYNPUT_BACKEND_AVAILABLE
def test_detect_xdg_session_type_wayland(self):
"""Test detection of Wayland via XDG_SESSION_TYPE."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=False):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.WAYLAND
def test_detect_xdg_session_type_x11(self):
"""Test detection of X11 via XDG_SESSION_TYPE."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=False):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.X11
def test_detect_flatpak_wayland_without_socket_uses_x11(self):
"""Flatpak with only X11/XWayland is labeled X11 (injection path).
Global shortcuts still prefer evdev via create_backend when FLATPAK_ID
is set — pynput over XWayland is not truly global.
"""
env = {
"FLATPAK_ID": "com.vocalinux.Vocalinux",
"XDG_SESSION_TYPE": "wayland",
"DISPLAY": ":0",
}
with patch.dict(os.environ, env, clear=True):
assert DesktopEnvironment.detect() == DesktopEnvironment.X11
def test_detect_flatpak_wayland_with_socket_stays_wayland(self):
"""With a Wayland socket exposed, detection still reports Wayland."""
env = {
"FLATPAK_ID": "com.vocalinux.Vocalinux",
"XDG_SESSION_TYPE": "wayland",
"WAYLAND_DISPLAY": "wayland-0",
}
with patch.dict(os.environ, env, clear=True):
assert DesktopEnvironment.detect() == DesktopEnvironment.WAYLAND
def test_create_backend_flatpak_prefers_evdev(self):
"""Flatpak must use evdev so hotkeys work when another app is focused."""
from vocalinux.ui.keyboard_backends import create_backend
fake = MagicMock()
fake.is_available.return_value = True
env = {
"FLATPAK_ID": "com.vocalinux.Vocalinux",
"XDG_SESSION_TYPE": "wayland",
"DISPLAY": ":0",
}
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_native_x11_prefers_pynput(self):
"""Outside Flatpak on X11, pynput remains the default."""
from vocalinux.ui.keyboard_backends import create_backend
env = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}
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=MagicMock()
) as mock_pynput,
):
backend = create_backend()
assert backend is mock_pynput.return_value
mock_pynput.assert_called_once()
mock_evdev.assert_not_called()
def test_detect_xdg_session_type_case_insensitive(self):
"""Test that XDG_SESSION_TYPE detection is case-insensitive."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "WAYLAND"}, clear=False):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.WAYLAND
def test_detect_xdg_session_type_mixed_case(self):
"""Test that XDG_SESSION_TYPE detection handles mixed case."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "Wayland"}, clear=False):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.WAYLAND
def test_detect_wayland_display_env_var(self):
"""Test detection of Wayland via WAYLAND_DISPLAY env var (fallback)."""
env = {
"WAYLAND_DISPLAY": "wayland-0",
}
with patch.dict(os.environ, env, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.WAYLAND
def test_detect_display_env_var(self):
"""Test detection of X11 via DISPLAY env var (fallback)."""
env = {
"DISPLAY": ":0",
}
with patch.dict(os.environ, env, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.X11
def test_detect_wayland_display_priority_over_display(self):
"""Test that WAYLAND_DISPLAY takes priority over DISPLAY."""
env = {
"WAYLAND_DISPLAY": "wayland-0",
"DISPLAY": ":0",
}
with patch.dict(os.environ, env, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.WAYLAND
def test_detect_xdg_session_type_priority_over_env_vars(self):
"""Test that XDG_SESSION_TYPE takes priority over WAYLAND_DISPLAY/DISPLAY."""
env = {
"XDG_SESSION_TYPE": "x11",
"WAYLAND_DISPLAY": "wayland-0",
}
with patch.dict(os.environ, env, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.X11
def test_detect_unknown_no_env_vars(self):
"""Test detection returns unknown when no env vars are set."""
with patch.dict(os.environ, {}, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.UNKNOWN
def test_detect_unknown_xdg_session_type_unknown(self):
"""Test detection returns unknown for unknown XDG_SESSION_TYPE."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "unknown_session"}, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.UNKNOWN
def test_constants_defined(self):
"""Test that DesktopEnvironment constants are defined."""
assert DesktopEnvironment.X11 == "x11"
assert DesktopEnvironment.WAYLAND == "wayland"
assert DesktopEnvironment.UNKNOWN == "unknown"
class TestCreateBackendPreferred:
"""Test create_backend() with preferred backend specified."""
def test_create_backend_preferred_evdev_available_and_usable(self):
"""Test preferred evdev backend when available and usable."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = True
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
result = create_backend(preferred_backend="evdev")
assert result == mock_evdev_backend
mock_evdev_backend.is_available.assert_called_once()
def test_create_backend_preferred_evdev_not_available(self):
"""Test preferred evdev backend when module not available."""
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
result = create_backend(preferred_backend="evdev")
assert result is None
def test_create_backend_preferred_evdev_available_but_not_usable(self):
"""Test preferred evdev backend when available but not usable."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = False
mock_evdev_backend.get_permission_hint.return_value = "Permission denied"
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
result = create_backend(preferred_backend="evdev")
assert result is None
def test_create_backend_preferred_pynput_available(self):
"""Test preferred pynput backend when available."""
mock_pynput_backend = MagicMock()
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
):
result = create_backend(preferred_backend="pynput")
assert result == mock_pynput_backend
def test_create_backend_preferred_pynput_not_available(self):
"""Test preferred pynput backend when not available."""
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
result = create_backend(preferred_backend="pynput")
assert result is None
def test_create_backend_preferred_unknown_backend(self):
"""Test create_backend with unknown preferred backend."""
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
result = create_backend(preferred_backend="unknown_backend")
assert result is None
def test_create_backend_preferred_with_custom_shortcut(self):
"""Test that shortcut parameter is passed to backend."""
mock_pynput_backend = MagicMock()
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
) as mock_constructor:
result = create_backend(
preferred_backend="pynput", shortcut="alt+alt", mode="push_to_talk"
)
mock_constructor.assert_called_once_with(shortcut="alt+alt", mode="push_to_talk")
assert result == mock_pynput_backend
def test_create_backend_preferred_with_custom_mode(self):
"""Test that mode parameter is passed to backend."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = True
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
) as mock_constructor:
result = create_backend(preferred_backend="evdev", mode="push_to_talk")
mock_constructor.assert_called_once()
call_kwargs = mock_constructor.call_args[1]
assert call_kwargs["mode"] == "push_to_talk"
class TestCreateBackendAutoDetect:
"""Test create_backend() with auto-detection based on desktop environment."""
def test_create_backend_wayland_evdev_available_and_usable(self):
"""Test Wayland environment auto-selects evdev when available."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = True
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
result = create_backend()
assert result == mock_evdev_backend
def test_create_backend_wayland_evdev_not_available(self):
"""Test Wayland returns None when evdev not available."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
result = create_backend()
assert result is None
def test_create_backend_wayland_evdev_available_but_not_usable(self):
"""Test Wayland returns None when evdev not usable."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = False
mock_evdev_backend.get_permission_hint.return_value = "Permission denied"
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
result = create_backend()
assert result is None
def test_create_backend_x11_pynput_available(self):
"""Test X11 environment auto-selects pynput."""
mock_pynput_backend = MagicMock()
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
):
result = create_backend()
assert result == mock_pynput_backend
def test_create_backend_x11_pynput_not_available_evdev_available(self):
"""Test X11 falls back to evdev when pynput not available."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = True
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
result = create_backend()
assert result == mock_evdev_backend
def test_create_backend_unknown_env_pynput_available(self):
"""Test unknown environment defaults to pynput."""
mock_pynput_backend = MagicMock()
with patch.dict(os.environ, {}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
):
result = create_backend()
assert result == mock_pynput_backend
def test_create_backend_unknown_env_no_backends(self):
"""Test unknown environment returns None when no backends available."""
with patch.dict(os.environ, {}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
result = create_backend()
assert result is None
def test_create_backend_no_backends_available(self):
"""Test create_backend returns None when no backends available at all."""
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
result = create_backend()
assert result is None
def test_create_backend_with_custom_parameters(self):
"""Test that custom parameters are passed to auto-selected backend."""
mock_pynput_backend = MagicMock()
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
) as mock_constructor:
result = create_backend(shortcut="alt+alt", mode="push_to_talk")
mock_constructor.assert_called_once_with(
shortcut="alt+alt", mode="push_to_talk"
)
def test_create_backend_default_parameters(self):
"""Test that default parameters are used when not specified."""
mock_pynput_backend = MagicMock()
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
) as mock_constructor:
result = create_backend()
# Check that the constructor was called with some default shortcut/mode
mock_constructor.assert_called_once()
call_kwargs = mock_constructor.call_args[1]
# Shortcut and mode should be passed as defaults
assert "shortcut" in call_kwargs
assert "mode" in call_kwargs
class TestCreateBackendFallback:
"""Test create_backend() fallback scenarios."""
def test_create_backend_pynput_not_available_evdev_available_not_usable(self):
"""Test fallback to evdev when pynput unavailable and evdev not usable."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = False
mock_evdev_backend.get_permission_hint.return_value = "Permission denied"
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
result = create_backend()
assert result is None
def test_create_backend_wayland_preferred_pynput_fails_evdev_available(self):
"""Test that preferred pynput on Wayland fails and then tries evdev."""
# When preferred_backend is pynput and Wayland is detected,
# if pynput is not available, it falls back to auto-detection which tries evdev
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = True
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
result = create_backend(preferred_backend="pynput")
# Falls back to auto-detection which selects evdev on Wayland
assert result == mock_evdev_backend