fix(spec16): close the review findings for the Void port (#34)
CI / security (push) Successful in 1m14s
CI / lint (push) Successful in 24s
CI / ai-review (push) Skipped

Model recommendation keyed to the installed build and free memory, fork-sourced Void templates, signed xbps release workflow on Gitea with the pinned repository key, niri injection guard and wtype-first order, tightened naming sweep, web copy fixes, and CI security and duplication fixes.
This commit was merged in pull request #34.
This commit is contained in:
2026-11-06 14:13:49 +00:00
parent 5075933677
commit 180cd4d512
68 changed files with 2566 additions and 1138 deletions
+326
View File
@@ -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:-<none>} 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"
+2
View File
@@ -8,6 +8,8 @@ updates:
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
github-actions:
patterns:
+31 -122
View File
@@ -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 <fingerprint>.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:
+1
View File
@@ -13,6 +13,7 @@
"**/dist/**",
"**/build/**",
"**/*.lock",
"requirements/*.txt",
"**/package-lock.json",
"**/*.md",
"docs/**"
+18
View File
@@ -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
+20 -13
View File
@@ -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 <fingerprint>.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
+3 -2
View File
@@ -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
+9
View File
@@ -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
+78 -11
View File
@@ -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
+3 -2
View File
@@ -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
+99 -33
View File
@@ -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 <soubarna@live.in>`.
`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 <fingerprint>.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.
+3 -4
View File
@@ -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
;;
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>public-key</key>
<data>LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUE5dDhGbEVPUXNaUDZROHNhaXlZMQpRSkhpQ1A5ZU00aWd3R2xRQ1FxMncxOVlkOURza2wxNTdsZEJFTHJBWkVVV2orYjJYODVHc0xKZFNGbHRGaTRUClZFUGpIUlZpbVdFU3V1MG0zK28zK3V2d0R4LzlaZHRMK01aZUIwWTBHZThFSGM1aVFCSk9NSnVSZTlzWERQSEwKclB6ZUZ3NFE4SEYyWGdEVXpvNUZncVd4TEIyQ0xleEhFVGhxNHNSWWFtUU85OE9JS0xWUWVSbEpBOVU4RXBoRgp3QUQ5R3BBR1FxUnBZMTRYV09nNmIva2RpMUhjeWxMZ1p0YVoyQzE1TW5KT2lTanpTTjRKdDdmdHg2czNKRGJzCmpPTjRsWXZxakFETkZQdEFKbmxlUlVHSEwxL20xa3VMbG11eFUyVThkOGJGK1dxUGFKem1oQUcvSHBNdkJqOXMKTjFSYUE0RkhIZ2pueUc4dHJibzQyUjByN1BGb1p6T1g2VWNzSmRMaUZVMFpqRll2QVh6MmViMmlvUjR0eTNWOQoxb0UvbFBsaGQ5SzFPdmZBazRFRVdYMHhCcThlMkEzczZSNG9jWDdlNzFJY2QwbVowRjVTMDhmR1ZlamdvTm5hCmJBcXRQaXdFZGdoVVExT0FGdS9VQmlxdkloODJNbmphbVNGN25BaUw4UldxMFZiSlZ5bGNqOVVtV0R5N3FWam8KTjFkWFRHNGcrT3AyM2JpZlVxMDJzRERzVXhZd3ptdHVtVENHczlSWlQ2N0JpNXRRRU1DZy9ZbUhkRkllbnEzUwpRSVNpYlNWejA2T3M1U3RJZHRlM2dEcjBROHRLYnJzRVk1Q1IwSnM1d1hYOFJvZ3MwbVArRXV2OVByNTNNT1k0CllidXpENEMzWWcvNkhjelY0WHkwOGVjQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=</data>
<key>public-key-size</key>
<integer>4096</integer>
<key>signature-by</key>
<string>Bongbetic &lt;soubarna@live.in&gt;</string>
</dict>
</plist>
+10 -3
View File
@@ -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:
+17 -26
View File
@@ -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 <jatinkrmalik@gmail.com>"
maintainer="Xavier Karma <xavier@bongbetic.com>"
license="AGPL-3.0-only"
homepage="https://github.com/VocaHQ/vocalinux"
distfiles="https://github.com/VocaHQ/vocalinux/releases/download/v${version}/vocalinux-${version}.tar.gz"
checksum=1def4d8f0f14f2df2f2fee64ca29e6865e3d82e551f7d465c2a78ec6d4d72b1d
# The 0.18.5 release asset still carries the pre-rename archive root.
# 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"
}
+2
View File
@@ -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.
+16 -3
View File
@@ -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)
+12 -1
View File
@@ -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:
+1
View File
@@ -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"]
+1
View File
@@ -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:
+2
View File
@@ -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
+31 -26
View File
@@ -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"
+4 -1
View File
@@ -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:
+3 -2
View File
@@ -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)
+11
View File
@@ -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
+16 -23
View File
@@ -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):
+1 -1
View File
@@ -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:
@@ -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",
+42 -13
View File
@@ -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,
@@ -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 "", []
+2 -3
View File
@@ -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 ""
+30 -48
View File
@@ -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),
},
}
+25 -64
View File
@@ -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:
+3
View File
@@ -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)
+5 -1
View File
@@ -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
+104 -1
View File
@@ -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."""
+30 -23
View File
@@ -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()
+7 -6
View File
@@ -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():
+156 -92
View File
@@ -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/<file>.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\.<system>\.|apps\.<system>\.|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", "<h1>Install vocalinux today</h1>"),
("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"
+7 -2
View File
@@ -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",
{
+12 -11
View File
@@ -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."""
+80 -31
View File
@@ -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/<name>/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:
+190 -70
View File
@@ -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:
"<repo-url-from-release-notes>" 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"
+98 -315
View File
@@ -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):
+764
View File
@@ -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
(``<pkg>.x86_64.xbps`` plus ``<pkg>.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 <xbps@voculinux.com>\nfingerprint: {fingerprint}\n",
encoding="utf-8",
)
(keys / f"{fingerprint}.plist").write_text("<plist></plist>\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
Generated
+4
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -28
View File
@@ -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;
+2
View File
@@ -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);
}
};
@@ -38,6 +38,7 @@ const CHROME =
async function resolveBaseUrl(): Promise<string | null> {
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 {
+12 -3
View File
@@ -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.
</p>
<p className="mb-8 max-w-4xl text-sm text-muted-foreground">
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.
</p>
</section>
<section className="space-y-6">
@@ -644,13 +653,13 @@ export default function ChangelogPage() {
<div className="mt-4 border-t border-border pt-4">
<a
href={`https://github.com/VocaHQ/vocalinux/releases/tag/${release.version}`}
href={`${UPSTREAM_REPO_URL}/releases/tag/${release.version}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
<Download className="h-4 w-4" />
View release on GitHub
View upstream release on GitHub
<ChevronRight className="h-4 w-4" />
</a>
</div>
+5 -5
View File
@@ -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() {
<span className="inline-flex items-center gap-2">
<ChevronRight className="h-4 w-4 text-primary" />
<a
href="https://github.com/VocaHQ/vocalinux/issues"
href={`${SOURCE_REPO_URL}/issues`}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-primary hover:underline"
>
Open an issue on GitHub
Open an issue in the Voculinux repository
</a>
</span>
</li>
+3 -2
View File
@@ -457,8 +457,9 @@ flatpak update`}</Commands>
Need a different package?
</h2>
<p className="mb-6 text-muted-foreground">
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.
</p>
<div className="flex flex-wrap gap-4">
<Link
+5 -5
View File
@@ -167,14 +167,14 @@ const distroConfig: Record<DistroSlug, DistroConfig> = {
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<DistroSlug, DistroConfig> = {
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,
+3 -2
View File
@@ -391,8 +391,9 @@ export default function NixosInstallPage() {
Other ways to install
</h2>
<p className="mb-6 text-muted-foreground">
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.
</p>
<div className="flex flex-wrap gap-4">
<Link
+25 -29
View File
@@ -3,7 +3,11 @@ import { type Metadata } from "next";
import { ChevronRight, Cpu, Laptop, Terminal } from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell";
import { TerminalBlock } from "@/components/terminal-block";
import { buildPageMetadata } from "@/lib/seo";
import {
buildPageMetadata,
SOURCE_REPO_URL,
UPSTREAM_REPO_URL,
} from "@/lib/seo";
const installCommand =
"curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh -o /tmp/vl.sh && bash /tmp/vl.sh --interactive";
@@ -12,6 +16,9 @@ const installDisplayCommand = `curl -fsSL \\
-o /tmp/vl.sh && \\
bash /tmp/vl.sh --interactive`;
const codeClass =
"rounded bg-muted px-1.5 py-0.5 font-mono text-sm text-foreground";
const distroGuides = [
{
name: "Ubuntu",
@@ -113,7 +120,7 @@ export default function InstallGuidesPage() {
<div className="install-alts">
<a
className="install-alt"
href="https://github.com/VocaHQ/vocalinux/releases"
href={`${UPSTREAM_REPO_URL}/releases`}
rel="noopener noreferrer"
>
<p className="install-alt-kicker">No installer</p>
@@ -125,7 +132,7 @@ export default function InstallGuidesPage() {
</a>
<a
className="install-alt"
href="https://github.com/VocaHQ/vocalinux/blob/main/docs/INSTALL.md#distro-packages-deb--rpm"
href={`${SOURCE_REPO_URL}/src/branch/main/docs/INSTALL.md#distro-packages-deb--rpm`}
rel="noopener noreferrer"
>
<p className="install-alt-kicker">New</p>
@@ -144,11 +151,7 @@ export default function InstallGuidesPage() {
sideload the .flatpak bundle.
</p>
</a>
<a
className="install-alt"
href="https://snapcraft.io/voculinux"
rel="noopener noreferrer"
>
<div className="install-alt">
<p className="install-alt-kicker">Snap Store</p>
<h2>Snap</h2>
<p>
@@ -156,36 +159,33 @@ export default function InstallGuidesPage() {
published (not published yet). amd64 only; a .snap will be
attached to fork releases too.
</p>
</a>
<a
className="install-alt"
href="https://aur.archlinux.org/packages/voculinux"
rel="noopener noreferrer"
>
</div>
<div className="install-alt">
<p className="install-alt-kicker">Arch</p>
<h2>AUR</h2>
<p>
<code>yay -S voculinux</code>, or voculinux-bin for the prebuilt
package. Fork packages are not published yet.
</p>
</a>
</div>
<a
className="install-alt"
href="https://github.com/VocaHQ/vocalinux/blob/main/docs/INSTALL.md#from-source"
href={`${SOURCE_REPO_URL}/src/branch/main/docs/INSTALL.md#from-source`}
rel="noopener noreferrer"
>
<p className="install-alt-kicker">From source</p>
<h2>Build it yourself</h2>
<p>
Clone the repo and run <code>./install.sh</code>. The upstream
install guide has the full source path.
Clone the repo and run <code>./install.sh</code>. The install
guide in the Voculinux repository has the full source path.
</p>
</a>
</div>
<p className="mt-4 text-sm text-muted-foreground">
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.
</p>
</section>
@@ -218,15 +218,11 @@ export default function InstallGuidesPage() {
<h2 className="mb-4">After installation</h2>
<ul className="space-y-3 text-muted-foreground">
<li>
Launch with{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm text-foreground">
vocalinux
</code>{" "}
(the upstream binary; fork releases will use{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm text-foreground">
voculinux
</code>
).
Launch with <code className={codeClass}>voculinux</code> once fork
releases are published. The install command above fetches the
upstream script, so today it puts{" "}
<code className={codeClass}>vocalinux</code> (upstream) on your PATH
instead.
</li>
<li>
Hold Right Alt (Option) to dictate, or switch to toggle mode in
+3 -8
View File
@@ -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",
+8 -8
View File
@@ -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() {
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0" />
Read every line of code on{" "}
Read every line of code in the{" "}
<a
href="https://github.com/VocaHQ/vocalinux"
href={SOURCE_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="font-semibold hover:underline"
>
GitHub
Voculinux repository
</a>
</li>
<li className="flex items-start gap-2">
@@ -281,7 +281,7 @@ export default function OfflinePage() {
</section>
<section className="rounded-[12px] border border-border bg-muted p-8">
<h2 className="mb-4 font-display text-2xl font-semibold">Truly Private Voice Dictation</h2>
<h2 className="mb-4 font-display text-2xl font-semibold">Voice Dictation That Stays on Your Machine by Default</h2>
<p className="mb-6 text-muted-foreground">
Install Voculinux and use a local engine if you want speech recognition on the machine.
Remote API stays optional.
+8 -8
View File
@@ -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
</h1>
<p className="mb-8 max-w-4xl text-lg text-muted-foreground">
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{" "}
<a
href="https://vocahq.com"
href="https://github.com/VocaHQ/vocalinux"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary hover:underline"
>
VocaHQ
Vocalinux (upstream, by VocaHQ)
</a>
. 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.
</p>
<a
href="https://github.com/VocaHQ/vocalinux"
href={SOURCE_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full border border-border bg-background px-4 py-2 text-sm font-semibold text-foreground hover:bg-muted"
>
<Zap className="h-4 w-4" />
View on GitHub
View the Voculinux source
</a>
</div>
</div>
+35 -39
View File
@@ -5,20 +5,22 @@ import Link from "next/link";
import Image from "next/image";
import { SiteChrome } from "@/components/site-chrome";
import { TerminalBlock, TerminalPrompt } from "@/components/terminal-block";
import { GITHUB_REPO_URL } from "@/lib/seo";
import { SOURCE_REPO_URL, UPSTREAM_REPO_URL } from "@/lib/seo";
const GITHUB_REPO_PATH = GITHUB_REPO_URL.replace("https://github.com/", "");
const GITHUB_RAW_BASE = `https://raw.githubusercontent.com/${GITHUB_REPO_PATH}`;
// The install and uninstall scripts below are fetched from upstream: the fork
// has no published raw script host yet. The install section labels this.
const UPSTREAM_REPO_PATH = UPSTREAM_REPO_URL.replace("https://github.com/", "");
const UPSTREAM_RAW_BASE = `https://raw.githubusercontent.com/${UPSTREAM_REPO_PATH}`;
const installCommands = {
interactiveInstallCommand: `curl -fsSL ${GITHUB_RAW_BASE}/main/install.sh -o /tmp/vl.sh && bash /tmp/vl.sh --interactive`,
interactiveInstallCommand: `curl -fsSL ${UPSTREAM_RAW_BASE}/main/install.sh -o /tmp/vl.sh && bash /tmp/vl.sh --interactive`,
interactiveInstallDisplayCommand: `curl -fsSL \\
${GITHUB_RAW_BASE}/main/install.sh \\
${UPSTREAM_RAW_BASE}/main/install.sh \\
-o /tmp/vl.sh && \\
bash /tmp/vl.sh --interactive`,
uninstallCommand: `curl -fsSL ${GITHUB_RAW_BASE}/main/uninstall.sh -o /tmp/vul.sh && bash /tmp/vul.sh`,
uninstallCommand: `curl -fsSL ${UPSTREAM_RAW_BASE}/main/uninstall.sh -o /tmp/vul.sh && bash /tmp/vul.sh`,
uninstallDisplayCommand: `curl -fsSL \\
${GITHUB_RAW_BASE}/main/uninstall.sh \\
${UPSTREAM_RAW_BASE}/main/uninstall.sh \\
-o /tmp/vul.sh && \\
bash /tmp/vul.sh`,
};
@@ -32,7 +34,7 @@ const homeJsonLd = [
applicationCategory: "UtilitiesApplication",
operatingSystem: "Linux",
isAccessibleForFree: true,
license: `${GITHUB_REPO_URL}/blob/main/LICENSE`,
license: `${SOURCE_REPO_URL}/src/branch/main/LICENSE`,
offers: {
"@type": "Offer",
price: "0",
@@ -47,7 +49,7 @@ const homeJsonLd = [
url: "https://github.com/jatinkrmalik",
},
url: "https://voculinux.com/",
downloadUrl: GITHUB_REPO_URL,
downloadUrl: SOURCE_REPO_URL,
screenshot: "https://voculinux.com/og-image.png",
featureList: [
"Local speech recognition with whisper.cpp, Faster Whisper, Whisper, VOSK, and Parakeet",
@@ -96,7 +98,7 @@ const homeJsonLd = [
name: "Does Voculinux collect usage telemetry?",
acceptedAnswer: {
"@type": "Answer",
text: "No. The installed app does not send usage telemetry, analytics events, or background usage pings. Even the project maintainer cannot see how many people install or actively use Voculinux.",
text: "No. The installed app does not send usage telemetry or analytics events. Its update checker only asks GitHub Releases whether a newer version exists.",
},
},
{
@@ -238,7 +240,7 @@ const ribbonItems = [
"no account",
"system tray",
"whisper.cpp",
"no telemetry",
"no app telemetry",
];
export default function HomePage() {
@@ -305,7 +307,7 @@ export default function HomePage() {
</a>
<a
className="btn btn-secondary"
href={GITHUB_REPO_URL}
href={SOURCE_REPO_URL}
rel="noopener noreferrer"
>
View the source{" "}
@@ -318,7 +320,7 @@ export default function HomePage() {
</div>
<div className="hero-proof">
<span>X11 and Wayland</span>
<span>No telemetry</span>
<span>No app telemetry</span>
<span>AGPL-3.0</span>
</div>
</div>
@@ -714,8 +716,9 @@ export default function HomePage() {
<article className="boundary">
<h3>What uses a network</h3>
<p>
Model and app downloads, plus any Remote API host you add.
Nothing is sent to a Voca speech cloud.
Model downloads, the update check against GitHub Releases, and
any Remote API host you add. Nothing is sent to a Voca speech
cloud.
</p>
</article>
<article className="boundary">
@@ -759,7 +762,7 @@ export default function HomePage() {
<div className="install-alts">
<a
className="install-alt"
href={`${GITHUB_REPO_URL}/releases`}
href={`${SOURCE_REPO_URL}/releases`}
rel="noopener noreferrer"
>
<p className="install-alt-kicker">Void Linux</p>
@@ -772,7 +775,7 @@ export default function HomePage() {
</a>
<a
className="install-alt"
href={`${GITHUB_REPO_URL}/releases`}
href={`${UPSTREAM_REPO_URL}/releases`}
rel="noopener noreferrer"
>
<p className="install-alt-kicker">No installer</p>
@@ -784,7 +787,7 @@ export default function HomePage() {
</a>
<a
className="install-alt"
href={`${GITHUB_REPO_URL}/blob/main/docs/INSTALL.md#distro-packages-deb--rpm`}
href={`${SOURCE_REPO_URL}/src/branch/main/docs/INSTALL.md#distro-packages-deb--rpm`}
rel="noopener noreferrer"
>
<p className="install-alt-kicker">New</p>
@@ -796,7 +799,7 @@ export default function HomePage() {
</a>
<a
className="install-alt"
href={`${GITHUB_REPO_URL}/blob/main/docs/INSTALL.md#flatpak`}
href={`${SOURCE_REPO_URL}/src/branch/main/docs/INSTALL.md#flatpak`}
rel="noopener noreferrer"
>
<p className="install-alt-kicker">Auto-updates</p>
@@ -807,11 +810,7 @@ export default function HomePage() {
sideload the .flatpak bundle.
</p>
</a>
<a
className="install-alt"
href="https://snapcraft.io/voculinux"
rel="noopener noreferrer"
>
<div className="install-alt">
<p className="install-alt-kicker">Snap Store</p>
<h3>Snap</h3>
<p>
@@ -819,36 +818,33 @@ export default function HomePage() {
is published (not published yet). amd64 only; a .snap will be
attached to fork releases too.
</p>
</a>
<a
className="install-alt"
href="https://aur.archlinux.org/packages/voculinux"
rel="noopener noreferrer"
>
</div>
<div className="install-alt">
<p className="install-alt-kicker">Arch</p>
<h3>AUR</h3>
<p>
<code>yay -S voculinux</code>, or voculinux-bin for the prebuilt
package. Fork packages are not published yet.
</p>
</a>
</div>
<a
className="install-alt"
href={`${GITHUB_REPO_URL}/blob/main/docs/INSTALL.md#from-source`}
href={`${SOURCE_REPO_URL}/src/branch/main/docs/INSTALL.md#from-source`}
rel="noopener noreferrer"
>
<p className="install-alt-kicker">From source</p>
<h3>Build it yourself</h3>
<p>
Clone the repo and run <code>./install.sh</code>. The upstream
install guide has the full source path.
Clone the repo and run <code>./install.sh</code>. The install
guide in the Voculinux repository has the full source path.
</p>
</a>
</div>
<p className="mt-4 text-sm text-muted-foreground">
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.
</p>
<p className="section-link" style={{ marginTop: "1.6rem" }}>
<Link href="/install/ubuntu/">Ubuntu guide</Link>
@@ -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() {
</div>
<a
className="family-card"
href={GITHUB_REPO_URL}
href={UPSTREAM_REPO_URL}
rel="noopener noreferrer"
>
<div className="family-card-top">
@@ -1081,7 +1077,7 @@ export default function HomePage() {
</a>
<a
className="btn btn-secondary"
href={GITHUB_REPO_URL}
href={SOURCE_REPO_URL}
rel="noopener noreferrer"
>
View the source
+13 -8
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import { type Metadata } from "next";
import { CheckCircle2, Lock, Server, Shield, Eye, Database, ExternalLink } 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";
export const metadata: Metadata = buildPageMetadata({
title: "Privacy Policy - Local Voice Dictation",
@@ -92,8 +92,8 @@ export default function PrivacyPage() {
</div>
<h2 className="subpage-card-title mb-2 text-xl font-semibold">Open Source</h2>
<p className="text-sm text-muted-foreground">
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.
</p>
</article>
</section>
@@ -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.
</p>
<p>
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.
</p>
<p>
Those limits apply to the installed app. They do not describe this
website.
@@ -179,7 +184,7 @@ export default function PrivacyPage() {
</li>
<li className="border-l-2 border-primary pl-4">
<strong className="text-foreground">2. Local Processing</strong> - 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
</li>
<li className="border-l-2 border-primary pl-4">
<strong className="text-foreground">3. Text Output</strong> - Transcribed text is
@@ -242,7 +247,7 @@ export default function PrivacyPage() {
</thead>
<tbody className="text-sm text-muted-foreground">
<tr className="border-b border-border">
<td className="py-3 font-medium text-foreground">Data leaves device</td>
<td className="py-3 font-medium text-foreground">Audio leaves device</td>
<td className="py-3 text-primary">
Only with Remote API
</td>
@@ -251,7 +256,7 @@ export default function PrivacyPage() {
<tr className="border-b border-border">
<td className="py-3 font-medium text-foreground">Internet required</td>
<td className="py-3 text-primary">
Only for Remote API or a model download
Only for Remote API, a model download or the update check
</td>
<td className="py-3 text-primary">Yes</td>
</tr>
@@ -283,12 +288,12 @@ export default function PrivacyPage() {
please reach out:
</p>
<a
href="https://github.com/VocaHQ/vocalinux/issues"
href={`${SOURCE_REPO_URL}/issues`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-primary hover:underline"
>
Open an issue on GitHub
Open an issue in the Voculinux repository
<ExternalLink className="h-4 w-4" />
</a>
</section>
+5 -5
View File
@@ -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() {
</h1>
<p className="mb-8 max-w-4xl text-lg text-muted-foreground">
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.
</p>
</section>
@@ -320,12 +320,12 @@ export default function TroubleshootingPage() {
<ChevronRight className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
<span>
<a
href="https://github.com/VocaHQ/vocalinux/issues"
href={`${SOURCE_REPO_URL}/issues`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-semibold text-primary hover:underline"
>
Search existing issues on GitHub
Search existing issues in the Voculinux repository
<ExternalLink className="h-3.5 w-3.5" />
</a>
</span>
@@ -334,7 +334,7 @@ export default function TroubleshootingPage() {
<ChevronRight className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
<span>
<a
href="https://github.com/VocaHQ/vocalinux/issues/new"
href={`${SOURCE_REPO_URL}/issues/new`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-semibold text-primary hover:underline"
+7 -17
View File
@@ -2,9 +2,9 @@
import React, { useEffect, useId, useState } from "react";
import Link from "next/link";
import { Star } from "lucide-react";
import { Code } from "lucide-react";
import { VoculinuxLogo } from "@/components/optimized-image";
import { GITHUB_REPO_URL } from "@/lib/seo";
import { SOURCE_REPO_URL } from "@/lib/seo";
import {
footerGroups,
homeNav,
@@ -14,10 +14,6 @@ import {
type NavLink,
} from "@/lib/site-nav";
// Star count is fetched once at build time (next.config.js) so visitors never
// call api.github.com. Empty or missing falls back to the "GitHub" label.
const GITHUB_STARS = Number(process.env.NEXT_PUBLIC_GITHUB_STARS) || null;
function SmartLink({
href,
className,
@@ -65,7 +61,6 @@ export function SiteChrome({
variant?: "home" | "page";
}) {
const [open, setOpen] = useState(false);
const stars = GITHUB_STARS;
const navId = useId();
const nav = variant === "home" ? homeNav : pageNav;
const ctaHref = variant === "home" ? "#install" : "/#install";
@@ -141,22 +136,17 @@ export function SiteChrome({
<div className="header-actions">
<a
className="header-stars"
href={GITHUB_REPO_URL}
className="header-source"
href={SOURCE_REPO_URL}
rel="noopener noreferrer"
aria-label={
stars !== null
? `Voculinux on GitHub, ${stars.toLocaleString("en-US")} stars`
: "Voculinux on GitHub"
}
aria-label="Voculinux source repository"
>
<Star
<Code
className="h-4 w-4"
strokeWidth={1.75}
fill="currentColor"
aria-hidden="true"
/>
<span>{stars !== null ? stars.toLocaleString("en-US") : "GitHub"}</span>
<span>Source</span>
</a>
<button
className="nav-toggle"
@@ -1,6 +1,11 @@
import fs from "fs";
import path from "path";
import { SITE_NAME, SITE_URL } from "../seo";
import {
SITE_NAME,
SITE_URL,
SOURCE_REPO_URL,
UPSTREAM_REPO_URL,
} from "../seo";
const readPage = (...segments: string[]): string =>
fs.readFileSync(path.join(process.cwd(), "src", "app", ...segments), "utf-8");
@@ -104,3 +109,79 @@ describe("fork install honesty (ticket #24, AC1)", () => {
}
});
});
describe("fork source and star count (spec16 review)", () => {
const read = (...segments: string[]): string =>
fs.readFileSync(path.join(process.cwd(), ...segments), "utf-8");
it("points the source link at the fork host, not upstream", () => {
expect(SOURCE_REPO_URL).toBe("https://git.bongbetic.com/xavierk/voculinux");
expect(UPSTREAM_REPO_URL).toBe("https://github.com/VocaHQ/vocalinux");
});
it("never fetches or shows the upstream repository's star count", () => {
const config = read("next.config.js");
const chrome = read("src", "components", "site-chrome.tsx");
for (const [name, source] of [
["next.config.js", config],
["site-chrome", chrome],
] as const) {
expect(`${name}: ${source}`).not.toMatch(/api\.github\.com/);
expect(`${name}: ${source}`).not.toMatch(
/stargazers|NEXT_PUBLIC_GITHUB_STARS/,
);
}
expect(chrome).toContain("SOURCE_REPO_URL");
});
it("uses the upstream constant only on labelled upstream surfaces", () => {
const chrome = read("src", "components", "site-chrome.tsx");
const nav = read("src", "lib", "site-nav.ts");
expect(chrome).not.toContain("UPSTREAM_REPO_URL");
expect(nav).not.toContain("UPSTREAM_REPO_URL");
});
});
describe("AUR and privacy honesty (spec16 review)", () => {
const distro = readPage("install", "[distro]", "page.tsx");
const install = readPage("install", "page.tsx");
const offline = readPage("offline", "page.tsx");
const home = readPage("page.tsx");
it("does not tell Arch users the AUR package is installable today", () => {
expect(distro).not.toContain(
"Arch users can install Voculinux from the AUR",
);
expect(distro).not.toContain("Install Voculinux from the AUR");
expect(distro).toContain("AUR packages (not published yet)");
});
it("does not link the unpublished AUR and Snap packages", () => {
for (const [name, source] of [
["home", home],
["install", install],
] as const) {
expect(`${name}: ${source}`).not.toContain(
"https://aur.archlinux.org/packages/voculinux",
);
expect(`${name}: ${source}`).not.toContain(
"https://snapcraft.io/voculinux",
);
}
});
it("claims no more than no usage telemetry in the installed app", () => {
expect(offline).not.toContain("Zero Data Collection");
expect(offline).not.toContain("Truly Private");
expect(home).not.toContain("cannot see how many people install");
expect(home).not.toContain("<span>No telemetry</span>");
});
it("names the install-page launcher as upstream wherever it spells the old name", () => {
const bare = install
.split("\n")
.filter((line) => /vocalinux/i.test(line))
.filter((line) => !/upstream|VocaHQ\//i.test(line));
expect(bare).toEqual([]);
});
});
+7 -1
View File
@@ -7,7 +7,13 @@ export const DEFAULT_OG_IMAGE_ALT =
"Dictate locally. In any app. Offline voice dictation for Linux.";
export const TWITTER_HANDLE = "@vocahq";
export const VOCAHQ_URL = "https://vocahq.com";
export const GITHUB_REPO_URL = "https://github.com/VocaHQ/vocalinux";
// The fork's own source, issue and release host. Everything that says "our
// source", "our issues" or "our releases" resolves here.
export const SOURCE_REPO_URL = "https://git.bongbetic.com/xavierk/voculinux";
// Upstream Vocalinux on GitHub. Retained only as labelled provenance (the
// upstream install script, the AppImage and packages published there, and the
// upstream release history behind the changelog).
export const UPSTREAM_REPO_URL = "https://github.com/VocaHQ/vocalinux";
export const X_URL = "https://x.com/vocahq";
export const DISCORD_URL = "https://discord.gg/t6muquAJbm";
export const EMAIL_URL = "mailto:hello@vocahq.com";
+7 -3
View File
@@ -1,7 +1,7 @@
import {
DISCORD_URL,
EMAIL_URL,
GITHUB_REPO_URL,
SOURCE_REPO_URL,
X_URL,
} from "@/lib/seo";
@@ -35,7 +35,11 @@ export const mobileExtraNav: NavLink[] = [
export const socialLinks: { href: string; label: string; icon: string }[] = [
{ href: X_URL, label: "X", icon: "/brand/social/x.svg" },
{ href: DISCORD_URL, label: "Discord", icon: "/brand/social/discord.svg" },
{ href: GITHUB_REPO_URL, label: "GitHub", icon: "/brand/social/github.svg" },
{
href: SOURCE_REPO_URL,
label: "Voculinux source repository",
icon: "/brand/social/github.svg",
},
{ href: EMAIL_URL, label: "Email hello@vocahq.com", icon: "/brand/social/mail.svg" },
];
@@ -102,7 +106,7 @@ export const footerGroups: { title: string; links: NavLink[] }[] = [
{ href: "/troubleshooting/", label: "Troubleshooting" },
{ href: "/privacy/", label: "Privacy" },
{ href: "/changelog/", label: "Changelog" },
{ href: GITHUB_REPO_URL, label: "Source" },
{ href: SOURCE_REPO_URL, label: "Source" },
{ href: "https://bongbetic.com", label: "Bongbetic" },
],
},
+2 -2
View File
@@ -357,7 +357,7 @@
margin-left: auto;
}
.header-stars {
.header-source {
display: inline-flex;
gap: 0.4rem;
align-items: center;
@@ -373,7 +373,7 @@
text-decoration: none;
}
.header-stars:hover {
.header-source:hover {
color: var(--brand-dark);
background: var(--brand-softer);
border-color: var(--brand);