From 50ea1109d7f839bad94a3b6159e3b15d9487b01e Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sat, 5 Sep 2026 18:55:37 -0700 Subject: [PATCH] feat(snap): Snap packaging recipe and Snap Store publish strategy (#519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(snap): add Snap packaging recipe and store publish strategy Add a strict-confinement snapcraft.yaml aligned with the current app version and Flatpak sandbox lessons (mic, desktop, network models, xdotool/xsel injection). Document human-only Snap Store steps and mark install docs as not published yet. Refs #48 * fix(snap): adopt-info so craftctl can set version from version.py snapcraft pack failed: craftctl set version requires adopt-info on the part. * fix(snap): stop staging GTK/GLib over the gnome platform Prepending snap-staged GI typelibs and lib paths caused a segfault on from gi.repository import Gtk. Use platform AppIndicator/GTK and only stage PortAudio plus xdotool/clipboard helpers. * docs(snap): note platform GTK path to avoid GI segfault * fix(snap): route ALSA through Pulse so Settings does not abort PortAudio enumerates ALSA devices when Settings opens. Missing /usr/share/alsa/alsa.conf inside the snap triggered a PortAudio assert. Ship an asound.conf that uses the pulse plugin, stage libasound2-plugins, and bind ALSA paths via layout. * fix(snap): keep ALSA conf under snap/local for snapcraft Move asound.conf out of snap/alsa so snapcraft stops warning about non-snapcraft paths in the snap directory. * fix(snap): request raw-input for global keyboard shortcuts Strict confinement blocks /proc/bus/input and /dev/input, so Wayland evdev hotkeys never start. Declare the raw-input plug (manual connect) and print snap-specific connect instructions instead of host input-group hints. * fix(hotkeys): discover keyboards via evdev when /proc is blocked Snap raw-input grants /dev/input/event* but still denies /proc/bus/input/devices, so find_keyboard_devices() returned empty and hotkeys never started. Fall back to evdev.list_devices() when /proc cannot be read. * fix(snap): restore UI sounds and prefer wtype for Wayland inject Stage paplay (pulseaudio-utils) and wtype; teach audio_feedback about pw-play/canberra. xdotool-only injection often hits the wrong window on native Wayland even when it reports success. * fix(snap): sync 0.16.2/AGPL metadata and AppImage-safe audio env Align snapcraft fallback version and store metadata with current main (VocaHQ URLs, AGPL-3.0), keep docs pointing at #48 on VocaHQ, pass host_env to pw-play/canberra paths, and document ordered Snap Store human deploy steps. * test(snap): harden audio host_env unit test against suite pollution Patch os.path.exists like sibling audio_feedback tests instead of relying on a real tempfile path that can collide with other suite mocks. * style(snap): black-format new audio and snap packaging tests * docs(snap): treat Store listing as live; edge→candidate→stable refresh Name vocalinux is already on snapcraft.io (edge @ 0.14.0-beta rev 6). Document upload/release without re-register, add snap-store-refresh.sh helper, and point INSTALL/README/DISTRO at the live listing. * fix(snap): move to core24 and close evdev capability FDs core22 only ships Python 3.10, which fails requires-python >=3.11, so the snap base is now core24 (Python 3.12) with the gnome-46 platform and the Ubuntu 24.04 libasound2t64 package. Also close InputDevice after failed capability scans and annotate the proc parser with TextIO. * Snap: align store listing metadata and icon Match snapcraft.yaml summary, description, license, website, and store icon to the live Snap Store listing. * Snap: trim packaging docs, script, and unused SVG Drop store refresh wrapper, long packaging README, duplicate snap/gui SVG, and speculative pw-play/canberra paths; keep recipe and confinement fixes. * ci: publish snap to edge and candidate on release tags * Snap: set grade stable for candidate channel CI * Snap: skip pip PyGObject; use gnome extension GI --- .github/workflows/release.yml | 34 ++++ .gitignore | 7 + README.md | 12 ++ docs/DISTRO_COMPATIBILITY.md | 2 +- docs/INSTALL.md | 23 +++ snap/gui/vocalinux.desktop | 15 ++ snap/gui/vocalinux.png | Bin 0 -> 26912 bytes snap/local/alsa/asound.conf | 16 ++ snap/snapcraft.yaml | 129 ++++++++++++++ .../ui/keyboard_backends/evdev_backend.py | 168 +++++++++++++----- src/vocalinux/ui/keyboard_shortcuts.py | 14 +- tests/test_evdev_backend_ext.py | 27 ++- tests/test_snap_packaging.py | 60 +++++++ 13 files changed, 460 insertions(+), 47 deletions(-) create mode 100644 snap/gui/vocalinux.desktop create mode 100644 snap/gui/vocalinux.png create mode 100644 snap/local/alsa/asound.conf create mode 100644 snap/snapcraft.yaml create mode 100644 tests/test_snap_packaging.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fb1a24..f446f5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -356,6 +356,40 @@ jobs: ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} commit_message: 'Release ${{ github.ref_name }}' + # Store-only: do not attach the .snap to the GitHub Release. grade: stable + # allows candidate; do not auto-release to stable — promote after QA. + # Secret: SNAPCRAFT_STORE_CREDENTIALS (snapcraft export-login). + publish-snap: + name: Build and Publish Snap + needs: build-and-release + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Require Snap Store credentials + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + run: | + if [ -z "$SNAPCRAFT_STORE_CREDENTIALS" ]; then + echo "SNAPCRAFT_STORE_CREDENTIALS is unset; cannot publish the snap" + exit 1 + fi + + - name: Build snap + id: build + uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1 + + - name: Publish snap to edge and candidate + uses: snapcore/action-publish@214b86e5ca036ead1668c79afb81e550e6c54d40 # v1 + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: ${{ steps.build.outputs.snap }} + release: edge,candidate + deploy-website: name: Deploy Website runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 881bbe1..f5db04c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,13 @@ dist/ build/ *.egg-info/ *.egg +*.snap +# snapcraft local build trees (repo root only) +/parts/ +/prime/ +/stage/ +/snap/.snapcraft/ +/.snapcraft/ # Virtual environments venv/ diff --git a/README.md b/README.md index 5fc63ed..fd42f33 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,18 @@ cd vocalinux The installer handles everything: system dependencies, Python environment, speech models, and desktop integration. +### Snap (Ubuntu Snap Store) + +In-repo recipe: `snap/snapcraft.yaml` (issue [#48](https://github.com/VocaHQ/vocalinux/issues/48)). Store listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux). Pack and upload steps: [docs/INSTALL.md](docs/INSTALL.md). + +```bash +sudo snap install vocalinux --edge # current public channel (0.16.2 refresh) +# sudo snap install vocalinux --candidate # after candidate release +# sudo snap install vocalinux # after stable promotion +sudo snap connect vocalinux:audio-record # if mic is not auto-connected +sudo snap connect vocalinux:raw-input # global keyboard shortcuts (evdev) +``` + ### 🌙 Nightly Releases (Bleeding Edge) For developers and early adopters who want to test the latest features, check out our [GitHub Releases page](https://github.com/VocaHQ/vocalinux/releases) which includes both beta and nightly builds. diff --git a/docs/DISTRO_COMPATIBILITY.md b/docs/DISTRO_COMPATIBILITY.md index 39e1a12..49b954c 100644 --- a/docs/DISTRO_COMPATIBILITY.md +++ b/docs/DISTRO_COMPATIBILITY.md @@ -13,7 +13,7 @@ The cross-distribution compatibility improvements have been implemented in phase | **Phase 5** | ✅ **Complete** | **Fixed remaining hardcoded GI_TYPELIB_PATH values in install.sh and CI workflow** | | **Phase 6** | ✅ **Complete** | **Added wrapper script verification tests, updated documentation** | | **Phase 7** | ✅ **Complete** | **Flatpak packaging (whisper.cpp engine) for universal distribution support — see [`packaging/flatpak/`](../packaging/flatpak/README.md). Not on Flathub: submission [flathub#9368](https://github.com/flathub/flathub/pull/9368) closed 2026-07-23 on policy grounds; channel tracked in [#167](https://github.com/VocaHQ/vocalinux/issues/167).** | -| Phase 8 | 📋 Planned | Snap packaging for Ubuntu Software Store | +| Phase 8 | 🚧 In progress | Snap recipe in-repo + Store listing live ([snapcraft.io/vocalinux](https://snapcraft.io/vocalinux)); install `--edge` and pack/upload steps in [docs/INSTALL.md](INSTALL.md) | ## Technical Implementation diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f5da716..d74ffa1 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -136,6 +136,29 @@ model during setup. On Ubuntu 24.04+ or Pop!_OS, install `libgirepository-2.0-dev` if `libgirepository1.0-dev` is not available. +### From 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 build and publish to Snap Store `edge` and `candidate` +via CI when `SNAPCRAFT_STORE_CREDENTIALS` is set. `stable` is still a +manual promote after QA. + +```bash +sudo snap install vocalinux --edge + +# Optional: grant microphone / hotkeys if snapd does not auto-connect them +sudo snap connect vocalinux:audio-record +sudo snap connect vocalinux:raw-input +``` + +Pack and upload (requires `snapcraft` + LXD/Multipass): + +```bash +snapcraft pack +snapcraft upload --release=edge vocalinux_*.snap +``` + ## System Requirements | Requirement | Details | diff --git a/snap/gui/vocalinux.desktop b/snap/gui/vocalinux.desktop new file mode 100644 index 0000000..7263676 --- /dev/null +++ b/snap/gui/vocalinux.desktop @@ -0,0 +1,15 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Vocalinux +Comment=Free, offline voice dictation for Linux +GenericName=Voice Typing +Exec=vocalinux +Icon=${SNAP}/usr/share/icons/hicolor/scalable/apps/vocalinux.svg +Terminal=false +Categories=Utility;Accessibility;AudioVideo; +Keywords=voice;typing;dictation;speech;recognition;transcription;accessibility; +StartupNotify=true +StartupWMClass=com.vocalinux.Vocalinux +X-GNOME-UsesNotifications=true +X-SnapInstanceName=vocalinux diff --git a/snap/gui/vocalinux.png b/snap/gui/vocalinux.png new file mode 100644 index 0000000000000000000000000000000000000000..44651a4ff66bbaec81d0eb7a71280e3559be7ef1 GIT binary patch literal 26912 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.11) +# Fallback version; live version is set from src/vocalinux/version.py via +# adopt-info + craftctl set in the vocalinux part (required for craftctl). +version: "0.16.2" +adopt-info: vocalinux +summary: Free offline voice dictation for Linux +description: | + Vocalinux is free voice typing for Linux. Hold a shortcut or toggle dictation, and text lands in almost any app on X11 or Wayland. + + Speech recognition runs on your machine after you download a model. No Voca account. No cloud transcription on the default path. + + This snap ships the whisper.cpp engine plus VOSK. The optional OpenAI Whisper / PyTorch stack is not bundled (too large for the snap). + + Features: + - 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 + + Sandbox notes: + - Global hotkeys need: sudo snap connect vocalinux:raw-input + - Microphone may need: sudo snap connect vocalinux:audio-record + - Config and models live under ~/snap/vocalinux/ + +grade: stable +confinement: strict +license: AGPL-3.0-only +icon: snap/gui/vocalinux.png +contact: https://github.com/VocaHQ/vocalinux/issues +issues: https://github.com/VocaHQ/vocalinux/issues +website: https://vocalinux.com +source-code: https://github.com/VocaHQ/vocalinux +donation: https://github.com/sponsors/jatinkrmalik + +apps: + vocalinux: + command: bin/vocalinux + extensions: [gnome] + plugs: + # gnome extension already grants desktop, desktop-legacy, gsettings, + # opengl, wayland, x11, unity7 (tray / indicators). + - audio-record # microphone (may require manual connect) + - audio-playback # start/stop feedback tones (PipeWire-pulse / PulseAudio) + - home # optional access outside snap HOME (shared files) + - network # download speech models on first run + - network-bind # optional remote_api engine + # Global hotkeys via python-evdev (/proc/bus/input + /dev/input/event*). + # Super-privileged: not auto-connected; user must: + # sudo snap connect vocalinux:raw-input + - raw-input + common-id: com.vocalinux.Vocalinux + desktop: usr/share/applications/vocalinux.desktop + environment: + # Prefer bundled CLI helpers (xdotool, xsel, xclip, wl-copy). + # Do NOT prepend $SNAP/usr/lib over the gnome extension platform path: + # that loads a second GLib/GTK and segfaults on `from gi.repository import Gtk`. + PATH: $SNAP/usr/bin:$SNAP/bin:$PATH + # PortAudio opens ALSA first. Point at snap-local config that routes to Pulse. + ALSA_CONFIG_PATH: $SNAP/etc/asound.conf + +# Make ALSA data/plugins visible at the paths libasound expects. +layout: + /usr/share/alsa: + bind: $SNAP/usr/share/alsa + /usr/lib/$CRAFT_ARCH_TRIPLET/alsa-lib: + bind: $SNAP/usr/lib/$CRAFT_ARCH_TRIPLET/alsa-lib + +parts: + vocalinux: + plugin: python + source: . + source-type: local + build-environment: + - PIP_DISABLE_PIP_VERSION_CHECK: "1" + build-packages: + - build-essential + - cmake + - git + - pkg-config + - portaudio19-dev + - libgirepository1.0-dev + - libcairo2-dev + - python3-dev + - libdbus-1-dev + stage-packages: + # Only extras not provided by the gnome-46-2404 platform content snap. + # Staging GTK/GLib/AppIndicator here duplicates the platform and can crash GI. + - libportaudio2 + - libasound2t64 # Ubuntu 24.04 / core24 name for libasound2 + - libasound2-data + - 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) + - xsel + - xclip + - wl-clipboard + python-packages: + - pip + - setuptools + - wheel + override-pull: | + craftctl default + version="$(python3 -c "import pathlib, re; t = pathlib.Path('${CRAFT_PART_SRC}/src/vocalinux/version.py').read_text(); m = re.search(r'__version__\\s*=\\s*[\\'\"]([^\\'\"]+)', t); assert m, 'version not found'; print(m.group(1))")" + craftctl set version="$version" + # PyGObject/GI provided by gnome extension. + sed -i '/^[[:space:]]*"PyGObject/d' "${CRAFT_PART_SRC}/pyproject.toml" + override-build: | + craftctl default + install -Dm644 "${CRAFT_PROJECT_DIR}/snap/gui/vocalinux.desktop" \ + "${CRAFT_PART_INSTALL}/usr/share/applications/vocalinux.desktop" + install -Dm644 "${CRAFT_PROJECT_DIR}/resources/icons/scalable/vocalinux.svg" \ + "${CRAFT_PART_INSTALL}/usr/share/icons/hicolor/scalable/apps/vocalinux.svg" + # meta/gui is what snapd uses for the store/desktop icon when present. + install -Dm644 "${CRAFT_PROJECT_DIR}/resources/icons/scalable/vocalinux.svg" \ + "${CRAFT_PART_INSTALL}/meta/gui/icon.svg" + install -Dm644 "${CRAFT_PROJECT_DIR}/snap/local/alsa/asound.conf" \ + "${CRAFT_PART_INSTALL}/etc/asound.conf" diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index 693b1b8..435e48c 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -12,7 +12,7 @@ import re import select import threading import time -from typing import Optional +from typing import Optional, TextIO # Try to import evdev try: @@ -119,38 +119,100 @@ def find_keyboard_devices() -> list[str]: """ Find all keyboard input devices. + Prefers /proc/bus/input/devices (host installs). Falls back to + ``evdev.list_devices()`` when that path cannot be read (common under snap + confinement even with raw-input, which grants /dev/input/event* but may + still deny /proc/bus/input/devices). + Returns: List of device paths for keyboard devices """ - keyboard_devices = [] - try: - # Read from /proc/bus/input/devices to find keyboards - with open("/proc/bus/input/devices", "r") as f: - current_device = None - for line in f: - line = line.rstrip("\n") - if line.startswith("I: Bus="): - current_device = {"handlers": []} - elif line.startswith("H: Handlers=") and current_device is not None: - handlers = line.split("=", 1)[1].strip() - current_device["handlers"] = handlers.split() - elif line.startswith("B: KEY=") and current_device is not None: - # Check if this device has keyboard keys (bit 0 is set) - key_bits = line.split("=", 1)[1].strip() - # The first hex digit after KEY= contains keyboard capability - # If it's not 0, 1, or ffffffffff, it has keyboard keys - if key_bits and key_bits != "0": - # Check if event handler exists - for handler in current_device.get("handlers", []): - if handler.startswith("event"): - device_path = f"/dev/input/{handler}" - if os.path.exists(device_path): - keyboard_devices.append(device_path) - current_device = None - + return _parse_keyboard_devices_from_proc( + open("/proc/bus/input/devices", "r", encoding="utf-8", errors="replace") + ) except (IOError, OSError) as e: - logger.error(f"Error reading input devices: {e}") + logger.debug(f"Cannot read /proc/bus/input/devices: {e}") + fallback = _find_keyboard_devices_from_evdev() + if fallback: + # Hot-plug rescans call this often; keep the noise down after first hit. + logger.debug( + "Using evdev.list_devices() fallback for keyboard discovery " + f"({len(fallback)} device(s))" + ) + return fallback + + +def _parse_keyboard_devices_from_proc(proc_file: TextIO) -> list[str]: + """Parse an open /proc/bus/input/devices stream for event KEY devices.""" + keyboard_devices: list[str] = [] + current_device = None + with proc_file as f: + for line in f: + line = line.rstrip("\n") + if line.startswith("I: Bus="): + current_device = {"handlers": []} + elif line.startswith("H: Handlers=") and current_device is not None: + handlers = line.split("=", 1)[1].strip() + current_device["handlers"] = handlers.split() + elif line.startswith("B: KEY=") and current_device is not None: + # Check if this device has keyboard keys (bit 0 is set) + key_bits = line.split("=", 1)[1].strip() + # The first hex digit after KEY= contains keyboard capability + # If it's not 0, 1, or ffffffffff, it has keyboard keys + if key_bits and key_bits != "0": + # Check if event handler exists + for handler in current_device.get("handlers", []): + if handler.startswith("event"): + device_path = f"/dev/input/{handler}" + if os.path.exists(device_path): + keyboard_devices.append(device_path) + current_device = None + return keyboard_devices + + +def _find_keyboard_devices_from_evdev() -> list[str]: + """Discover keyboards by opening devices from ``evdev.list_devices()``.""" + if not EVDEV_AVAILABLE: + return [] + + keyboard_devices: list[str] = [] + try: + paths = list(evdev.list_devices()) + except Exception as e: + logger.debug(f"evdev.list_devices() failed: {e}") + return [] + + for path in paths: + try: + device = InputDevice(path) + try: + capabilities = device.capabilities() + finally: + device.close() + except (OSError, IOError, TypeError, ValueError): + continue + + # Prefer devices that look like keyboards: EV_KEY with a common letter + # or a modifier. This includes combo "Keyboard" nodes on mice/remotes. + key_caps = capabilities.get(ecodes.EV_KEY, []) if capabilities else [] + if not key_caps: + continue + if any( + code in key_caps + for code in ( + KEY_LEFTCTRL, + KEY_RIGHTCTRL, + KEY_LEFTALT, + KEY_RIGHTALT, + KEY_LEFTSHIFT, + KEY_RIGHTSHIFT, + KEY_LEFTMETA, + KEY_RIGHTMETA, + getattr(ecodes, "KEY_A", -1), + ) + ): + keyboard_devices.append(path) return keyboard_devices @@ -326,22 +388,46 @@ class EvdevKeyboardBackend(KeyboardBackend): if not EVDEV_AVAILABLE: return "Install python-evdev: pip install evdev" + in_snap = bool(os.environ.get("SNAP_NAME") or os.environ.get("SNAP")) + try: devices = find_keyboard_devices() - if not devices: - return None # No devices found, not a permission issue + if devices: + # Try to open the first device to check permissions + for device_path in devices[:1]: + try: + InputDevice(device_path) + return None # Successfully opened, permissions OK + except (OSError, IOError) as e: + if "Permission denied" in str(e) or e.errno == errno.EACCES: + if in_snap: + return ( + "Snap is missing input-device access. " + "Connect once, then restart:\n" + "sudo snap connect vocalinux:raw-input" + ) + return ( + "Add your user to the 'input' group and log out/in:\n" + "sudo usermod -a -G input $USER" + ) + return None - # Try to open the first device to check permissions - for device_path in devices[:1]: # Just check the first one - try: - InputDevice(device_path) - return None # Successfully opened, permissions OK - except (OSError, IOError) as e: - if "Permission denied" in str(e) or e.errno == errno.EACCES: - return ( - "Add your user to the 'input' group and log out/in:\n" - "sudo usermod -a -G input $USER" - ) + # No devices found — snap without raw-input often cannot list anything. + if in_snap: + return ( + "Snap is missing input-device access. Connect once, then restart:\n" + "sudo snap connect vocalinux:raw-input" + ) + + try: + with open("/proc/bus/input/devices", "r"): + pass + except (OSError, IOError) as e: + if "Permission denied" in str(e) or getattr(e, "errno", None) == errno.EACCES: + return ( + "Add your user to the 'input' group and log out/in:\n" + "sudo usermod -a -G input $USER" + ) except Exception: pass diff --git a/src/vocalinux/ui/keyboard_shortcuts.py b/src/vocalinux/ui/keyboard_shortcuts.py index 5e71776..1f2a549 100644 --- a/src/vocalinux/ui/keyboard_shortcuts.py +++ b/src/vocalinux/ui/keyboard_shortcuts.py @@ -91,10 +91,22 @@ class KeyboardShortcutManager: def _log_unavailable_hints(self): """Log helpful hints when no backend is available.""" + import os + env = DesktopEnvironment.detect() + in_snap = bool(os.environ.get("SNAP_NAME") or os.environ.get("SNAP")) + + logger.warning("=" * 60) + if in_snap: + logger.warning("Keyboard shortcuts need Snap input access") + logger.warning("=" * 60) + logger.warning("Global hotkeys read /dev/input (evdev). Connect once:") + logger.warning(" sudo snap connect vocalinux:raw-input") + logger.warning("Then restart Vocalinux. Until then, use the system tray.") + logger.warning("=" * 60) + return if env == DesktopEnvironment.WAYLAND: - logger.warning("=" * 60) logger.warning("Keyboard shortcuts not available on Wayland") logger.warning("=" * 60) logger.warning("To enable keyboard shortcuts on Wayland:") diff --git a/tests/test_evdev_backend_ext.py b/tests/test_evdev_backend_ext.py index 5d09a83..a1e35d0 100644 --- a/tests/test_evdev_backend_ext.py +++ b/tests/test_evdev_backend_ext.py @@ -75,14 +75,33 @@ B: KEY=ff000000000000 0 0 0 def test_find_keyboard_devices_io_error(self): """Test error handling when /proc/bus/input/devices cannot be read.""" with patch("builtins.open", side_effect=IOError("Permission denied")): - result = find_keyboard_devices() - assert result == [] + with patch( + "vocalinux.ui.keyboard_backends.evdev_backend._find_keyboard_devices_from_evdev", + return_value=[], + ): + result = find_keyboard_devices() + assert result == [] def test_find_keyboard_devices_file_not_found(self): """Test error handling when /proc/bus/input/devices doesn't exist.""" with patch("builtins.open", side_effect=FileNotFoundError()): - result = find_keyboard_devices() - assert result == [] + with patch( + "vocalinux.ui.keyboard_backends.evdev_backend._find_keyboard_devices_from_evdev", + return_value=[], + ): + result = find_keyboard_devices() + assert result == [] + + def test_find_keyboard_devices_falls_back_to_evdev_list(self): + """When /proc is denied (e.g. snap), discover keyboards via list_devices.""" + with patch("builtins.open", side_effect=PermissionError(13, "Permission denied")): + with patch( + "vocalinux.ui.keyboard_backends.evdev_backend._find_keyboard_devices_from_evdev", + return_value=["/dev/input/event10"], + ) as fallback: + result = find_keyboard_devices() + assert result == ["/dev/input/event10"] + fallback.assert_called_once() def test_find_keyboard_devices_no_keyboard_capability(self): """Test filtering devices without keyboard capability.""" diff --git a/tests/test_snap_packaging.py b/tests/test_snap_packaging.py new file mode 100644 index 0000000..425cd8f --- /dev/null +++ b/tests/test_snap_packaging.py @@ -0,0 +1,60 @@ +"""Structural tests for Snap packaging (issue #48).""" + +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +SNAPCRAFT_YAML = REPO_ROOT / "snap" / "snapcraft.yaml" +DESKTOP_FILE = REPO_ROOT / "snap" / "gui" / "vocalinux.desktop" +SNAP_PNG = REPO_ROOT / "snap" / "gui" / "vocalinux.png" + + +def test_snapcraft_recipe_and_gui_assets() -> None: + assert SNAPCRAFT_YAML.is_file() + doc = yaml.safe_load(SNAPCRAFT_YAML.read_text(encoding="utf-8")) + assert doc["name"] == "vocalinux" + assert doc["base"] == "core24" + assert doc["summary"] == "Free offline voice dictation for Linux" + assert doc["license"] == "AGPL-3.0-only" + assert doc["icon"] == "snap/gui/vocalinux.png" + assert doc["website"] == "https://vocalinux.com" + assert doc["confinement"] == "strict" + assert doc["grade"] == "stable" + + plugs = set((doc.get("apps") or {}).get("vocalinux", {}).get("plugs") or []) + assert "raw-input" in plugs + assert "audio-record" in plugs + + assert DESKTOP_FILE.is_file() + assert SNAP_PNG.is_file() + assert SNAP_PNG.stat().st_size > 0 + + +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") + doc = yaml.safe_load(text) + override_pull = doc["parts"]["vocalinux"]["override-pull"] + assert "PyGObject" in override_pull + assert "pyproject.toml" in override_pull + assert "gnome extension" in override_pull + stage = doc["parts"]["vocalinux"].get("stage-packages") or [] + assert "python3-gi" not in stage + assert "python3-gi-cairo" not in stage + + +def test_release_publishes_snap_to_edge_and_candidate() -> None: + """v* tags must ship the snap to the store, not attach it to GitHub.""" + text = (REPO_ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + assert " publish-snap:\n" in text + job = text.split(" publish-snap:\n", 1)[1].split("\n deploy-website:", 1)[0] + assert "needs: build-and-release" in job + assert "snapcore/action-build@" in job + assert "snapcore/action-publish@" in job + assert "release: edge,candidate" in job + assert "release: stable" not in job + assert "SNAPCRAFT_STORE_CREDENTIALS is unset; cannot publish the snap" in job + assert "gh release upload" not in job + assert "action-gh-release" not in job + assert "upload-artifact" not in job