diff --git a/.gitea/workflows/xbps-release.yml b/.gitea/workflows/xbps-release.yml
new file mode 100644
index 0000000..9f37c33
--- /dev/null
+++ b/.gitea/workflows/xbps-release.yml
@@ -0,0 +1,326 @@
+name: xbps-release
+
+# Signed Void xbps repository release (ticket #29). A human pushes a version
+# tag; this workflow builds every Void template in the pinned Void glibc
+# container with podman (packaging/void/repo.sh), indexes the full x86_64
+# repository, signs every package and the repository metadata with the RSA4096
+# repository key, and attaches the complete signed repository to two releases:
+#
+# - the tag's own release (the permanent record of what that tag shipped), and
+# - the rolling release named `latest`, because the client registration URL
+# (README.md) serves the newest release's downloads:
+# https://git.bongbetic.com/xavierk/voculinux/releases/download/latest
+#
+# Hard gates, each exiting 1 before anything is published:
+# 1. the tag must EXACTLY equal "v" + __version__ in src/vocalinux/version.py;
+# 2. the XBPS_SIGNING_KEY secret must be non-empty (no key, no repo, never a
+# silent skip and never an unsigned upload);
+# 3. every class of repository file must exist, nothing but glibc x86_64 data
+# may exist, every package must carry its signature, and the signing key's
+# fingerprint must equal the one the README tells users to pin.
+#
+# Nothing here commits or pushes to any branch, and nothing bumps the Void
+# templates: the post-release template bump is a normal pull request
+# (docs/RELEASE_PROCESS.md). The private key exists only as the CI secret and a
+# 0600 temp file that the last step removes. Never Docker: podman only.
+
+on:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+concurrency:
+ group: xbps-release
+ cancel-in-progress: false
+
+jobs:
+ xbps-release:
+ name: Build, sign and publish the xbps repository
+ runs-on: bongbetic-ci
+ timeout-minutes: 180
+ env:
+ GITEA_API: https://git.bongbetic.com/api/v1/repos/xavierk/voculinux
+ # Must equal the fingerprint in README.md, packaging/void/README.md and
+ # packaging/void/repository-key.plist; tests/test_xbps_release_workflow.py
+ # enforces it. A secret holding any other key fails before publishing.
+ XBPS_KEY_FINGERPRINT: "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25"
+ XBPS_KEY_FILE: /tmp/xbps-sign-key-${{ gitea.run_id }}.pem
+ steps:
+ - name: Checkout
+ env:
+ GITEA_TOKEN: ${{ gitea.token }}
+ SERVER_URL: ${{ gitea.server_url }}
+ REPOSITORY: ${{ gitea.repository }}
+ COMMIT_SHA: ${{ gitea.sha }}
+ run: |
+ git init -q . && git remote add origin "${SERVER_URL}/${REPOSITORY}.git" && git -c http.extraheader="Authorization: token ${GITEA_TOKEN}" fetch -q --depth=1 origin "${COMMIT_SHA}" && git checkout -q FETCH_HEAD
+
+ - name: Require the tag to equal the version in version.py
+ shell: bash
+ env:
+ TAG: ${{ gitea.ref_name }}
+ run: |
+ set -euo pipefail
+ VERSION=$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' src/vocalinux/version.py)
+ if [ -z "$VERSION" ]; then
+ echo "::error::could not read __version__ from src/vocalinux/version.py; refusing to publish" >&2
+ exit 1
+ fi
+ echo "tag: ${TAG}"
+ echo "version: v${VERSION}"
+ if [ "$TAG" != "v${VERSION}" ]; then
+ echo "::error::tag ${TAG} is not exactly v${VERSION} (src/vocalinux/version.py); refusing to publish the signed repository" >&2
+ exit 1
+ fi
+
+ # Fail closed: no key, no repo, no publish. This runs before any
+ # container so a missing secret costs seconds, not a build.
+ - name: Require the repository signing key
+ shell: bash
+ env:
+ XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }}
+ run: |
+ set -euo pipefail
+ if [ -z "${XBPS_SIGNING_KEY:-}" ]; then
+ echo "::error::XBPS_SIGNING_KEY is unset or empty; refusing to publish an unsigned repository (packaging/void/README.md)" >&2
+ exit 1
+ fi
+ umask 077
+ rm -f "$XBPS_KEY_FILE"
+ printf '%s' "$XBPS_SIGNING_KEY" > "$XBPS_KEY_FILE"
+ chmod 600 "$XBPS_KEY_FILE"
+
+ - name: Install the tools repo.sh and the publish step need
+ shell: bash
+ run: |
+ set -euo pipefail
+ missing=""
+ for tool in podman openssl curl python3; do
+ command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
+ done
+ if [ -n "$missing" ]; then
+ SUDO=""
+ [ "$(id -u)" -eq 0 ] || SUDO="sudo"
+ $SUDO apt-get update
+ # shellcheck disable=SC2086
+ $SUDO apt-get install -y --no-install-recommends $missing
+ fi
+ podman --version
+
+ # repo.sh is the only path that builds or signs anything: pinned Void
+ # glibc image, every template in smoke-gate order, full x86_64 index,
+ # signed packages and metadata. It re-validates the key (RSA4096) before
+ # any container starts.
+ - name: Build and sign the repository
+ shell: bash
+ run: |
+ set -euo pipefail
+ XBPS_SIGNING_KEY_FILE="$XBPS_KEY_FILE" bash packaging/void/repo.sh
+ ls -la publish/xbps/repo publish/xbps/keys
+
+ - name: Verify the signed repository is complete
+ shell: bash
+ run: |
+ set -euo pipefail
+ ROOT=$PWD
+ cd publish/xbps
+ fail() { echo "::error::$*" >&2; exit 1; }
+
+ # Every class must exist, so a partial repository never attaches.
+ for pat in 'repo/*.x86_64.xbps' 'repo/*.sig2' 'repo/*-repodata*' 'keys/FINGERPRINT' 'keys/*.plist'; do
+ compgen -G "$pat" > /dev/null || fail "missing from the signed repository: $pat"
+ done
+
+ # Every template the smoke gate builds must be in the repository.
+ PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$ROOT/packaging/void/smoke.sh")
+ [ -n "$PKGS" ] || fail "could not read the package order from packaging/void/smoke.sh"
+ for name in $PKGS; do
+ compgen -G "repo/${name}-[0-9]*.x86_64.xbps" > /dev/null || fail "package missing from the repository: ${name}"
+ done
+
+ # Glibc x86_64 data only: no musl, no other architecture, no noarch.
+ while IFS= read -r name; do
+ case "$name" in
+ *musl*) fail "musl data in the repository: $name" ;;
+ *.x86_64.xbps | *.x86_64.xbps.sig2 | x86_64-repodata | x86_64-repodata.*) ;;
+ *) fail "unexpected file (only glibc x86_64 data is published): repo/$name" ;;
+ esac
+ done < <(find repo -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
+ while IFS= read -r name; do
+ case "$name" in
+ FINGERPRINT | *.plist) ;;
+ *) fail "unexpected file among the client keys: keys/$name" ;;
+ esac
+ done < <(find keys -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
+
+ # Never publish an unsigned package, or a signature without its package.
+ for pkg in repo/*.x86_64.xbps; do
+ [ -s "${pkg}.sig2" ] || fail "package has no signature: ${pkg}"
+ done
+ for sig in repo/*.sig2; do
+ [ -f "${sig%.sig2}" ] || fail "signature without its package: ${sig}"
+ done
+
+ # The repository must be signed by the key the README tells users to
+ # pin, and the staged client key must be that key.
+ FP=$(sed -n 's/^fingerprint: //p' keys/FINGERPRINT)
+ if [ "$FP" != "$XBPS_KEY_FINGERPRINT" ]; then
+ fail "signing key fingerprint ${FP:-} is not the pinned ${XBPS_KEY_FINGERPRINT}; refusing to publish"
+ fi
+ [ "$(find keys -name '*.plist' | wc -l)" -eq 1 ] || fail "expected exactly one client key plist"
+ [ -s "keys/${FP}.plist" ] || fail "client key plist keys/${FP}.plist is missing"
+ echo "OK: complete signed glibc x86_64 repository, key ${FP}"
+
+ # Releases go through the Gitea REST API. Any non-2xx response fails the
+ # job. Only files staged by repo.sh upload; nothing is built or signed
+ # here. Package files, signatures and the client key go first and the
+ # repodata goes last, so a client never sees an index that lists packages
+ # which are not there yet.
+ - name: Publish to the tag release and the latest release
+ shell: bash
+ env:
+ GITEA_TOKEN: ${{ gitea.token }}
+ TAG: ${{ gitea.ref_name }}
+ COMMIT_SHA: ${{ gitea.sha }}
+ run: |
+ set -euo pipefail
+ cd publish/xbps
+ BODY=$(mktemp)
+ trap 'rm -f "$BODY"' EXIT
+
+ fail() { echo "::error::$*" >&2; exit 1; }
+
+ urlencode() {
+ local s="$1" out="" c i
+ for ((i = 0; i < ${#s}; i++)); do
+ c="${s:i:1}"
+ case "$c" in
+ [a-zA-Z0-9._~-]) out+="$c" ;;
+ *) out+=$(printf '%%%02X' "'$c") ;;
+ esac
+ done
+ printf '%s' "$out"
+ }
+
+ # call METHOD URL [curl args...]: response body lands in $BODY.
+ call() {
+ local method="$1" url="$2" code
+ shift 2
+ code=$(curl -sS -o "$BODY" -w '%{http_code}' -X "$method" \
+ -H "Authorization: token ${GITEA_TOKEN}" "$@" "$url") \
+ || fail "request failed: ${method} ${url}"
+ case "$code" in
+ 2[0-9][0-9]) ;;
+ *) cat "$BODY" >&2; echo >&2; fail "${method} ${url} returned HTTP ${code}" ;;
+ esac
+ }
+
+ json_field() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "$BODY" "$1"; }
+
+ release_payload() {
+ python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target_commitish": sys.argv[2], "name": sys.argv[3], "body": sys.argv[4], "draft": False, "prerelease": False}))' "$@"
+ }
+
+ # release_id TAG: sets REL_ID to the release id, or to "" when the
+ # tag has no release. These helpers set a variable instead of
+ # printing, because bash clears `set -e` inside $(...): a failure in
+ # a nested substitution would otherwise be swallowed.
+ release_id() {
+ local code
+ REL_ID=""
+ code=$(curl -sS -o "$BODY" -w '%{http_code}' \
+ -H "Authorization: token ${GITEA_TOKEN}" \
+ "${GITEA_API}/releases/tags/$(urlencode "$1")") \
+ || fail "request failed: GET releases/tags/$1"
+ case "$code" in
+ 200) REL_ID=$(json_field id) ;;
+ 404) ;;
+ *) cat "$BODY" >&2; echo >&2; fail "GET releases/tags/$1 returned HTTP ${code}" ;;
+ esac
+ }
+
+ # ensure_release TAG NAME NOTES: sets REL_ID, creating the release
+ # when the tag has none.
+ ensure_release() {
+ local payload
+ release_id "$1"
+ if [ -z "$REL_ID" ]; then
+ payload=$(release_payload "$1" "$COMMIT_SHA" "$2" "$3")
+ call POST "${GITEA_API}/releases" -H 'Content-Type: application/json' -d "$payload"
+ REL_ID=$(json_field id)
+ fi
+ }
+
+ # delete_assets ID MODE: MODE is "all", or "same" to remove only the
+ # assets whose names this run uploads again (a rerun on a tag release).
+ delete_assets() {
+ local listing aid aname
+ call GET "${GITEA_API}/releases/$1/assets"
+ listing=$(python3 -c 'import json,sys; [print(a["id"], a["name"]) for a in json.load(open(sys.argv[1]))]' "$BODY")
+ [ -n "$listing" ] || return 0
+ while read -r aid aname; do
+ if [ "$2" = "all" ] || printf '%s\n' "$NAMES" | grep -qxF -- "$aname"; then
+ call DELETE "${GITEA_API}/releases/$1/assets/${aid}"
+ echo "deleted: ${aname}"
+ fi
+ done <<< "$listing"
+ }
+
+ upload_assets() {
+ local f name
+ for f in "${FILES[@]}"; do
+ name=$(basename "$f")
+ call POST "${GITEA_API}/releases/$1/assets?name=$(urlencode "$name")" \
+ -F "attachment=@${f};filename=${name}"
+ echo "uploaded: ${name}"
+ done
+ }
+
+ # verify_assets ID: every file we uploaded is on the release.
+ verify_assets() {
+ local have
+ call GET "${GITEA_API}/releases/$1/assets"
+ have=$(python3 -c 'import json,sys; [print(a["name"]) for a in json.load(open(sys.argv[1]))]' "$BODY" | sort)
+ [ "$(printf '%s\n' "$NAMES" | sort | comm -23 - <(printf '%s\n' "$have") | wc -l)" -eq 0 ] \
+ || fail "release $1 is missing uploaded assets"
+ }
+
+ # Packages, signatures and the client key first, the repodata last.
+ mapfile -t FILES < <(printf '%s\n' repo/*.x86_64.xbps repo/*.sig2 keys/FINGERPRINT keys/*.plist repo/*-repodata* | awk '!seen[$0]++')
+ NAMES=$(for f in "${FILES[@]}"; do basename "$f"; done)
+ echo "assets to publish: $(printf '%s\n' "$NAMES" | wc -l)"
+
+ NOTES=$(printf '%s\n\n%s\n' \
+ "Signed xbps repository, Void Linux glibc x86_64. This is a third-party repository published by this project, not by upstream Void Linux. Registration: README.md." \
+ "$(cat keys/FINGERPRINT)")
+
+ echo "== tag release ${TAG}"
+ ensure_release "$TAG" "$TAG" "$NOTES"
+ TAG_ID=$REL_ID
+ delete_assets "$TAG_ID" same
+ upload_assets "$TAG_ID"
+ verify_assets "$TAG_ID"
+
+ # The rolling release the registration URL points at. The tag
+ # created with it stays where it was first made; the assets are the
+ # source of truth for what clients download.
+ echo "== latest release"
+ LATEST_NOTES=$(printf '%s\n\n%s\n' \
+ "Rolling signed xbps repository, currently built from ${TAG} (${COMMIT_SHA})." \
+ "$NOTES")
+ ensure_release latest latest "$LATEST_NOTES"
+ LATEST_ID=$REL_ID
+ PATCH_PAYLOAD=$(python3 -c 'import json,sys; print(json.dumps({"name": sys.argv[1], "body": sys.argv[2]}))' latest "$LATEST_NOTES")
+ call PATCH "${GITEA_API}/releases/${LATEST_ID}" -H 'Content-Type: application/json' -d "$PATCH_PAYLOAD"
+ delete_assets "$LATEST_ID" all
+ upload_assets "$LATEST_ID"
+ verify_assets "$LATEST_ID"
+ echo "OK: signed repository published to ${TAG} and latest"
+
+ - name: Remove the signing key
+ if: always()
+ shell: bash
+ run: rm -f "$XBPS_KEY_FILE"
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index e684638..9b92698 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -8,6 +8,8 @@ updates:
directory: /
schedule:
interval: weekly
+ cooldown:
+ default-days: 7
groups:
github-actions:
patterns:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5f386f1..9fc3b5d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -290,19 +290,12 @@ jobs:
refresh from the Store. amd64 only.
### Void Linux (glibc x86_64, signed xbps repo)
- Also attached below: the signed repository files (`*.x86_64.xbps`
- with signatures, `x86_64-repodata`, and the client key
- `FINGERPRINT` plus the fingerprint-named `.plist`). This is a
- third-party repository published by this project, not upstream
- Void Linux:
- ```bash
- # as root (the files come from this release's assets):
- cp .plist /var/db/xbps/keys/
- echo 'repository=https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/' > /etc/xbps.d/voculinux.conf
- sudo xbps-install -Sy voculinux
- ```
- Answer `Y` at the fingerprint prompt only after checking it
- matches the attached `FINGERPRINT` file.
+ Not attached here. The signed xbps repository is published by the
+ tag-triggered Gitea workflow `.gitea/workflows/xbps-release.yml` to
+ the release named `latest` on the project's Gitea host. It is a
+ third-party repository published by this project, not upstream Void
+ Linux. The registration lines and the key fingerprint to pin are in
+ the Void Linux section of the README.
### Verifying what you downloaded
`SHA256SUMS` covers every file here and is attached once the aarch64 AppImage,
@@ -379,10 +372,12 @@ jobs:
- name: Attach to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ github.ref_name }}
+ REPO: ${{ github.repository }}
run: |
- gh release upload "${{ github.ref_name }}" \
+ gh release upload "$TAG" \
dist/*.AppImage \
- --repo ${{ github.repository }} \
+ --repo "$REPO" \
--clobber
# The .deb and .rpm are thin: they depend on the distro's Python, GTK,
@@ -431,10 +426,12 @@ jobs:
- name: Attach to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ github.ref_name }}
+ REPO: ${{ github.repository }}
run: |
- gh release upload "${{ github.ref_name }}" \
+ gh release upload "$TAG" \
dist/*.deb dist/*.rpm \
- --repo ${{ github.repository }} \
+ --repo "$REPO" \
--clobber
build-native-arm64:
@@ -477,10 +474,12 @@ jobs:
- name: Attach to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ github.ref_name }}
+ REPO: ${{ github.repository }}
run: |
- gh release upload "${{ github.ref_name }}" \
+ gh release upload "$TAG" \
dist/*.deb dist/*.rpm \
- --repo ${{ github.repository }} \
+ --repo "$REPO" \
--clobber
# Pins match .github/workflows/flatpak.yml. These jobs start with the tag
@@ -790,12 +789,14 @@ jobs:
- name: Attach to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ github.ref_name }}
+ REPO: ${{ github.repository }}
run: |
set -euo pipefail
ls -la dist/*.flatpak
- gh release upload "${{ github.ref_name }}" \
+ gh release upload "$TAG" \
dist/*.flatpak \
- --repo ${{ github.repository }} \
+ --repo "$REPO" \
--clobber
# Runs last because the aarch64 AppImage, both Flatpak bundles, the native
@@ -804,7 +805,7 @@ jobs:
# missing one looks like the tampered one.
publish-checksums:
name: Checksums and Provenance
- needs: [build-and-release, build-appimage-arm64, build-native, build-native-arm64, build-flatpak-amd64, build-flatpak-arm64, attach-flatpak, attach-snap, build-xbps]
+ needs: [build-and-release, build-appimage-arm64, build-native, build-native-arm64, build-flatpak-amd64, build-flatpak-arm64, attach-flatpak, attach-snap]
runs-on: ubuntu-latest
# An attach-job failure must not skip checksums: this job hashes the build
# artifacts, which exist regardless of whether an upload to the release
@@ -820,7 +821,7 @@ jobs:
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
# Verification-only artifacts must never enter the release manifest.
- pattern: '@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64|xbps-repo)'
+ pattern: '@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64)'
merge-multiple: true
path: dist
@@ -831,9 +832,6 @@ jobs:
for pat in '*.whl' '*.tar.gz' '*.AppImage' '*.deb' '*.rpm' '*.flatpak' '*.snap'; do
compgen -G "$pat" > /dev/null || { echo "missing artifact: $pat"; exit 1; }
done
- for pat in 'repo/*.x86_64.xbps' 'repo/*.sig2' 'repo/*-repodata*' 'keys/FINGERPRINT' 'keys/*.plist'; do
- compgen -G "$pat" > /dev/null || { echo "missing artifact: $pat"; exit 1; }
- done
test "$(compgen -G '*.AppImage' | wc -l)" -eq 2
test "$(compgen -G '*.deb' | wc -l)" -eq 2
test "$(compgen -G '*.rpm' | wc -l)" -eq 2
@@ -845,12 +843,8 @@ jobs:
set -euo pipefail
ls -la
# Bare names, so `sha256sum -c` works in whatever directory the user
- # downloaded the release into. The xbps repo files hash from their
- # own directories for the same reason: the release attaches them
- # flat, and RSA signatures stay the primary trust root.
+ # downloaded the release into.
sha256sum -- *.whl *.tar.gz *.AppImage *.deb *.rpm *.flatpak *.snap | tee SHA256SUMS
- (cd repo && sha256sum -- *.x86_64.xbps *.sig2 *-repodata*) >> SHA256SUMS
- (cd keys && sha256sum -- FINGERPRINT *.plist) >> SHA256SUMS
# Attesting from the manifest rather than a second glob: the file we
# publish and the set we attest cannot drift apart.
@@ -862,10 +856,12 @@ jobs:
- name: Attach SHA256SUMS to the release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ github.ref_name }}
+ REPO: ${{ github.repository }}
run: |
- gh release upload "${{ github.ref_name }}" \
+ gh release upload "$TAG" \
dist/SHA256SUMS \
- --repo ${{ github.repository }} \
+ --repo "$REPO" \
--clobber
publish-pypi:
@@ -1043,94 +1039,6 @@ jobs:
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: 'Release ${{ github.ref_name }}'
- # The signed Void xbps repository (ticket #29): every template builds in
- # smoke-gate order in the pinned Void container via podman, the full
- # repository index is signed with the RSA4096 repository key, and the
- # packages, signatures, repodata, and client key land on this release.
- # XBPS_SIGNING_KEY is required — an unsigned repository can never
- # publish, so a missing key fails the job instead of skipping it (unlike
- # the opt-in Flatpak remote and AUR publishes). The voculinux template
- # builds the canonical release sdist, so this waits for build-and-release,
- # and the tag must match src/vocalinux/version.py or the job aborts.
- build-xbps:
- name: Build signed xbps repository
- needs: build-and-release
- runs-on: ubuntu-latest
- permissions:
- contents: write
- steps:
- - name: Checkout code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
- - name: Verify tag matches the version source of truth
- run: |
- set -euo pipefail
- TAG="${GITHUB_REF#refs/tags/v}"
- PACKAGE_VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' src/vocalinux/version.py)
- echo "Tag version: $TAG"
- echo "Package version: $PACKAGE_VERSION"
- if [[ "$PACKAGE_VERSION" != *"$TAG"* ]] && [[ "$TAG" != *"$PACKAGE_VERSION"* ]]; then
- echo "::error::Tag v$TAG does not match src/vocalinux/version.py ($PACKAGE_VERSION); refusing to ship the signed repo"
- exit 1
- fi
-
- # Fail-closed: no key, no repo, no publish — never a silent skip.
- - name: Require the repository signing key
- env:
- XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }}
- run: |
- if [ -z "$XBPS_SIGNING_KEY" ]; then
- echo "::error::XBPS_SIGNING_KEY is unset; refusing to publish an unsigned repository (packaging/void/README.md)"
- exit 1
- fi
-
- - name: Install podman
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends podman
-
- - name: Build and sign the repository
- env:
- XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }}
- run: |
- set -euo pipefail
- printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem
- chmod 600 /tmp/xbps-sign-key.pem
- XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem bash packaging/void/repo.sh
- rm -f /tmp/xbps-sign-key.pem
- ls -la publish/xbps/repo publish/xbps/keys
-
- - name: Upload the signed repository
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- with:
- name: xbps-repo
- path: |
- publish/xbps/repo/*
- publish/xbps/keys/*
- if-no-files-found: error
- retention-days: 7
-
- - name: Attach the signed repository to the release
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -euo pipefail
- cd publish/xbps
- # Full signed repo or nothing: binary packages (*.x86_64.xbps)
- # with their signatures, the signed repository metadata
- # (*-repodata), and the client key files (FINGERPRINT plus the
- # fingerprint-named .plist). Each class is required below, so a
- # partial repo can never attach.
- compgen -G 'repo/*.x86_64.xbps' > /dev/null || { echo "missing packages"; exit 1; }
- compgen -G 'repo/*.sig2' > /dev/null || { echo "missing signatures"; exit 1; }
- compgen -G 'repo/*-repodata*' > /dev/null || { echo "missing repodata"; exit 1; }
- compgen -G 'keys/FINGERPRINT' > /dev/null || { echo "missing FINGERPRINT"; exit 1; }
- compgen -G 'keys/*.plist' > /dev/null || { echo "missing client key"; exit 1; }
- gh release upload "${{ github.ref_name }}" \
- repo/*.x86_64.xbps repo/*.sig2 repo/*-repodata* keys/FINGERPRINT keys/*.plist \
- --repo ${{ github.repository }} \
- --clobber
-
# GitHub Release first, Store second. A super-privileged plug can still
# queue a human review and refuse edge/candidate; the .snap still
# has to land on the tag for sideload. grade: stable allows candidate; do
@@ -1180,11 +1088,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
+ REPO: ${{ github.repository }}
run: |
set -euo pipefail
ls -la dist/*.snap
gh release upload "$TAG" dist/*.snap \
- --repo ${{ github.repository }} \
+ --repo "$REPO" \
--clobber
publish-snap:
diff --git a/.jscpd.json b/.jscpd.json
index df53728..ac64c36 100644
--- a/.jscpd.json
+++ b/.jscpd.json
@@ -13,6 +13,7 @@
"**/dist/**",
"**/build/**",
"**/*.lock",
+ "requirements/*.txt",
"**/package-lock.json",
"**/*.md",
"docs/**"
diff --git a/AGENTS.md b/AGENTS.md
index 1c9fcc6..bd79532 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -148,6 +148,8 @@ Optional extras: `vosk`, `whisper`, `vad`, `dev`.
```
src/vocalinux/
├── main.py, version.py, common_types.py, dbus_service.py, post_processor.py
+├── app_identity.py # user-visible identity constants (one slug); retained package name is vocalinux
+├── legacy_import.py # one-time import of a legacy Vocalinux config, dictionary and restore token
├── audio/
│ ├── capture.py # PortAudio capture sources and device handling
│ ├── pipewire.py # PipeWire system-audio capture
@@ -160,6 +162,7 @@ src/vocalinux/
├── gateway_embed/ # optional local VocaGateway lifecycle
├── speech_recognition/
│ ├── recognition_manager.py # VAD, segmentation, and engine dispatch
+│ ├── model_ladder.py # memory-aware whisper.cpp rungs, backend order, step-down
│ ├── engines/ # engine implementations and availability
│ ├── command_processor.py # voice commands
│ ├── command_phrases.py # voice command phrases and localized forms
@@ -193,6 +196,18 @@ Also: `tests/`, `docs/`, `packaging/` (AppImage, AUR, Flatpak, native .deb/.rpm)
`uninstall.sh`, `web/`. `scripts/distro-package-map.yaml` owns installer package
names; `install.d/package_map.sh` is generated by `just distro-packages`.
+- `packaging/void/`: xbps source templates (`srcpkgs/`), the podman build, smoke and
+ signed-repository pipeline (`repo.sh`, `container-repo.sh`), and the repository
+ public key (`repository-key.plist`). The app template consumes the fork's release
+ sdist; its checksum is pinned only after that release exists.
+- `scripts/measure_perf_baseline.py`: the locked performance protocol (see
+ `docs/PERF_BASELINE_PROTOCOL.md`). Its host binaries obey the same `host_env()` rule as the package.
+- `.gitea/workflows/`: the CI home (`ci.yml`, and `xbps-release.yml` for signed xbps
+ releases on version tags). `CI.md` describes the Gitea runner. The GitHub
+ `.github/workflows/` tree is the upstream mirror and is not the fork's release path.
+- `docs/NIRI.md`: niri activation (documented keybind, toggle triggers) and the
+ first-dictation experience on niri.
+
| Task | Start here |
|---|---|
| Voice command | `speech_recognition/command_processor.py` |
@@ -200,6 +215,9 @@ names; `install.d/package_map.sh` is generated by `just distro-packages`.
| Text injection | `text_injection/text_injector.py`, `focused_window.py`, `remote_desktop_portal.py`, `ibus_engine.py` |
| Settings | `ui/config_manager.py`, `ui/settings_dialog.py` |
| Hotkeys | `ui/keyboard_shortcuts.py`, `ui/keyboard_backends/` |
+| Model recommendation and memory ladder | `utils/whispercpp_model_info.py` (`get_recommended_model`), `speech_recognition/model_ladder.py` |
+| niri activation and tray | `docs/NIRI.md`, `dbus_service.py`, `ui/tray_indicator.py` |
+| Void packaging and signed repo | `packaging/void/`, `.gitea/workflows/xbps-release.yml`, `docs/RELEASE_PROCESS.md` |
## Style
diff --git a/README.md b/README.md
index c177c8d..43e75ff 100644
--- a/README.md
+++ b/README.md
@@ -238,30 +238,37 @@ The flake also ships `nixosModules.voculinux` and `homeManagerModules.voculinux`
### Void Linux (signed xbps repo)
-Void Linux **glibc `x86_64` only**. Each tagged release attaches a signed
-personal xbps repository: binary packages (`*.x86_64.xbps`), signatures,
+Void Linux **glibc `x86_64` only**. Each version tag publishes a signed
+personal xbps repository to the project's rolling `latest` release:
+binary packages (`*.x86_64.xbps`) with their signatures, the signed
repository metadata (`x86_64-repodata`), and the client key
-(`FINGERPRINT` plus the fingerprint-named `.plist`).
+(`FINGERPRINT` plus the fingerprint-named `.plist`). The registration URL
+below always serves the newest release.
This is a **third-party repository** — it is published by this project,
not by upstream Void Linux, and its packages are built from the
templates in `packaging/void/srcpkgs/`. Details: [packaging/void/README.md](packaging/void/README.md).
-```bash
-# as root on the Void system (the files come from the release assets):
-cp .plist /var/db/xbps/keys/
-TAG=v0.18.5 # match the release you install from
-echo "repository=https://github.com/VocaHQ/vocalinux/releases/download/${TAG}/" > /etc/xbps.d/voculinux.conf
+Repository key, RSA 4096. Pin this fingerprint:
+
+```text
+fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25
```
-Prefer the release notes for the exact repository URL. On first sync,
-answer `Y` at the fingerprint prompt only after checking it matches the
-`FINGERPRINT` file attached to the same release:
-
```bash
-sudo xbps-install -Sy voculinux
+# as root on the Void system: trust the key, register the repository, install
+curl -fsSL -o '/var/db/xbps/keys/fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25.plist' \
+ https://git.bongbetic.com/xavierk/voculinux/raw/branch/main/packaging/void/repository-key.plist
+echo 'repository=https://git.bongbetic.com/xavierk/voculinux/releases/download/latest' > /etc/xbps.d/voculinux.conf
+xbps-install -S voculinux
```
+The key file is named by its fingerprint, so xbps finds it on the first
+sync and asks nothing. If xbps still shows a fingerprint prompt, answer
+`N` unless it reads exactly the fingerprint above (it must also match the
+`FINGERPRINT` file attached to the release). Do not add `-y` to the first
+sync: it would accept an unknown key without asking.
+
### From source
```bash
diff --git a/docs/DISTRO_COMPATIBILITY.md b/docs/DISTRO_COMPATIBILITY.md
index a34f2df..227714b 100644
--- a/docs/DISTRO_COMPATIBILITY.md
+++ b/docs/DISTRO_COMPATIBILITY.md
@@ -198,8 +198,9 @@ sudo xbps-install -Sy python3-pip python3-gobject gtk+3 libayatana-appindicator
```
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.
+fallback rung is unavailable there. On niri, text is typed directly through
+`wtype` with no permission prompt. IBus is not used on niri. On GNOME and
+KDE the RemoteDesktop portal is tried first where it is available.
### Solus
```bash
diff --git a/docs/NIRI.md b/docs/NIRI.md
index be0e8e1..c3798a2 100644
--- a/docs/NIRI.md
+++ b/docs/NIRI.md
@@ -57,6 +57,15 @@ under **Settings → Shortcuts**; with it off, remove the niri bind. A key
claimed on both sides fires twice: the compositor toggles over D-Bus while
the internal listener fires on its own.
+## First dictation
+
+There is no permission prompt on niri. The RemoteDesktop portal is never
+used there, because no portal backend implements it, so your first dictation
+types straight into the focused window through `wtype` with nothing to approve
+or deny. Do not pin `portal` as a niri recommendation. IBus is excluded on
+niri as well. The GNOME and KDE first-run behaviour is described in
+[USER_GUIDE.md](USER_GUIDE.md#first-dictation-on-niri-gnome-and-kde).
+
## Hold-to-talk stays in the app
A compositor bind only sends the key press; it never sees the release, so
diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md
index e098450..246e3d1 100644
--- a/docs/RELEASE_PROCESS.md
+++ b/docs/RELEASE_PROCESS.md
@@ -37,6 +37,24 @@ publish succeeds.
`publish-aur` skips itself when `AUR_SSH_PRIVATE_KEY` is unset. See docs/AUR.md.
+### Void xbps repository (required for a Void release)
+
+The signed Void repository is released by `.gitea/workflows/xbps-release.yml`
+on the fork's Gitea host, not by `release.yml`. Set it up once:
+
+| what | value |
+|---|---|
+| Secret | `XBPS_SIGNING_KEY`: the PEM-encoded RSA 4096 repository private key (Gitea repository settings, Actions, Secrets) |
+| Public key | `packaging/void/repository-key.plist`, committed, fingerprint `fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25` |
+| Runner | `bongbetic-ci` with podman (the workflow installs it with apt when absent). Never Docker |
+| Token | the job token must be allowed to create releases and upload release assets |
+
+Without the secret the workflow fails and publishes nothing; it never signs
+with a different key either, because the fingerprint of the key that signed
+must equal the one above. The private key is never committed; only the public
+`.plist` is. Key generation and the check that a private key matches the
+committed public key are in `packaging/void/README.md`.
+
## Quick Release Checklist
Use this checklist for every release:
@@ -81,7 +99,7 @@ Install `just` from https://just.systems or the distro package `just`.
- [ ] Commit all version changes
- [ ] Push branch and create PR
- [ ] After PR merge, create tag: `git tag -a vX.Y.Z-PHASE -m "Release X.Y.Z-PHASE"`
-- [ ] Push tag: `git push origin vX.Y.Z-PHASE`
+- [ ] Push tag: `git push origin vX.Y.Z-PHASE` (this also starts the Gitea xbps workflow, see Step 8)
### After the release run finishes
- [ ] Run `just verify-release` - the published release verifies as published
@@ -316,13 +334,21 @@ Update the supported versions table:
After the tag is cut, the Void templates in `packaging/void/srcpkgs/` must
point at the new release: bump `version` in the `voculinux` template (and
any template whose upstream moved) and refresh every pinned `checksum`
-(`sha256` sums of the release sdist and distfiles). Land the bump as a
-**pull request** through the normal test, lint, and type gates — never as
-a direct or bot commit to `main`, and never hand-edited onto the release
-itself (see `just verify-release`). `tests/test_void_app_template.py`
-fails if the template version or checksum drifts from
-`src/vocalinux/version.py` and the published sdist, and the signed xbps
-repository for the *next* release builds from these templates.
+(`sha256` sums of the release sdist and distfiles). The `voculinux` template
+builds the fork's release asset `voculinux-X.Y.Z.tar.gz`; its `checksum` is
+the sha256 of that exact downloaded asset, never a value copied from
+upstream, and stays `PENDING_FORK_RELEASE_SHA256` (which fails closed in
+`xbps-src`) until the asset exists. Land the bump as a **pull request**
+through the normal test, lint, and type gates, never as a direct or bot
+commit to `main`, and never hand-edited onto the release itself (see
+`just verify-release`). `tests/test_void_app_template.py` fails if the
+template version or checksum drifts from `src/vocalinux/version.py` and the
+published sdist.
+
+Nothing automates this: the xbps workflow never commits or pushes. The
+signed repository a tag publishes is built from the templates as committed
+at that tag, so a bump merged after tag `vX.Y.Z` first ships with the next
+tag push.
### Step 4: Update Website
@@ -383,7 +409,7 @@ bash -n install.sh
### Upstream Fix Intake (Cherry-Pick Policy)
-Upstream Voculinux fixes enter this fork only as hand cherry-picks: taken at
+Upstream Vocalinux fixes enter this fork only as hand cherry-picks: taken at
defined sync points, never as continuous tracking and never as wholesale
merges. Like every other change, each port goes through the pull request flow
with the standard test, lint, and type gates. Never land one as a direct or
@@ -404,8 +430,10 @@ worktree per branch, every change via a pull request, never commit on `main`
renames on top. Conflicts concentrate at the renamed user-visible surfaces
(application name, entry points, desktop and D-Bus identity, configuration
and data directories, package, website) precisely because the internal
- Python module name stays `voculinux` as a retained internal name — that
- split is what keeps hand cherry-picks nearly conflict-free everywhere else.
+ Python module name stays `vocalinux` (`src/vocalinux/`) as a retained
+ internal name, so only the user-visible surfaces diverge from upstream, and
+ that split is what keeps hand cherry-picks nearly conflict-free everywhere
+ else.
Upstream URLs and changelog history stay untouched for provenance.
- **What qualifies.** Fixes and documentation that changes user instructions
port. Features are re-decided per release, not ported automatically.
@@ -507,6 +535,39 @@ After pushing the tag, the GitHub Actions workflow will automatically:
Monitor at: https://github.com/VocaHQ/vocalinux/actions
+#### Void xbps repository (Gitea tag workflow)
+
+Pushing the tag to `origin` (the fork's Gitea repository) also starts
+`.gitea/workflows/xbps-release.yml`. Pushing a tag is a human act and is
+approved each time: the workflow has no schedule and no manual trigger.
+
+1. **Tag equals version.** The tag must be exactly `v` plus `__version__` in
+ `src/vocalinux/version.py`. Any other tag, including one that merely
+ contains the version, exits 1 and publishes nothing.
+2. **Signing key required.** An empty `XBPS_SIGNING_KEY` exits 1 before any
+ container runs. The key is written to a 0600 temp file, passed to
+ `packaging/void/repo.sh`, and removed by a step that always runs.
+3. **Build and sign.** `repo.sh` is the only path that builds or signs: the
+ pinned Void glibc container under podman, every template in smoke-gate
+ order, the full `x86_64` index, every package and the metadata signed.
+4. **Verify.** Packages, signatures, repodata, `FINGERPRINT` and the client
+ key must all be present, nothing but glibc `x86_64` data may be present,
+ every package must be signed, and the signing fingerprint must be the
+ pinned one. A partial or unsigned repository never attaches.
+5. **Publish.** The complete signed set attaches to the tag's release on
+ Gitea and replaces the assets of the rolling `latest` release, which the
+ client registration URL
+ (`https://git.bongbetic.com/xavierk/voculinux/releases/download/latest`)
+ serves. Push tags in version order: pushing an older tag refreshes
+ `latest` with the older repository.
+
+The workflow never commits or pushes to any branch and never bumps a
+template; that is the pull request in section 3.5. The `voculinux` template
+fetches the fork's `vX.Y.Z` release asset, so that asset must exist and its
+checksum must be in the template before the build can pass. A tag whose
+templates still carry the pending checksum fails at the build step, by
+design.
+
### Step 9: Post-Release Tasks
- [ ] Verify GitHub Release was created correctly
@@ -526,6 +587,12 @@ Monitor at: https://github.com/VocaHQ/vocalinux/actions
- [ ] Verify PyPI package was published (if applicable), and that its wheel sha256
matches the line for that wheel in `SHA256SUMS`
- [ ] Verify website was deployed (check voculinux.com)
+- [ ] Verify the Gitea xbps run succeeded, and that both the tag release and
+ `latest` carry the full signed set (`*.x86_64.xbps`, `*.sig2`,
+ `x86_64-repodata`, `FINGERPRINT`, the `.plist`) and that the fingerprint
+ matches the one in `README.md`
+- [ ] Open the Void template bump pull request (section 3.5); never commit
+ it directly to `main`
- [ ] If the Flatpak remote is configured, verify it picked up the tag:
`flatpak remote-info vocahq com.voculinux.Voculinux` (docs/FLATPAK_REMOTE.md)
- [ ] Announce on social media/communities
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index da62c57..827d50a 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -343,8 +343,9 @@ in place and may select IBus. An explicit non-IBus pin (`portal`, `wtype`,
On Wayland, when IBus is not selected, autodetection tries the RemoteDesktop
portal first -- it is the only injection path Wayland officially supports and
-it needs no uinput device or helper daemon -- then `ydotool`, `wtype`, and
-finally `xdotool` under XWayland.
+it needs no uinput device or helper daemon -- then direct typing with `wtype`,
+then the virtual-input tool `ydotool`, and finally `xdotool` under XWayland.
+On niri the portal is never tried, because no backend there implements it.
On X11 the injection tool is `xdotool` regardless of which non-`ibus` value you
pin, so `xdotool` is the name to use there when IBus is unreliable in a
diff --git a/packaging/void/README.md b/packaging/void/README.md
index 61827fb..384999c 100644
--- a/packaging/void/README.md
+++ b/packaging/void/README.md
@@ -14,6 +14,7 @@ packaging/void/
├── smoke.sh ticket #28 gate: every template, one command
├── repo.sh ticket #29 pipeline: build all, index, sign
├── container-repo.sh ticket #29 container half (index + sign + verify)
+├── repository-key.plist the repository's PUBLIC key (never a private key)
├── tool_checksums.txt pinned base image and void-packages commit
└── srcpkgs/
├── python3-pywhispercpp/ ticket #26: whisper.cpp Python binding
@@ -68,40 +69,49 @@ for the sdist; `tests/test_void_pywhispercpp_template.py` fails otherwise.
## 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; xbps-src
-moves the single top-level directory to `pkgname-version` itself, so no
-`wrksrc` override is needed (`wrksrc` is deprecated and `xlint` rejects
-it). The first release cut after the rename points `distfiles` at the
-renamed asset.
+upstream. The template builds the fork's canonical release source
+distribution, `voculinux-0.18.5.tar.gz`, attached to the fork's `v0.18.5`
+release on git.bongbetic.com, with a pinned `checksum` (see
+[Checksum](#checksum)); `tests/test_void_app_template.py` fails if the
+version drifts from `version.py`.
-- **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
+- **Runtime deps** are Void's own interpreter, GTK and desktop bindings,
+ the packaged Python libraries, the three templates from tickets #25
+ and #26 (`python3-pynput`, `python3-PyAudio`, `python3-pywhispercpp`),
+ and the helper tools the app shells out to: PipeWire (capture), `wtype`
+ (direct typing on niri), `wl-clipboard`, `xdotool` and
+ `pulseaudio-utils`. `ibus` is not a dependency: it cannot inject on
+ niri. 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,
+- **Installed names**: the `voculinux.desktop` entry, the hicolor icons,
and a `usr/share/voculinux/packaged` marker the app reads to present
- package-manager updates. The 0.18.5 sdist predates the rename, so
- `post_install` promotes the old file names at install time; the payload
- smoke rejects any pre-rename leftovers in the OS-visible paths.
+ package-manager updates. The fork's sdist already carries the final
+ names, so the template installs them as they are; the payload smoke
+ rejects any pre-rename leftovers in the OS-visible paths.
- **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.
+ onnxruntime), the niri first run, 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`).
+## Checksum
+
+The template's `checksum=` is `PENDING_FORK_RELEASE_SHA256` until the
+fork's `v0.18.5` release asset (`voculinux-0.18.5.tar.gz` on
+git.bongbetic.com/xavierk/voculinux) exists. Once it does, set `checksum=`
+to the `sha256sum` of that exact downloaded file, never a value copied from
+upstream, and land the change in a pull request. Until then `xbps-src`
+fails closed on the pending value, so neither the smoke gate nor the signed
+repository can build `voculinux`.
+
## Building
```sh
@@ -137,7 +147,7 @@ so a renamed or added template cannot silently drop out.
`packaging/void/repo.sh` builds every template in smoke-gate order, indexes
the full repository (`x86_64-repodata`), and signs every binary package
-(`*.sig2`) plus the repository metadata with the RSA4096 repository key —
+(`*.sig2`) plus the repository metadata with the RSA 4096 repository key —
one command, same pinned container via podman, no host Void install:
```sh
@@ -156,29 +166,85 @@ and a `FINGERPRINT` file carrying the signer identity and fingerprint for
release notes). `publish/` is local build output and stays uncommitted.
Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before any
-container runs, so an unsigned repository can never publish. CI holds the
-PEM-encoded key in the `XBPS_SIGNING_KEY` secret and writes it to a file
-first (a file keeps newlines intact and the key out of process listings).
+container runs, so an unsigned repository can never publish.
-**Maintainer key setup (one time):**
+### The repository key
+
+One RSA 4096 key signs everything. Its fingerprint, which clients pin:
+
+```text
+fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25
+```
+
+- **Public half**: committed as `packaging/void/repository-key.plist`
+ (the same file xbps keeps in `/var/db/xbps/keys/`, named by the
+ fingerprint). Signer identity in the file: `Bongbetic `.
+ `tests/test_void_signed_repo.py` derives the fingerprint from this file
+ and fails if either README, or the release workflow, shows a different one.
+- **Private half**: never committed, never in the repository, never on a
+ build host's disk beyond the run. It exists as the `XBPS_SIGNING_KEY`
+ CI secret (PEM text, newlines intact) and in the maintainer's own backup.
+- **Generating a new key** (only if the key is ever replaced; clients then
+ have to re-pin, so this is a release-notes event):
```sh
openssl genrsa -out xbps-sign-key.pem 4096
# back the key up somewhere safe, then store it as XBPS_SIGNING_KEY
+openssl rsa -in xbps-sign-key.pem -pubout # the public half, to build the .plist from
```
-**Client setup:**
+- **Checking a private key against the committed public key**, before it is
+ stored as the secret (both lines must print the same digest):
```sh
-# as root on the Void system (the release notes carry the repo URL):
-cp .plist /var/db/xbps/keys/
-TAG=v0.18.5 # match the release you install from
-echo "repository=https://github.com/VocaHQ/vocalinux/releases/download/${TAG}/" > /etc/xbps.d/voculinux.conf
-xbps-install -Sy voculinux
+openssl rsa -in xbps-sign-key.pem -pubout 2>/dev/null | openssl md5
+python3 -c "import plistlib,sys; sys.stdout.buffer.write(plistlib.load(open('packaging/void/repository-key.plist','rb'))['public-key'])" | openssl md5
```
-Answer `Y` when xbps shows the fingerprint prompt on first sync, and check
-it matches the published `FINGERPRINT` file. `tests/test_void_signed_repo.py`
+### Releasing: the Gitea workflow
+
+A human pushes a version tag; `.gitea/workflows/xbps-release.yml` does the
+rest. It never runs on a schedule or a manual dispatch.
+
+1. The tag must equal `v` plus `__version__` in `src/vocalinux/version.py`,
+ exactly; any other tag exits 1 and publishes nothing.
+2. The CI secret `XBPS_SIGNING_KEY` must be set; empty exits 1 before any
+ container runs. The key is written to a 0600 temp file, handed to
+ `repo.sh` as `XBPS_SIGNING_KEY_FILE`, and removed by a final step that
+ always runs.
+3. `repo.sh` builds and signs inside the pinned container (podman, never
+ Docker).
+4. The output is checked: every class of file present (`*.x86_64.xbps`,
+ `*.sig2`, `*-repodata*`, `FINGERPRINT`, the `.plist`), every package
+ signed, nothing but glibc `x86_64` data, and the signing key's
+ fingerprint equal to the one above.
+5. Everything attaches to the tag's release and to the rolling `latest`
+ release (replacing that release's old assets), through the Gitea API.
+ `latest` is what the client registration URL serves.
+
+The workflow never commits or pushes. Bumping the templates to the new
+release is a normal pull request (`docs/RELEASE_PROCESS.md`).
+
+Secret setup, once: Gitea repository settings, Actions, Secrets, add
+`XBPS_SIGNING_KEY` with the PEM private key. The runner (`bongbetic-ci`)
+needs podman (the workflow installs it with apt when absent), and the job
+token must be allowed to create releases.
+
+### Client setup
+
+```sh
+# as root on the Void system
+curl -fsSL -o '/var/db/xbps/keys/fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25.plist' \
+ https://git.bongbetic.com/xavierk/voculinux/raw/branch/main/packaging/void/repository-key.plist
+echo 'repository=https://git.bongbetic.com/xavierk/voculinux/releases/download/latest' > /etc/xbps.d/voculinux.conf
+xbps-install -S voculinux
+```
+
+The key file is named by its fingerprint, so xbps finds it on the first
+sync. If xbps shows a fingerprint prompt anyway, answer `N` unless it
+reads exactly the fingerprint above and matches the `FINGERPRINT` file on
+the release; do not add `-y` to the first sync. `tests/test_void_signed_repo.py`
asserts offline that the pipeline stays wired to every `srcpkgs` template,
stays podman-only on the pinned inputs, and keeps the fail-closed key
-contract.
+contract; `tests/test_xbps_release_workflow.py` runs the workflow's gates
+against fixtures.
diff --git a/packaging/void/container-main.sh b/packaging/void/container-main.sh
index 930bc94..d980cd1 100755
--- a/packaging/void/container-main.sh
+++ b/packaging/void/container-main.sh
@@ -61,9 +61,8 @@ voculinux)
echo "== version and import smoke $PKGNAME"
voculinux --version
cat > /voculinux-smoke.py <<'PYEOF'
-# The 0.18.5 sdist predates the rename: the importable top-level package
-# and the distribution metadata still carry the old name. The first
-# release cut after the rename moves these to voculinux.
+# The importable package keeps its retained internal name (vocalinux); the
+# distribution metadata is the fork's own name (voculinux).
import importlib
MODULES = (
@@ -81,7 +80,7 @@ for module in MODULES:
print("import-smoke OK:", ", ".join(MODULES))
from importlib.metadata import version
-print("vocalinux", version("vocalinux"))
+print("voculinux", version("voculinux"))
PYEOF
python3 /voculinux-smoke.py
;;
diff --git a/packaging/void/repository-key.plist b/packaging/void/repository-key.plist
new file mode 100644
index 0000000..877083c
--- /dev/null
+++ b/packaging/void/repository-key.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ public-key
+ LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUE5dDhGbEVPUXNaUDZROHNhaXlZMQpRSkhpQ1A5ZU00aWd3R2xRQ1FxMncxOVlkOURza2wxNTdsZEJFTHJBWkVVV2orYjJYODVHc0xKZFNGbHRGaTRUClZFUGpIUlZpbVdFU3V1MG0zK28zK3V2d0R4LzlaZHRMK01aZUIwWTBHZThFSGM1aVFCSk9NSnVSZTlzWERQSEwKclB6ZUZ3NFE4SEYyWGdEVXpvNUZncVd4TEIyQ0xleEhFVGhxNHNSWWFtUU85OE9JS0xWUWVSbEpBOVU4RXBoRgp3QUQ5R3BBR1FxUnBZMTRYV09nNmIva2RpMUhjeWxMZ1p0YVoyQzE1TW5KT2lTanpTTjRKdDdmdHg2czNKRGJzCmpPTjRsWXZxakFETkZQdEFKbmxlUlVHSEwxL20xa3VMbG11eFUyVThkOGJGK1dxUGFKem1oQUcvSHBNdkJqOXMKTjFSYUE0RkhIZ2pueUc4dHJibzQyUjByN1BGb1p6T1g2VWNzSmRMaUZVMFpqRll2QVh6MmViMmlvUjR0eTNWOQoxb0UvbFBsaGQ5SzFPdmZBazRFRVdYMHhCcThlMkEzczZSNG9jWDdlNzFJY2QwbVowRjVTMDhmR1ZlamdvTm5hCmJBcXRQaXdFZGdoVVExT0FGdS9VQmlxdkloODJNbmphbVNGN25BaUw4UldxMFZiSlZ5bGNqOVVtV0R5N3FWam8KTjFkWFRHNGcrT3AyM2JpZlVxMDJzRERzVXhZd3ptdHVtVENHczlSWlQ2N0JpNXRRRU1DZy9ZbUhkRkllbnEzUwpRSVNpYlNWejA2T3M1U3RJZHRlM2dEcjBROHRLYnJzRVk1Q1IwSnM1d1hYOFJvZ3MwbVArRXV2OVByNTNNT1k0CllidXpENEMzWWcvNkhjelY0WHkwOGVjQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=
+ public-key-size
+ 4096
+ signature-by
+ Bongbetic <soubarna@live.in>
+
+
diff --git a/packaging/void/srcpkgs/voculinux/INSTALL.msg b/packaging/void/srcpkgs/voculinux/INSTALL.msg
index ea96da2..fead780 100644
--- a/packaging/void/srcpkgs/voculinux/INSTALL.msg
+++ b/packaging/void/srcpkgs/voculinux/INSTALL.msg
@@ -6,16 +6,23 @@ 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.
+download on first run into ~/.local/share/voculinux
+and are checked against pinned checksums.
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.
+Text injection: on niri there is no permission
+prompt, and typing goes direct through wtype. On
+GNOME and KDE the RemoteDesktop portal asks once
+for keyboard access and remembers the answer where
+the desktop supports it. If you deny it, dictation
+falls back to the next typing method you have.
+
ydotool is not packaged for Void, so the ydotool
-injection fallback rung is absent; the app uses
-IBus, the RemoteDesktop portal, wtype or xdotool.
+injection fallback rung is absent.
Update through your package manager instead of the
in-app release downloads:
diff --git a/packaging/void/srcpkgs/voculinux/template b/packaging/void/srcpkgs/voculinux/template
index 9871159..fdc331a 100644
--- a/packaging/void/srcpkgs/voculinux/template
+++ b/packaging/void/srcpkgs/voculinux/template
@@ -7,41 +7,32 @@ revision=1
build_style=python3-pep517
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"
+# Runtime helpers: pipewire (pw-record, pw-dump) for capture, wtype for
+# direct typing on niri, wl-clipboard (wl-copy, wl-paste) for clipboard
+# paths, xdotool for X11 and the XWayland fallback, pulseaudio-utils for
+# pactl source listing. ibus is not a dependency: it is excluded on niri.
+depends="python3 python3-gobject python3-cairo gtk+3 libayatana-appindicator gobject-introspection python3-numpy python3-requests python3-pysocks python3-psutil python3-evdev python3-xlib python3-tqdm python3-platformdirs python3-PyAudio python3-pynput python3-pywhispercpp pipewire wtype wl-clipboard xdotool pulseaudio-utils"
short_desc="Free, offline voice dictation for Linux"
-maintainer="Jatin K Malik "
+maintainer="Xavier Karma "
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.
-# xbps-src moves a single-top-dir archive to pkgname-version itself, so no
-# wrksrc override is needed (wrksrc is deprecated and xlint rejects it).
-# The first release cut after the rename ships the archive under the new
-# name; that bump points distfiles at the renamed asset with its digest.
+homepage="https://git.bongbetic.com/xavierk/voculinux"
+distfiles="https://git.bongbetic.com/xavierk/voculinux/releases/download/v${version}/voculinux-${version}.tar.gz"
+# Pending: set to the sha256 of voculinux-${version}.tar.gz once the fork's
+# v${version} release exists (see packaging/void/README.md, "Checksum").
+# Until then xbps-src fails closed on this value; it never falls back to the
+# upstream archive.
+checksum=PENDING_FORK_RELEASE_SHA256
post_install() {
- # The 0.18.5 sdist predates the rename: the entry and icons still
- # carry the old names. Promote them at install time — the entry lands
- # as voculinux.desktop with rewritten Name/Exec/Icon, old-prefixed
- # icons land renamed (vinstall takes the installed name, as vman
- # uses it). The first release cut after the rename ships the new
- # names; that bump drops the renames. Entry points land on PATH
- # through the wheel build; the marker is what the app reads to
+ # The fork's sdist already names the desktop entry, icons and launchers
+ # voculinux (pyproject entry points and the repo's voculinux.desktop), so
+ # the files install as they are. The marker is what the app reads to
# present package-manager updates (see INSTALL.msg).
vlicense LICENSE
- sed -e 's/vocalinux/voculinux/g' -e 's/Vocalinux/Voculinux/g' \
- vocalinux.desktop > voculinux.desktop
vinstall voculinux.desktop 644 usr/share/applications
for icon in resources/icons/scalable/*.svg; do
- vinstall "$icon" 644 usr/share/icons/hicolor/scalable/apps \
- "$(basename "$icon" | sed 's/^vocalinux/voculinux/')"
+ vinstall "$icon" 644 usr/share/icons/hicolor/scalable/apps
done
- # The wheel's entry points still carry the old names: promote the
- # launchers (this runs before the provides hooks, so cmd:voculinux
- # is what the package provides).
- mv "${DESTDIR}/usr/bin/vocalinux" "${DESTDIR}/usr/bin/voculinux"
- mv "${DESTDIR}/usr/bin/vocalinux-gui" "${DESTDIR}/usr/bin/voculinux-gui"
vmkdir usr/share/voculinux
touch "${DESTDIR}/usr/share/voculinux/packaged"
}
diff --git a/pyproject.toml b/pyproject.toml
index 0b46612..1866cdc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -133,6 +133,8 @@ Changelog = "https://github.com/VocaHQ/vocalinux/releases"
# Contributors may use uv 0.12 or 0.13. CI and lock/export generation use
# uv 0.13.3 for reproducible export formatting (see `just lock`).
required-version = ">=0.12,<0.14"
+# Supply-chain cooldown: wait a week before resolving newly published releases.
+exclude-newer = "7 days"
[tool.setuptools]
# Resource dirs carry no .py files, but setuptools still treats them as packages.
diff --git a/scripts/generate-model-checksums.py b/scripts/generate-model-checksums.py
index 3031121..7f94d39 100755
--- a/scripts/generate-model-checksums.py
+++ b/scripts/generate-model-checksums.py
@@ -64,8 +64,18 @@ class Entry(NamedTuple):
size: int
+def _https(url: str) -> str:
+ """Refuse anything but https before it reaches urlopen (which also reads file:)."""
+ if not url.startswith("https://"):
+ raise SystemExit(f"refusing non-https URL: {url}")
+ return url
+
+
def _fetch_json(url: str):
- request = urllib.request.Request(url, headers={"User-Agent": "voculinux-checksum-generator"})
+ request = urllib.request.Request(
+ _https(url), headers={"User-Agent": "voculinux-checksum-generator"}
+ )
+ # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- https enforced by _https; fixed upstream endpoints
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
return json.load(response)
@@ -92,8 +102,11 @@ def _download_and_hash(url: str, expected_md5: str) -> tuple[str, int]:
a mismatch means the listing and the file disagree, which is worth stopping
for rather than pinning either one.
"""
- sha256, md5, size = hashlib.sha256(), hashlib.md5(), 0
- request = urllib.request.Request(url, headers={"User-Agent": "voculinux-checksum-generator"})
+ sha256, md5, size = hashlib.sha256(), hashlib.md5(usedforsecurity=False), 0
+ request = urllib.request.Request(
+ _https(url), headers={"User-Agent": "voculinux-checksum-generator"}
+ )
+ # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- https enforced by _https
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
for chunk in iter(lambda: response.read(1024 * 1024), b""):
sha256.update(chunk)
diff --git a/scripts/measure_perf_baseline.py b/scripts/measure_perf_baseline.py
index dfb4be2..2fb326a 100644
--- a/scripts/measure_perf_baseline.py
+++ b/scripts/measure_perf_baseline.py
@@ -12,6 +12,7 @@ from __future__ import annotations
import argparse
import json
+import logging
import math
import os
import platform
@@ -23,6 +24,8 @@ import time
from pathlib import Path
from typing import Any, Optional
+logger = logging.getLogger(__name__)
+
PROTOCOL_VERSION = "1.0.0"
_VMRSS_RE = re.compile(r"^VmRSS:\s+(\d+)\s+kB", re.MULTILINE)
@@ -117,6 +120,9 @@ def parse_nvidia_smi_memory(csv_text: str) -> Optional[tuple[int, int]]:
def vram_mib() -> Optional[tuple[int, int]]:
"""Return ``(total_mib, free_mib)`` of the first NVIDIA GPU, if any."""
+ # Lazy: the parse-only subcommands must run without the package installed.
+ from vocalinux.utils.host_process import host_env
+
try:
proc = subprocess.run(
[
@@ -127,8 +133,10 @@ def vram_mib() -> Optional[tuple[int, int]]:
capture_output=True,
text=True,
timeout=30,
+ env=host_env(),
)
- except (OSError, subprocess.SubprocessError):
+ except (OSError, subprocess.SubprocessError) as error:
+ logger.debug("nvidia-smi unavailable: %s", error)
return None
if proc.returncode != 0:
return None
@@ -441,6 +449,8 @@ def cmd_inject_probe(args: argparse.Namespace) -> int:
if wayland:
import shutil
+ from vocalinux.utils.host_process import host_env
+
if shutil.which("wtype") is not None and backend != "x11":
for _ in range(5):
begin = time.monotonic()
@@ -449,6 +459,7 @@ def cmd_inject_probe(args: argparse.Namespace) -> int:
capture_output=True,
text=True,
timeout=10,
+ env=host_env(),
)
wtype_roundtrips.append(time.monotonic() - begin)
try:
diff --git a/scripts/sync_flatpak_deps.py b/scripts/sync_flatpak_deps.py
index fc0982f..417b208 100755
--- a/scripts/sync_flatpak_deps.py
+++ b/scripts/sync_flatpak_deps.py
@@ -122,6 +122,7 @@ def parse_export(path: Path) -> dict[str, tuple[str, set[str]]]:
def pypi_artifacts(name: str, version: str) -> list[dict]:
url = PYPI_JSON.format(name=name, version=version)
+ # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- fixed https://pypi.org endpoint; name and version come from uv.lock
with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
return json.load(response)["urls"]
diff --git a/scripts/verify_release.py b/scripts/verify_release.py
index 0ef6a55..a66b6d5 100755
--- a/scripts/verify_release.py
+++ b/scripts/verify_release.py
@@ -188,6 +188,7 @@ def check_pypi(version: str, stored: dict[str, str]) -> list[str]:
try:
url = f"https://pypi.org/pypi/{PYPI_PROJECT}/{version}/json"
+ # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- fixed https://pypi.org host; version is the release being verified
with urllib.request.urlopen(url, timeout=PYPI_TIMEOUT) as response:
payload = json.load(response)
except urllib.error.HTTPError as error:
diff --git a/snap/local/check_runtime.py b/snap/local/check_runtime.py
index 4c33d34..f91190e 100644
--- a/snap/local/check_runtime.py
+++ b/snap/local/check_runtime.py
@@ -103,6 +103,7 @@ def smoke(root: Path) -> None:
site = audit(root, manifest["packages"])
sys.path.insert(0, str(site))
for name in ("numpy", "requests", "psutil", "pyaudio", "cairo", "evdev", "vocalinux"):
+ # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import -- names come from the literal tuple above
module = importlib.import_module(name)
if not module.__file__ or not Path(module.__file__).resolve().is_relative_to(
site.resolve()
@@ -118,6 +119,7 @@ def smoke(root: Path) -> None:
"vocalinux.speech_recognition.recognition_manager",
"vocalinux.text_injection.text_injector",
):
+ # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import -- names come from the literal tuple above
importlib.import_module(name)
import gi
diff --git a/src/vocalinux/app_identity.py b/src/vocalinux/app_identity.py
index 4a97bc8..cbce773 100644
--- a/src/vocalinux/app_identity.py
+++ b/src/vocalinux/app_identity.py
@@ -2,45 +2,50 @@
Ticket #18 rename: every user-visible identity string that is hardcoded in
more than one place is driven from this single module. The fork takes its
-own name everywhere a user can see it; the internal ``voculinux`` Python
-package name is retained (see :data:`RETAINED_SURFACES`) and unrelated to
-these values.
+own name everywhere a user can see it. The Python import package name
+``vocalinux`` is retained internally (see :data:`RETAINED_SURFACES`) and is
+unrelated to these values.
One-time legacy import of a previous Voculinux install lives in
:mod:`vocalinux.legacy_import` and keys off :data:`LEGACY_APP_DIR_NAME`.
"""
+#: The one place the lowercase fork slug is spelled. Every lowercase identity
+#: below is derived from it, so a rename is one edit here plus the surfaces
+#: listed in RENAME_SURFACES.
+_SLUG: str = "voculinux"
+
APP_NAME: str = "Voculinux"
-APP_ID: str = "voculinux"
-APP_DIR_NAME: str = "voculinux"
-CLI_PROG: str = "voculinux"
+APP_ID: str = _SLUG
+APP_DIR_NAME: str = _SLUG
+CLI_PROG: str = _SLUG
-BUS_NAME: str = "com.voculinux.Voculinux"
-OBJECT_PATH: str = "/com/voculinux/Voculinux"
-INTERFACE_NAME: str = "com.voculinux.Voculinux"
+BUS_NAME: str = f"com.{_SLUG}.Voculinux"
+OBJECT_PATH: str = f"/com/{_SLUG}/Voculinux"
+INTERFACE_NAME: str = f"com.{_SLUG}.Voculinux"
-DESKTOP_FILENAME: str = "voculinux.desktop"
-DESKTOP_ICON_NAME: str = "voculinux"
+DESKTOP_FILENAME: str = f"{_SLUG}.desktop"
+DESKTOP_ICON_NAME: str = _SLUG
TRAY_TITLE: str = APP_NAME
-ICON_DEFAULT: str = "voculinux-microphone-off"
-ICON_ACTIVE: str = "voculinux-microphone"
-ICON_PROCESSING: str = "voculinux-microphone-process"
+ICON_DEFAULT: str = f"{_SLUG}-microphone-off"
+ICON_ACTIVE: str = f"{_SLUG}-microphone"
+ICON_PROCESSING: str = f"{_SLUG}-microphone-process"
ICON_BASE_NAMES: tuple[str, ...] = (
- "voculinux",
+ _SLUG,
ICON_ACTIVE,
ICON_DEFAULT,
ICON_PROCESSING,
)
-ENGINE_NAME: str = "voculinux"
-ENGINE_LONGNAME: str = "Voculinux"
-ENGINE_DESCRIPTION: str = "Voculinux voice dictation (use as default input method)"
-COMPONENT_NAME: str = "org.freedesktop.IBus.Voculinux"
-IBUS_DIR_NAME: str = "voculinux-ibus"
-IBUS_TEXTDOMAIN: str = "voculinux"
+ENGINE_NAME: str = _SLUG
+ENGINE_LONGNAME: str = APP_NAME
+ENGINE_DESCRIPTION: str = f"{APP_NAME} voice dictation (use as default input method)"
+COMPONENT_NAME: str = f"org.freedesktop.IBus.{APP_NAME}"
+IBUS_DIR_NAME: str = f"{_SLUG}-ibus"
+IBUS_TEXTDOMAIN: str = _SLUG
-UPDATE_CHECKER_USER_AGENT: str = "Voculinux-UpdateChecker"
+UPDATE_CHECKER_USER_AGENT: str = f"{APP_NAME}-UpdateChecker"
def user_agent(version: str) -> str:
@@ -48,11 +53,11 @@ def user_agent(version: str) -> str:
return f"{APP_ID}/{version}"
-RESOURCE_DIR_NAME: str = "voculinux"
+RESOURCE_DIR_NAME: str = _SLUG
-VERIFICATION_STAMP_NAME: str = ".voculinux_verified"
-LOG_FILE_PREFIX: str = "voculinux_logs_"
-PORTAL_TOKEN_PREFIX: str = "voculinux_"
+VERIFICATION_STAMP_NAME: str = f".{_SLUG}_verified"
+LOG_FILE_PREFIX: str = f"{_SLUG}_logs_"
+PORTAL_TOKEN_PREFIX: str = f"{_SLUG}_"
ENV_FORCE_BACKEND: str = "VOCULINUX_FORCE_BACKEND"
ENV_INJECT_MODIFIER_WAIT: str = "VOCULINUX_INJECT_MODIFIER_WAIT"
diff --git a/src/vocalinux/gateway_embed/health.py b/src/vocalinux/gateway_embed/health.py
index d2d0f72..ded60bf 100644
--- a/src/vocalinux/gateway_embed/health.py
+++ b/src/vocalinux/gateway_embed/health.py
@@ -7,6 +7,8 @@ import urllib.error
import urllib.request
from dataclasses import dataclass
+from .urls import require_http_url
+
logger = logging.getLogger(__name__)
DEFAULT_BASE_URL = "http://127.0.0.1:8765"
@@ -23,8 +25,9 @@ class HealthSnapshot:
def _get_status(url: str, *, timeout: float) -> tuple[int | None, str]:
- request = urllib.request.Request(url, method="GET")
try:
+ request = urllib.request.Request(require_http_url(url), method="GET")
+ # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- scheme checked by require_http_url
with urllib.request.urlopen(request, timeout=timeout) as response:
return int(response.status), ""
except urllib.error.HTTPError as exc:
diff --git a/src/vocalinux/gateway_embed/pairing.py b/src/vocalinux/gateway_embed/pairing.py
index d529544..d7573ad 100644
--- a/src/vocalinux/gateway_embed/pairing.py
+++ b/src/vocalinux/gateway_embed/pairing.py
@@ -10,7 +10,7 @@ from dataclasses import dataclass
from typing import Any, Optional
from ..common_types import Readable
-from .urls import is_unusable_phone_url, reject_loopback_url
+from .urls import is_unusable_phone_url, reject_loopback_url, require_http_url
logger = logging.getLogger(__name__)
@@ -61,7 +61,8 @@ def _authorized_get(
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
- request = urllib.request.Request(url, headers=headers, method="GET")
+ request = urllib.request.Request(require_http_url(url), headers=headers, method="GET")
+ # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- scheme checked by require_http_url
with urllib.request.urlopen(request, timeout=timeout) as response:
return _read_capped(response, limit)
diff --git a/src/vocalinux/gateway_embed/urls.py b/src/vocalinux/gateway_embed/urls.py
index bed871d..d12b492 100644
--- a/src/vocalinux/gateway_embed/urls.py
+++ b/src/vocalinux/gateway_embed/urls.py
@@ -182,3 +182,14 @@ def reject_loopback_url(url: str | None) -> str | None:
if is_unusable_phone_url(url):
return None
return str(url).strip()
+
+
+def require_http_url(url: str) -> str:
+ """Return *url* when it is an http(s) URL, else raise ``ValueError``.
+
+ ``urllib.request.urlopen`` also opens ``file:`` and ``ftp:`` URLs, so every
+ gateway request passes its URL through here before it is opened.
+ """
+ if urlparse(url).scheme not in ("http", "https"):
+ raise ValueError(f"gateway URL must use http or https: {url!r}")
+ return url
diff --git a/src/vocalinux/legacy_import.py b/src/vocalinux/legacy_import.py
index 8e1d1b8..760b71e 100644
--- a/src/vocalinux/legacy_import.py
+++ b/src/vocalinux/legacy_import.py
@@ -18,50 +18,43 @@ from .app_identity import APP_DIR_NAME, LEGACY_APP_DIR_NAME
logger = logging.getLogger(__name__)
-#: Directory basenames, deliberately literal: the legacy name must keep
-#: resolving to the old install even after the rename.
-LEGACY_CONFIG_DIRNAME: str = LEGACY_APP_DIR_NAME
-NEW_CONFIG_DIRNAME: str = APP_DIR_NAME
-
CONFIG_FILENAME: str = "config.json"
TERMS_FILENAME: str = "dictionary.txt"
CORRECTIONS_FILENAME: str = "custom-dictionary-corrections.json"
RESTORE_TOKEN_FILENAME: str = "remote_desktop_restore_token"
-#: ``(subdirectory, filename)`` pairs copied ``config -> config`` or
-#: ``data -> data``. Models are deliberately absent: they re-resolve through
-#: the pinned-checksum download path instead of being migrated.
-CONFIG_PAYLOAD: tuple[tuple[str, str], ...] = (
- ("", CONFIG_FILENAME),
- ("", TERMS_FILENAME),
- ("", CORRECTIONS_FILENAME),
-)
-DATA_PAYLOAD: tuple[tuple[str, str], ...] = (("", RESTORE_TOKEN_FILENAME),)
+#: Files copied from the legacy configuration directory. Models are
+#: deliberately absent: they re-resolve through the pinned-checksum download
+#: path instead of being migrated.
+CONFIG_PAYLOAD: tuple[str, ...] = (CONFIG_FILENAME, TERMS_FILENAME, CORRECTIONS_FILENAME)
+#: Files copied from the legacy data directory.
+DATA_PAYLOAD: tuple[str, ...] = (RESTORE_TOKEN_FILENAME,)
-def _xdg_dir(env_var: str, fallback: str) -> str:
- """Return an XDG base directory (empty values treated as unset)."""
- return os.environ.get(env_var) or os.path.expanduser(fallback)
+def _app_dir(xdg_env_var: str, xdg_fallback: str, app_dir_name: str) -> str:
+ """Return an application directory under an XDG base (empty values unset)."""
+ base = os.environ.get(xdg_env_var) or os.path.expanduser(xdg_fallback)
+ return os.path.join(base, app_dir_name)
def legacy_config_dir() -> str:
"""Return the previous Vocalinux configuration directory."""
- return os.path.join(_xdg_dir("XDG_CONFIG_HOME", "~/.config"), LEGACY_CONFIG_DIRNAME)
+ return _app_dir("XDG_CONFIG_HOME", "~/.config", LEGACY_APP_DIR_NAME)
def new_config_dir() -> str:
"""Return the Voculinux configuration directory."""
- return os.path.join(_xdg_dir("XDG_CONFIG_HOME", "~/.config"), NEW_CONFIG_DIRNAME)
+ return _app_dir("XDG_CONFIG_HOME", "~/.config", APP_DIR_NAME)
def legacy_data_dir() -> str:
"""Return the previous Vocalinux data directory."""
- return os.path.join(_xdg_dir("XDG_DATA_HOME", "~/.local/share"), LEGACY_CONFIG_DIRNAME)
+ return _app_dir("XDG_DATA_HOME", "~/.local/share", LEGACY_APP_DIR_NAME)
def new_data_dir() -> str:
"""Return the Voculinux data directory."""
- return os.path.join(_xdg_dir("XDG_DATA_HOME", "~/.local/share"), NEW_CONFIG_DIRNAME)
+ return _app_dir("XDG_DATA_HOME", "~/.local/share", APP_DIR_NAME)
def _copy_once(src: str, dst: str) -> bool:
@@ -103,14 +96,14 @@ def import_legacy_once() -> list[str]:
return []
imported: list[str] = []
- for _subdir, filename in CONFIG_PAYLOAD:
+ for filename in CONFIG_PAYLOAD:
src = os.path.join(legacy_config, filename)
dst = os.path.join(new_config_dir(), filename)
if _copy_once(src, dst):
imported.append(dst)
legacy_data = legacy_data_dir()
if os.path.isdir(legacy_data):
- for _subdir, filename in DATA_PAYLOAD:
+ for filename in DATA_PAYLOAD:
src = os.path.join(legacy_data, filename)
dst = os.path.join(new_data_dir(), filename)
if _copy_once(src, dst):
diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py
index 0cafaaa..f6e479e 100644
--- a/src/vocalinux/main.py
+++ b/src/vocalinux/main.py
@@ -394,7 +394,7 @@ def main():
# Register cleanup to release lock on exit
atexit.register(single_instance.release_lock)
- # One-time legacy Voculinux import (ticket #18): copies a previous
+ # One-time legacy Vocalinux import (ticket #18): copies a previous
# install's config, dictionary, and portal token into the new locations
# before anything reads them. No-op on every later run.
try:
diff --git a/src/vocalinux/speech_recognition/model_ladder.py b/src/vocalinux/speech_recognition/model_ladder.py
index 681940e..dc4150a 100644
--- a/src/vocalinux/speech_recognition/model_ladder.py
+++ b/src/vocalinux/speech_recognition/model_ladder.py
@@ -8,12 +8,14 @@ Rungs (quantized Turbo first, small floor last)::
large-v3-turbo-q8_0 -> large-v3-turbo-q5_0 -> small.en / small
-Thresholds derive from ticket #19's measured transcribe peaks (1132 MiB
-for q8_0, 846 MiB for q5_0) plus the 512 MiB desktop headroom from the
-locked baseline protocol
-(``scripts/measure_perf_baseline.py::recommend_thresholds``). The
-derivation is pinned by ``tests/test_model_ladder.py`` so a re-measure
-forces a threshold review.
+The thresholds are PROVISIONAL. They are the rule
+``scripts/measure_perf_baseline.py::recommend_thresholds`` applies (measured
+footprint plus the 512 MiB desktop headroom) to the transcribe peaks
+recorded for ticket #19 (1132 MiB for q8_0, 846 MiB for q5_0). Ticket #19
+is still open and its measurement is not recorded in this repository, so
+these numbers are the calibration to replace, not evidence. A re-measure
+changes the constants here, and ``tests/test_model_ladder.py`` pins them so
+that change is deliberate.
Backend order follows the same readiness rule: CUDA ranks before Vulkan
only when a complete architecture-matching CUDA toolkit is installed
@@ -39,7 +41,7 @@ LADDER_RUNGS: tuple[str, ...] = (DEFAULT_RUNG, LOW_MEMORY_RUNG)
#: the baseline protocol adds to measured footprints.
HEADROOM_MIB: float = 512.0
-#: Ticket #19 measured transcribe peaks (MiB) the thresholds below derive from.
+#: Provisional transcribe peaks (MiB) from ticket #19, not yet recorded in the repo.
_MEASURED_Q8_0_PEAK_MIB: float = 1132.0
_MEASURED_Q5_0_PEAK_MIB: float = 846.0
@@ -53,6 +55,16 @@ PREFER_Q5_0_ABOVE_MIB: float = _MEASURED_Q5_0_PEAK_MIB + HEADROOM_MIB
MIN_CUDA_COMPUTE_CAPABILITY: tuple[int, int] = (7, 0)
+#: Backends whose kernels run on a GPU, so their model must fit free VRAM on a
+#: discrete card. A CPU build keeps its model in system RAM.
+GPU_BACKENDS: tuple[str, ...] = ("vulkan", "cuda")
+
+
+def _floor_rung(language_is_english: bool) -> str:
+ """Return the floor rung for the language: English-only pairs with ``.en``."""
+ return FLOOR_RUNG_ENGLISH if language_is_english else FLOOR_RUNG_MULTILINGUAL
+
+
def select_ladder_rung(language_is_english: bool, free_mib: float) -> str:
"""Return the ladder rung that fits ``free_mib`` MiB of free memory.
@@ -62,7 +74,7 @@ def select_ladder_rung(language_is_english: bool, free_mib: float) -> str:
return DEFAULT_RUNG
if free_mib >= PREFER_Q5_0_ABOVE_MIB:
return LOW_MEMORY_RUNG
- return FLOOR_RUNG_ENGLISH if language_is_english else FLOOR_RUNG_MULTILINGUAL
+ return _floor_rung(language_is_english)
def resolve_startup_rung(
@@ -89,7 +101,7 @@ def next_rung_down(rung: str, language_is_english: bool) -> str | None:
if rung == DEFAULT_RUNG:
return LOW_MEMORY_RUNG
if rung == LOW_MEMORY_RUNG:
- return FLOOR_RUNG_ENGLISH if language_is_english else FLOOR_RUNG_MULTILINGUAL
+ return _floor_rung(language_is_english)
return None
@@ -197,6 +209,21 @@ def free_system_ram_mib() -> float | None:
return None
+def free_memory_for_backend(gpu_backend: str) -> tuple[float | None, str]:
+ """Return ``(free MiB, source)`` for the memory the installed build uses.
+
+ ``gpu_backend`` is what the installed whisper.cpp build actually has
+ (``vulkan``, ``cuda`` or ``cpu``). A GPU build reads free VRAM on a
+ discrete NVIDIA card; integrated graphics and CPU builds read free system
+ RAM. ``source`` is ``"VRAM"`` or ``"RAM"`` so callers can say which.
+ """
+ if gpu_backend in GPU_BACKENDS:
+ vram = free_vram_mib()
+ if vram is not None:
+ return vram, "VRAM"
+ return free_system_ram_mib(), "RAM"
+
+
def free_memory_mib() -> float | None:
"""Return free memory in MiB: discrete-GPU VRAM when present, else RAM."""
vram = free_vram_mib()
@@ -242,12 +269,14 @@ __all__ = [
"PREFER_Q8_0_ABOVE_MIB",
"PREFER_Q5_0_ABOVE_MIB",
"MIN_CUDA_COMPUTE_CAPABILITY",
+ "GPU_BACKENDS",
"select_ladder_rung",
"resolve_startup_rung",
"next_rung_down",
"is_ladder_rung",
"cuda_toolkit_ready_from_probe",
"cuda_toolkit_ready",
+ "free_memory_for_backend",
"free_memory_mib",
"preferred_backend_order",
"describe_environment",
diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py
index ce534bf..2de0d23 100644
--- a/src/vocalinux/text_injection/text_injector.py
+++ b/src/vocalinux/text_injection/text_injector.py
@@ -100,6 +100,23 @@ def _is_kde_plasma_session() -> bool:
return "kde" in desktop or "plasma" in desktop
+def _is_niri_session() -> bool:
+ """Return True when the session runs niri (spec #16, niri guard).
+
+ niri exports ``NIRI_SOCKET`` to its children and sets
+ ``XDG_CURRENT_DESKTOP=niri``. Either signal counts, so the niri guard
+ cannot lapse just because a launcher dropped the desktop variables.
+ """
+ if os.environ.get("NIRI_SOCKET"):
+ return True
+ desktop_values = (
+ os.environ.get("XDG_CURRENT_DESKTOP", ""),
+ os.environ.get("XDG_SESSION_DESKTOP", ""),
+ os.environ.get("DESKTOP_SESSION", ""),
+ )
+ return any("niri" in value.lower() for value in desktop_values)
+
+
def _kde_wayland_ibus_hint() -> str:
"""Return the KDE Plasma Wayland IBus setup hint."""
return (
@@ -442,6 +459,11 @@ class TextInjector:
if self.environment != DesktopEnvironment.WAYLAND:
# X11 / XWayland: IBus reaches apps through XIM regardless of DE.
return True
+ if _is_niri_session():
+ # Explicit niri guard (spec #16): IBus reports success while niri's
+ # native clients receive nothing, so the ibus-wayland bridge
+ # exception below never reopens IBus on niri.
+ return False
desktop = " ".join(
os.environ.get(var, "")
for var in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION")
@@ -667,11 +689,11 @@ class TextInjector:
def _resolve_wayland_key_tool(self) -> Optional[str]:
"""Pick (or reuse) the Wayland virtual-keyboard tool for key events.
- Mirrors the preference ``_check_dependencies`` already applies: prefer
- ydotool via uinput once its daemon is (or can be made) ready, and only
- fall back to wtype. Callers that resolve a tool after startup -- IBus
- shortcut routing, BackSpace key events -- use this instead of picking
- their own order.
+ Uses the same rung order as ``_check_dependencies`` (spec #16, ticket
+ #31): direct keysym typing (wtype) first, then the virtual-input tool
+ (ydotool, whose daemon is started when it is the tool in use). Callers
+ that resolve a tool after startup -- IBus shortcut routing, BackSpace
+ key events -- use this instead of picking their own order.
Returns:
The tool name, or None if neither is installed.
@@ -679,16 +701,17 @@ class TextInjector:
if getattr(self, "wayland_tool", None):
return self.wayland_tool
- ydotool_available = shutil.which("ydotool") is not None
wtype_available = shutil.which("wtype") is not None
+ ydotool_available = shutil.which("ydotool") is not None
- # _ensure_ydotoold can block for ~2s, so it stays outside the lock.
- if ydotool_available and self._ensure_ydotoold():
- tool = "ydotool"
- elif ydotool_available and not wtype_available:
- tool = "ydotool"
- elif wtype_available:
+ if wtype_available:
tool = "wtype"
+ elif ydotool_available:
+ # _ensure_ydotoold can block for ~2s, so it stays outside the lock.
+ # A daemonless ydotool is still the tool in use, as in
+ # _check_dependencies; _ensure_ydotoold logs why it is not ready.
+ self._ensure_ydotoold()
+ tool = "ydotool"
else:
return None
@@ -1002,7 +1025,11 @@ class TextInjector:
pinned_tool_present = (forced == "wtype" and wtype_available) or (
forced == "ydotool" and ydotool_available
)
- portal_available = not pinned_tool_present and self._portal_probe()
+ # niri ships no RemoteDesktop backend, so the portal is never probed
+ # there: no probe, no permission prompt, no portal rung (spec #16).
+ portal_available = (
+ not pinned_tool_present and not _is_niri_session() and self._portal_probe()
+ )
if ydotool_available:
_warn_if_ydotool_globally_enabled()
@@ -2975,6 +3002,7 @@ class TextInjector:
type_timeout = max(5, len(chunk) * self._key_delay_seconds(key_delay) * 4)
try:
subprocess.run(
+ # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-tainted-env-args.dangerous-subprocess-use-tainted-env-args -- argv list, no shell; text follows `--`
cmd,
check=True,
stderr=subprocess.PIPE,
@@ -3420,6 +3448,7 @@ class TextInjector:
try:
subprocess.run(
+ # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-tainted-env-args.dangerous-subprocess-use-tainted-env-args -- argv list, no shell; only fixed key codes and a count
cmd,
check=True,
stderr=subprocess.PIPE,
diff --git a/src/vocalinux/ui/keyboard_backends/layout_key_map.py b/src/vocalinux/ui/keyboard_backends/layout_key_map.py
index 5b9f168..1398427 100644
--- a/src/vocalinux/ui/keyboard_backends/layout_key_map.py
+++ b/src/vocalinux/ui/keyboard_backends/layout_key_map.py
@@ -270,6 +270,7 @@ def _kde_layout_memory_paths() -> list[str]:
def _read_kde_layout_memory(path: str) -> tuple[str, list[str]]:
"""Return ``(SwitchMode, currentLayout tokens)`` from a layout_memory.xml."""
try:
+ # nosemgrep: python.lang.security.use-defused-xml-parse.use-defused-xml-parse -- local KDE layout_memory.xml from the user's own session, not remote input
tree = ET.parse(path)
except FileNotFoundError:
return "", []
diff --git a/src/vocalinux/utils/model_checksums.py b/src/vocalinux/utils/model_checksums.py
index 3665776..0b6a46d 100644
--- a/src/vocalinux/utils/model_checksums.py
+++ b/src/vocalinux/utils/model_checksums.py
@@ -32,6 +32,7 @@ import logging
import os
import re
from functools import lru_cache
+from importlib.resources import files # nosemgrep: python37-compatibility-importlib2 -- py>=3.11
from typing import Dict, NamedTuple, Optional
from ..app_identity import VERIFICATION_STAMP_NAME
@@ -72,10 +73,8 @@ def _manifest_text() -> str:
:func:`verify_model_file`, which is the safe direction to fail.
"""
try:
- from importlib.resources import files
-
return files(__package__).joinpath(_MANIFEST_NAME).read_text(encoding="utf-8")
- except (ImportError, TypeError, ModuleNotFoundError):
+ except (TypeError, ModuleNotFoundError):
pass
except (FileNotFoundError, OSError):
return ""
diff --git a/src/vocalinux/utils/vosk_model_info.py b/src/vocalinux/utils/vosk_model_info.py
index 305f4e1..526ff94 100644
--- a/src/vocalinux/utils/vosk_model_info.py
+++ b/src/vocalinux/utils/vosk_model_info.py
@@ -196,6 +196,34 @@ SUPPORTED_LANGUAGES = {
}
+# medium and large ship the same best-available table. Each entry gets a copy so
+# no caller can change one size's languages through the other.
+_BEST_AVAILABLE_LANGUAGES: dict[str, str] = {
+ "en-us": "vosk-model-en-us-0.22",
+ "en-in": "vosk-model-en-in-0.5",
+ "ar": "vosk-model-ar-0.22-linto-1.1.0",
+ "ca": "vosk-model-small-ca-0.4",
+ "zh": "vosk-model-cn-0.22",
+ "cs": "vosk-model-small-cs-0.4-rhasspy",
+ "nl": "vosk-model-nl-spraakherkenning-0.6",
+ "fr": "vosk-model-fr-0.22",
+ "de": "vosk-model-de-0.21",
+ "hi": "vosk-model-hi-0.22",
+ "it": "vosk-model-it-0.22",
+ "ja": "vosk-model-ja-0.22",
+ "ko": "vosk-model-small-ko-0.22",
+ "fa": "vosk-model-fa-0.42",
+ "pl": "vosk-model-small-pl-0.22",
+ "pt": "vosk-model-pt-fb-v0.1.1-pruned",
+ "ru": "vosk-model-ru-0.22",
+ "es": "vosk-model-es-0.42",
+ "sv": "vosk-model-small-sv-rhasspy-0.15",
+ "tr": "vosk-model-small-tr-0.3",
+ "uk": "vosk-model-uk-v3",
+ "vi": "vosk-model-vn-0.4",
+}
+
+
# VOSK model metadata for display and download path resolution.
# When Alphacephei only ships a small model, medium/large point at that same
# zip so size selection never resolves to None (issue #550).
@@ -231,58 +259,12 @@ VOSK_MODEL_INFO = {
"medium": {
"size_mb": 1500,
"desc": "Balanced accuracy/speed",
- "languages": {
- "en-us": "vosk-model-en-us-0.22",
- "en-in": "vosk-model-en-in-0.5",
- "ar": "vosk-model-ar-0.22-linto-1.1.0",
- "ca": "vosk-model-small-ca-0.4",
- "zh": "vosk-model-cn-0.22",
- "cs": "vosk-model-small-cs-0.4-rhasspy",
- "nl": "vosk-model-nl-spraakherkenning-0.6",
- "fr": "vosk-model-fr-0.22",
- "de": "vosk-model-de-0.21",
- "hi": "vosk-model-hi-0.22",
- "it": "vosk-model-it-0.22",
- "ja": "vosk-model-ja-0.22",
- "ko": "vosk-model-small-ko-0.22",
- "fa": "vosk-model-fa-0.42",
- "pl": "vosk-model-small-pl-0.22",
- "pt": "vosk-model-pt-fb-v0.1.1-pruned",
- "ru": "vosk-model-ru-0.22",
- "es": "vosk-model-es-0.42",
- "sv": "vosk-model-small-sv-rhasspy-0.15",
- "tr": "vosk-model-small-tr-0.3",
- "uk": "vosk-model-uk-v3",
- "vi": "vosk-model-vn-0.4",
- },
+ "languages": dict(_BEST_AVAILABLE_LANGUAGES),
},
"large": {
"size_mb": 1500,
"desc": "Same as medium (best available)",
- "languages": {
- "en-us": "vosk-model-en-us-0.22",
- "en-in": "vosk-model-en-in-0.5",
- "ar": "vosk-model-ar-0.22-linto-1.1.0",
- "ca": "vosk-model-small-ca-0.4",
- "zh": "vosk-model-cn-0.22",
- "cs": "vosk-model-small-cs-0.4-rhasspy",
- "nl": "vosk-model-nl-spraakherkenning-0.6",
- "fr": "vosk-model-fr-0.22",
- "de": "vosk-model-de-0.21",
- "hi": "vosk-model-hi-0.22",
- "it": "vosk-model-it-0.22",
- "ja": "vosk-model-ja-0.22",
- "ko": "vosk-model-small-ko-0.22",
- "fa": "vosk-model-fa-0.42",
- "pl": "vosk-model-small-pl-0.22",
- "pt": "vosk-model-pt-fb-v0.1.1-pruned",
- "ru": "vosk-model-ru-0.22",
- "es": "vosk-model-es-0.42",
- "sv": "vosk-model-small-sv-rhasspy-0.15",
- "tr": "vosk-model-small-tr-0.3",
- "uk": "vosk-model-uk-v3",
- "vi": "vosk-model-vn-0.4",
- },
+ "languages": dict(_BEST_AVAILABLE_LANGUAGES),
},
}
diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py
index d4172e6..777eeb9 100644
--- a/src/vocalinux/utils/whispercpp_model_info.py
+++ b/src/vocalinux/utils/whispercpp_model_info.py
@@ -7,7 +7,6 @@ for whisper.cpp, supporting Vulkan, CUDA, and CPU backends.
import ctypes
import logging
-import math
import os
import re
import subprocess
@@ -541,75 +540,37 @@ def detect_cpu_info() -> str:
return "CPU"
-def _parse_cuda_vram_gib(backend_info: str) -> Optional[float]:
- """Parse VRAM in GiB from nvidia-smi-style backend info.
-
- Accepts MiB/MB/GiB/GB (case-insensitive). MiB and MB are converted by
- dividing by 1024. Returns None when no size can be parsed.
- """
- if not isinstance(backend_info, str) or not backend_info:
- return None
- match = re.search(r"(\d+(?:\.\d+)?)\s*(gi?b|mi?b)", backend_info, flags=re.IGNORECASE)
- if not match:
- return None
- amount = float(match.group(1))
- unit = match.group(2).lower()
- if unit in {"mb", "mib"}:
- amount /= 1024.0
- return amount
-
-
def get_recommended_model() -> tuple[str, str]:
- """
- Get the recommended whisper.cpp model based on system configuration.
+ """Recommend a whisper.cpp model from the installed build and free memory.
+
+ Spec #16 / ticket #20: the recommendation is a rung of the model ladder
+ chosen from memory measured now, never from total memory. The memory that
+ counts follows the backend the installed build actually has: free VRAM on a
+ discrete GPU for a Vulkan or CUDA build, free system RAM otherwise (CPU
+ builds, or integrated graphics with no NVIDIA reading). An informational
+ hardware probe no longer decides the model, so a Vulkan-capable Nvidia card
+ reaches the ladder.
+
+ Returns the rung before language adaptation; the floor is returned as the
+ multilingual variant and callers pair it with ``.en`` for English.
Returns:
Tuple of (model_name, reason)
"""
- try:
- import psutil
+ from ..speech_recognition.model_ladder import (
+ FLOOR_RUNG_MULTILINGUAL,
+ free_memory_for_backend,
+ resolve_startup_rung,
+ )
+ from ..speech_recognition.recognition_manager import detect_pywhispercpp_gpu_backend
- ram_gb = math.ceil(psutil.virtual_memory().total / (1024**3))
-
- # Detect available compute backends
- backend, backend_info = detect_compute_backend()
-
- if backend == ComputeBackend.VULKAN:
- # Vulkan can handle larger models efficiently
- if ram_gb >= 8:
- return "small", f"Vulkan GPU with {ram_gb}GB RAM"
- else:
- return "base", f"Vulkan GPU with {ram_gb}GB RAM"
- elif backend == ComputeBackend.CUDA:
- # A driver without the complete toolkit cannot run the CUDA
- # build, so the VRAM ladder below would be unreachable. Fall
- # through to the Vulkan/CPU branches instead (ticket #20).
- from ..speech_recognition.model_ladder import cuda_toolkit_ready
-
- if cuda_toolkit_ready():
- vram_gb = _parse_cuda_vram_gib(backend_info or "")
- if vram_gb is not None:
- if vram_gb >= 8:
- return "medium", f"CUDA GPU with {vram_gb:g}GB VRAM"
- if vram_gb >= 4:
- return "small", f"CUDA GPU with {vram_gb:g}GB VRAM"
- return "base", "CUDA GPU with limited VRAM"
- return "small", "CUDA GPU detected"
- backend = ComputeBackend.CPU
- if backend == ComputeBackend.CPU:
- # CPU-only recommendations based on RAM
- if ram_gb >= 16:
- return "base", f"{ram_gb}GB RAM - CPU inference"
- elif ram_gb >= 8:
- return "tiny", f"{ram_gb}GB RAM - optimized for speed"
- else:
- return "tiny", f"Limited RAM ({ram_gb}GB) - fastest model"
-
- except ImportError:
- logger.debug("psutil not available for system detection")
-
- # Default recommendation
- return "tiny", "Default recommendation"
+ gpu_backend = detect_pywhispercpp_gpu_backend()
+ free_mib, source = free_memory_for_backend(gpu_backend)
+ if free_mib is None:
+ logger.info("Free memory unreadable; recommending the floor model")
+ return FLOOR_RUNG_MULTILINGUAL, "Free memory unreadable; floor model"
+ rung = resolve_startup_rung("", False, free_mib)
+ return rung, f"{rung}: {free_mib:.0f} MiB free {source} ({gpu_backend} build)"
def _model_file_path(model_name: str) -> str:
diff --git a/tests/conftest.py b/tests/conftest.py
index b1f9d9c..e6c4e88 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -41,6 +41,9 @@ for _var in (
"XDG_SESSION_DESKTOP",
"DESKTOP_SESSION",
"WAYLAND_DISPLAY",
+ # niri exports this to every child; it would turn on the niri guard
+ # (text_injector._is_niri_session) in tests that model another desktop.
+ "NIRI_SOCKET",
):
os.environ.pop(_var, None)
atexit.register(shutil.rmtree, _XDG_ISOLATION_DIR, ignore_errors=True)
diff --git a/tests/test_host_process.py b/tests/test_host_process.py
index 705c980..de8ef10 100644
--- a/tests/test_host_process.py
+++ b/tests/test_host_process.py
@@ -14,6 +14,10 @@ from vocalinux.utils.host_process import host_env
REPO_ROOT = Path(__file__).resolve().parents[1]
SOURCE = REPO_ROOT / "src" / "vocalinux"
+#: The measurement script drives the app's own probes (nvidia-smi, wtype, the
+#: injector) on the target machine, so it obeys the same rule as the package.
+#: Build-tool scripts (uv, git, cmake) run outside any bundle and are exempt.
+MEASUREMENT_SCRIPT = REPO_ROOT / "scripts" / "measure_perf_baseline.py"
SUBPROCESS_FUNCS = {"run", "Popen", "check_output", "check_call", "call"}
@@ -78,7 +82,7 @@ def test_host_binaries_get_a_stripped_environment_and_our_own_python_does_not():
dies on the wrong GLib, and our own interpreter stripped of it cannot find
its stdlib or GI stack."""
inherit, stripped = [], []
- for path in sorted(SOURCE.rglob("*.py")):
+ for path in sorted([*SOURCE.rglob("*.py"), MEASUREMENT_SCRIPT]):
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if not _is_subprocess_call(node):
continue
diff --git a/tests/test_injection_ladder.py b/tests/test_injection_ladder.py
index 2415a3b..ac52ac7 100644
--- a/tests/test_injection_ladder.py
+++ b/tests/test_injection_ladder.py
@@ -36,7 +36,11 @@ from unittest.mock import MagicMock, patch
import pytest
from vocalinux.text_injection.remote_desktop_portal import RemoteDesktopPortalError
-from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
+from vocalinux.text_injection.text_injector import (
+ DesktopEnvironment,
+ TextInjector,
+ _is_niri_session,
+)
_TI = "vocalinux.text_injection.text_injector"
@@ -187,6 +191,105 @@ class TestNiriLadder:
assert runs == [["wtype", "--", "hi"]]
+class TestNiriGuard:
+ """Autodetection on niri never selects the portal or IBus (spec #16, c4).
+
+ The guard is explicit, so it does not depend on the portal probe failing
+ or on the ibus-wayland bridge being absent. Explicit user pins stay
+ honoured as escape hatches and are covered elsewhere.
+ """
+
+ def test_niri_never_selects_portal_even_when_a_backend_answers(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """A RemoteDesktop backend that answers on niri must still be skipped."""
+ injector = _selecting_injector(
+ monkeypatch,
+ desktop="niri",
+ tools={"wtype": "/usr/bin/wtype"},
+ portal_available=True,
+ )
+ assert injector.wayland_tool == "wtype"
+ assert injector._portal is None
+
+ def test_niri_never_selects_ibus_even_with_ibus_wayland_bridge_running(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """The ibus-wayland bridge exception does not reopen IBus on niri."""
+ with patch(f"{_TI}.IBusTextInjector") as mock_ibus_class:
+ injector = _selecting_injector(
+ monkeypatch,
+ desktop="niri",
+ tools={"wtype": "/usr/bin/wtype"},
+ portal_available=False,
+ ibus_available=True,
+ ibus_bridge_running=True,
+ )
+ mock_ibus_class.assert_not_called()
+ assert injector._ibus_injector is None
+ assert injector.wayland_tool == "wtype"
+
+ def test_bridge_check_is_false_on_niri_even_with_bridge_running(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """The compositor-bridge predicate itself answers no on niri."""
+ monkeypatch.setenv("XDG_CURRENT_DESKTOP", "niri")
+ monkeypatch.setattr(
+ TextInjector, "_ibus_wayland_bridge_running", staticmethod(lambda: True)
+ )
+ injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
+ assert injector._wayland_compositor_bridges_ibus() is False
+
+ def test_niri_is_recognised_from_its_session_socket(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """NIRI_SOCKET identifies niri even when the desktop variable is unset."""
+ for var in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ monkeypatch.setenv("NIRI_SOCKET", "/run/user/1000/niri.wayland-1.1.sock")
+ assert _is_niri_session() is True
+
+ def test_other_desktops_are_not_niri(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A GNOME session keeps the portal and IBus eligible."""
+ monkeypatch.delenv("NIRI_SOCKET", raising=False)
+ monkeypatch.setenv("XDG_CURRENT_DESKTOP", "GNOME")
+ assert _is_niri_session() is False
+
+
+class TestKeyEventToolOrder:
+ """Key events after startup follow the same rung order as autodetection.
+
+ Direct typing (wtype) outranks the virtual-input tool (ydotool) here too,
+ so BackSpace and IBus shortcut routing never silently switch tools.
+ """
+
+ @staticmethod
+ def _tools(monkeypatch: pytest.MonkeyPatch, names: tuple) -> None:
+ monkeypatch.setattr("shutil.which", lambda cmd: f"/usr/bin/{cmd}" if cmd in names else None)
+
+ def test_wtype_outranks_a_ready_ydotool(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ self._tools(monkeypatch, ("wtype", "ydotool"))
+ ensure = MagicMock(return_value=True)
+ monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: ensure())
+ injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
+ assert injector._resolve_wayland_key_tool() == "wtype"
+ assert injector.wayland_tool == "wtype"
+ ensure.assert_not_called()
+
+ def test_ydotool_is_used_only_when_wtype_is_absent(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ self._tools(monkeypatch, ("ydotool",))
+ monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: True)
+ injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
+ assert injector._resolve_wayland_key_tool() == "ydotool"
+
+ def test_no_key_tool_when_neither_is_installed(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ self._tools(monkeypatch, ())
+ injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
+ assert injector._resolve_wayland_key_tool() is None
+
+
class TestPortalFirstWhereImplemented:
"""GNOME/KDE implement RemoteDesktop, so the portal leads there."""
diff --git a/tests/test_model_ladder.py b/tests/test_model_ladder.py
index c98a16f..296b8ff 100644
--- a/tests/test_model_ladder.py
+++ b/tests/test_model_ladder.py
@@ -2,9 +2,10 @@
All behavior is exercised with mocked probes and loaders: no GPU, no
network, no hardware. The threshold-derivation test pins the rung
-thresholds to ticket #19's measured transcribe peaks (1132 MiB for q8_0,
-846 MiB for q5_0) plus the 512 MiB protocol headroom, so any re-measure
-forces a threshold review.
+thresholds to the provisional transcribe peaks (1132 MiB for q8_0, 846 MiB
+for q5_0, ticket #19, not yet recorded in the repo) plus the 512 MiB
+protocol headroom. Replacing them with a recorded measurement is a
+deliberate change to this test.
"""
from unittest.mock import MagicMock, patch
@@ -294,27 +295,33 @@ class TestStartupRungSelection:
assert manager.get_model_size_for_engine("whisper_cpp") == "medium.en"
-class TestRecommendationGate:
- """Driver-only CUDA demotes to CPU logic; toolkit keeps the VRAM ladder."""
+class TestFreeMemoryForBackend:
+ """The memory reading follows the installed build (spec #16, ticket #20)."""
- def _recommend(self, backend, toolkit):
- from vocalinux.utils import whispercpp_model_info as info
+ def test_vulkan_build_reads_discrete_vram_when_present(self) -> None:
+ with (
+ patch.object(model_ladder, "free_vram_mib", return_value=2600.0),
+ patch.object(model_ladder, "free_system_ram_mib", return_value=9000.0),
+ ):
+ assert model_ladder.free_memory_for_backend("vulkan") == (2600.0, "VRAM")
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3))
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch.object(info, "detect_compute_backend", return_value=backend):
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=toolkit,
- ):
- return info.get_recommended_model()
+ def test_cuda_build_reads_discrete_vram_when_present(self) -> None:
+ with patch.object(model_ladder, "free_vram_mib", return_value=1500.0):
+ assert model_ladder.free_memory_for_backend("cuda") == (1500.0, "VRAM")
- def test_driver_only_cuda_demotes_to_cpu(self) -> None:
- model, _reason = self._recommend(("cuda", "NVIDIA RTX 2050 (4GB)"), False)
- assert model == "base"
+ def test_gpu_build_without_an_nvidia_reading_reads_ram(self) -> None:
+ """Integrated graphics: no discrete VRAM reading, so system RAM decides."""
+ with (
+ patch.object(model_ladder, "free_vram_mib", return_value=None),
+ patch.object(model_ladder, "free_system_ram_mib", return_value=6000.0),
+ ):
+ assert model_ladder.free_memory_for_backend("vulkan") == (6000.0, "RAM")
- def test_toolkit_cuda_keeps_vram_ladder(self) -> None:
- model, reason = self._recommend(("cuda", "NVIDIA RTX 2050 (4GB)"), True)
- assert model == "small"
- assert "VRAM" in reason
+ def test_cpu_build_never_reads_vram(self) -> None:
+ vram = MagicMock(return_value=2600.0)
+ with (
+ patch.object(model_ladder, "free_vram_mib", vram),
+ patch.object(model_ladder, "free_system_ram_mib", return_value=1200.0),
+ ):
+ assert model_ladder.free_memory_for_backend("cpu") == (1200.0, "RAM")
+ vram.assert_not_called()
diff --git a/tests/test_release_integrity.py b/tests/test_release_integrity.py
index af007e4..30c5129 100644
--- a/tests/test_release_integrity.py
+++ b/tests/test_release_integrity.py
@@ -118,12 +118,11 @@ def test_release_publication_cannot_bypass_the_wheel_gate():
"publish-aur",
"publish-aur-bin",
"publish-flatpak-remote",
- "build-xbps",
):
assert "build-and-release" in _needs(jobs[name]), name
checksums = jobs["publish-checksums"]
assert "needs.build-and-release.result == 'success'" in checksums
- pattern = "'@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64|xbps-repo)'"
+ pattern = "'@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64)'"
assert f"pattern: {pattern}" in checksums
@@ -179,10 +178,12 @@ def test_the_manifest_covers_every_kind_of_artifact_we_publish():
assert checksum_line, "publish-checksums does not generate SHA256SUMS"
for pattern in ("*.whl", "*.tar.gz", "*.AppImage", "*.flatpak", "*.snap"):
assert pattern in checksum_line.group(1), f"{pattern} is published but unchecksummed"
- # The signed xbps repo hashes from its own directories (bare names, same
- # as the flat files the release attaches), alongside the client key.
- for pattern in ("*.x86_64.xbps", "*-repodata*", "FINGERPRINT", "*.plist"):
- assert pattern in block, f"{pattern} is published but unchecksummed"
+ # The signed xbps repository is published by the Gitea tag workflow, not
+ # attached to this release, so the manifest must not require or hash it:
+ # a pattern for files that never arrive would fail every release.
+ assert "xbps" not in _without_comments(block).lower()
+ for pattern in ("*.x86_64.xbps", "*-repodata*", "FINGERPRINT"):
+ assert pattern not in block, f"{pattern} is not a GitHub release artifact"
def test_provenance_is_generated_from_the_published_manifest():
diff --git a/tests/test_rename_sweep.py b/tests/test_rename_sweep.py
index c07a725..8e916ff 100644
--- a/tests/test_rename_sweep.py
+++ b/tests/test_rename_sweep.py
@@ -1,24 +1,34 @@
-"""Ticket #18 + #24: offline naming-sweep — no old-name strings on surfaces.
+"""Ticket #18 + #24: offline naming-sweep: no old-name strings on surfaces.
The fork takes its own name everywhere a user can see it. Anything still
-spelling the old name must be on the allowlist below:
+spelling the old name must match a retained surface below. Each rule is
+scoped to what the surface is, not to a word that happens to co-occur:
-- upstream provenance (``VocaHQ/vocalinux`` URLs),
-- the retained internal Python module name (``src/vocalinux`` paths,
- ``vocalinux.*`` imports/attributes, ``-m vocalinux.main``),
-- retained internal class names (``VocalinuxEngine*``, ``VocalinuxDBusService``),
-- one-time-legacy import sources (old config/dictionary/bus literals),
+- upstream provenance: ``VocaHQ/vocalinux`` and ``jatinkrmalik/vocalinux``
+ slugs, and prose that labels the old name as upstream or as the fork's
+ origin ("Vocalinux (upstream)", "a hard fork of Vocalinux");
+- the retained internal Python package (ticket #17): ``src/vocalinux`` paths,
+ dotted module references to modules that exist under ``src/vocalinux``,
+ import and ``-m`` forms, ``vocalinux/.py`` module paths, and the
+ retained CamelCase class names (``VocalinuxEngine``, ...);
+- the package name as a quoted tooling or test identifier (package lists,
+ mock module names, fixture trees), only in tooling and test paths;
+- the one-time legacy import sources (ticket #18), only in the files that
+ implement or test the import, and only on lines that say so;
+- pre-rename guards that name the old files they reject (ticket #28);
- generated or historical files (``uv.lock``, ``requirements/*.txt``,
- ``CHANGELOG.md``),
-- web upstream surfaces (ticket #24): upstream GitHub/raw/nix flake URLs,
- nix flake API identifiers, and the nixos-page fork-status note that names
- the upstream flake as installing Vocalinux rather than Voculinux.
+ ``CHANGELOG.md``) and the git worktree pointer file ``.git``;
+- web upstream surfaces (ticket #24): the nix flake snippets on the nixos page.
+
+``tests/test_rename_sweep.py`` also checks the allowlist itself, so a loosened
+rule fails here before it lets an old name onto a user-visible surface.
"""
import os
import re
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+SRC_PACKAGE = os.path.join(REPO_ROOT, "src", "vocalinux")
OLD_NAME = re.compile(r"vocalinux", re.IGNORECASE)
@@ -34,7 +44,9 @@ SKIP_DIRS = {
"out",
}
SKIP_SUFFIXES = (".egg-info",)
-SKIP_FILES = {"uv.lock", "CHANGELOG.md"}
+#: ``.git`` is a file in linked worktrees (``gitdir: .../worktrees/...``), so it
+#: is skipped by name in addition to being a skipped directory.
+SKIP_FILES = {"uv.lock", "CHANGELOG.md", ".git"}
SKIP_PREFIXES = ("requirements/",)
BINARY_EXTS = (
".wav",
@@ -60,98 +72,119 @@ BINARY_EXTS = (
#: (matched in _allowed) may still spell the old name there.
WEB_OWNED = {"web/src/app/page.tsx", "web/package.json"}
+#: Top-level modules and packages of the retained package, read from the tree
+#: so a dotted reference to a module that does not exist is not allowlisted.
+RETAINED_MODULE_HEADS = frozenset(
+ [name[:-3] for name in os.listdir(SRC_PACKAGE) if name.endswith(".py")]
+ + [
+ name
+ for name in os.listdir(SRC_PACKAGE)
+ if os.path.isdir(os.path.join(SRC_PACKAGE, name)) and not name.startswith("__")
+ ]
+) - {"__init__"}
+
+_UPSTREAM_SLUG = re.compile(r"(?:VocaHQ|jatinkrmalik)/vocalinux", re.IGNORECASE)
+_UPSTREAM_LABEL = re.compile(
+ r"upstream[^\n]{0,40}vocalinux|vocalinux[^\n]{0,40}upstream|fork of vocalinux",
+ re.IGNORECASE,
+)
+_DOTTED_MODULE = re.compile(
+ r"\bvocalinux\.(?:__\w+__|"
+ + "|".join(sorted(RETAINED_MODULE_HEADS, key=len, reverse=True))
+ + r")\b"
+)
+_IMPORT_FORM = re.compile(r"(?:^|[\s\"'])(?:from|import)\s+vocalinux\b|-m\s+vocalinux\b")
+#: Prose that names the retained package as such (ticket #17's inventory).
+_RETAINED_PROSE = re.compile(
+ r"retained[^\n]{0,60}vocalinux|vocalinux[^\n]{0,60}(?:retained|import package|module name)"
+ r"|(?:import package|module name)[^\n]{0,60}vocalinux",
+ re.IGNORECASE,
+)
+#: Prose that names the old install as the source of the one-time import.
+_LEGACY_PROSE = re.compile(r"\b(?:legacy|previous|old) Vocalinux\b", re.IGNORECASE)
+_MODULE_PATH = re.compile(
+ r"src/vocalinux\b"
+ r"|site-packages/vocalinux\b"
+ r"|\"src\"\s*/\s*\"vocalinux\""
+ r"|\bvocalinux/[\w./-]*\.py\b"
+ r"|\"vocalinux/"
+ r"|\bsite\s*/\s*\"vocalinux\""
+ r"|\"platform/vocalinux\""
+)
+_CAMEL_CLASS = re.compile(r"Vocalinux[A-Z]\w*")
+#: Quoted package names in tooling and tests (package lists, mock targets,
+#: ``"vocalinux."`` prefixes), and scanner regex literals in web tests.
+_PACKAGE_SEGMENT = re.compile(r"[\"']vocalinux(?:[\"']|\.)|/vocalinux/[a-z]*")
+_TOOLING_PREFIXES = (
+ "tests/",
+ "web/src/lib/__tests__/",
+ "scripts/",
+ "snap/local/",
+ ".flake8",
+ "MANIFEST.in",
+ "install.sh",
+ "install.d/",
+ "uninstall.sh",
+ "pyproject.toml",
+ "justfile",
+)
+#: Files that implement or test the one-time legacy import (ticket #18), and the
+#: pre-XDG compatibility path the custom dictionary keeps for stored settings.
+_LEGACY_SOURCE_FILES = frozenset(
+ {
+ "src/vocalinux/legacy_import.py",
+ "src/vocalinux/app_identity.py",
+ "src/vocalinux/custom_dictionary.py",
+ "tests/test_legacy_import.py",
+ "tests/test_app_identity.py",
+ "tests/test_custom_dictionary.py",
+ "tests/test_rename_sweep.py",
+ }
+)
+_LEGACY_LINE = re.compile(
+ r"legacy|previous|pre-XDG|\.config/vocalinux|\.config\"?\s*/\s*\"vocalinux\""
+ r"|\.local/share/vocalinux",
+ re.IGNORECASE,
+)
+#: Pre-rename file names a packaging guard rejects (ticket #28).
+_PRE_RENAME_GUARD = re.compile(r"usr/share/(?:applications|icons/[^\s\"]*)/vocalinux")
+_NIX_FLAKE_IDENTIFIER = re.compile(
+ r"packages\.\.|apps\.\.|nixosModules\.|homeManagerModules\.|programs\."
+ r"|\bflake\b|\binputs?\b|outputs = \{|nixpkgs, |pkgs\.vocalinux|nix run|nix profile"
+ r"|github:VocaHQ",
+ re.IGNORECASE,
+)
+
def _allowed(path: str, line: str) -> bool:
- """Return True when an old-name match is an allowlisted surface."""
- low = line.lower()
- if "vocahq/" in low or "jatinkrmalik/vocalinux" in low:
+ """Return True when an old-name match is an allowlisted retained surface."""
+ if path == "tests/test_rename_sweep.py":
+ # This file defines the allowlist and quotes the old name to do so.
return True
- if "vocalinux" in low and "voculinux" in low:
- # Migration-compat shims that name both installs at once.
+ if _UPSTREAM_SLUG.search(line) or _UPSTREAM_LABEL.search(line):
return True
- if "upstream" in low:
- # Upstream-provenance mentions that explicitly label the old name
- # as upstream (fork-status notes, provenance cards).
+ if _MODULE_PATH.search(line) or _DOTTED_MODULE.search(line):
return True
- if "/{name}/vocalinux" in line:
- # Upstream repo-name template (retained provenance).
+ if _IMPORT_FORM.search(line) or _CAMEL_CLASS.search(line) or _RETAINED_PROSE.search(line):
return True
- if path == "snap/local/check_runtime.py" and '"vocalinux"' in line:
- # Smoke imports resolve the retained import package.
- return True
- if re.search(r'vocalinux\.["a-z_]', line):
- return True
- if line.strip().rstrip(",").lstrip("/ ") == '"vocalinux"':
- # Retained module-dir path segment element in tests and tooling
- # (single-line and os.path.join element forms).
- return True
- if 'site / "vocalinux"' in line or '"platform/vocalinux"' in line:
- # Fixture module trees named after the retained import package.
- return True
- if '"vocalinux/' in line:
- # Fixture module file paths under the retained package name.
- return True
- if "/vocalinux/" in line or "src/vocalinux" in line:
- return True
- if "-m vocalinux" in line or "import vocalinux" in line:
- return True
- if "VocalinuxEngine" in line or "VocalinuxDBusService" in line:
- return True
- if "cmdline" in line:
- return True
- if " - vocalinux" in line:
- # Log logger names keep the retained module path.
- return True
- if path in (
- "src/vocalinux/legacy_import.py",
- "tests/test_legacy_import.py",
- ):
- return True
- if "LEGACY" in line or "legacy" in line:
- return True
- if "~/.config/vocalinux" in line or '".config" / "vocalinux"' in line:
- # Documented one-time-import source (ticket #18).
- return True
- if "from vocalinux" in line:
- return True
- if '"src"' in line and '"vocalinux"' in line:
- # Retained module-dir path segments in tests and tooling.
+ if _LEGACY_PROSE.search(line):
return True
if path == "web/src/app/install/nixos/page.tsx":
- # The Nix flake is upstream's: flake URL, input/output names, module
- # paths, and programs.* options spell the retained upstream API.
- # The page carries a fork-status note stating those snippets install
- # Vocalinux (upstream), not Voculinux.
+ # The Nix flake is upstream's: flake URL, input and output names and
+ # programs.* options spell the retained upstream API. The page carries
+ # a fork-status note saying those snippets install Vocalinux, not
+ # Voculinux, so only flake-shaped lines are allowed here.
+ return bool(_NIX_FLAKE_IDENTIFIER.search(line))
+ if path in _LEGACY_SOURCE_FILES and _LEGACY_LINE.search(line):
return True
- if path == "tests/test_rename_sweep.py":
+ if path.startswith(_TOOLING_PREFIXES) and _PACKAGE_SEGMENT.search(line):
return True
- if path == "pyproject.toml" and re.search(r'"vocalinux[."\'\]]', line):
+ if path == "packaging/void/container-main.sh" and _PRE_RENAME_GUARD.search(line):
return True
- if path == "pyproject.toml" and "vocalinux.version" in line:
- return True
- 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 path == "tests/test_void_app_template.py" and "usr/bin/vocalinux" in line:
- # Pre-rename sdist launchers the template promotes at install time
- # (ticket #28); dropped at the first post-rename release bump.
- return True
- if path == "packaging/void/container-main.sh" and (
- "usr/share/applications/vocalinux" in line or 'version("vocalinux")' in line
+ if path == "src/vocalinux/utils/update_checker.py" and (
+ 'casefold() == "vocalinux"' in line or '"/{name}/vocalinux"' in line
):
- # Pre-rename sdist compat (ticket #28): the payload guard names the
- # old file names it rejects, and the import smoke queries the old
- # distribution name. Dropped at the first post-rename release bump.
- return True
- if '== "vocalinux"' in line or '.startswith("vocalinux' in line:
- # Retained module identity comparisons in test isolation helpers.
+ # Upstream repository identity the update checker recognises.
return True
return False
@@ -194,3 +227,34 @@ def test_owned_web_files_carry_new_name() -> None:
with open(os.path.join(REPO_ROOT, rel), encoding="utf-8") as handle:
text = handle.read()
assert "Voculinux" in text or "voculinux" in text, rel
+
+
+def test_allowlist_denies_user_facing_old_names() -> None:
+ """A user-facing old name is never allowlisted, wherever it sits."""
+ denied = [
+ ("web/src/app/page.tsx", "
Install vocalinux today
"),
+ ("README.md", "Launch vocalinux from your app menu."),
+ ("docs/UPDATE.md", "Your settings live in ~/.local/share/vocalinux."),
+ ("src/vocalinux/ui/tray_indicator.py", 'TRAY_TITLE = "vocalinux"'),
+ ("src/vocalinux/ui/tray_indicator.py", 'ICON = "vocalinux-microphone"'),
+ ("packaging/void/srcpkgs/voculinux/template", "pkgname=vocalinux"),
+ ("docs/INSTALL.md", "Open vocalinux.desktop from the menu."),
+ ]
+ for path, line in denied:
+ assert not _allowed(path, line), f"{path}: {line!r} must not be allowlisted"
+
+
+def test_allowlist_keeps_the_retained_surfaces() -> None:
+ """Upstream provenance, the retained package and the legacy import stay allowed."""
+ kept = [
+ ("README.md", "Upstream: https://github.com/VocaHQ/vocalinux/issues"),
+ ("web/src/app/page.tsx", "Voculinux is a hard fork of Vocalinux for Void Linux."),
+ ("web/src/app/install/[distro]/page.tsx", "installs Vocalinux (upstream), not Voculinux."),
+ ("src/vocalinux/ui/tray_indicator.py", "from vocalinux.utils import paths"),
+ ("src/vocalinux/app_identity.py", 'LEGACY_BUS_NAME: str = "com.vocalinux.Vocalinux"'),
+ ("tests/test_app_identity.py", "from vocalinux import app_identity"),
+ ("packaging/void/container-main.sh", 'grep -E "usr/share/applications/vocalinux"'),
+ ("install.sh", 'local PKG_NAME="vocalinux"'),
+ ]
+ for path, line in kept:
+ assert _allowed(path, line), f"{path}: {line!r} is a retained surface"
diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py
index 027cee0..6b486f0 100644
--- a/tests/test_text_injector.py
+++ b/tests/test_text_injector.py
@@ -2804,9 +2804,14 @@ class TestCompositorIBusBridging(unittest.TestCase):
self.assertFalse(injector._wayland_compositor_bridges_ibus(), desktop)
def test_unbridged_compositors_use_ibus_when_bridge_running(self):
- """ibus-wayland supplies the input-method-v2 relay these compositors lack (#607)."""
+ """ibus-wayland supplies the input-method-v2 relay these compositors lack (#607).
+
+ niri is deliberately absent: the explicit niri guard (spec #16) keeps
+ IBus off there whatever the bridge probe says (see TestNiriGuard in
+ tests/test_injection_ladder.py).
+ """
injector = self._bare_injector()
- for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "niri", "river"):
+ for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "river"):
with patch.dict(
"os.environ",
{
diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py
index d9bf18f..98d5518 100644
--- a/tests/test_text_injector_ext.py
+++ b/tests/test_text_injector_ext.py
@@ -1505,25 +1505,25 @@ class TestInjectKeyboardShortcutRouting(unittest.TestCase):
obj._inject_shortcut_with_xdotool.assert_called_once_with("ctrl+z")
obj._inject_shortcut_with_wayland_tool.assert_not_called()
- def test_wayland_ibus_prefers_ydotool(self):
- """Reaches the real Wayland helper: a stub would pass while it's broken."""
+ def test_wayland_ibus_prefers_wtype_over_ready_ydotool(self):
+ """Direct typing outranks the virtual-input tool (spec #16, ticket #31)."""
obj = _make_injector(self._env("WAYLAND_IBUS"))
obj._inject_shortcut_with_xdotool = MagicMock(return_value=True)
obj._ydotool_legacy_named_keys = False
obj._ensure_ydotoold = MagicMock(return_value=True)
obj._wait_for_modifiers_released = MagicMock()
+ which = lambda c: f"/usr/bin/{c}" if c in ("wtype", "ydotool") else None # noqa: E731
- with patch("shutil.which", return_value="/usr/bin/x") as which:
+ with patch("shutil.which", side_effect=which):
with patch("subprocess.run") as mock_run:
self.assertTrue(obj._inject_keyboard_shortcut("ctrl+a"))
- # _check_dependencies prefers the uinput helper once its daemon is ready.
- self.assertTrue(which.called)
- self.assertEqual(obj.wayland_tool, "ydotool")
+ self.assertEqual(obj.wayland_tool, "wtype")
self.assertEqual(
mock_run.call_args[0][0],
- ["ydotool", "key", "29:1", "30:1", "30:0", "29:0"],
+ ["wtype", "-M", "ctrl", "-k", "a", "-m", "ctrl"],
)
+ obj._ensure_ydotoold.assert_not_called()
obj._inject_shortcut_with_xdotool.assert_not_called()
def test_wayland_ibus_falls_back_to_wtype(self):
@@ -1638,14 +1638,15 @@ class TestPressBackspace(unittest.TestCase):
self.assertTrue(obj.press_backspace(1))
self.assertEqual(mock_run.call_args[0][0], ["wtype", "-k", "BackSpace"])
- def test_prefers_ydotool_when_both_tools_are_present(self):
- """_check_dependencies prefers the uinput helper; match that order."""
+ def test_prefers_wtype_when_both_tools_are_present(self):
+ """Key events use the same rung order as startup: wtype before ydotool."""
obj = _make_backspace_injector("WAYLAND_IBUS", legacy=False)
with patch("subprocess.run") as mock_run:
with patch("shutil.which", return_value="/usr/bin/x"):
self.assertTrue(obj.press_backspace(1))
- self.assertEqual(obj.wayland_tool, "ydotool")
- self.assertEqual(mock_run.call_args[0][0][:2], ["ydotool", "key"])
+ self.assertEqual(obj.wayland_tool, "wtype")
+ self.assertEqual(mock_run.call_args[0][0][0], "wtype")
+ obj._ensure_ydotoold.assert_not_called()
def test_waits_for_modifiers_before_resolving_the_tool(self):
"""A held PTT modifier turns BackSpace into Ctrl+BackSpace."""
diff --git a/tests/test_void_app_template.py b/tests/test_void_app_template.py
index 934462c..490c03a 100644
--- a/tests/test_void_app_template.py
+++ b/tests/test_void_app_template.py
@@ -60,10 +60,32 @@ EXTRA_VOID_DEPS = (
"python3",
"gtk+3",
"libayatana-appindicator",
- "ibus",
"gobject-introspection",
)
+#: Command-line helpers the app runs at runtime, and the Void package each
+#: comes from. The release gate needs them on a packaged install (ticket
+#: #27; spec #16 "Void packaging"). ydotool is deliberately absent: it is not
+#: packaged for Void, so its fallback rung does not exist there.
+RUNTIME_HELPER_PACKAGES = {
+ "pipewire": "pw-record and pw-dump for PipeWire capture",
+ "wtype": "direct keysym typing, the first rung on niri",
+ "wl-clipboard": "wl-copy and wl-paste for the clipboard paths",
+ "xdotool": "X11 and XWayland injection",
+ "pulseaudio-utils": "pactl source listing",
+}
+
+#: The checksum the template may carry before the fork's release exists. It is
+#: explicit and fails closed in xbps-src; it is never the upstream digest.
+PENDING_CHECKSUM = "PENDING_FORK_RELEASE_SHA256"
+
+#: sha256 of the upstream 0.18.5 archive. The template must never pin it: the
+#: package is the fork's own release, not a copy of upstream's.
+UPSTREAM_0_18_5_SHA256 = "1def4d8f0f14f2df2f2fee64ca29e6865e3d82e551f7d465c2a78ec6d4d72b1d"
+
+#: The fork's release host. Every distfile and the homepage live here.
+FORK_RELEASE_HOST = "https://git.bongbetic.com/xavierk/voculinux"
+
#: 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
@@ -78,8 +100,12 @@ VERIFIED_VOID_PACKAGES = frozenset(
"python3-cairo",
"gtk+3",
"libayatana-appindicator",
- "ibus",
"gobject-introspection",
+ "pipewire",
+ "wtype",
+ "wl-clipboard",
+ "xdotool",
+ "pulseaudio-utils",
"python3-numpy",
"python3-requests",
"python3-pysocks",
@@ -208,7 +234,7 @@ def test_template_metadata_shape() -> None:
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 variables["homepage"] == FORK_RELEASE_HOST
assert len(variables["short_desc"]) <= 72
assert re.fullmatch(r"[0-9][A-Za-z0-9.]*", variables["version"])
@@ -219,29 +245,35 @@ def test_template_version_matches_version_py() -> None:
def test_distfiles_sources_the_canonical_release_by_checksum() -> None:
- """One release-asset distfile, checksum-pinned; never a git snapshot."""
+ """One fork release-asset distfile, checksum-verified; never upstream or git."""
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"
+ assert distfiles == (
+ f"{FORK_RELEASE_HOST}/releases/download/v${{version}}/"
+ f"{variables['pkgname']}-${{version}}.tar.gz"
+ ), "distfiles must be the fork's own release sdist for this version"
+ assert "github.com" not in distfiles and "VocaHQ" not in distfiles, "no upstream archive"
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.
- # xbps-src moves a single-top-dir archive to pkgname-version itself
- # (common/hooks/do-extract/00-distfiles.sh), so the pre-rename archive
- # root needs no override — and wrksrc is deprecated, xlint rejects it.
- root = Path(distfiles.rsplit("/", 1)[1].replace("${version}", version))
- assert root.suffixes[-2:] == [".tar", ".gz"]
- assert (
- root.name[: -len(".tar.gz")] != f"{variables['pkgname']}-{version}"
- ), "the release asset is still rooted under the pre-rename archive name"
+ assert distfiles.rstrip().endswith(".tar.gz"), "the release payload is the sdist tarball"
+ # The sdist's top-level directory is pkgname-version, so xbps-src needs no
+ # wrksrc override (deprecated; xlint rejects it).
+ root = distfiles.rsplit("/", 1)[1].replace("${version}", version)
+ assert root == f"{variables['pkgname']}-{version}.tar.gz", "archive name must match pkgname"
assert "wrksrc" not in variables, "deprecated wrksrc override: xlint fails the template"
+def test_checksum_is_pinned_to_the_fork_release_or_explicitly_pending() -> None:
+ """A 64-hex digest once the fork release exists; until then, the explicit
+ pending marker. Never the upstream digest, never empty."""
+ checksum = _template_vars()["checksum"]
+ assert checksum != UPSTREAM_0_18_5_SHA256, "the template must not pin the upstream archive"
+ assert (
+ re.fullmatch(r"[0-9a-f]{64}", checksum) or checksum == PENDING_CHECKSUM
+ ), f"checksum must be a sha256 or the explicit pending marker, not {checksum!r}"
+
+
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())
@@ -297,22 +329,30 @@ def test_package_ships_no_speech_models() -> None:
assert "model" not in code, "models are not package payload"
-def test_post_install_installs_the_renamed_paths_from_the_old_sdist() -> None:
- """The 0.18.5 sdist predates the rename, so post_install promotes the
- old names at install time: the entry lands as voculinux.desktop with
- rewritten Name/Exec/Icon, and old-prefixed icons land renamed."""
+def test_post_install_installs_the_fork_names_without_renaming() -> None:
+ """The fork's sdist already carries the new names, so post_install installs
+ them as they are: no sed rewrite and no mv of upstream launchers."""
body = _post_install_body()
assert "vlicense LICENSE" in body
- assert "vocalinux.desktop" in body, "the sdist entry still carries the old name"
- assert "voculinux.desktop" in body and "usr/share/applications" in body
- assert "s/Vocalinux/Voculinux/" in body, "the entry Name must be rewritten"
- assert "s/vocalinux/voculinux/" in body, "the entry Exec/Icon must be rewritten"
+ assert "vinstall voculinux.desktop 644 usr/share/applications" in body
assert "resources/icons/scalable" in body
assert "usr/share/icons/hicolor/scalable/apps" in body
- assert "s/^vocalinux/voculinux/" in body, "old-prefixed icons must land renamed"
- assert "usr/bin/vocalinux" in body, "the sdist launchers still carry the old names"
- assert "usr/bin/voculinux" in body, "the launchers must land under the new names"
assert "usr/share/voculinux/packaged" in body
+ for upstream_step in ("sed ", " mv ", "vocalinux"):
+ assert (
+ upstream_step not in body
+ ), f"post_install still renames upstream names: {upstream_step}"
+ assert "usr/bin/vocalinux" not in _template_text(), "no upstream launcher is promoted"
+
+
+def test_runtime_helpers_are_declared_and_ibus_is_not() -> None:
+ """Capture, direct typing, the clipboard and X11 helpers come from xbps;
+ ibus is excluded because it is not an answer on niri (spec #16)."""
+ depends = set(_template_vars()["depends"].split())
+ for package, why in RUNTIME_HELPER_PACKAGES.items():
+ assert package in depends, f"depends must pull in {package} ({why})"
+ assert "ibus" not in depends, "ibus is excluded: it cannot inject on niri"
+ assert "ydotool" not in depends, "ydotool is not packaged for Void"
def test_build_fetches_nothing_beyond_the_sdist() -> None:
@@ -323,11 +363,11 @@ def test_build_fetches_nothing_beyond_the_sdist() -> None:
def test_no_verification_disabling_options() -> None:
- """Dependency verification stays on; distfiles stay checksum-pinned."""
+ """Dependency verification stays on; distfiles stay checksum-bound."""
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"])
+ assert "checksum" in variables, "the distfile must carry a checksum line"
def test_template_style_is_lint_clean() -> None:
@@ -348,6 +388,15 @@ def test_install_msg_covers_the_xbps_only_facts() -> None:
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"
+ assert "no permission" in text, "niri first run has no permission prompt"
+ assert "wtype" in text, "niri types direct through wtype"
+ assert "ibus" not in text, "IBus is excluded; the message must not point at it"
+ sentences = re.split(r"(?<=\.)\s+", " ".join(text.split()))
+ niri_sentences = [sentence for sentence in sentences if "niri" in sentence]
+ assert niri_sentences, "the message must describe the niri first run"
+ assert not any(
+ "portal" in sentence for sentence in niri_sentences
+ ), "the portal is never recommended on niri"
def test_install_msg_line_length() -> None:
diff --git a/tests/test_void_signed_repo.py b/tests/test_void_signed_repo.py
index 1b57dde..96b5cad 100644
--- a/tests/test_void_signed_repo.py
+++ b/tests/test_void_signed_repo.py
@@ -19,8 +19,13 @@ Seams under test (public interfaces, not internals):
pipeline, same shape as the other packaging gates.
- ``packaging/void/README.md`` — the documented maintainer and client
flow, including the CI secret name.
+- ``packaging/void/repository-key.plist`` — the committed PUBLIC half of
+ the repository key, whose fingerprint the READMEs and the Gitea release
+ workflow pin. ``tests/test_xbps_release_workflow.py`` runs the workflow.
-Independent sources of truth: ``packaging/void/srcpkgs/*/template``
+Independent sources of truth: the xbps fingerprint definition (the MD5 of
+the SSH-format ``ssh-rsa`` blob of the public key, per libxbps
+``xbps_pubkey2fp``), ``packaging/void/srcpkgs/*/template``
owns the template set (a renamed or added template must not silently
drop out), ``packaging/void/tool_checksums.txt`` owns the pins,
``packaging/void/smoke.sh`` owns the dependency order the signed repo
@@ -29,11 +34,20 @@ signing mechanics the container half must invoke. The container run
itself is the proof; these tests are the wiring guard.
"""
+import base64
+import hashlib
+import plistlib
import re
+import struct
from pathlib import Path
+from typing import Tuple
+
+import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml"
+GITEA_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml"
+KEY_PLIST = REPO_ROOT / "packaging" / "void" / "repository-key.plist"
ROOT_README = REPO_ROOT / "README.md"
RELEASE_PROCESS = REPO_ROOT / "docs" / "RELEASE_PROCESS.md"
VOID = REPO_ROOT / "packaging" / "void"
@@ -208,79 +222,51 @@ def _release_workflow_text() -> str:
return RELEASE_WORKFLOW.read_text(encoding="utf-8")
-def _xbps_job_text() -> str:
- """The release xbps job block only — other jobs have their own runtimes."""
- text = _release_workflow_text()
- lines = text.splitlines(keepends=True)
- start = next(
- (i for i, line in enumerate(lines) if re.match(r"^ build-xbps:", line)),
- None,
- )
- assert start is not None, "release.yml must define a build-xbps job"
- end = next(
- (
- i
- for i, line in enumerate(lines[start + 1 :], start + 1)
- if re.match(r"^ [a-z][a-z0-9-]*:", line)
- ),
- len(lines),
- )
- return "".join(lines[start:end])
+def _gitea_workflow_text() -> str:
+ assert GITEA_WORKFLOW.is_file(), f"missing Gitea xbps release workflow: {GITEA_WORKFLOW}"
+ return GITEA_WORKFLOW.read_text(encoding="utf-8")
-def test_release_workflow_builds_signed_xbps_on_tag() -> None:
- """The tag pipeline builds the signed repo and attaches it to the release."""
- text = _xbps_job_text()
- assert (
- "packaging/void/repo.sh" in text
- ), "the xbps job must run the signed-repo pipeline, not a second inline flow"
- assert "podman" in text, "the release xbps job must install podman (never Docker)"
- assert "docker" not in text.lower().replace(
- "never docker", ""
- ), "the release xbps job must never invoke Docker"
+def test_the_xbps_job_lives_in_the_gitea_workflow_not_the_github_release() -> None:
+ """One signing path: the tag-triggered Gitea workflow runs repo.sh."""
+ github = _release_workflow_text()
+ for marker in (
+ "build-xbps",
+ "xbps-repo",
+ "XBPS_SIGNING_KEY",
+ "packaging/void/repo.sh",
+ ):
+ assert marker not in github, f"release.yml must not carry the xbps job ({marker})"
+ gitea = _gitea_workflow_text()
+ assert "packaging/void/repo.sh" in gitea, "the Gitea workflow must run the signed-repo pipeline"
+ assert SIGNING_SECRET in gitea, "the Gitea workflow must read the XBPS_SIGNING_KEY secret"
+ assert "podman" in gitea, "the Gitea workflow must run the pipeline with podman"
+ workflow = yaml.safe_load(gitea)
+ assert workflow[True] == {"push": {"tags": ["v*"]}}, "tag pushes are the only trigger"
-def test_release_workflow_xbps_job_fails_closed_without_key() -> None:
- """An unset XBPS_SIGNING_KEY fails the xbps job — never a silent skip."""
- text = _xbps_job_text()
- assert SIGNING_SECRET in text, "the xbps job must read the XBPS_SIGNING_KEY secret"
- assert re.search(
- r"XBPS_SIGNING_KEY.{0,400}?exit 1", text, re.DOTALL
- ), "an empty XBPS_SIGNING_KEY must exit 1, not skip the publish"
+#: Registration URL every client snippet names: the rolling ``latest``
+#: release the Gitea workflow keeps current, so the URL serves the newest
+#: release's downloads (the tag itself is not part of the client config).
+CONCRETE_REPO_DOWNLOAD = "https://git.bongbetic.com/xavierk/voculinux/releases/download/latest"
+REGISTRATION_LINE = f"repository={CONCRETE_REPO_DOWNLOAD}"
-def test_release_workflow_xbps_job_gates_tag_against_version() -> None:
- """The xbps job only ships the repo when the tag matches version.py."""
- text = _xbps_job_text()
- assert "version.py" in text, "the xbps job must gate on version.py"
- assert re.search(
- r"[Tt]ag.{0,200}?[Vv]ersion|[Vv]ersion.{0,200}?[Tt]ag", text
- ), "release.yml must compare the tag against the version source of truth"
+def _void_section(readme: str) -> str:
+ """The root README's Void Linux section, up to the next ``###`` heading."""
+ start = readme.index("### Void Linux")
+ end = readme.find("\n### ", start + 1)
+ return readme[start : end if end != -1 else len(readme)]
-def test_release_workflow_attaches_signed_repo_to_release() -> None:
- """Packages, signatures, repodata, and the client key land on the release."""
- text = _xbps_job_text()
- assert "gh release upload" in text, "the xbps job must attach its output to the release"
- for marker in ("*.x86_64.xbps", "repodata", "FINGERPRINT"):
- assert marker in text, f"the xbps job must attach {marker} to the release"
-
-
-#: Concrete release-download prefix every client snippet must name. The
-#: xbps job attaches the signed repo flat to the tag release with
-#: ``gh release upload "${{ github.ref_name }}"``, so the repository URL is
-#: the per-tag download prefix below.
-CONCRETE_REPO_DOWNLOAD = "https://github.com/VocaHQ/vocalinux/releases/download/"
-
-
-def test_client_registration_names_concrete_repo_url() -> None:
- """No self-referential placeholder: every client snippet names the
- concrete release-download URL the xbps job attaches the repo to."""
- workflow = _release_workflow_text()
+def test_client_registration_names_the_rolling_latest_release_url() -> None:
+ """No placeholder, no per-tag or GitHub URL: the registration line is the
+ fork's Gitea `latest` release, which the workflow republishes every tag."""
root = ROOT_README.read_text(encoding="utf-8")
void_readme = README.read_text(encoding="utf-8")
for name, text in (
- ("release.yml", workflow),
+ ("release.yml", _release_workflow_text()),
+ ("xbps-release.yml", _gitea_workflow_text()),
("README.md", root),
("packaging/void/README.md", void_readme),
):
@@ -288,13 +274,23 @@ def test_client_registration_names_concrete_repo_url() -> None:
"" not in text
), f"{name} still carries the self-referential repo-URL placeholder"
assert "repository=<" not in text, f"{name} still carries a placeholder repository URL"
- assert CONCRETE_REPO_DOWNLOAD in root, "root README must name the concrete release download URL"
- assert (
- CONCRETE_REPO_DOWNLOAD in void_readme
- ), "void README must name the concrete release download URL"
- assert (
- "releases/download/${{ github.ref_name }}" in workflow
- ), "release.yml must render the concrete per-tag download URL at publish time"
+ for name, text in (
+ ("README.md", _void_section(root)),
+ ("packaging/void/README.md", void_readme),
+ ):
+ assert REGISTRATION_LINE in text, f"{name} must carry the registration line"
+ assert "releases/download/${TAG}" not in text, f"{name} must not pin a per-tag URL"
+ assert "github.com/VocaHQ/vocalinux/releases" not in text, f"{name} points at upstream"
+
+
+def test_registration_url_is_the_release_the_workflow_refreshes() -> None:
+ """The URL clients register and the API the workflow publishes to must
+ name the same repository and the same `latest` tag."""
+ workflow = yaml.safe_load(_gitea_workflow_text())
+ api = workflow["jobs"]["xbps-release"]["env"]["GITEA_API"]
+ host, _, rest = api.partition("/api/v1/repos/")
+ assert CONCRETE_REPO_DOWNLOAD == f"{host}/{rest}/releases/download/latest"
+ assert "ensure_release latest" in _gitea_workflow_text()
def test_root_readme_registers_the_void_repo() -> None:
@@ -306,6 +302,17 @@ def test_root_readme_registers_the_void_repo() -> None:
assert "xbps-install" in text, "root README must give the install command"
+def test_root_readme_shows_the_fingerprint_beside_the_registration() -> None:
+ """The fingerprint to pin sits in the Void section, with the registration
+ line and the install command, not somewhere a reader must hunt for."""
+ section = _void_section(ROOT_README.read_text(encoding="utf-8"))
+ assert _key_fingerprint() in section, "the Void section must show the fingerprint to pin"
+ assert REGISTRATION_LINE in section
+ assert "xbps-install" in section
+ assert "-Sy" not in section, "the first sync must not auto-accept an unknown key"
+ assert "third-party" in section.lower() or "third party" in section.lower()
+
+
def test_root_readme_states_void_scope_and_third_party_caveat() -> None:
"""glibc x86_64 only, and this is a third-party repo outside upstream Void."""
text = ROOT_README.read_text(encoding="utf-8").lower()
@@ -325,3 +332,116 @@ def test_release_process_documents_void_template_bump_as_pr() -> None:
assert re.search(
r"[Pp]ull request", text
), "docs/RELEASE_PROCESS.md must require the bump as a pull request"
+
+
+def _der(data: bytes, pos: int) -> Tuple[int, bytes, int]:
+ """Read one DER TLV at ``pos``: (tag, value, position after it)."""
+ tag, first = data[pos], data[pos + 1]
+ pos += 2
+ length = first
+ if first & 0x80:
+ count = first & 0x7F
+ length = int.from_bytes(data[pos : pos + count], "big")
+ pos += count
+ return tag, data[pos : pos + length], pos + length
+
+
+def _rsa_public_numbers(pem: str) -> Tuple[int, int]:
+ """(modulus, exponent) from an RSA SubjectPublicKeyInfo PEM, stdlib only."""
+ body = "".join(line for line in pem.splitlines() if not line.startswith("-----"))
+ tag, spki, _ = _der(base64.b64decode(body), 0)
+ assert tag == 0x30, "SubjectPublicKeyInfo must be a SEQUENCE"
+ _, _, after_algorithm = _der(spki, 0)
+ tag, bits, _ = _der(spki, after_algorithm)
+ assert tag == 0x03 and bits[0] == 0, "expected a BIT STRING holding the RSAPublicKey"
+ tag, sequence, _ = _der(bits[1:], 0)
+ assert tag == 0x30
+ tag, modulus, after_modulus = _der(sequence, 0)
+ assert tag == 0x02
+ tag, exponent, _ = _der(sequence, after_modulus)
+ assert tag == 0x02
+ return int.from_bytes(modulus, "big"), int.from_bytes(exponent, "big")
+
+
+def _mpint(value: int) -> bytes:
+ """SSH ``mpint``: length-prefixed big-endian, 0x00 pad when the top bit is set."""
+ raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
+ if raw[0] & 0x80:
+ raw = b"\x00" + raw
+ return struct.pack(">I", len(raw)) + raw
+
+
+def _public_pem() -> str:
+ assert KEY_PLIST.is_file(), f"missing committed repository public key: {KEY_PLIST}"
+ entry = plistlib.loads(KEY_PLIST.read_bytes())
+ return entry["public-key"].decode("ascii")
+
+
+def _key_fingerprint() -> str:
+ """The xbps fingerprint of the committed key: MD5 of the ssh-rsa blob."""
+ modulus, exponent = _rsa_public_numbers(_public_pem())
+ blob = struct.pack(">I", 7) + b"ssh-rsa" + _mpint(exponent) + _mpint(modulus)
+ # An identifier xbps defines as MD5, not a security use of the hash.
+ digest = hashlib.md5(blob, usedforsecurity=False).hexdigest()
+ return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
+
+
+def test_committed_repository_key_is_a_public_rsa4096_key_only() -> None:
+ """Only the public half is ever committed: never private key material."""
+ raw = KEY_PLIST.read_text(encoding="utf-8")
+ pem = _public_pem()
+ assert "PRIVATE KEY" not in raw and "PRIVATE KEY" not in pem
+ assert pem.startswith("-----BEGIN PUBLIC KEY-----")
+ entry = plistlib.loads(KEY_PLIST.read_bytes())
+ assert set(entry) == {"public-key", "public-key-size", "signature-by"}
+ modulus, exponent = _rsa_public_numbers(pem)
+ assert modulus.bit_length() == 4096, "the repository key must be RSA 4096"
+ assert entry["public-key-size"] == 4096
+ assert exponent == 65537
+
+
+def test_key_fingerprint_follows_the_xbps_format() -> None:
+ fingerprint = _key_fingerprint()
+ assert re.fullmatch(r"([0-9a-f]{2}:){15}[0-9a-f]{2}", fingerprint), fingerprint
+
+
+def test_readmes_pin_the_fingerprint_of_the_committed_key() -> None:
+ """Users compare against the README, so it must be derived, not typed."""
+ fingerprint = _key_fingerprint()
+ for name, path in (
+ ("README.md", ROOT_README),
+ ("packaging/void/README.md", README),
+ ):
+ assert fingerprint in path.read_text(encoding="utf-8"), f"{name} must show {fingerprint}"
+
+
+def test_workflow_publishes_only_under_the_pinned_fingerprint() -> None:
+ """A secret holding another key fails the verify step before publishing."""
+ workflow = yaml.safe_load(_gitea_workflow_text())
+ assert workflow["jobs"]["xbps-release"]["env"]["XBPS_KEY_FINGERPRINT"] == _key_fingerprint()
+
+
+def test_void_readme_documents_the_secret_and_the_public_key_only_rule() -> None:
+ text = README.read_text(encoding="utf-8")
+ assert "repository-key.plist" in text, "README must name the committed public key"
+ assert "never committed" in text.lower() or "never commit" in text.lower()
+ assert "openssl genrsa" in text and "4096" in text
+ assert SIGNING_SECRET in text
+
+
+def test_the_private_key_is_never_committed() -> None:
+ """Only the public half lives in the repository: no PEM private key block in
+ the Void packaging tree or the workflows that sign with it."""
+ private_block = re.compile(rb"-----BEGIN [A-Z ]*PRIVATE KEY-----")
+ roots = (
+ REPO_ROOT / "packaging" / "void",
+ REPO_ROOT / ".gitea",
+ REPO_ROOT / ".github",
+ )
+ scanned = 0
+ for root in roots:
+ for path in root.rglob("*"):
+ if path.is_file():
+ scanned += 1
+ assert not private_block.search(path.read_bytes()), f"private key in {path}"
+ assert scanned > 20, "the scan must actually cover the packaging and workflow trees"
diff --git a/tests/test_whispercpp_model_info_ext.py b/tests/test_whispercpp_model_info_ext.py
index b409528..d4db8c9 100644
--- a/tests/test_whispercpp_model_info_ext.py
+++ b/tests/test_whispercpp_model_info_ext.py
@@ -965,321 +965,104 @@ class TestDetectComputeBackend(unittest.TestCase):
class TestGetRecommendedModel(unittest.TestCase):
- """Test cases for get_recommended_model function."""
-
- def test_get_recommended_model_returns_tuple(self):
- """Test that get_recommended_model returns a tuple."""
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3))
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend",
- return_value=("cpu", "Intel Core i7"),
- ):
- import importlib
-
- import vocalinux.utils.whispercpp_model_info as mod
-
- importlib.reload(mod)
- result = mod.get_recommended_model()
- self.assertIsInstance(result, tuple)
- self.assertEqual(len(result), 2)
-
- def test_get_recommended_model_with_vulkan_high_ram(self):
- """Test model recommendation with Vulkan and 8GB+ RAM."""
- import sys
-
- # Create a mock psutil module
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3))
-
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend"
- ) as mock_backend:
- mock_backend.return_value = ("vulkan", "Intel Arc A770")
-
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=True,
- ):
- from vocalinux.utils.whispercpp_model_info import get_recommended_model
-
- model, reason = get_recommended_model()
-
- self.assertEqual(model, "small")
-
- def test_get_recommended_model_with_cuda_high_vram(self):
- """Test model recommendation with CUDA and high VRAM."""
- import sys
-
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3))
-
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend"
- ) as mock_backend:
- mock_backend.return_value = ("cuda", "NVIDIA RTX 4090 (24GB)")
-
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=True,
- ):
- from vocalinux.utils.whispercpp_model_info import get_recommended_model
-
- model, reason = get_recommended_model()
-
- # High VRAM should recommend medium or higher
- self.assertIn(model, ["medium", "large", "small"])
-
- def test_get_recommended_model_with_driver_only_cuda_demotes(self):
- """A CUDA driver without the toolkit cannot run the build: CPU logic."""
- import sys
-
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3))
-
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend"
- ) as mock_backend:
- mock_backend.return_value = ("cuda", "NVIDIA RTX 4090 (24GB)")
-
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=False,
- ):
- from vocalinux.utils.whispercpp_model_info import get_recommended_model
-
- model, _reason = get_recommended_model()
-
- # 16 GB system RAM under CPU logic recommends base.
- self.assertEqual(model, "base")
-
- def test_get_recommended_model_with_cpu_high_ram(self):
- """Test model recommendation with CPU and 16GB+ RAM."""
- import sys
-
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3))
-
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend"
- ) as mock_backend:
- mock_backend.return_value = ("cpu", "Intel Core i7")
-
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=True,
- ):
- from vocalinux.utils.whispercpp_model_info import get_recommended_model
-
- model, reason = get_recommended_model()
-
- self.assertEqual(model, "base")
-
- def test_get_recommended_model_with_cpu_medium_ram(self):
- """Test model recommendation with CPU and 8GB RAM."""
- import sys
-
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3))
-
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend"
- ) as mock_backend:
- mock_backend.return_value = ("cpu", "AMD Ryzen 5")
-
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=True,
- ):
- from vocalinux.utils.whispercpp_model_info import get_recommended_model
-
- model, reason = get_recommended_model()
-
- self.assertEqual(model, "tiny")
-
- def test_get_recommended_model_with_cpu_low_ram(self):
- """Test model recommendation with CPU and 4GB RAM."""
- import sys
-
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=4 * (1024**3))
-
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend"
- ) as mock_backend:
- mock_backend.return_value = ("cpu", "Intel Atom")
-
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=True,
- ):
- from vocalinux.utils.whispercpp_model_info import get_recommended_model
-
- model, reason = get_recommended_model()
-
- self.assertEqual(model, "tiny")
-
- def test_get_recommended_model_returns_valid_model_name(self):
- """Test that get_recommended_model returns a valid model name."""
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3))
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend",
- return_value=("cpu", "Intel Core i7"),
- ):
- import importlib
-
- import vocalinux.utils.whispercpp_model_info as mod
-
- importlib.reload(mod)
- model, reason = mod.get_recommended_model()
- self.assertIn(model, mod.AVAILABLE_MODELS)
-
- def test_get_recommended_model_returns_reason_string(self):
- """Test that get_recommended_model returns a reason string."""
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3))
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend",
- return_value=("cpu", "Intel Core i7"),
- ):
- import importlib
-
- import vocalinux.utils.whispercpp_model_info as mod
-
- importlib.reload(mod)
- model, reason = mod.get_recommended_model()
- self.assertIsInstance(reason, str)
- self.assertGreater(len(reason), 0)
-
- def test_parse_cuda_vram_mib_and_gb(self):
- """nvidia-smi reports MiB; older mocks used GB. Both must parse."""
- from vocalinux.utils.whispercpp_model_info import _parse_cuda_vram_gib
-
- self.assertEqual(_parse_cuda_vram_gib("NVIDIA GeForce RTX 3060 (6144 MiB)"), 6.0)
- self.assertEqual(_parse_cuda_vram_gib("NVIDIA GeForce RTX 3070 (8192 MiB)"), 8.0)
- self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 4090 (24GB)"), 24.0)
- self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 3080 (10 GB)"), 10.0)
- self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 3060 (8 GiB)"), 8.0)
- self.assertEqual(_parse_cuda_vram_gib("NVIDIA GTX 1650 (4096 MB)"), 4.0)
- self.assertIsNone(_parse_cuda_vram_gib("NVIDIA GPU"))
- self.assertIsNone(_parse_cuda_vram_gib(""))
-
- def test_get_recommended_model_with_cuda_mib_medium(self):
- """8 GiB of CUDA VRAM reported as MiB must recommend medium."""
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3))
-
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend"
- ) as mock_backend:
- mock_backend.return_value = ("cuda", "NVIDIA GeForce RTX 3070 (8192 MiB)")
-
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=True,
- ):
- from vocalinux.utils.whispercpp_model_info import get_recommended_model
-
- model, reason = get_recommended_model()
-
- self.assertEqual(model, "medium")
- self.assertIn("VRAM", reason)
-
- def test_get_recommended_model_with_cuda_mib_small(self):
- """6 GiB of CUDA VRAM reported as MiB must recommend small."""
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3))
-
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend"
- ) as mock_backend:
- mock_backend.return_value = ("cuda", "NVIDIA GeForce RTX 3060 (6144 MiB)")
-
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=True,
- ):
- from vocalinux.utils.whispercpp_model_info import get_recommended_model
-
- model, reason = get_recommended_model()
-
- self.assertEqual(model, "small")
-
- def test_get_recommended_model_with_cuda_mib_base(self):
- """Low CUDA VRAM reported as MiB must recommend base."""
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3))
-
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend"
- ) as mock_backend:
- mock_backend.return_value = ("cuda", "NVIDIA GeForce GTX 1050 (2048 MiB)")
-
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=True,
- ):
- from vocalinux.utils.whispercpp_model_info import get_recommended_model
-
- model, reason = get_recommended_model()
-
- self.assertEqual(model, "base")
-
- def test_get_recommended_model_with_cuda_unparseable_vram_falls_back(self):
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3))
-
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend"
- ) as mock_backend:
- mock_backend.return_value = ("cuda", "NVIDIA GPU")
-
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=True,
- ):
- from vocalinux.utils.whispercpp_model_info import get_recommended_model
-
- model, reason = get_recommended_model()
-
- self.assertEqual(model, "small")
- self.assertIn("CUDA GPU detected", reason)
-
- def test_get_recommended_model_vulkan_ceils_fractional_ram(self):
- """~7.4 GiB must count as 8 GiB so Vulkan recommends small, not base."""
- mock_psutil = MagicMock()
- mock_psutil.virtual_memory.return_value = MagicMock(total=int(7.4 * (1024**3)))
-
- with patch.dict("sys.modules", {"psutil": mock_psutil}):
- with patch(
- "vocalinux.utils.whispercpp_model_info.detect_compute_backend"
- ) as mock_backend:
- mock_backend.return_value = ("vulkan", "Intel Arc A770")
-
- with patch(
- "vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready",
- return_value=True,
- ):
- from vocalinux.utils.whispercpp_model_info import get_recommended_model
-
- model, reason = get_recommended_model()
-
- self.assertEqual(model, "small")
- self.assertIn("8GB", reason)
+ """The recommendation is a ladder rung keyed to the installed build and free memory.
+
+ Spec #16 / ticket #20. Every reading is mocked: the installed build's
+ backend, free VRAM from nvidia-smi and free system RAM. Total memory is
+ never consulted, so no test here passes a total as a free reading.
+ """
+
+ LADDER = {"large-v3-turbo-q8_0", "large-v3-turbo-q5_0", "small"}
+
+ def _recommend(self, build_backend, free_vram=None, free_ram_mib=10 * 1024.0):
+ from vocalinux.speech_recognition import model_ladder, recognition_manager
+ from vocalinux.utils import whispercpp_model_info as info
+
+ with (
+ patch.object(
+ recognition_manager,
+ "detect_pywhispercpp_gpu_backend",
+ return_value=build_backend,
+ ),
+ patch.object(model_ladder, "free_vram_mib", return_value=free_vram),
+ patch.object(model_ladder, "free_system_ram_mib", return_value=free_ram_mib),
+ ):
+ return info.get_recommended_model()
+
+ def test_returns_tuple(self):
+ model, reason = self._recommend("vulkan", free_vram=2600.0)
+ self.assertIsInstance(model, str)
+ self.assertIsInstance(reason, str)
+
+ def test_ample_free_vram_on_vulkan_build_selects_default_rung(self):
+ model, reason = self._recommend("vulkan", free_vram=2600.0)
+ self.assertEqual(model, "large-v3-turbo-q8_0")
+ self.assertIn("VRAM", reason)
+
+ def test_constrained_free_vram_steps_to_q5_rung(self):
+ model, reason = self._recommend("vulkan", free_vram=1500.0)
+ self.assertEqual(model, "large-v3-turbo-q5_0")
+ self.assertIn("1500 MiB free VRAM", reason)
+
+ def test_tight_free_vram_selects_floor(self):
+ model, _reason = self._recommend("vulkan", free_vram=900.0)
+ self.assertEqual(model, "small")
+
+ def test_total_vram_is_not_the_reading(self):
+ """A 4 GB card whose VRAM is mostly taken by the desktop gets the floor."""
+ model, _reason = self._recommend("vulkan", free_vram=512.0)
+ self.assertEqual(model, "small")
+
+ def test_integrated_graphics_read_free_ram(self):
+ """No NVIDIA reading means the Vulkan build keeps its model in system RAM."""
+ model, reason = self._recommend("vulkan", free_vram=None, free_ram_mib=10 * 1024.0)
+ self.assertEqual(model, "large-v3-turbo-q8_0")
+ self.assertIn("RAM", reason)
+
+ def test_cpu_build_ignores_a_visible_gpu_and_reads_ram(self):
+ """A CPU-only build never runs on the GPU, so VRAM is not the constraint."""
+ model, reason = self._recommend("cpu", free_vram=2600.0, free_ram_mib=1200.0)
+ self.assertEqual(model, "small")
+ self.assertIn("RAM", reason)
+ self.assertNotIn("VRAM", reason)
+
+ def test_keys_to_the_installed_build_not_the_hardware_probe(self):
+ """The informational probe reporting CPU must not hide an installed GPU build."""
+ from vocalinux.speech_recognition import model_ladder, recognition_manager
+ from vocalinux.utils import whispercpp_model_info as info
+
+ with (
+ patch.object(
+ info, "detect_compute_backend", return_value=(info.ComputeBackend.CPU, "x")
+ ),
+ patch.object(
+ recognition_manager,
+ "detect_pywhispercpp_gpu_backend",
+ return_value="vulkan",
+ ),
+ patch.object(model_ladder, "free_vram_mib", return_value=2600.0),
+ patch.object(model_ladder, "free_system_ram_mib", return_value=512.0),
+ ):
+ model, reason = info.get_recommended_model()
+ self.assertEqual(model, "large-v3-turbo-q8_0")
+ self.assertIn("VRAM", reason)
+
+ def test_unreadable_memory_recommends_the_floor(self):
+ model, reason = self._recommend("vulkan", free_vram=None, free_ram_mib=None)
+ self.assertEqual(model, "small")
+ self.assertIn("unreadable", reason)
+
+ def test_recommendation_stays_inside_the_ladder_for_any_reading(self):
+ """Unquantized turbo, the large family and medium never come back as a default."""
+ for backend in ("vulkan", "cuda", "cpu"):
+ for free_vram in (None, 0.0, 900.0, 1500.0, 2600.0, 8000.0):
+ for free_ram in (512.0, 1200.0, 4096.0, 16384.0):
+ model, _reason = self._recommend(backend, free_vram, free_ram)
+ self.assertIn(model, self.LADDER, (backend, free_vram, free_ram))
+
+ def test_reason_names_the_backend_the_build_has(self):
+ _model, reason = self._recommend("cuda", free_vram=2600.0)
+ self.assertIn("cuda build", reason)
class TestGetModelPath(unittest.TestCase):
diff --git a/tests/test_xbps_release_workflow.py b/tests/test_xbps_release_workflow.py
new file mode 100644
index 0000000..cf68164
--- /dev/null
+++ b/tests/test_xbps_release_workflow.py
@@ -0,0 +1,764 @@
+"""Offline guards for the signed xbps release workflow (ticket #29).
+
+Releasing the Void repository is a tag push on the fork's Gitea host. The
+workflow ``.gitea/workflows/xbps-release.yml`` gates the tag against
+``src/vocalinux/version.py``, requires the ``XBPS_SIGNING_KEY`` secret, builds
+and signs only through ``packaging/void/repo.sh`` (podman, pinned Void glibc
+container), verifies the staged repository, and publishes it to the tag's
+release and to the rolling ``latest`` release the client registration URL
+serves.
+
+These tests parse the workflow and then *run* its gate, verify, build and
+publish steps in a scratch directory, so they assert behaviour rather than
+spelling:
+
+- the shell of each step is executed with the environment the workflow
+ itself declares (``${{ ... }}`` expressions are rendered from an explicit
+ context, so a step wired to the wrong secret or token fails loudly);
+- ``curl`` is replaced by a small stateful stand-in for the Gitea release API
+ on ``PATH``, so the publish step's requests, their order, and its refusal to
+ continue past a non-2xx response are observable.
+
+Independent sources of truth: the tag/version pair comes from the test (not the
+workflow), the repository layout comes from what ``xbps-rindex`` writes
+(``.x86_64.xbps`` plus ``.x86_64.xbps.sig2`` and ``x86_64-repodata``),
+and the Gitea REST contract (release create, asset upload and delete, tag
+lookup) comes from the Gitea API documentation.
+"""
+
+import json
+import os
+import re
+import shutil
+import stat
+import subprocess
+import sys
+import textwrap
+from pathlib import Path
+from typing import Dict, List, Optional
+from urllib.parse import unquote
+
+import pytest
+import yaml
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml"
+JOB = "xbps-release"
+
+GATE_TAG = "Require the tag to equal the version in version.py"
+GATE_KEY = "Require the repository signing key"
+INSTALL_TOOLS = "Install the tools repo.sh and the publish step need"
+BUILD = "Build and sign the repository"
+VERIFY = "Verify the signed repository is complete"
+PUBLISH = "Publish to the tag release and the latest release"
+CLEANUP = "Remove the signing key"
+STEP_ORDER = (GATE_TAG, GATE_KEY, INSTALL_TOOLS, BUILD, VERIFY, PUBLISH, CLEANUP)
+
+API = "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux"
+TOKEN = "test-token-not-a-secret"
+SHA = "0123456789abcdef0123456789abcdef01234567"
+FAKE_KEY = "-----BEGIN RSA PRIVATE KEY-----\nline-one\nline-two\n-----END RSA PRIVATE KEY-----\n"
+
+
+def _text() -> str:
+ assert WORKFLOW.is_file(), f"missing signed xbps release workflow: {WORKFLOW}"
+ return WORKFLOW.read_text(encoding="utf-8")
+
+
+def _workflow() -> dict:
+ return yaml.safe_load(_text())
+
+
+def _job() -> dict:
+ return _workflow()["jobs"][JOB]
+
+
+def _step(name: str) -> dict:
+ matches = [s for s in _job()["steps"] if s.get("name") == name]
+ assert len(matches) == 1, f"workflow must have exactly one step named {name!r}"
+ return matches[0]
+
+
+def _run_text(name: str) -> str:
+ return _step(name)["run"]
+
+
+def _without_comments(text: str) -> str:
+ return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
+
+
+def _render(value: str, context: Dict[str, str]) -> str:
+ """Render ``${{ expr }}`` from an explicit context; an unknown one is a bug."""
+
+ def substitute(match: "re.Match[str]") -> str:
+ expression = match.group(1)
+ assert expression in context, f"workflow uses an unexpected expression: {expression}"
+ return context[expression]
+
+ return re.sub(r"\$\{\{\s*([\w.]+)\s*\}\}", substitute, value)
+
+
+def _run_step(
+ name: str,
+ cwd: Path,
+ context: Optional[Dict[str, str]] = None,
+ extra_env: Optional[Dict[str, str]] = None,
+ path_prefix: Optional[Path] = None,
+) -> subprocess.CompletedProcess:
+ """Execute one step's ``run`` script with the env the workflow declares."""
+ ctx = {"gitea.run_id": "42", "gitea.token": TOKEN, "gitea.sha": SHA}
+ ctx.update(context or {})
+ step = _step(name)
+ env = {k: os.environ[k] for k in ("HOME", "LANG") if k in os.environ}
+ path = os.environ.get("PATH", "/usr/bin:/bin")
+ env["PATH"] = f"{path_prefix}{os.pathsep}{path}" if path_prefix else path
+ for source in (_job().get("env", {}), step.get("env", {})):
+ for key, value in source.items():
+ env[key] = _render(str(value), ctx)
+ env.update(extra_env or {})
+ script = cwd / f"{re.sub(r'[^a-z]+', '-', name.lower())}.sh"
+ script.write_text(step["run"], encoding="utf-8")
+ return subprocess.run(
+ ["bash", str(script)],
+ cwd=cwd,
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ check=False,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Workflow shape
+# ---------------------------------------------------------------------------
+
+
+def test_triggers_only_on_version_tag_pushes() -> None:
+ """A tag push is a human act: no schedule, no dispatch, no PR trigger."""
+ triggers = _workflow()[True] # YAML 1.1 reads the bare key `on` as True
+ assert triggers == {"push": {"tags": ["v*"]}}, triggers
+
+
+def test_grants_only_contents_write() -> None:
+ assert _workflow()["permissions"] == {"contents": "write"}
+
+
+def test_runs_on_the_project_runner_with_no_third_party_actions() -> None:
+ """Checkout is the git fetch the repo's own CI uses; nothing floats on a tag."""
+ assert _job()["runs-on"] == "bongbetic-ci"
+ uses = [s["uses"] for s in _job()["steps"] if "uses" in s]
+ assert not uses, f"the signing job must not run third-party actions: {uses}"
+
+
+def test_steps_run_in_the_gated_order() -> None:
+ """Both gates precede any container; verify precedes publish; key cleanup is last."""
+ names = [s.get("name") for s in _job()["steps"]]
+ assert names[0] == "Checkout"
+ assert tuple(names[1:]) == STEP_ORDER, names
+
+
+def test_signing_key_is_removed_by_an_always_step() -> None:
+ last = _job()["steps"][-1]
+ assert last["name"] == CLEANUP
+ assert last["if"] == "always()"
+ assert "rm -f" in last["run"] and "$XBPS_KEY_FILE" in last["run"]
+ assert _job()["env"]["XBPS_KEY_FILE"].startswith("/tmp/xbps-sign-key-")
+
+
+def test_signing_secret_is_read_by_exactly_one_step() -> None:
+ """The secret is exposed to the gate that stages it, never to the build."""
+ text = _text()
+ assert text.count("secrets.XBPS_SIGNING_KEY") == 1
+ assert (
+ _step(GATE_KEY)["env"]["XBPS_SIGNING_KEY"] == "${{ secrets.XBPS_SIGNING_KEY }}"
+ ), "the key gate must read the XBPS_SIGNING_KEY secret"
+ assert "XBPS_SIGNING_KEY" not in _step(BUILD).get("env", {})
+
+
+def test_the_tag_reaches_scripts_through_env_never_interpolated() -> None:
+ """`${{ gitea.ref_name }}` inside a run script is shell injection by tag name."""
+ for step in _job()["steps"]:
+ assert "${{" not in step.get("run", ""), step.get("name")
+ assert _step(GATE_TAG)["env"]["TAG"] == "${{ gitea.ref_name }}"
+
+
+def test_repo_sh_is_the_only_build_and_sign_path() -> None:
+ """No step builds, indexes or signs by hand; only repo.sh does, once."""
+ code = _without_comments(_text())
+ assert code.count("packaging/void/repo.sh") == 1
+ for forbidden in ("xbps-src", "xbps-rindex", "openssl rsa", "xlint", "gpg "):
+ assert forbidden not in code, f"{forbidden!r} belongs inside repo.sh, not the workflow"
+ assert "repo.sh" in _run_text(BUILD)
+
+
+def test_pipeline_uses_podman_and_never_docker() -> None:
+ code = _without_comments(_text()).lower()
+ assert "podman" in _run_text(INSTALL_TOOLS)
+ assert "docker" not in code
+
+
+def test_workflow_never_commits_pushes_or_bumps_templates() -> None:
+ """Template bumps are a normal pull request, never a bot commit."""
+ code = _without_comments(_text())
+ for forbidden in ("git commit", "git push", "git tag", "git add", "srcpkgs", "gh pr"):
+ assert forbidden not in code, f"the workflow must not run {forbidden!r}"
+
+
+def test_workflow_text_names_no_assistant() -> None:
+ assert not re.search(r"claude|anthropic", _text(), re.IGNORECASE)
+
+
+def test_publish_goes_to_the_fork_gitea_api_with_the_job_token() -> None:
+ assert _job()["env"]["GITEA_API"] == API
+ env = _step(PUBLISH)["env"]
+ assert env["GITEA_TOKEN"] == "${{ gitea.token }}"
+ assert "Authorization: token ${GITEA_TOKEN}" in _run_text(PUBLISH)
+ assert "gh release" not in _text(), "GitHub CLI cannot reach the fork's Gitea releases"
+
+
+# ---------------------------------------------------------------------------
+# Gate 1: the tag equals the version source of truth
+# ---------------------------------------------------------------------------
+
+
+def _version_file(root: Path, body: str) -> None:
+ target = root / "src" / "vocalinux" / "version.py"
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(body, encoding="utf-8")
+
+
+def _gate_tag(tmp_path: Path, tag: str, version_py: str) -> subprocess.CompletedProcess:
+ _version_file(tmp_path, version_py)
+ return _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": tag})
+
+
+@pytest.mark.parametrize(
+ ("tag", "version", "ok"),
+ [
+ ("v0.18.5", "0.18.5", True),
+ ("v0.19.0-beta", "0.19.0-beta", True),
+ ("0.18.5", "0.18.5", False), # no v prefix
+ ("v0.18.50", "0.18.5", False), # tag contains the version: loose match would pass
+ ("v0.18", "0.18.5", False), # version contains the tag: loose match would pass
+ ("v0.18.5-beta", "0.18.5", False),
+ ("v0.18.5", "0.18.5-beta", False),
+ ("v0.18.4", "0.18.5", False),
+ ("vv0.18.5", "0.18.5", False),
+ ],
+)
+def test_tag_gate_requires_exact_v_plus_version(
+ tmp_path: Path, tag: str, version: str, ok: bool
+) -> None:
+ result = _gate_tag(tmp_path, tag, f'"""Version."""\n\n__version__ = "{version}"\n')
+ if ok:
+ assert result.returncode == 0, result.stderr
+ else:
+ assert result.returncode == 1, f"{tag} vs {version} must exit 1: {result.stdout}"
+ assert "refusing to publish" in result.stderr
+
+
+def test_tag_gate_fails_when_the_version_cannot_be_read(tmp_path: Path) -> None:
+ result = _gate_tag(tmp_path, "v0.18.5", '"""Version."""\n__version_info__ = (0, 18, 5)\n')
+ assert result.returncode == 1
+ assert "__version__" in result.stderr
+
+
+# ---------------------------------------------------------------------------
+# Gate 2: the signing key
+# ---------------------------------------------------------------------------
+
+
+def test_unset_signing_key_exits_1_and_writes_nothing(tmp_path: Path) -> None:
+ key_file = tmp_path / "key.pem"
+ result = _run_step(
+ GATE_KEY,
+ tmp_path,
+ {"secrets.XBPS_SIGNING_KEY": ""},
+ {"XBPS_KEY_FILE": str(key_file)},
+ )
+ assert result.returncode == 1
+ assert "XBPS_SIGNING_KEY" in result.stderr
+ assert "unsigned" in result.stderr
+ assert not key_file.exists(), "no key material may be written when the secret is empty"
+
+
+def test_signing_key_is_staged_0600_with_newlines_intact(tmp_path: Path) -> None:
+ key_file = tmp_path / "key.pem"
+ result = _run_step(
+ GATE_KEY,
+ tmp_path,
+ {"secrets.XBPS_SIGNING_KEY": FAKE_KEY},
+ {"XBPS_KEY_FILE": str(key_file)},
+ )
+ assert result.returncode == 0, result.stderr
+ assert key_file.read_text(encoding="utf-8") == FAKE_KEY
+ assert stat.S_IMODE(key_file.stat().st_mode) == 0o600
+ assert FAKE_KEY.splitlines()[1] not in result.stdout + result.stderr, "key was echoed"
+
+
+def test_cleanup_step_removes_the_key_file(tmp_path: Path) -> None:
+ key_file = tmp_path / "key.pem"
+ key_file.write_text(FAKE_KEY, encoding="utf-8")
+ result = _run_step(CLEANUP, tmp_path, extra_env={"XBPS_KEY_FILE": str(key_file)})
+ assert result.returncode == 0, result.stderr
+ assert not key_file.exists()
+
+
+# ---------------------------------------------------------------------------
+# Build: repo.sh is handed the key file, nothing else
+# ---------------------------------------------------------------------------
+
+
+def test_build_step_runs_repo_sh_with_the_key_file_only(tmp_path: Path) -> None:
+ """repo.sh gets XBPS_SIGNING_KEY_FILE and never the secret itself."""
+ script = tmp_path / "packaging" / "void" / "repo.sh"
+ script.parent.mkdir(parents=True)
+ script.write_text(
+ textwrap.dedent("""\
+ #!/bin/sh
+ set -eu
+ [ -s "$XBPS_SIGNING_KEY_FILE" ] || exit 7
+ printf '%s' "$XBPS_SIGNING_KEY_FILE" > called-with
+ printf '%s' "${XBPS_SIGNING_KEY:-unset}" > secret-seen
+ mkdir -p publish/xbps/repo publish/xbps/keys
+ """),
+ encoding="utf-8",
+ )
+ key_file = tmp_path / "key.pem"
+ key_file.write_text(FAKE_KEY, encoding="utf-8")
+ result = _run_step(BUILD, tmp_path, extra_env={"XBPS_KEY_FILE": str(key_file)})
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "called-with").read_text(encoding="utf-8") == str(key_file)
+ assert (tmp_path / "secret-seen").read_text(encoding="utf-8") == "unset"
+
+
+def test_build_step_fails_when_repo_sh_fails(tmp_path: Path) -> None:
+ """A failing repo.sh (missing or non-RSA4096 key, build error) ends the job."""
+ script = tmp_path / "packaging" / "void" / "repo.sh"
+ script.parent.mkdir(parents=True)
+ script.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
+ result = _run_step(BUILD, tmp_path, extra_env={"XBPS_KEY_FILE": str(tmp_path / "k")})
+ assert result.returncode != 0
+
+
+# ---------------------------------------------------------------------------
+# Verify: the full signed glibc x86_64 repository, nothing else
+# ---------------------------------------------------------------------------
+
+FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25"
+SMOKE_SH = REPO_ROOT / "packaging" / "void" / "smoke.sh"
+
+
+def _smoke_packages() -> List[str]:
+ """The templates the smoke gate builds, in order: the repository's package set."""
+ match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M)
+ assert match, "smoke.sh must declare its ordered PKGS list"
+ return match.group(1).split()
+
+
+#: One built package per template, versioned the way xbps-src names them.
+PACKAGES = [f"{name}-1.0_1" for name in _smoke_packages()]
+
+
+def _stage(root: Path, fingerprint: str = FINGERPRINT) -> Path:
+ """Lay out publish/xbps the way repo.sh stages it, plus the smoke gate's list."""
+ smoke = root / "packaging" / "void" / "smoke.sh"
+ smoke.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy(SMOKE_SH, smoke)
+ out = root / "publish" / "xbps"
+ repo, keys = out / "repo", out / "keys"
+ repo.mkdir(parents=True)
+ keys.mkdir()
+ for pkg in PACKAGES:
+ (repo / f"{pkg}.x86_64.xbps").write_bytes(b"package bytes " + pkg.encode())
+ (repo / f"{pkg}.x86_64.xbps.sig2").write_bytes(b"signature " + pkg.encode())
+ (repo / "x86_64-repodata").write_bytes(b"signed index")
+ (keys / "FINGERPRINT").write_text(
+ f"signed-by: Voculinux \nfingerprint: {fingerprint}\n",
+ encoding="utf-8",
+ )
+ (keys / f"{fingerprint}.plist").write_text("\n", encoding="utf-8")
+ return out
+
+
+def test_the_pinned_fingerprint_is_what_the_verify_step_checks() -> None:
+ assert _job()["env"]["XBPS_KEY_FINGERPRINT"] == FINGERPRINT
+
+
+def test_verify_accepts_the_complete_signed_repository(tmp_path: Path) -> None:
+ _stage(tmp_path)
+ result = _run_step(VERIFY, tmp_path)
+ assert result.returncode == 0, result.stderr
+ assert "complete signed glibc x86_64 repository" in result.stdout
+
+
+@pytest.mark.parametrize(
+ "missing",
+ [
+ f"repo/{PACKAGES[0]}.x86_64.xbps",
+ f"repo/{PACKAGES[-1]}.x86_64.xbps",
+ f"repo/{PACKAGES[0]}.x86_64.xbps.sig2",
+ "repo/x86_64-repodata",
+ "keys/FINGERPRINT",
+ f"keys/{FINGERPRINT}.plist",
+ ],
+)
+def test_verify_rejects_a_repository_missing_any_class(tmp_path: Path, missing: str) -> None:
+ out = _stage(tmp_path)
+ (out / missing).unlink()
+ result = _run_step(VERIFY, tmp_path)
+ assert result.returncode == 1, f"removing {missing} must fail verification"
+
+
+@pytest.mark.parametrize(
+ "extra",
+ [
+ "repo/voculinux-1.0_1.x86_64-musl.xbps",
+ "repo/voculinux-1.0_1.aarch64.xbps",
+ "repo/voculinux-1.0_1.noarch.xbps",
+ "repo/x86_64-musl-repodata",
+ "repo/aarch64-repodata",
+ "repo/notes.txt",
+ "keys/private.pem",
+ ],
+)
+def test_verify_rejects_anything_but_glibc_x86_64_data(tmp_path: Path, extra: str) -> None:
+ out = _stage(tmp_path)
+ (out / extra).write_bytes(b"x")
+ result = _run_step(VERIFY, tmp_path)
+ assert result.returncode == 1, f"{extra} must not be publishable"
+
+
+def test_verify_rejects_an_unsigned_package(tmp_path: Path) -> None:
+ """One signature present does not excuse a package without its own."""
+ out = _stage(tmp_path)
+ (out / "repo" / f"{PACKAGES[-1]}.x86_64.xbps.sig2").write_bytes(b"")
+ result = _run_step(VERIFY, tmp_path)
+ assert result.returncode == 1
+ assert "no signature" in result.stderr
+
+
+def test_verify_rejects_a_signature_whose_package_is_gone(tmp_path: Path) -> None:
+ out = _stage(tmp_path)
+ (out / "repo" / f"{PACKAGES[0]}.x86_64.xbps.sig2").write_bytes(b"sig")
+ (out / "repo" / "ghost-1.0_1.x86_64.xbps.sig2").write_bytes(b"sig")
+ result = _run_step(VERIFY, tmp_path)
+ assert result.returncode == 1
+ assert "without its package" in result.stderr
+
+
+def test_verify_rejects_a_repository_signed_by_another_key(tmp_path: Path) -> None:
+ """A throwaway or wrong key must never publish under the pinned fingerprint."""
+ other = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff"
+ _stage(tmp_path, fingerprint=other)
+ result = _run_step(VERIFY, tmp_path)
+ assert result.returncode == 1
+ assert FINGERPRINT in result.stderr and other in result.stderr
+
+
+# ---------------------------------------------------------------------------
+# Publish: tag release plus rolling latest release, through a fake Gitea API
+# ---------------------------------------------------------------------------
+
+FAKE_CURL = '''#!{python}
+"""Stateful stand-in for curl against the Gitea release API."""
+import json
+import os
+import re
+import sys
+from urllib.parse import unquote, urlsplit
+
+STATE = os.environ["FAKE_GITEA_STATE"]
+BASE = os.environ["FAKE_GITEA_BASE"]
+
+
+def load():
+ with open(STATE, encoding="utf-8") as handle:
+ return json.load(handle)
+
+
+def save(state):
+ with open(STATE, "w", encoding="utf-8") as handle:
+ json.dump(state, handle)
+
+
+def main(argv):
+ out = method = data = url = None
+ headers, forms = [], []
+ i = 0
+ while i < len(argv):
+ arg = argv[i]
+ if arg == "-o":
+ out, i = argv[i + 1], i + 2
+ elif arg == "-w":
+ i += 2
+ elif arg == "-X":
+ method, i = argv[i + 1], i + 2
+ elif arg == "-H":
+ headers.append(argv[i + 1])
+ i += 2
+ elif arg == "-d":
+ data, i = argv[i + 1], i + 2
+ elif arg == "-F":
+ forms.append(argv[i + 1])
+ i += 2
+ elif arg.startswith("-"):
+ i += 1
+ else:
+ url, i = arg, i + 1
+ method = method or "GET"
+ state = load()
+ entry = {{
+ "method": method,
+ "url": url,
+ "auth": [h for h in headers if h.lower().startswith("authorization:")],
+ "data": json.loads(data) if data else None,
+ "forms": forms,
+ }}
+ state["log"].append(entry)
+ status, body = route(state, method, url, data, forms)
+ fail = os.environ.get("FAKE_GITEA_FAIL", "")
+ if fail:
+ fmethod, fneedle, fcode = fail.split("|")
+ if method == fmethod and fneedle in url:
+ status, body = int(fcode), {{"message": "injected failure"}}
+ save(state)
+ if out:
+ with open(out, "w", encoding="utf-8") as handle:
+ handle.write(json.dumps(body) if body is not None else "")
+ sys.stdout.write(str(status))
+ return 0
+
+
+def route(state, method, url, data, forms):
+ if not url.startswith(BASE):
+ return 599, {{"message": "unexpected host " + url}}
+ path = urlsplit(url[len(BASE):]).path
+ query = urlsplit(url).query
+ releases = state["releases"]
+ m = re.fullmatch(r"/releases/tags/(.+)", path)
+ if m and method == "GET":
+ tag = unquote(m.group(1))
+ for rel in releases.values():
+ if rel["tag_name"] == tag:
+ return 200, rel
+ return 404, {{"message": "not found"}}
+ if path == "/releases" and method == "POST":
+ payload = json.loads(data)
+ rid = state["next_id"]
+ state["next_id"] += 1
+ rel = dict(payload, id=rid, assets=[])
+ releases[str(rid)] = rel
+ return 201, rel
+ m = re.fullmatch(r"/releases/(\\d+)", path)
+ if m and method == "PATCH":
+ rel = releases[m.group(1)]
+ rel.update(json.loads(data))
+ return 200, rel
+ m = re.fullmatch(r"/releases/(\\d+)/assets", path)
+ if m and method == "GET":
+ return 200, releases[m.group(1)]["assets"]
+ if m and method == "POST":
+ rel = releases[m.group(1)]
+ name = unquote(re.search(r"(?:^|&)name=([^&]*)", query).group(1))
+ if any(a["name"] == name for a in rel["assets"]):
+ return 409, {{"message": "asset already exists"}}
+ source = re.match(r"attachment=@(.*?);filename=(.*)$", forms[0])
+ size = os.path.getsize(source.group(1))
+ asset = {{"id": state["next_id"], "name": name, "size": size}}
+ state["next_id"] += 1
+ rel["assets"].append(asset)
+ return 201, asset
+ m = re.fullmatch(r"/releases/(\\d+)/assets/(\\d+)", path)
+ if m and method == "DELETE":
+ rel = releases[m.group(1)]
+ rel["assets"] = [a for a in rel["assets"] if str(a["id"]) != m.group(2)]
+ return 204, None
+ return 404, {{"message": "no such route " + method + " " + path}}
+
+
+sys.exit(main(sys.argv[1:]))
+'''
+
+
+class FakeGitea:
+ """A stateful curl stand-in on PATH plus helpers to read what it recorded."""
+
+ def __init__(self, root: Path) -> None:
+ self.bin = root / "fakebin"
+ self.bin.mkdir()
+ self.state_file = root / "gitea-state.json"
+ curl = self.bin / "curl"
+ curl.write_text(FAKE_CURL.format(python=sys.executable), encoding="utf-8")
+ curl.chmod(0o755)
+ self.state_file.write_text(
+ json.dumps({"next_id": 100, "releases": {}, "log": []}), encoding="utf-8"
+ )
+
+ @property
+ def state(self) -> dict:
+ return json.loads(self.state_file.read_text(encoding="utf-8"))
+
+ def seed_release(self, tag: str, assets: List[str]) -> None:
+ state = self.state
+ rid = state["next_id"]
+ state["next_id"] += 1
+ state["releases"][str(rid)] = {
+ "id": rid,
+ "tag_name": tag,
+ "name": tag,
+ "body": "old",
+ "assets": [{"id": rid * 1000 + n, "name": a, "size": 1} for n, a in enumerate(assets)],
+ }
+ self.state_file.write_text(json.dumps(state), encoding="utf-8")
+
+ def release(self, tag: str) -> dict:
+ for rel in self.state["releases"].values():
+ if rel["tag_name"] == tag:
+ return rel
+ raise AssertionError(f"no release for tag {tag!r}")
+
+ def asset_names(self, tag: str) -> List[str]:
+ return [a["name"] for a in self.release(tag)["assets"]]
+
+ def uploads(self, tag: str) -> List[str]:
+ """Asset names POSTed to this tag's release, in upload order."""
+ prefix = f"{API}/releases/{self.release(tag)['id']}/assets?name="
+ return [
+ unquote(e["url"][len(prefix) :])
+ for e in self.state["log"]
+ if e["method"] == "POST" and e["url"].startswith(prefix)
+ ]
+
+ def run_publish(
+ self, root: Path, tag: str = "v0.18.5", fail: str = ""
+ ) -> subprocess.CompletedProcess:
+ return _run_step(
+ PUBLISH,
+ root,
+ {"gitea.ref_name": tag},
+ {
+ "FAKE_GITEA_STATE": str(self.state_file),
+ "FAKE_GITEA_BASE": API,
+ "FAKE_GITEA_FAIL": fail,
+ },
+ path_prefix=self.bin,
+ )
+
+
+def _staged_names(out: Path) -> List[str]:
+ return sorted(p.name for sub in ("repo", "keys") for p in (out / sub).iterdir())
+
+
+@pytest.fixture()
+def staged(tmp_path: Path) -> Path:
+ if shutil.which("python3") is None:
+ pytest.skip("python3 is required by the publish step")
+ _stage(tmp_path)
+ return tmp_path
+
+
+def test_publish_attaches_the_full_repository_to_both_releases(staged: Path) -> None:
+ gitea = FakeGitea(staged)
+ result = gitea.run_publish(staged)
+ assert result.returncode == 0, result.stdout + result.stderr
+ expected = _staged_names(staged / "publish" / "xbps")
+ for tag in ("v0.18.5", "latest"):
+ assert sorted(gitea.asset_names(tag)) == expected, tag
+ uploaded = gitea.uploads(tag)
+ assert sorted(uploaded) == expected, f"{tag}: uploaded {uploaded}"
+ assert uploaded[-1] == "x86_64-repodata", "the index goes last, after its packages"
+ assert FINGERPRINT in gitea.release("v0.18.5")["body"]
+
+
+def test_publish_creates_latest_at_the_workflow_commit(staged: Path) -> None:
+ gitea = FakeGitea(staged)
+ assert gitea.run_publish(staged).returncode == 0
+ latest = gitea.release("latest")
+ assert latest["target_commitish"] == SHA
+ assert latest["tag_name"] == "latest"
+ tag_release = gitea.release("v0.18.5")
+ assert tag_release["target_commitish"] == SHA
+ assert not tag_release["draft"] and not latest["draft"]
+ assert "v0.18.5" in latest["body"], "latest says which tag it was built from"
+
+
+def test_publish_talks_only_to_the_fork_api_authenticated(staged: Path) -> None:
+ gitea = FakeGitea(staged)
+ assert gitea.run_publish(staged).returncode == 0
+ log = gitea.state["log"]
+ assert log, "the publish step made no API calls"
+ for entry in log:
+ assert entry["url"].startswith(API + "/"), entry["url"]
+ assert entry["auth"] == [f"Authorization: token {TOKEN}"], entry
+ # The tag release is created and filled before latest is touched.
+ posts = [e["data"]["tag_name"] for e in log if e["url"] == API + "/releases"]
+ assert posts == ["v0.18.5", "latest"]
+
+
+def test_publish_replaces_every_asset_on_an_existing_latest_release(staged: Path) -> None:
+ gitea = FakeGitea(staged)
+ gitea.seed_release("latest", ["voculinux-0.18.4_1.x86_64.xbps", "x86_64-repodata", "old.txt"])
+ assert gitea.run_publish(staged).returncode == 0
+ assert sorted(gitea.asset_names("latest")) == _staged_names(staged / "publish" / "xbps")
+ assert len([r for r in gitea.state["releases"].values() if r["tag_name"] == "latest"]) == 1
+ assert "v0.18.5" in gitea.release("latest")["body"]
+
+
+def test_publish_rerun_keeps_foreign_assets_on_the_tag_release(staged: Path) -> None:
+ """Rerunning a tag replaces only its own files; hand-attached assets survive."""
+ gitea = FakeGitea(staged)
+ gitea.seed_release("v0.18.5", ["x86_64-repodata", "voculinux-0.18.5.tar.gz"])
+ assert gitea.run_publish(staged).returncode == 0
+ names = gitea.asset_names("v0.18.5")
+ assert "voculinux-0.18.5.tar.gz" in names
+ assert names.count("x86_64-repodata") == 1
+ assert set(_staged_names(staged / "publish" / "xbps")) <= set(names)
+
+
+def test_publish_percent_encodes_the_colon_fingerprint_asset_name(staged: Path) -> None:
+ gitea = FakeGitea(staged)
+ assert gitea.run_publish(staged).returncode == 0
+ plist_urls = [e["url"] for e in gitea.state["log"] if ".plist" in e["url"]]
+ assert plist_urls and all(":" not in u.split("name=")[1] for u in plist_urls), plist_urls
+ assert f"{FINGERPRINT}.plist" in gitea.asset_names("latest")
+
+
+@pytest.mark.parametrize(
+ "fail",
+ [
+ "POST|/releases|403", # token may not create releases
+ "POST|/assets?|500", # an upload fails midway
+ "DELETE|/assets/|500",
+ "GET|/releases/tags/|500",
+ "PATCH|/releases/|500",
+ ],
+)
+def test_publish_fails_the_job_on_any_non_2xx_response(staged: Path, fail: str) -> None:
+ gitea = FakeGitea(staged)
+ gitea.seed_release("latest", ["stale"])
+ gitea.seed_release("v0.18.5", ["x86_64-repodata"])
+ result = gitea.run_publish(staged, fail=fail)
+ assert result.returncode != 0, f"{fail} must fail the job"
+ assert "HTTP" in result.stderr or "failed" in result.stderr
+
+
+def test_publish_does_not_touch_latest_when_the_tag_release_fails(staged: Path) -> None:
+ gitea = FakeGitea(staged)
+ result = gitea.run_publish(staged, fail="POST|/assets?|500")
+ assert result.returncode != 0
+ assert not any(r["tag_name"] == "latest" for r in gitea.state["releases"].values())
+
+
+def test_publish_uploads_only_staged_files(staged: Path) -> None:
+ """Nothing is built or created by hand: uploads are exactly the staged set."""
+ gitea = FakeGitea(staged)
+ assert gitea.run_publish(staged).returncode == 0
+ staged_names = set(_staged_names(staged / "publish" / "xbps"))
+ for tag in ("v0.18.5", "latest"):
+ assert set(gitea.uploads(tag)) == staged_names
+ for entry in gitea.state["log"]:
+ for form in entry["forms"]:
+ path = re.match(r"attachment=@(.*?);", form).group(1)
+ assert path.startswith(("repo/", "keys/")), path
diff --git a/uv.lock b/uv.lock
index 199f238..b497bb4 100644
--- a/uv.lock
+++ b/uv.lock
@@ -10,6 +10,10 @@ resolution-markers = [
"python_full_version < '3.12' and sys_platform == 'ios'",
]
+[options]
+exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
+exclude-newer-span = "P7D"
+
[[package]]
name = "alabaster"
version = "1.0.0"
diff --git a/web/DESIGN.md b/web/DESIGN.md
index d23a69f..920d3d2 100644
--- a/web/DESIGN.md
+++ b/web/DESIGN.md
@@ -136,7 +136,7 @@ Flat fills, 1px borders, paper-window shadows. Small rotations only on decorativ
- Long URLs wrap (`break-all` / `overflow-wrap`) so mobile never overflows
### Navigation
-- Sticky paper bar, mark + name, 3–5 text links, GitHub stars on desktop
+- Sticky paper bar, mark + name, 3–5 text links, a "Source" link to the fork repository on desktop (no star count)
- Mobile menu keeps Install Voculinux as the conversion action
- Real mobile disclosure with `aria-expanded`
- Resource-rich footer, not a second mega-menu in the header
diff --git a/web/PRODUCT.md b/web/PRODUCT.md
index 241e0b8..99a6d13 100644
--- a/web/PRODUCT.md
+++ b/web/PRODUCT.md
@@ -22,7 +22,7 @@ Offline-first Linux voice typing with real desktop integration (system tray, X11
- System tray indicator and settings GUI (GTK) with searchable sidebar navigation and an always-visible sidebar footer for dictation status / Test Dictation / Close
- Dictation into terminals, browsers, IDEs, office apps
- Engines and models chosen for hardware (CPU, optional Vulkan GPU with discrete-device preference)
-- Guides and comparison pages on voculinux.com; source on GitHub
+- Guides and comparison pages on voculinux.com; source on the fork's own Gitea host (`https://git.bongbetic.com/xavierk/voculinux`); upstream GitHub is linked only as labelled provenance
## Capabilities and constraints
diff --git a/web/next.config.js b/web/next.config.js
index 063779c..a1b0e16 100644
--- a/web/next.config.js
+++ b/web/next.config.js
@@ -1,5 +1,3 @@
-import { PHASE_PRODUCTION_BUILD } from 'next/constants.js';
-
// @ts-ignore
/** @type {import("next").NextConfig} */
const config = {
@@ -73,29 +71,4 @@ const config = {
},
};
-// The header star count is fetched once per production build and inlined, so
-// visitors never call api.github.com (Lighthouse flagged it as third-party
-// code on every page). Any failure falls back to the plain "GitHub" label.
-async function fetchGitHubStars() {
- try {
- const res = await fetch('https://api.github.com/repos/VocaHQ/vocalinux', {
- headers: process.env.GITHUB_TOKEN
- ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
- : {},
- signal: AbortSignal.timeout(5000),
- });
- if (!res.ok) return '';
- const data = await res.json();
- return data.stargazers_count ? String(data.stargazers_count) : '';
- } catch {
- return '';
- }
-}
-
-export default async function nextConfig(phase) {
- if (phase !== PHASE_PRODUCTION_BUILD) return config;
- return {
- ...config,
- env: { NEXT_PUBLIC_GITHUB_STARS: await fetchGitHubStars() },
- };
-}
+export default config;
diff --git a/web/src/app/__tests__/seo-assets.test.ts b/web/src/app/__tests__/seo-assets.test.ts
index e0d147d..e17a234 100644
--- a/web/src/app/__tests__/seo-assets.test.ts
+++ b/web/src/app/__tests__/seo-assets.test.ts
@@ -86,9 +86,11 @@ describe("SEO Assets", () => {
continue;
}
const routePath = `${prefix}/${entry.name}`;
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- walks this repo's own src/app tree; no external input
if (fs.existsSync(path.join(dir, entry.name, "page.tsx"))) {
routes.push(`https://voculinux.com${routePath}/`);
}
+ // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- walks this repo's own src/app tree; no external input
walk(path.join(dir, entry.name), routePath);
}
};
diff --git a/web/src/app/__tests__/viewport-layout.test.ts b/web/src/app/__tests__/viewport-layout.test.ts
index dbadd4c..32e895c 100644
--- a/web/src/app/__tests__/viewport-layout.test.ts
+++ b/web/src/app/__tests__/viewport-layout.test.ts
@@ -38,6 +38,7 @@ const CHROME =
async function resolveBaseUrl(): Promise {
if (process.env.BASE_URL) return process.env.BASE_URL.replace(/\/$/, "");
try {
+ // nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request -- loopback dev-server probe in a test; never a shipped request
const res = await fetch("http://127.0.0.1:3456/", { signal: AbortSignal.timeout(2000) });
if (res.ok) return "http://127.0.0.1:3456";
} catch {
diff --git a/web/src/app/changelog/page.tsx b/web/src/app/changelog/page.tsx
index 12cbe62..5d04d38 100644
--- a/web/src/app/changelog/page.tsx
+++ b/web/src/app/changelog/page.tsx
@@ -11,7 +11,11 @@ import {
Zap,
} from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell";
-import { absoluteUrl, buildPageMetadata } from "@/lib/seo";
+import {
+ absoluteUrl,
+ buildPageMetadata,
+ UPSTREAM_REPO_URL,
+} from "@/lib/seo";
const releases = [
{
@@ -574,6 +578,11 @@ export default function ChangelogPage() {
Track every release and see how Voculinux has evolved. From initial
alpha to stable releases, follow the journey of Linux voice dictation.
+
+ Fork status: the releases below are the upstream Vocalinux history
+ this fork is ported from. Voculinux fork releases are not published
+ yet, so each release link goes to upstream GitHub Releases.
+
@@ -644,13 +653,13 @@ export default function ChangelogPage() {
diff --git a/web/src/app/faq/page.tsx b/web/src/app/faq/page.tsx
index b3100bf..84a1614 100644
--- a/web/src/app/faq/page.tsx
+++ b/web/src/app/faq/page.tsx
@@ -12,7 +12,7 @@ import {
Zap,
} from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell";
-import { buildPageMetadata } from "@/lib/seo";
+import { buildPageMetadata, SOURCE_REPO_URL } from "@/lib/seo";
const faqCategories = [
{
@@ -41,11 +41,11 @@ const faqCategories = [
questions: [
{
q: "Does Voculinux work offline?",
- a: "Local engines (whisper.cpp, Whisper, and VOSK) run on your Linux machine. Remote API is optional and only talks to a server you configure.",
+ a: "Local engines run on your Linux machine: whisper.cpp is the default, and Faster Whisper, Whisper, VOSK and Parakeet are best-effort extras. Remote API is optional and only talks to a server you configure; audio leaves the machine for that server.",
},
{
q: "Does Voculinux send any data to external servers?",
- a: "Local engines do not send voice data externally. Remote API mode sends audio only to the transcription server URL you configure.",
+ a: "Local engines do not send voice data externally. Remote API mode sends audio only to the transcription server URL you configure. The app also uses the network for model downloads and for its update check against GitHub Releases; neither carries your audio or transcripts.",
},
{
q: "Is my voice data stored anywhere?",
@@ -239,12 +239,12 @@ export default function FaqPage() {
- Open an issue on GitHub
+ Open an issue in the Voculinux repository
diff --git a/web/src/app/flatpak/page.tsx b/web/src/app/flatpak/page.tsx
index b8f6f91..67faccf 100644
--- a/web/src/app/flatpak/page.tsx
+++ b/web/src/app/flatpak/page.tsx
@@ -457,8 +457,9 @@ flatpak update`}
Need a different package?
- The install script covers every engine, the AUR and Nix flake fit Arch
- and NixOS, and the .deb and .rpm suit Debian, Ubuntu and Fedora.
+ The install script covers every engine, the Nix flake fits NixOS, and
+ the .deb and .rpm suit Debian, Ubuntu and Fedora. The AUR packages for
+ Arch are not published yet.
= {
name: "Arch Linux",
title: "Install Voice Dictation on Arch Linux",
description:
- "Arch Linux voice dictation guide for offline speech recognition. Install Voculinux from the AUR or the install script on Arch, Manjaro and EndeavourOS.",
+ "Arch Linux voice dictation guide for offline speech recognition on Arch, Manjaro and EndeavourOS with the install script. The Voculinux AUR packages are not published yet.",
intro:
- "Arch users can install Voculinux from the AUR or with the install script. Both keep whisper.cpp as the default engine and work across rolling-release desktops.",
+ "Arch users can set up dictation with the install script, which keeps whisper.cpp as the default engine and works across rolling-release desktops. The Voculinux AUR packages are not published yet, so there is no AUR install path today.",
prerequisites: [
"Arch Linux, Manjaro or EndeavourOS",
"Python 3.11+ with python-gobject",
"Working microphone",
- "curl installed (sudo pacman -Sy --needed curl), or an AUR helper such as yay",
+ "curl installed (sudo pacman -Sy --needed curl)",
],
supportedOn: [
"Arch Linux (rolling): good support",
@@ -184,7 +184,7 @@ const distroConfig: Record = {
installCommand: INSTALL_SCRIPT_COMMAND,
otherInstallOptions: [
{
- label: "AUR packages",
+ label: "AUR packages (not published yet)",
note: "Fork status: fork AUR packages are not published yet. Once published, voculinux builds the canonical release sdist against Arch dependencies. voculinux-bin installs the prebuilt release AppImage. voculinux-git is a community package that tracks main. The three conflict, so install only one.",
command: `yay -S voculinux # canonical release sdist, built against Arch dependencies
yay -S voculinux-git # community package; tracks main`,
@@ -194,7 +194,7 @@ yay -S voculinux-git # community package; tracks main`,
"Prefer whisper.cpp: it avoids the full PyTorch stack that OpenAI Whisper pulls in.",
"On custom compositor setups, verify text injection behavior in both terminal and browser contexts.",
"If you tweak keybinds heavily, set a non-conflicting activation shortcut in Voculinux settings.",
- "AUR packages update with your AUR helper; the in-app update checker only notifies.",
+ "Once the AUR packages are published they will update with your AUR helper; the in-app update checker only notifies.",
],
postInstallChecks: [
...COMMON_CHECKS,
diff --git a/web/src/app/install/nixos/page.tsx b/web/src/app/install/nixos/page.tsx
index 640fbc9..a4b45e3 100644
--- a/web/src/app/install/nixos/page.tsx
+++ b/web/src/app/install/nixos/page.tsx
@@ -391,8 +391,9 @@ export default function NixosInstallPage() {
Other ways to install
- Not on Nix? The install script, AUR, .deb, .rpm, AppImage and the
- VocaHQ Flatpak remote cover most other systems.
+ Not on Nix? The install script and the upstream .deb, .rpm and
+ AppImage downloads cover most other systems. The AUR packages and the
+ fork Flatpak remote are not published yet.
Fork status: packaged artefacts (AppImage, .deb/.rpm, Flatpak, Snap,
- AUR) are not published yet — the cards above link upstream artefacts
- or reserved fork names until the first fork release lands.
+ AUR) are not published yet. The AppImage card links upstream
+ artefacts; the Snap and AUR cards name the planned fork packages and
+ do not link anywhere until the first fork release lands.
@@ -218,15 +218,11 @@ export default function InstallGuidesPage() {
After installation
- Launch with{" "}
-
- vocalinux
- {" "}
- (the upstream binary; fork releases will use{" "}
-
- voculinux
-
- ).
+ Launch with voculinux once fork
+ releases are published. The install command above fetches the
+ upstream script, so today it puts{" "}
+ vocalinux (upstream) on your PATH
+ instead.
Hold Right Alt (Option) to dictate, or switch to toggle mode in
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx
index 4648b84..cc4a807 100644
--- a/web/src/app/layout.tsx
+++ b/web/src/app/layout.tsx
@@ -6,7 +6,7 @@ import {
absoluteUrl,
DEFAULT_OG_IMAGE_ALT,
DEFAULT_OG_IMAGE_PATH,
- GITHUB_REPO_URL,
+ SOURCE_REPO_URL,
SITE_NAME,
SITE_URL,
TWITTER_HANDLE,
@@ -128,12 +128,7 @@ const organizationJsonLd = {
url: absoluteUrl("/"),
logo: absoluteUrl("/voculinux.png"),
description: "Open-source, offline voice dictation software for Linux.",
- sameAs: [GITHUB_REPO_URL, "https://bongbetic.com"],
- founder: {
- "@type": "Person",
- name: "Jatin K Malik",
- url: "https://github.com/jatinkrmalik",
- },
+ sameAs: [SOURCE_REPO_URL, "https://bongbetic.com"],
};
const webSiteJsonLd = {
@@ -152,7 +147,7 @@ const webSiteJsonLd = {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
- urlTemplate: `${GITHUB_REPO_URL}/issues?q={search_term_string}`,
+ urlTemplate: `${SOURCE_REPO_URL}/issues?q={search_term_string}`,
},
"query-input": {
"@type": "PropertyValueSpecification",
diff --git a/web/src/app/offline/page.tsx b/web/src/app/offline/page.tsx
index 13eaadb..56131f8 100644
--- a/web/src/app/offline/page.tsx
+++ b/web/src/app/offline/page.tsx
@@ -11,7 +11,7 @@ import {
XCircle,
} from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell";
-import { absoluteUrl, buildPageMetadata } from "@/lib/seo";
+import { absoluteUrl, buildPageMetadata, SOURCE_REPO_URL } from "@/lib/seo";
const benefits = [
{
@@ -23,9 +23,9 @@ const benefits = [
iconBg: "bg-primary/10",
},
{
- title: "Zero Data Collection",
+ title: "No usage telemetry",
description:
- "No telemetry, no analytics, no usage tracking. Local engines have nowhere to send audio unless you enable Remote API.",
+ "The installed app sends no usage telemetry or analytics. Local engines do not send audio anywhere unless you enable Remote API. Model downloads and update checks still use the network.",
icon: Database,
iconColor: "text-primary",
iconBg: "bg-primary/10",
@@ -61,7 +61,7 @@ const sensitiveUseCases = [
{
title: "Local engines keep audio on the machine",
description:
- "whisper.cpp, Whisper, and VOSK process speech on your hardware. That is a default for those engines, not a legal or compliance promise.",
+ "Local engines process speech on your hardware: whisper.cpp is the default, and Faster Whisper, Whisper, VOSK and Parakeet are best-effort extras. That is a default for those engines, not a legal or compliance promise.",
},
{
title: "Works after the model is downloaded",
@@ -252,14 +252,14 @@ export default function OfflinePage() {
@@ -281,7 +281,7 @@ export default function OfflinePage() {
-
Truly Private Voice Dictation
+
Voice Dictation That Stays on Your Machine by Default
Install Voculinux and use a local engine if you want speech recognition on the machine.
Remote API stays optional.
diff --git a/web/src/app/open-source/page.tsx b/web/src/app/open-source/page.tsx
index 60148c5..40fa856 100644
--- a/web/src/app/open-source/page.tsx
+++ b/web/src/app/open-source/page.tsx
@@ -14,7 +14,7 @@ import {
Zap,
} from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell";
-import { absoluteUrl, buildPageMetadata } from "@/lib/seo";
+import { absoluteUrl, buildPageMetadata, SOURCE_REPO_URL } from "@/lib/seo";
const benefits = [
{
@@ -58,7 +58,7 @@ const vsProprietary = [
{ feature: "Linux Support", voculinux: "Native", proprietary: "Limited or none" },
{ feature: "Customization", voculinux: "Unlimited", proprietary: "Limited to vendor options" },
{ feature: "Vendor Dependency", voculinux: "None", proprietary: "High" },
- { feature: "Community Support", voculinux: "Active GitHub community", proprietary: "Paid support only" },
+ { feature: "Community Support", voculinux: "Public issue tracker", proprietary: "Paid support only" },
];
const techStack = [
@@ -87,7 +87,7 @@ const techStack = [
export const metadata: Metadata = buildPageMetadata({
title: "Open Source Dictation Software for Linux (AGPL-3.0)",
description:
- "Free, open source dictation software for Linux under AGPL-3.0. Offline speech recognition with whisper.cpp or VOSK, no required account, all code on GitHub.",
+ "Free, open source dictation software for Linux under AGPL-3.0. Offline speech recognition with whisper.cpp or VOSK, no required account, all code in the public repository.",
path: "/open-source",
keywords: [
"open source voice dictation",
@@ -141,14 +141,14 @@ export default function OpenSourcePage() {
Free & Open Source Voice Dictation
- Voculinux is 100% open source under the AGPL-3.0 license, and part of{" "}
+ Voculinux is 100% open source under the AGPL-3.0 license, a hard fork of{" "}
- VocaHQ
+ Vocalinux (upstream, by VocaHQ)
. No subscriptions, no vendor lock-in, no hidden costs. Local engines keep
audio on the machine. Remote API is optional.
@@ -289,13 +289,13 @@ export default function OpenSourcePage() {
translations - join the community.
Fork status: packaged artefacts (AppImage, .deb/.rpm, Flatpak, Snap,
- AUR, xbps) are not published yet — the cards above link upstream
- artefacts or reserved fork names until the first fork release lands.
+ AUR, xbps) are not published yet. The AppImage card links upstream
+ artefacts; the Snap and AUR cards name the planned fork packages and
+ do not link anywhere until the first fork release lands.
Ubuntu guide
@@ -900,7 +896,7 @@ export default function HomePage() {
{
question: "Does Voculinux collect usage telemetry?",
answer:
- "No. The installed app does not send usage telemetry, analytics events, or background usage pings.",
+ "No. The installed app does not send usage telemetry or analytics events. Its update checker only asks GitHub Releases whether a newer version exists.",
},
{
question: "Which Linux distributions are supported?",
@@ -1016,7 +1012,7 @@ export default function HomePage() {
- Fully open source under AGPL-3.0. Inspect the code yourself on GitHub. The installed
- app does not send usage telemetry.
+ Fully open source under AGPL-3.0. Inspect the code yourself in the Voculinux source
+ repository. The installed app does not send usage telemetry.
@@ -139,6 +139,11 @@ export default function PrivacyPage() {
Local engines keep audio on your device. Remote API sends audio only
to the server you configure.
+
+ The installed app does use the network for model downloads and for
+ its update check against GitHub Releases. Neither request carries
+ your audio or transcripts. Settings has a proxy page for both.
+
Those limits apply to the installed app. They do not describe this
website.
@@ -179,7 +184,7 @@ export default function PrivacyPage() {
2. Local Processing - Audio is
- processed by whisper.cpp, Whisper, or VOSK on your CPU/GPU
+ processed by your local engine (whisper.cpp by default) on your CPU/GPU
3. Text Output - Transcribed text is
@@ -242,7 +247,7 @@ export default function PrivacyPage() {
-
Data leaves device
+
Audio leaves device
Only with Remote API
@@ -251,7 +256,7 @@ export default function PrivacyPage() {
Internet required
- Only for Remote API or a model download
+ Only for Remote API, a model download or the update check
Yes
@@ -283,12 +288,12 @@ export default function PrivacyPage() {
please reach out:
- Open an issue on GitHub
+ Open an issue in the Voculinux repository
diff --git a/web/src/app/troubleshooting/page.tsx b/web/src/app/troubleshooting/page.tsx
index eac924a..4789dac 100644
--- a/web/src/app/troubleshooting/page.tsx
+++ b/web/src/app/troubleshooting/page.tsx
@@ -10,7 +10,7 @@ import {
Wrench,
} from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell";
-import { absoluteUrl, buildPageMetadata } from "@/lib/seo";
+import { absoluteUrl, buildPageMetadata, SOURCE_REPO_URL } from "@/lib/seo";
const troubleshootingItems = [
{
@@ -221,7 +221,7 @@ export default function TroubleshootingPage() {
Having issues with Voculinux? Find solutions to common problems below.
- Can't find your issue? Open a GitHub issue for help.
+ Can't find your issue? Open an issue in the Voculinux repository for help.