feat(void): add xbps template for the voculinux app

Ticket xavierk/voculinux#27, built on #18 (rename) plus the #25/#26
dependency templates. The template builds the app from the canonical
checksum-verified release sdist, depends on Void's interpreter, GTK,
desktop bindings and packaged Python libraries, and installs the
renamed desktop entry, icons and a packaged-install marker. No speech
models, no vendored wheels, no pip at build or install time.

Packaged installs keep background update checks as a signal, but every
update affordance presents the package-manager path
(src/vocalinux/utils/packaging.py). Also corrects the Void distro
inventory (Ayatana provider name; ydotool marked absent) and regens
the installer map.
This commit is contained in:
soubarnak
2026-11-06 03:03:33 +05:30
parent 27f157355d
commit d7a4b8c21e
13 changed files with 685 additions and 14 deletions
+5 -1
View File
@@ -193,10 +193,14 @@ sudo apk add py3-gobject3 py3-pip gtk+3.0 py3-cairo portaudio-dev \
### Void Linux ### Void Linux
```bash ```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 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 ### Solus
```bash ```bash
sudo eopkg install python3-pip python3-gobject gtk3 libappindicator \ sudo eopkg install python3-pip python3-gobject gtk3 libappindicator \
+1 -1
View File
@@ -101,7 +101,7 @@ load_distro_package_map() {
;; ;;
void) 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) XDOTOOL_PACKAGES=(xdotool)
WTYPE_PACKAGES=(wtype) WTYPE_PACKAGES=(wtype)
YDOTOOL_PACKAGES=(ydotool) YDOTOOL_PACKAGES=(ydotool)
+35
View File
@@ -27,6 +27,7 @@ packaging/void/
| Template | Ticket | Notes | | Template | Ticket | Notes |
|---|---|---| |---|---|---|
| `python3-pywhispercpp` | #26 | Vulkan backend on by default; see below | | `python3-pywhispercpp` | #26 | Vulkan backend on by default; see below |
| `voculinux` | #27 | The app, from the canonical release sdist; see below |
## python3-pywhispercpp ## 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 verified to exist in current void-packages; the container build
re-verifies via dependency resolution. 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 ## Building
```sh ```sh
packaging/void/build.sh python3-pywhispercpp packaging/void/build.sh python3-pywhispercpp
packaging/void/build.sh python3-pywhispercpp -- -o ~vulkan pkg 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 Podman only, never Docker. The base image digest and the void-packages
+28
View File
@@ -31,6 +31,32 @@ echo "built: $PKGFILE"
xbps-install -Sy >/dev/null xbps-install -Sy >/dev/null
xbps-install -y --repository="$(dirname "$PKGFILE")" "$PKGNAME" >/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" echo "== compiler flag scan of the shipped ggml library"
LIBMEMBER=$(tar -tf "$PKGFILE" 2>/dev/null | grep -E "libggml-cpu\.so" | head -n1) LIBMEMBER=$(tar -tf "$PKGFILE" 2>/dev/null | grep -E "libggml-cpu\.so" | head -n1)
[ -n "$LIBMEMBER" ] || { [ -n "$LIBMEMBER" ] || {
@@ -71,3 +97,5 @@ assert has_vulkan == want_vulkan, "vulkan backend present=%s, expected=%s" % (ha
print("smoke ok") print("smoke ok")
PYEOF PYEOF
EXPECT_VULKAN="$EXPECT_VULKAN" python3 /smoke.py EXPECT_VULKAN="$EXPECT_VULKAN" python3 /smoke.py
;;
esac
@@ -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 <user>
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
+32
View File
@@ -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 <jatinkrmalik@gmail.com>"
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"
}
+7 -1
View File
@@ -177,10 +177,16 @@ distributions:
void: void:
# cairo-devel is needed because the pinned pycairo builds from source. # cairo-devel is needed because the pinned pycairo builds from source.
# base-devel compiles the pywhispercpp sdist and the evdev/PyAudio sdists. # 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: text_input:
xdotool: [xdotool] xdotool: [xdotool]
wtype: [wtype] 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] ydotool: [ydotool]
solus: solus:
+2 -4
View File
@@ -9,6 +9,7 @@ import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa: E402 from gi.repository import Gtk # noqa: E402
from ..utils.packaging import is_packaged_install, updates_description
from ..utils.update_checker import ( from ..utils.update_checker import (
DEFAULT_CHECK_AUTOMATICALLY, DEFAULT_CHECK_AUTOMATICALLY,
build_channel, build_channel,
@@ -434,10 +435,7 @@ class AboutSectionMixin(Gtk.Dialog):
build = build_channel() build = build_channel()
updates_group = PreferencesGroup( updates_group = PreferencesGroup(
title="Updates", title="Updates",
description=( description=updates_description(build, packaged=is_packaged_install()),
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."
),
keywords=("update", "version", "release", "upgrade", "appimage", "nightly", "channel"), keywords=("update", "version", "release", "upgrade", "appimage", "nightly", "channel"),
) )
+10 -7
View File
@@ -74,6 +74,12 @@ from ..speech_recognition.diarization import (
transcribe_audio_file, transcribe_audio_file,
) )
from ..suspend_handler import SuspendHandler 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.resource_manager import ResourceManager
from ..utils.update_checker import DEFAULT_CHECK_AUTOMATICALLY, ReleaseInfo from ..utils.update_checker import DEFAULT_CHECK_AUTOMATICALLY, ReleaseInfo
from ..utils.update_monitor import UpdateMonitor from ..utils.update_monitor import UpdateMonitor
@@ -1572,11 +1578,9 @@ class TrayIndicator:
"""Reveal the tray menu entry that opens About / release notes.""" """Reveal the tray menu entry that opens About / release notes."""
if self._update_menu_item is None: if self._update_menu_item is None:
return return
label = f"Update Available ({release.tag_name})…" packaged = is_packaged_install()
self._update_menu_item.set_label(label) self._update_menu_item.set_label(update_menu_label(release.tag_name, packaged=packaged))
self._update_menu_item.set_tooltip_text( self._update_menu_item.set_tooltip_text(update_menu_tooltip(packaged=packaged))
"Open Settings → About for release notes and download links"
)
self._update_menu_item.show() self._update_menu_item.show()
self._sync_indicator_menu() self._sync_indicator_menu()
@@ -1601,8 +1605,7 @@ class TrayIndicator:
"-a", "-a",
TRAY_TITLE, TRAY_TITLE,
f"{TRAY_TITLE} update available", f"{TRAY_TITLE} update available",
f"{release.tag_name} is ready. Open the tray menu or " update_notification_body(release.tag_name, packaged=is_packaged_install()),
"Settings → About for release notes.",
], ],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+64
View File
@@ -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
+93
View File
@@ -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
+7
View File
@@ -110,6 +110,13 @@ def _allowed(path: str, line: str) -> bool:
if 'PKG_NAME="vocalinux"' in line: if 'PKG_NAME="vocalinux"' in line:
# Installer probes the retained import package, not the distribution. # Installer probes the retained import package, not the distribution.
return True 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: if '== "vocalinux"' in line or '.startswith("vocalinux' in line:
# Retained module identity comparisons in test isolation helpers. # Retained module identity comparisons in test isolation helpers.
return True return True
+379
View File
@@ -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/<name>/template
#: paths (python3-devel is a subpackage of the python3 template, as
#: recorded for ticket #25). Re-verify with `xbps-query -Rs <name>` 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/<pkgname>."""
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/<pkgname>, 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", "")