diff --git a/docs/DISTRO_COMPATIBILITY.md b/docs/DISTRO_COMPATIBILITY.md index 5a1c8a0..a34f2df 100644 --- a/docs/DISTRO_COMPATIBILITY.md +++ b/docs/DISTRO_COMPATIBILITY.md @@ -193,10 +193,14 @@ sudo apk add py3-gobject3 py3-pip gtk+3.0 py3-cairo portaudio-dev \ ### Void Linux ```bash -sudo xbps-install -Sy python3-pip python3-gobject gtk+3 libappindicator \ +sudo xbps-install -Sy python3-pip python3-gobject gtk+3 libayatana-appindicator \ gobject-introspection portaudio-devel python3-devel pkg-config ``` +Note: `ydotool` is not packaged for Void, so the ydotool injection +fallback rung is unavailable there; the app uses IBus, the RemoteDesktop +portal, `wtype` or `xdotool` instead. + ### Solus ```bash sudo eopkg install python3-pip python3-gobject gtk3 libappindicator \ diff --git a/install.d/package_map.sh b/install.d/package_map.sh index fb8a7f6..b0e936f 100644 --- a/install.d/package_map.sh +++ b/install.d/package_map.sh @@ -101,7 +101,7 @@ load_distro_package_map() { ;; void) - SYSTEM_PACKAGES=(python3-pip python3-gobject gtk+3 libappindicator-gtk3 gobject-introspection portaudio-devel cairo-devel python3-devel pkg-config cmake base-devel wget curl unzip shaderc patchelf Vulkan-Tools xclip xsel wl-clipboard) + SYSTEM_PACKAGES=(python3-pip python3-gobject gtk+3 libayatana-appindicator gobject-introspection portaudio-devel cairo-devel python3-devel pkg-config cmake base-devel wget curl unzip shaderc patchelf Vulkan-Tools xclip xsel wl-clipboard) XDOTOOL_PACKAGES=(xdotool) WTYPE_PACKAGES=(wtype) YDOTOOL_PACKAGES=(ydotool) diff --git a/packaging/void/README.md b/packaging/void/README.md index 516484f..500e6c4 100644 --- a/packaging/void/README.md +++ b/packaging/void/README.md @@ -27,6 +27,7 @@ packaging/void/ | Template | Ticket | Notes | |---|---|---| | `python3-pywhispercpp` | #26 | Vulkan backend on by default; see below | +| `voculinux` | #27 | The app, from the canonical release sdist; see below | ## python3-pywhispercpp @@ -61,11 +62,45 @@ for the sdist; `tests/test_void_pywhispercpp_template.py` fails otherwise. verified to exist in current void-packages; the container build re-verifies via dependency resolution. +## voculinux + +Version `0.18.5`, matching `src/vocalinux/version.py` — never newest +upstream. The template builds the canonical release source distribution +(the `releases/download` sdist, the same bytes published to PyPI) with a +pinned `checksum`; `tests/test_void_app_template.py` fails otherwise. +The 0.18.5 asset still carries the pre-rename archive root, so the +template sets `wrksrc` explicitly; the first release cut after the +rename drops the override and points `distfiles` at the renamed asset. + +- **Runtime deps** are Void's own interpreter, GTK and desktop bindings + (`python3`, `python3-gobject`, `python3-cairo`, `gtk+3`, + `libayatana-appindicator`, `ibus`, `gobject-introspection`), the + packaged Python libraries, and the three templates from tickets #25 + and #26 (`python3-pynput`, `python3-PyAudio`, `python3-pywhispercpp`). + Every name was verified to exist in current void-packages; the + container build re-verifies via dependency resolution. +- **No payload beyond the app**: no speech models (they download on + first run), no vendored wheels, no pip at build or install time. The + container smoke fails the build if the payload carries model weights + or wheels. +- **Renamed paths**: the `voculinux.desktop` entry, the hicolor icons, + and a `usr/share/voculinux/packaged` marker the app reads to present + package-manager updates. +- **INSTALL.msg** covers what xbps cannot express: the `input` group for + the evdev hotkey backend, first-run model downloads, optional engines + as manual additions (vosk, OpenAI whisper, Parakeet, faster-whisper, + onnxruntime), the absent `ydotool` rung, and updating through xbps. +- **Update checker**: packaged installs keep background checks as a + signal, but every update affordance (tray entry, notification, About + page) presents the package-manager path + (`src/vocalinux/utils/packaging.py`). + ## Building ```sh packaging/void/build.sh python3-pywhispercpp packaging/void/build.sh python3-pywhispercpp -- -o ~vulkan pkg +packaging/void/build.sh voculinux ``` Podman only, never Docker. The base image digest and the void-packages diff --git a/packaging/void/container-main.sh b/packaging/void/container-main.sh index 635e525..2fbfa87 100755 --- a/packaging/void/container-main.sh +++ b/packaging/void/container-main.sh @@ -31,6 +31,32 @@ echo "built: $PKGFILE" xbps-install -Sy >/dev/null xbps-install -y --repository="$(dirname "$PKGFILE")" "$PKGNAME" >/dev/null +case "$PKGNAME" in +voculinux) + echo "== payload $PKGNAME: desktop entry, icons, packaged marker" + tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/applications/voculinux.desktop" || { + echo "renamed desktop entry missing from the built package" >&2 + exit 1 + } + tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/icons/hicolor/scalable/apps/voculinux.svg" || { + echo "renamed icons missing from the built package" >&2 + exit 1 + } + tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/voculinux/packaged" || { + echo "packaged-install marker missing from the built package" >&2 + exit 1 + } + echo "== payload $PKGNAME: no speech models, no vendored wheels" + if tar -tf "$PKGFILE" 2>/dev/null | grep -E "models/|\.ggml$|\.bin$|\.pt$|\.whl$"; then + echo "package payload carries models or wheels" >&2 + exit 1 + fi + echo "no models or wheels in the shipped package" + echo "== version and import smoke $PKGNAME" + voculinux --version + python3 -c "from importlib.metadata import version; print('voculinux ' + version('voculinux'))" + ;; +*) echo "== compiler flag scan of the shipped ggml library" LIBMEMBER=$(tar -tf "$PKGFILE" 2>/dev/null | grep -E "libggml-cpu\.so" | head -n1) [ -n "$LIBMEMBER" ] || { @@ -71,3 +97,5 @@ assert has_vulkan == want_vulkan, "vulkan backend present=%s, expected=%s" % (ha print("smoke ok") PYEOF EXPECT_VULKAN="$EXPECT_VULKAN" python3 /smoke.py + ;; +esac diff --git a/packaging/void/srcpkgs/voculinux/INSTALL.msg b/packaging/void/srcpkgs/voculinux/INSTALL.msg new file mode 100644 index 0000000..ea96da2 --- /dev/null +++ b/packaging/void/srcpkgs/voculinux/INSTALL.msg @@ -0,0 +1,22 @@ +Voculinux installed system-wide from an xbps package. + +Hotkeys need the input group: add your user with + sudo usermod -aG input +then log out and back in, or the evdev hotkey +backend cannot open /dev/input devices. + +Speech models are not in this package: they +download on first run into ~/.local/share/voculinux. + +Optional engines are manual additions outside the +guaranteed set: vosk, OpenAI whisper, Parakeet +(sherpa-onnx), faster-whisper and onnxruntime (VAD) +come from your own Python environment, not xbps. + +ydotool is not packaged for Void, so the ydotool +injection fallback rung is absent; the app uses +IBus, the RemoteDesktop portal, wtype or xdotool. + +Update through your package manager instead of the +in-app release downloads: + sudo xbps-install -Syu voculinux diff --git a/packaging/void/srcpkgs/voculinux/template b/packaging/void/srcpkgs/voculinux/template new file mode 100644 index 0000000..821f5ce --- /dev/null +++ b/packaging/void/srcpkgs/voculinux/template @@ -0,0 +1,32 @@ +# Template file for 'voculinux' +pkgname=voculinux +version=0.18.5 +revision=1 +build_style=python3-module +hostmakedepends="python3-setuptools" +makedepends="python3-devel" +depends="python3 python3-gobject python3-cairo gtk+3 libayatana-appindicator ibus gobject-introspection python3-numpy python3-requests python3-pysocks python3-psutil python3-evdev python3-xlib python3-tqdm python3-platformdirs python3-PyAudio python3-pynput python3-pywhispercpp" +short_desc="Free, offline voice dictation for Linux" +maintainer="Jatin K Malik " +license="AGPL-3.0-only" +homepage="https://github.com/VocaHQ/vocalinux" +distfiles="https://github.com/VocaHQ/vocalinux/releases/download/v${version}/vocalinux-${version}.tar.gz" +checksum=1def4d8f0f14f2df2f2fee64ca29e6865e3d82e551f7d465c2a78ec6d4d72b1d +# The 0.18.5 release asset still carries the pre-rename archive root, so +# build under that directory. The first release cut after the rename ships +# the archive under the new name; that bump drops this override and points +# distfiles at the renamed asset with its published digest. +wrksrc="vocalinux-${version}" + +post_install() { + # Entry points land on PATH through the module build; install the + # renamed desktop entry, the full icon set, and the marker the app + # reads to present package-manager updates (see INSTALL.msg). + vlicense LICENSE + vinstall voculinux.desktop 644 usr/share/applications + for icon in resources/icons/scalable/*.svg; do + vinstall "$icon" 644 usr/share/icons/hicolor/scalable/apps + done + vmkdir usr/share/voculinux + touch "${DESTDIR}/usr/share/voculinux/packaged" +} diff --git a/scripts/distro-package-map.yaml b/scripts/distro-package-map.yaml index 5d3f0b1..902b658 100755 --- a/scripts/distro-package-map.yaml +++ b/scripts/distro-package-map.yaml @@ -177,10 +177,16 @@ distributions: void: # cairo-devel is needed because the pinned pycairo builds from source. # base-devel compiles the pywhispercpp sdist and the evdev/PyAudio sdists. - system: [python3-pip, python3-gobject, gtk+3, libappindicator-gtk3, gobject-introspection, portaudio-devel, cairo-devel, python3-devel, pkg-config, cmake, base-devel, wget, curl, unzip, shaderc, patchelf, Vulkan-Tools, xclip, xsel, wl-clipboard] + # libayatana-appindicator is the tray provider: the libappindicator-gtk3 + # name has no void-packages template (verified 2026-11-06). + system: [python3-pip, python3-gobject, gtk+3, libayatana-appindicator, gobject-introspection, portaudio-devel, cairo-devel, python3-devel, pkg-config, cmake, base-devel, wget, curl, unzip, shaderc, patchelf, Vulkan-Tools, xclip, xsel, wl-clipboard] text_input: xdotool: [xdotool] wtype: [wtype] + # ydotool is absent in void-packages (no srcpkgs template as of + # 2026-11-06), so the installer never attempts it on Void and the + # app's ydotool injection rung stays unavailable there. The entry + # keeps its shape so the generator contract is unchanged. ydotool: [ydotool] solus: diff --git a/src/vocalinux/ui/settings_about.py b/src/vocalinux/ui/settings_about.py index d55a3a9..fa9b454 100644 --- a/src/vocalinux/ui/settings_about.py +++ b/src/vocalinux/ui/settings_about.py @@ -9,6 +9,7 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa: E402 +from ..utils.packaging import is_packaged_install, updates_description from ..utils.update_checker import ( DEFAULT_CHECK_AUTOMATICALLY, build_channel, @@ -434,10 +435,7 @@ class AboutSectionMixin(Gtk.Dialog): build = build_channel() updates_group = PreferencesGroup( title="Updates", - description=( - f"This is a {build} build, so background checks only look for " - f"{build} releases. Pick a channel below to look one up by hand." - ), + description=updates_description(build, packaged=is_packaged_install()), keywords=("update", "version", "release", "upgrade", "appimage", "nightly", "channel"), ) diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index eb76cbb..d9a9535 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -74,6 +74,12 @@ from ..speech_recognition.diarization import ( transcribe_audio_file, ) from ..suspend_handler import SuspendHandler +from ..utils.packaging import ( + is_packaged_install, + update_menu_label, + update_menu_tooltip, + update_notification_body, +) from ..utils.resource_manager import ResourceManager from ..utils.update_checker import DEFAULT_CHECK_AUTOMATICALLY, ReleaseInfo from ..utils.update_monitor import UpdateMonitor @@ -1572,11 +1578,9 @@ class TrayIndicator: """Reveal the tray menu entry that opens About / release notes.""" if self._update_menu_item is None: return - label = f"Update Available ({release.tag_name})…" - self._update_menu_item.set_label(label) - self._update_menu_item.set_tooltip_text( - "Open Settings → About for release notes and download links" - ) + packaged = is_packaged_install() + self._update_menu_item.set_label(update_menu_label(release.tag_name, packaged=packaged)) + self._update_menu_item.set_tooltip_text(update_menu_tooltip(packaged=packaged)) self._update_menu_item.show() self._sync_indicator_menu() @@ -1601,8 +1605,7 @@ class TrayIndicator: "-a", TRAY_TITLE, f"{TRAY_TITLE} update available", - f"{release.tag_name} is ready. Open the tray menu or " - "Settings → About for release notes.", + update_notification_body(release.tag_name, packaged=is_packaged_install()), ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, diff --git a/src/vocalinux/utils/packaging.py b/src/vocalinux/utils/packaging.py new file mode 100644 index 0000000..82704cd --- /dev/null +++ b/src/vocalinux/utils/packaging.py @@ -0,0 +1,64 @@ +"""Facts about installs managed by a system package manager. + +Ticket #27 (Void app template): the xbps template installs an empty +``usr/share/voculinux/packaged`` marker. When it exists, background +update checks stay available as a signal, but every user-visible update +affordance presents the package-manager path instead of release +downloads, so packaged users are never misdirected away from +package-manager updates. +""" + +from pathlib import Path +from typing import Optional + +#: Installed by the xbps template's post_install; absent everywhere else. +PACKAGED_MARKER_PATH = Path("/usr/share/voculinux/packaged") + +#: Presented wherever a packaged install would otherwise offer a download. +PACKAGED_UPDATE_HINT = ( + "Installed from a system package: update with your package manager " + "(on Void: sudo xbps-install -Syu voculinux)." +) + + +def is_packaged_install(marker: Optional[Path] = None) -> bool: + """Return whether this process runs from a system-packaged install.""" + return (PACKAGED_MARKER_PATH if marker is None else marker).is_file() + + +def update_menu_label(tag_name: str, *, packaged: bool) -> str: + """Return the tray update entry label for this install kind.""" + if packaged: + return f"Update Available ({tag_name}, via package manager)…" + return f"Update Available ({tag_name})…" + + +def update_menu_tooltip(*, packaged: bool) -> str: + """Return the tray update entry tooltip for this install kind.""" + if packaged: + return ( + "Open Settings → About for release notes; " + "update with your package manager (on Void: sudo xbps-install -Syu voculinux)" + ) + return "Open Settings → About for release notes and download links" + + +def update_notification_body(tag_name: str, *, packaged: bool) -> str: + """Return the update notification body for this install kind.""" + if packaged: + return ( + f"{tag_name} is ready. Update with your package manager " + "instead of downloading a release." + ) + return f"{tag_name} is ready. Open the tray menu or Settings → About for release notes." + + +def updates_description(build: str, *, packaged: bool) -> str: + """Return the About-page Updates group description for this install kind.""" + description = ( + f"This is a {build} build, so background checks only look for " + f"{build} releases. Pick a channel below to look one up by hand." + ) + if packaged: + return f"{description} {PACKAGED_UPDATE_HINT}" + return description diff --git a/tests/test_packaged_install.py b/tests/test_packaged_install.py new file mode 100644 index 0000000..80eb6fb --- /dev/null +++ b/tests/test_packaged_install.py @@ -0,0 +1,93 @@ +"""Unit guards for packaged-install detection and its update presentation. + +Ticket #27 decides the update-checker behaviour for packaged installs: +background checks stay available as a signal, but every user-visible +update affordance presents the package-manager path instead of release +downloads. The xbps template installs the marker this module reads. +""" + +from pathlib import Path + +from vocalinux.utils.packaging import ( + PACKAGED_MARKER_PATH, + PACKAGED_UPDATE_HINT, + is_packaged_install, + update_menu_label, + update_menu_tooltip, + update_notification_body, + updates_description, +) + + +def test_default_marker_lives_under_the_renamed_share_dir() -> None: + """The template installs usr/share/voculinux/packaged; the code reads it.""" + assert PACKAGED_MARKER_PATH == Path("/usr/share/voculinux/packaged") + + +def test_unpackaged_install_by_default(tmp_path: Path) -> None: + """No marker in the test environment means a non-packaged install.""" + assert not is_packaged_install(marker=tmp_path / "packaged") + + +def test_packaged_install_when_the_marker_exists(tmp_path: Path) -> None: + """The xbps template drops the marker file at install time.""" + marker = tmp_path / "packaged" + marker.touch() + assert is_packaged_install(marker=marker) + + +def test_packaged_hint_names_the_package_manager_path() -> None: + """The presented path is xbps, not a release download.""" + assert "package manager" in PACKAGED_UPDATE_HINT + assert "xbps-install -Syu voculinux" in PACKAGED_UPDATE_HINT + + +def test_unpackaged_menu_label_is_unchanged() -> None: + """Source installs keep the release-download affordance they always had.""" + assert update_menu_label("v1.2.3", packaged=False) == "Update Available (v1.2.3)…" + + +def test_packaged_menu_label_presents_the_package_manager() -> None: + """Packaged installs must not misdirect users to release downloads.""" + label = update_menu_label("v1.2.3", packaged=True) + assert "v1.2.3" in label + assert "package manager" in label + + +def test_unpackaged_menu_tooltip_is_unchanged() -> None: + """Source installs keep pointing at release notes and download links.""" + tooltip = update_menu_tooltip(packaged=False) + assert "release notes" in tooltip and "download links" in tooltip + + +def test_packaged_menu_tooltip_names_xbps() -> None: + """The packaged tooltip tells the user where the update comes from.""" + assert "xbps" in update_menu_tooltip(packaged=True) + + +def test_packaged_notification_body_presents_the_package_manager() -> None: + """The desktop notification must not offer a download path either.""" + body = update_notification_body("v1.2.3", packaged=True) + assert "v1.2.3" in body + assert "package manager" in body + + +def test_unpackaged_notification_body_keeps_release_notes() -> None: + """Source installs keep the existing notification wording.""" + body = update_notification_body("v1.2.3", packaged=False) + assert "v1.2.3" in body + assert "release notes" in body + + +def test_unpackaged_updates_description_is_unchanged() -> None: + """The About page keeps its channel wording for source installs.""" + description = updates_description("stable", packaged=False) + assert "stable" in description + assert "package manager" not in description + + +def test_packaged_updates_description_presents_the_package_manager() -> None: + """The About page tells packaged users to update through xbps.""" + description = updates_description("stable", packaged=True) + assert "stable" in description + assert "package manager" in description diff --git a/tests/test_rename_sweep.py b/tests/test_rename_sweep.py index 07e9207..de8704a 100644 --- a/tests/test_rename_sweep.py +++ b/tests/test_rename_sweep.py @@ -122,6 +122,13 @@ def _allowed(path: str, line: str) -> bool: if 'PKG_NAME="vocalinux"' in line: # Installer probes the retained import package, not the distribution. return True + if path == "packaging/void/srcpkgs/voculinux/template" and "vocalinux-${version}" in line: + # Pre-rename release asset root (ticket #27); dropped at the first + # post-rename release bump. + return True + if path == "tests/test_void_app_template.py" and '"vocalinux"' in line: + # The app-template policy test asserts the old name is gone. + return True if '== "vocalinux"' in line or '.startswith("vocalinux' in line: # Retained module identity comparisons in test isolation helpers. return True diff --git a/tests/test_void_app_template.py b/tests/test_void_app_template.py new file mode 100644 index 0000000..dfeb7c9 --- /dev/null +++ b/tests/test_void_app_template.py @@ -0,0 +1,379 @@ +"""Offline policy guards for the Void application template (ticket #27). + +Spec #16 ships Voculinux itself as a Void package: an in-repo xbps +template in void-packages layout under ``packaging/void`` builds the app +from the canonical, checksum-verified release source distribution, +depends on Void's own interpreter, GTK, desktop bindings and packaged +Python libraries plus the dependency templates from tickets #25 and #26, +and installs the renamed desktop entry and icons. Speech models are not +package payload, nothing is vendored, and no pip runs at build or +install time. + +Independent sources of truth: ``src/vocalinux/version.py`` owns the +version, ``pyproject.toml`` owns the dependency ranges, ``MANIFEST.in`` +plus the renamed ``voculinux.desktop`` own the install paths, and the +GitHub release publish path (``releases/download``) plus ``xbps-src`` +conventions own the fetch shape. The template must agree with all of +them; the container smoke gate (ticket #28) builds on top. +""" + +import re +import tomllib +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +VOID = REPO_ROOT / "packaging" / "void" +SRC = VOID / "srcpkgs" +TEMPLATE = SRC / "voculinux" / "template" +INSTALL_MSG = SRC / "voculinux" / "INSTALL.msg" +PINS = VOID / "tool_checksums.txt" +BUILD_SH = VOID / "build.sh" +CONTAINER_MAIN = VOID / "container-main.sh" +VERSION_PY = REPO_ROOT / "src" / "vocalinux" / "version.py" +PYPROJECT = REPO_ROOT / "pyproject.toml" +DISTRO_MAP = REPO_ROOT / "scripts" / "distro-package-map.yaml" +DISTRO_DOC = REPO_ROOT / "docs" / "DISTRO_COMPATIBILITY.md" + +#: pip name (lowercase, dashes) -> the Void binary package providing it. +#: pywhispercpp/pynput/PyAudio come from the ticket #25/#26 templates in +#: this same tree; the rest are Void's own packages. +PIP_TO_VOID = { + "pywhispercpp": "python3-pywhispercpp", + "pynput": "python3-pynput", + "evdev": "python3-evdev", + "requests": "python3-requests", + "pysocks": "python3-pysocks", + "numpy": "python3-numpy", + "pyaudio": "python3-PyAudio", + "pygobject": "python3-gobject", + "psutil": "python3-psutil", +} + +#: Void packages beyond the direct pip mapping: pywhispercpp's own +#: install_requires (tqdm, platformdirs), pynput's X11 backend (xlib), +#: the directly imported cairo binding, and the desktop substrate. +EXTRA_VOID_DEPS = ( + "python3-tqdm", + "python3-platformdirs", + "python3-xlib", + "python3-cairo", + "python3", + "gtk+3", + "libayatana-appindicator", + "ibus", + "gobject-introspection", +) + +#: Every package name the app template may reference, verified to exist in +#: void-packages master on 2026-11-06 via the srcpkgs//template +#: paths (python3-devel is a subpackage of the python3 template, as +#: recorded for ticket #25). Re-verify with `xbps-query -Rs ` on a +#: Void system before extending this set. +VERIFIED_VOID_PACKAGES = frozenset( + { + "python3", + "python3-devel", + "python3-setuptools", + "python3-gobject", + "python3-cairo", + "gtk+3", + "libayatana-appindicator", + "ibus", + "gobject-introspection", + "python3-numpy", + "python3-requests", + "python3-pysocks", + "python3-psutil", + "python3-evdev", + "python3-xlib", + "python3-tqdm", + "python3-platformdirs", + "python3-PyAudio", + "python3-pynput", + "python3-pywhispercpp", + } +) + +#: Template options that switch off dependency verification. The app +#: resolves every dependency deliberately through the mapping above, +#: never by hiding it behind one of these. +FORBIDDEN_VARS = ("noverifypydeps", "nopyprovides") + + +def _template_text() -> str: + """Read the raw template file.""" + assert TEMPLATE.is_file(), f"missing Void app template: {TEMPLATE}" + return TEMPLATE.read_text(encoding="utf-8") + + +def _template_vars() -> dict: + """Parse top-level VAR=value assignments from the template file.""" + variables = {} + in_function = False + for line in _template_text().splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if re.fullmatch(r"[A-Za-z0-9_+-]+\(\)\s*\{", stripped): + in_function = True + continue + if in_function: + if stripped == "}": + in_function = False + continue + key, separator, value = stripped.partition("=") + if not separator or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key): + continue + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + variables[key] = value + return variables + + +def _code_lines() -> str: + """Template with comment lines stripped; behaviour lives in code.""" + return "\n".join( + line for line in _template_text().splitlines() if not line.lstrip().startswith("#") + ) + + +def _post_install_body() -> str: + """Shell lines inside post_install(), without comments.""" + lines = _template_text().splitlines() + start = next(i for i, line in enumerate(lines) if line.strip().startswith("post_install()")) + body = [] + for line in lines[start + 1 :]: + if line.strip() == "}": + break + if line.strip() and not line.lstrip().startswith("#"): + body.append(line.strip()) + return "\n".join(body) + + +def _app_version() -> str: + """Version owned by src/vocalinux/version.py.""" + match = re.search(r'^__version__ = "([^"]+)"', VERSION_PY.read_text(encoding="utf-8"), re.M) + assert match, "version.py no longer defines __version__" + return match.group(1) + + +def _project_dependencies() -> list: + """Full dependency clauses from pyproject.toml.""" + data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + return list(data["project"]["dependencies"]) + + +def test_app_template_lives_in_void_packages_layout() -> None: + """The app template sits where xbps-src expects it: srcpkgs/.""" + assert TEMPLATE.is_file(), f"{TEMPLATE} is missing" + assert INSTALL_MSG.is_file(), f"{INSTALL_MSG} is missing" + + +def test_pkgname_matches_template_directory() -> None: + """Void requires pkgname to match srcpkgs/, using the new name.""" + assert _template_vars()["pkgname"] == TEMPLATE.parent.name == "voculinux" + + +def test_mandatory_void_variables_are_present() -> None: + """The Manual's mandatory variables plus fetch inputs must be set.""" + variables = _template_vars() + for key in ( + "pkgname", + "version", + "revision", + "short_desc", + "maintainer", + "license", + "homepage", + "build_style", + "hostmakedepends", + "makedepends", + "depends", + "distfiles", + "checksum", + ): + assert variables.get(key), f"template is missing {key}" + assert variables["build_style"] == "python3-module" + assert "python3-setuptools" in variables["hostmakedepends"].split() + assert "python3-devel" in variables["makedepends"].split() + assert variables["revision"] == "1", "new templates start at revision 1" + + +def test_template_metadata_shape() -> None: + """Maintainer, license, homepage and description follow the Manual.""" + variables = _template_vars() + assert re.fullmatch(r".+ <[^@<>\s]+@[^@<>\s]+>", variables["maintainer"]) + assert "noreply" not in variables["maintainer"] + assert variables["license"] == "AGPL-3.0-only" + assert variables["homepage"] == "https://github.com/VocaHQ/vocalinux" + assert len(variables["short_desc"]) <= 72 + assert re.fullmatch(r"[0-9][A-Za-z0-9.]*", variables["version"]) + + +def test_template_version_matches_version_py() -> None: + """The template builds the current source version, not a stale one.""" + assert _template_vars()["version"] == _app_version() + + +def test_distfiles_sources_the_canonical_release_by_checksum() -> None: + """One release-asset distfile, checksum-pinned; never a git snapshot.""" + variables = _template_vars() + distfiles = variables["distfiles"] + version = variables["version"] + assert ( + "https://github.com/VocaHQ/vocalinux/releases/download/v${version}/" in distfiles + ), "distfiles must use the canonical release distribution for this version" + assert distfiles.rstrip().endswith(".tar.gz"), "the release payload is the sdist tarball" + for moving in ("/master", "/main", "/archive/", "git+"): + assert moving not in distfiles, f"distfiles must not track a moving ref: {moving}" + assert re.fullmatch(r"[0-9a-f]{64}", variables["checksum"]), "checksum must be sha256" + # The unpacked top-level directory must be where the template builds: + # wrksrc defaults to pkgname-version, so a distfile rooted under the + # pre-rename archive name needs an explicit wrksrc override. + root = Path(distfiles.rsplit("/", 1)[1].replace("${version}", version)) + assert root.suffixes[-2:] == [".tar", ".gz"] + expected_wrksrc = variables.get("wrksrc", f"{variables['pkgname']}-${{version}}") + assert expected_wrksrc.replace("${version}", version) == root.name[: -len(".tar.gz")] + + +def test_every_project_dependency_is_covered() -> None: + """pyproject.toml runtime deps plus substrate must all resolve to depends.""" + depends = set(_template_vars()["depends"].split()) + uncovered = [] + for clause in _project_dependencies(): + pip_name = re.match(r"^([A-Za-z0-9._-]+)", clause).group(1).lower().replace("_", "-") + void_name = PIP_TO_VOID.get(pip_name) + assert void_name, f"no Void mapping recorded for {pip_name}" + if void_name not in depends: + uncovered.append(f"{pip_name} -> {void_name}") + missing_extra = [name for name in EXTRA_VOID_DEPS if name not in depends] + assert not uncovered, f"deps with no Void coverage: {uncovered}" + assert not missing_extra, f"substrate missing from depends: {missing_extra}" + + +def test_template_dependencies_exist_in_void_packages() -> None: + """Template dependency lists name only verified void-packages names.""" + variables = _template_vars() + referenced = set() + for key in ("depends", "hostmakedepends", "makedepends", "checkdepends"): + for token in variables.get(key, "").split(): + if token.startswith("${"): + continue + referenced.add(token) + unknown = sorted(referenced - VERIFIED_VOID_PACKAGES) + assert not unknown, f"template references unverified packages: {unknown}" + + +def test_no_stale_indicator_library_name() -> None: + """The pre-rename libappindicator names do not exist in void-packages.""" + variables = _template_vars() + for key in ("depends", "makedepends", "hostmakedepends"): + for token in variables.get(key, "").split(): + assert token not in ("libappindicator", "libappindicator-gtk3"), ( + f"{key} names {token}, which has no void-packages template; " + "the Ayatana fork (libayatana-appindicator) is the provider" + ) + + +def test_no_pip_or_vendored_payload_at_build_or_install() -> None: + """The build installs from the sdist; pip never runs, nothing is vendored.""" + code = _code_lines().lower() + for forbidden in ("pip install", "pip3 install", "pip download", "requirements", ".whl"): + assert forbidden not in code, f"template runs pip or vendors wheels: {forbidden}" + assert "venv" not in code + body = _post_install_body().lower() + assert "pip" not in body, "post_install must not invoke pip" + + +def test_package_ships_no_speech_models() -> None: + """Speech models download on first run; they are not package payload.""" + code = _code_lines().lower() + assert "model" not in code, "models are not package payload" + + +def test_post_install_uses_the_renamed_paths() -> None: + """Desktop entry, icons, license and the packaged-install marker install.""" + body = _post_install_body() + assert "vlicense LICENSE" in body + assert "voculinux.desktop" in body and "usr/share/applications" in body + assert "resources/icons/scalable" in body + assert "usr/share/icons/hicolor/scalable/apps" in body + assert "usr/share/voculinux/packaged" in body + assert "vocalinux" not in body, "post_install must use only the renamed install paths" + + +def test_build_fetches_nothing_beyond_the_sdist() -> None: + """Only the homepage and the declared distfile may appear as URLs.""" + text = _template_text() + assert text.count("https://") == 2, "homepage + distfiles are the only URLs a template needs" + assert "FetchContent" not in text and "ExternalProject" not in text + + +def test_no_verification_disabling_options() -> None: + """Dependency verification stays on; distfiles stay checksum-pinned.""" + variables = _template_vars() + for key in FORBIDDEN_VARS: + assert key not in variables, f"template sets {key}" + assert re.fullmatch(r"[0-9a-f]{64}", variables["checksum"]) + + +def test_template_style_is_lint_clean() -> None: + """Trailing whitespace and a missing final newline fail xlint.""" + raw = TEMPLATE.read_bytes().decode("utf-8") + for lineno, line in enumerate(raw.splitlines(), 1): + assert line == line.rstrip(), f"trailing whitespace on line {lineno}" + assert "\t" not in line or line.startswith("\t"), f"stray tab on line {lineno}" + assert raw.endswith("\n"), "template must end with a newline" + + +def test_install_msg_covers_the_xbps_only_facts() -> None: + """INSTALL.msg tells the user what xbps cannot express.""" + assert INSTALL_MSG.is_file(), f"missing install message: {INSTALL_MSG}" + text = INSTALL_MSG.read_text(encoding="utf-8").lower() + assert "input" in text, "the evdev hotkey backend needs the input group" + assert "model" in text, "models download on first run" + for engine in ("vosk", "whisper", "parakeet", "faster-whisper", "onnxruntime"): + assert engine in text, f"optional-engine status must name {engine}" + assert "ydotool" in text, "the absent injection helper must be named" + + +def test_install_msg_line_length() -> None: + """The Manual asks for at most 80 columns per line in INSTALL.msg.""" + raw = INSTALL_MSG.read_bytes().decode("utf-8") + for lineno, line in enumerate(raw.splitlines(), 1): + assert len(line) <= 80, f"line {lineno} exceeds 80 columns" + assert raw.endswith("\n"), "INSTALL.msg must end with a newline" + + +def test_container_harness_covers_the_app_package() -> None: + """build.sh stays podman-only; container-main smokes the installed app.""" + build = BUILD_SH.read_text(encoding="utf-8") + assert "podman" in build, "the build harness must use podman" + assert "docker" not in build.lower().replace("never docker", "") + harness = CONTAINER_MAIN.read_text(encoding="utf-8") + assert "voculinux --version" in harness, "the app smoke checks the entry point" + assert "usr/share/applications/voculinux.desktop" in harness + assert "usr/share/icons/hicolor/scalable/apps/voculinux.svg" in harness + assert "usr/share/voculinux/packaged" in harness + assert "python3-pywhispercpp" in harness or "PKGNAME" in harness + + +def test_void_distro_inventory_matches_void_packages_reality() -> None: + """The installer inventory names the Ayatana provider and marks ydotool absent.""" + text = DISTRO_MAP.read_text(encoding="utf-8") + void_block = text.split(" void:", 1)[1].split("\n solus:", 1)[0] + code = "\n".join(line for line in void_block.splitlines() if not line.lstrip().startswith("#")) + assert "libayatana-appindicator" in code + assert "libappindicator-gtk3" not in code + assert "libappindicator " not in code.replace("libayatana-appindicator", "") + assert "absent" in void_block, "the ydotool absence must be marked in the inventory" + + +def test_void_install_docs_name_the_ayatana_provider() -> None: + """The Void install snippet must install a package that exists.""" + text = DISTRO_DOC.read_text(encoding="utf-8") + void_section = text.split("### Void Linux", 1)[1].split("###", 1)[0] + assert "libayatana-appindicator" in void_section + assert "libappindicator-gtk3" not in void_section + assert "libappindicator " not in void_section.replace("libayatana-appindicator", "")