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: / directory: /
schedule: schedule:
interval: weekly interval: weekly
cooldown:
default-days: 7
groups: groups:
github-actions: github-actions:
patterns: patterns:
+31 -122
View File
@@ -290,19 +290,12 @@ jobs:
refresh from the Store. amd64 only. refresh from the Store. amd64 only.
### Void Linux (glibc x86_64, signed xbps repo) ### Void Linux (glibc x86_64, signed xbps repo)
Also attached below: the signed repository files (`*.x86_64.xbps` Not attached here. The signed xbps repository is published by the
with signatures, `x86_64-repodata`, and the client key tag-triggered Gitea workflow `.gitea/workflows/xbps-release.yml` to
`FINGERPRINT` plus the fingerprint-named `.plist`). This is a the release named `latest` on the project's Gitea host. It is a
third-party repository published by this project, not upstream third-party repository published by this project, not upstream Void
Void Linux: Linux. The registration lines and the key fingerprint to pin are in
```bash the Void Linux section of the README.
# 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.
### Verifying what you downloaded ### Verifying what you downloaded
`SHA256SUMS` covers every file here and is attached once the aarch64 AppImage, `SHA256SUMS` covers every file here and is attached once the aarch64 AppImage,
@@ -379,10 +372,12 @@ jobs:
- name: Attach to release - name: Attach to release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: | run: |
gh release upload "${{ github.ref_name }}" \ gh release upload "$TAG" \
dist/*.AppImage \ dist/*.AppImage \
--repo ${{ github.repository }} \ --repo "$REPO" \
--clobber --clobber
# The .deb and .rpm are thin: they depend on the distro's Python, GTK, # The .deb and .rpm are thin: they depend on the distro's Python, GTK,
@@ -431,10 +426,12 @@ jobs:
- name: Attach to release - name: Attach to release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: | run: |
gh release upload "${{ github.ref_name }}" \ gh release upload "$TAG" \
dist/*.deb dist/*.rpm \ dist/*.deb dist/*.rpm \
--repo ${{ github.repository }} \ --repo "$REPO" \
--clobber --clobber
build-native-arm64: build-native-arm64:
@@ -477,10 +474,12 @@ jobs:
- name: Attach to release - name: Attach to release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: | run: |
gh release upload "${{ github.ref_name }}" \ gh release upload "$TAG" \
dist/*.deb dist/*.rpm \ dist/*.deb dist/*.rpm \
--repo ${{ github.repository }} \ --repo "$REPO" \
--clobber --clobber
# Pins match .github/workflows/flatpak.yml. These jobs start with the tag # Pins match .github/workflows/flatpak.yml. These jobs start with the tag
@@ -790,12 +789,14 @@ jobs:
- name: Attach to release - name: Attach to release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: | run: |
set -euo pipefail set -euo pipefail
ls -la dist/*.flatpak ls -la dist/*.flatpak
gh release upload "${{ github.ref_name }}" \ gh release upload "$TAG" \
dist/*.flatpak \ dist/*.flatpak \
--repo ${{ github.repository }} \ --repo "$REPO" \
--clobber --clobber
# Runs last because the aarch64 AppImage, both Flatpak bundles, the native # Runs last because the aarch64 AppImage, both Flatpak bundles, the native
@@ -804,7 +805,7 @@ jobs:
# missing one looks like the tampered one. # missing one looks like the tampered one.
publish-checksums: publish-checksums:
name: Checksums and Provenance 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 runs-on: ubuntu-latest
# An attach-job failure must not skip checksums: this job hashes the build # An attach-job failure must not skip checksums: this job hashes the build
# artifacts, which exist regardless of whether an upload to the release # artifacts, which exist regardless of whether an upload to the release
@@ -820,7 +821,7 @@ jobs:
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with: with:
# Verification-only artifacts must never enter the release manifest. # 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 merge-multiple: true
path: dist path: dist
@@ -831,9 +832,6 @@ jobs:
for pat in '*.whl' '*.tar.gz' '*.AppImage' '*.deb' '*.rpm' '*.flatpak' '*.snap'; do for pat in '*.whl' '*.tar.gz' '*.AppImage' '*.deb' '*.rpm' '*.flatpak' '*.snap'; do
compgen -G "$pat" > /dev/null || { echo "missing artifact: $pat"; exit 1; } compgen -G "$pat" > /dev/null || { echo "missing artifact: $pat"; exit 1; }
done 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 '*.AppImage' | wc -l)" -eq 2
test "$(compgen -G '*.deb' | wc -l)" -eq 2 test "$(compgen -G '*.deb' | wc -l)" -eq 2
test "$(compgen -G '*.rpm' | wc -l)" -eq 2 test "$(compgen -G '*.rpm' | wc -l)" -eq 2
@@ -845,12 +843,8 @@ jobs:
set -euo pipefail set -euo pipefail
ls -la ls -la
# Bare names, so `sha256sum -c` works in whatever directory the user # Bare names, so `sha256sum -c` works in whatever directory the user
# downloaded the release into. The xbps repo files hash from their # downloaded the release into.
# own directories for the same reason: the release attaches them
# flat, and RSA signatures stay the primary trust root.
sha256sum -- *.whl *.tar.gz *.AppImage *.deb *.rpm *.flatpak *.snap | tee SHA256SUMS 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 # Attesting from the manifest rather than a second glob: the file we
# publish and the set we attest cannot drift apart. # publish and the set we attest cannot drift apart.
@@ -862,10 +856,12 @@ jobs:
- name: Attach SHA256SUMS to the release - name: Attach SHA256SUMS to the release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: | run: |
gh release upload "${{ github.ref_name }}" \ gh release upload "$TAG" \
dist/SHA256SUMS \ dist/SHA256SUMS \
--repo ${{ github.repository }} \ --repo "$REPO" \
--clobber --clobber
publish-pypi: publish-pypi:
@@ -1043,94 +1039,6 @@ jobs:
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: 'Release ${{ github.ref_name }}' 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 # GitHub Release first, Store second. A super-privileged plug can still
# queue a human review and refuse edge/candidate; the .snap 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 # has to land on the tag for sideload. grade: stable allows candidate; do
@@ -1180,11 +1088,12 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: | run: |
set -euo pipefail set -euo pipefail
ls -la dist/*.snap ls -la dist/*.snap
gh release upload "$TAG" dist/*.snap \ gh release upload "$TAG" dist/*.snap \
--repo ${{ github.repository }} \ --repo "$REPO" \
--clobber --clobber
publish-snap: publish-snap:
+1
View File
@@ -13,6 +13,7 @@
"**/dist/**", "**/dist/**",
"**/build/**", "**/build/**",
"**/*.lock", "**/*.lock",
"requirements/*.txt",
"**/package-lock.json", "**/package-lock.json",
"**/*.md", "**/*.md",
"docs/**" "docs/**"
+18
View File
@@ -148,6 +148,8 @@ Optional extras: `vosk`, `whisper`, `vad`, `dev`.
``` ```
src/vocalinux/ src/vocalinux/
├── main.py, version.py, common_types.py, dbus_service.py, post_processor.py ├── 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/ ├── audio/
│ ├── capture.py # PortAudio capture sources and device handling │ ├── capture.py # PortAudio capture sources and device handling
│ ├── pipewire.py # PipeWire system-audio capture │ ├── pipewire.py # PipeWire system-audio capture
@@ -160,6 +162,7 @@ src/vocalinux/
├── gateway_embed/ # optional local VocaGateway lifecycle ├── gateway_embed/ # optional local VocaGateway lifecycle
├── speech_recognition/ ├── speech_recognition/
│ ├── recognition_manager.py # VAD, segmentation, and engine dispatch │ ├── 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 │ ├── engines/ # engine implementations and availability
│ ├── command_processor.py # voice commands │ ├── command_processor.py # voice commands
│ ├── command_phrases.py # voice command phrases and localized forms │ ├── 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 `uninstall.sh`, `web/`. `scripts/distro-package-map.yaml` owns installer package
names; `install.d/package_map.sh` is generated by `just distro-packages`. 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 | | Task | Start here |
|---|---| |---|---|
| Voice command | `speech_recognition/command_processor.py` | | 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` | | 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` | | Settings | `ui/config_manager.py`, `ui/settings_dialog.py` |
| Hotkeys | `ui/keyboard_shortcuts.py`, `ui/keyboard_backends/` | | 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 ## 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 (signed xbps repo)
Void Linux **glibc `x86_64` only**. Each tagged release attaches a signed Void Linux **glibc `x86_64` only**. Each version tag publishes a signed
personal xbps repository: binary packages (`*.x86_64.xbps`), signatures, 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 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, This is a **third-party repository** — it is published by this project,
not by upstream Void Linux, and its packages are built from the 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). templates in `packaging/void/srcpkgs/`. Details: [packaging/void/README.md](packaging/void/README.md).
```bash Repository key, RSA 4096. Pin this fingerprint:
# as root on the Void system (the files come from the release assets):
cp <fingerprint>.plist /var/db/xbps/keys/ ```text
TAG=v0.18.5 # match the release you install from fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25
echo "repository=https://github.com/VocaHQ/vocalinux/releases/download/${TAG}/" > /etc/xbps.d/voculinux.conf
``` ```
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 ```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 ### From source
```bash ```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 Note: `ydotool` is not packaged for Void, so the ydotool injection
fallback rung is unavailable there; the app uses IBus, the RemoteDesktop fallback rung is unavailable there. On niri, text is typed directly through
portal, `wtype` or `xdotool` instead. `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 ### Solus
```bash ```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 claimed on both sides fires twice: the compositor toggles over D-Bus while
the internal listener fires on its own. 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 ## Hold-to-talk stays in the app
A compositor bind only sends the key press; it never sees the release, so 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. `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 ## Quick Release Checklist
Use this checklist for every release: 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 - [ ] Commit all version changes
- [ ] Push branch and create PR - [ ] Push branch and create PR
- [ ] After PR merge, create tag: `git tag -a vX.Y.Z-PHASE -m "Release X.Y.Z-PHASE"` - [ ] 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 ### After the release run finishes
- [ ] Run `just verify-release` - the published release verifies as published - [ ] 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 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 point at the new release: bump `version` in the `voculinux` template (and
any template whose upstream moved) and refresh every pinned `checksum` any template whose upstream moved) and refresh every pinned `checksum`
(`sha256` sums of the release sdist and distfiles). Land the bump as a (`sha256` sums of the release sdist and distfiles). The `voculinux` template
**pull request** through the normal test, lint, and type gates — never as builds the fork's release asset `voculinux-X.Y.Z.tar.gz`; its `checksum` is
a direct or bot commit to `main`, and never hand-edited onto the release the sha256 of that exact downloaded asset, never a value copied from
itself (see `just verify-release`). `tests/test_void_app_template.py` upstream, and stays `PENDING_FORK_RELEASE_SHA256` (which fails closed in
fails if the template version or checksum drifts from `xbps-src`) until the asset exists. Land the bump as a **pull request**
`src/vocalinux/version.py` and the published sdist, and the signed xbps through the normal test, lint, and type gates, never as a direct or bot
repository for the *next* release builds from these templates. 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 ### Step 4: Update Website
@@ -383,7 +409,7 @@ bash -n install.sh
### Upstream Fix Intake (Cherry-Pick Policy) ### 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 defined sync points, never as continuous tracking and never as wholesale
merges. Like every other change, each port goes through the pull request flow 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 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 renames on top. Conflicts concentrate at the renamed user-visible surfaces
(application name, entry points, desktop and D-Bus identity, configuration (application name, entry points, desktop and D-Bus identity, configuration
and data directories, package, website) precisely because the internal and data directories, package, website) precisely because the internal
Python module name stays `voculinux` as a retained internal name — that Python module name stays `vocalinux` (`src/vocalinux/`) as a retained
split is what keeps hand cherry-picks nearly conflict-free everywhere else. 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. Upstream URLs and changelog history stay untouched for provenance.
- **What qualifies.** Fixes and documentation that changes user instructions - **What qualifies.** Fixes and documentation that changes user instructions
port. Features are re-decided per release, not ported automatically. 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 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 ### Step 9: Post-Release Tasks
- [ ] Verify GitHub Release was created correctly - [ ] 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 - [ ] Verify PyPI package was published (if applicable), and that its wheel sha256
matches the line for that wheel in `SHA256SUMS` matches the line for that wheel in `SHA256SUMS`
- [ ] Verify website was deployed (check voculinux.com) - [ ] 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: - [ ] If the Flatpak remote is configured, verify it picked up the tag:
`flatpak remote-info vocahq com.voculinux.Voculinux` (docs/FLATPAK_REMOTE.md) `flatpak remote-info vocahq com.voculinux.Voculinux` (docs/FLATPAK_REMOTE.md)
- [ ] Announce on social media/communities - [ ] 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 On Wayland, when IBus is not selected, autodetection tries the RemoteDesktop
portal first -- it is the only injection path Wayland officially supports and portal first -- it is the only injection path Wayland officially supports and
it needs no uinput device or helper daemon -- then `ydotool`, `wtype`, and it needs no uinput device or helper daemon -- then direct typing with `wtype`,
finally `xdotool` under XWayland. 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 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 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 ├── smoke.sh ticket #28 gate: every template, one command
├── repo.sh ticket #29 pipeline: build all, index, sign ├── repo.sh ticket #29 pipeline: build all, index, sign
├── container-repo.sh ticket #29 container half (index + sign + verify) ├── 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 ├── tool_checksums.txt pinned base image and void-packages commit
└── srcpkgs/ └── srcpkgs/
├── python3-pywhispercpp/ ticket #26: whisper.cpp Python binding ├── python3-pywhispercpp/ ticket #26: whisper.cpp Python binding
@@ -68,40 +69,49 @@ for the sdist; `tests/test_void_pywhispercpp_template.py` fails otherwise.
## voculinux ## voculinux
Version `0.18.5`, matching `src/vocalinux/version.py` — never newest Version `0.18.5`, matching `src/vocalinux/version.py` — never newest
upstream. The template builds the canonical release source distribution upstream. The template builds the fork's canonical release source
(the `releases/download` sdist, the same bytes published to PyPI) with a distribution, `voculinux-0.18.5.tar.gz`, attached to the fork's `v0.18.5`
pinned `checksum`; `tests/test_void_app_template.py` fails otherwise. release on git.bongbetic.com, with a pinned `checksum` (see
The 0.18.5 asset still carries the pre-rename archive root; xbps-src [Checksum](#checksum)); `tests/test_void_app_template.py` fails if the
moves the single top-level directory to `pkgname-version` itself, so no version drifts from `version.py`.
`wrksrc` override is needed (`wrksrc` is deprecated and `xlint` rejects
it). The first release cut after the rename points `distfiles` at the
renamed asset.
- **Runtime deps** are Void's own interpreter, GTK and desktop bindings - **Runtime deps** are Void's own interpreter, GTK and desktop bindings,
(`python3`, `python3-gobject`, `python3-cairo`, `gtk+3`, the packaged Python libraries, the three templates from tickets #25
`libayatana-appindicator`, `ibus`, `gobject-introspection`), the and #26 (`python3-pynput`, `python3-PyAudio`, `python3-pywhispercpp`),
packaged Python libraries, and the three templates from tickets #25 and the helper tools the app shells out to: PipeWire (capture), `wtype`
and #26 (`python3-pynput`, `python3-PyAudio`, `python3-pywhispercpp`). (direct typing on niri), `wl-clipboard`, `xdotool` and
Every name was verified to exist in current void-packages; the `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. container build re-verifies via dependency resolution.
- **No payload beyond the app**: no speech models (they download on - **No payload beyond the app**: no speech models (they download on
first run), no vendored wheels, no pip at build or install time. The first run), no vendored wheels, no pip at build or install time. The
container smoke fails the build if the payload carries model weights container smoke fails the build if the payload carries model weights
or wheels. 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 and a `usr/share/voculinux/packaged` marker the app reads to present
package-manager updates. The 0.18.5 sdist predates the rename, so package-manager updates. The fork's sdist already carries the final
`post_install` promotes the old file names at install time; the payload names, so the template installs them as they are; the payload smoke
smoke rejects any pre-rename leftovers in the OS-visible paths. rejects any pre-rename leftovers in the OS-visible paths.
- **INSTALL.msg** covers what xbps cannot express: the `input` group for - **INSTALL.msg** covers what xbps cannot express: the `input` group for
the evdev hotkey backend, first-run model downloads, optional engines the evdev hotkey backend, first-run model downloads, optional engines
as manual additions (vosk, OpenAI whisper, Parakeet, faster-whisper, 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 - **Update checker**: packaged installs keep background checks as a
signal, but every update affordance (tray entry, notification, About signal, but every update affordance (tray entry, notification, About
page) presents the package-manager path page) presents the package-manager path
(`src/vocalinux/utils/packaging.py`). (`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 ## Building
```sh ```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 `packaging/void/repo.sh` builds every template in smoke-gate order, indexes
the full repository (`x86_64-repodata`), and signs every binary package 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: one command, same pinned container via podman, no host Void install:
```sh ```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. release notes). `publish/` is local build output and stays uncommitted.
Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before any Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before any
container runs, so an unsigned repository can never publish. CI holds the container runs, so an unsigned repository can never publish.
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).
**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 ```sh
openssl genrsa -out xbps-sign-key.pem 4096 openssl genrsa -out xbps-sign-key.pem 4096
# back the key up somewhere safe, then store it as XBPS_SIGNING_KEY # 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 ```sh
# as root on the Void system (the release notes carry the repo URL): openssl rsa -in xbps-sign-key.pem -pubout 2>/dev/null | openssl md5
cp <fingerprint>.plist /var/db/xbps/keys/ python3 -c "import plistlib,sys; sys.stdout.buffer.write(plistlib.load(open('packaging/void/repository-key.plist','rb'))['public-key'])" | openssl md5
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
``` ```
Answer `Y` when xbps shows the fingerprint prompt on first sync, and check ### Releasing: the Gitea workflow
it matches the published `FINGERPRINT` file. `tests/test_void_signed_repo.py`
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, asserts offline that the pipeline stays wired to every `srcpkgs` template,
stays podman-only on the pinned inputs, and keeps the fail-closed key 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" echo "== version and import smoke $PKGNAME"
voculinux --version voculinux --version
cat > /voculinux-smoke.py <<'PYEOF' cat > /voculinux-smoke.py <<'PYEOF'
# The 0.18.5 sdist predates the rename: the importable top-level package # The importable package keeps its retained internal name (vocalinux); the
# and the distribution metadata still carry the old name. The first # distribution metadata is the fork's own name (voculinux).
# release cut after the rename moves these to voculinux.
import importlib import importlib
MODULES = ( MODULES = (
@@ -81,7 +80,7 @@ for module in MODULES:
print("import-smoke OK:", ", ".join(MODULES)) print("import-smoke OK:", ", ".join(MODULES))
from importlib.metadata import version from importlib.metadata import version
print("vocalinux", version("vocalinux")) print("voculinux", version("voculinux"))
PYEOF PYEOF
python3 /voculinux-smoke.py 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. backend cannot open /dev/input devices.
Speech models are not in this package: they 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 Optional engines are manual additions outside the
guaranteed set: vosk, OpenAI whisper, Parakeet guaranteed set: vosk, OpenAI whisper, Parakeet
(sherpa-onnx), faster-whisper and onnxruntime (VAD) (sherpa-onnx), faster-whisper and onnxruntime (VAD)
come from your own Python environment, not xbps. 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 ydotool is not packaged for Void, so the ydotool
injection fallback rung is absent; the app uses injection fallback rung is absent.
IBus, the RemoteDesktop portal, wtype or xdotool.
Update through your package manager instead of the Update through your package manager instead of the
in-app release downloads: in-app release downloads:
+17 -26
View File
@@ -7,41 +7,32 @@ revision=1
build_style=python3-pep517 build_style=python3-pep517
hostmakedepends="python3-setuptools" hostmakedepends="python3-setuptools"
makedepends="python3-devel" 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" 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" license="AGPL-3.0-only"
homepage="https://github.com/VocaHQ/vocalinux" homepage="https://git.bongbetic.com/xavierk/voculinux"
distfiles="https://github.com/VocaHQ/vocalinux/releases/download/v${version}/vocalinux-${version}.tar.gz" distfiles="https://git.bongbetic.com/xavierk/voculinux/releases/download/v${version}/voculinux-${version}.tar.gz"
checksum=1def4d8f0f14f2df2f2fee64ca29e6865e3d82e551f7d465c2a78ec6d4d72b1d # Pending: set to the sha256 of voculinux-${version}.tar.gz once the fork's
# The 0.18.5 release asset still carries the pre-rename archive root. # v${version} release exists (see packaging/void/README.md, "Checksum").
# xbps-src moves a single-top-dir archive to pkgname-version itself, so no # Until then xbps-src fails closed on this value; it never falls back to the
# wrksrc override is needed (wrksrc is deprecated and xlint rejects it). # upstream archive.
# The first release cut after the rename ships the archive under the new checksum=PENDING_FORK_RELEASE_SHA256
# name; that bump points distfiles at the renamed asset with its digest.
post_install() { post_install() {
# The 0.18.5 sdist predates the rename: the entry and icons still # The fork's sdist already names the desktop entry, icons and launchers
# carry the old names. Promote them at install time — the entry lands # voculinux (pyproject entry points and the repo's voculinux.desktop), so
# as voculinux.desktop with rewritten Name/Exec/Icon, old-prefixed # the files install as they are. The marker is what the app reads to
# 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
# present package-manager updates (see INSTALL.msg). # present package-manager updates (see INSTALL.msg).
vlicense LICENSE vlicense LICENSE
sed -e 's/vocalinux/voculinux/g' -e 's/Vocalinux/Voculinux/g' \
vocalinux.desktop > voculinux.desktop
vinstall voculinux.desktop 644 usr/share/applications vinstall voculinux.desktop 644 usr/share/applications
for icon in resources/icons/scalable/*.svg; do for icon in resources/icons/scalable/*.svg; do
vinstall "$icon" 644 usr/share/icons/hicolor/scalable/apps \ vinstall "$icon" 644 usr/share/icons/hicolor/scalable/apps
"$(basename "$icon" | sed 's/^vocalinux/voculinux/')"
done 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 vmkdir usr/share/voculinux
touch "${DESTDIR}/usr/share/voculinux/packaged" 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 # 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`). # uv 0.13.3 for reproducible export formatting (see `just lock`).
required-version = ">=0.12,<0.14" required-version = ">=0.12,<0.14"
# Supply-chain cooldown: wait a week before resolving newly published releases.
exclude-newer = "7 days"
[tool.setuptools] [tool.setuptools]
# Resource dirs carry no .py files, but setuptools still treats them as packages. # 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 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): 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 with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
return json.load(response) 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 a mismatch means the listing and the file disagree, which is worth stopping
for rather than pinning either one. for rather than pinning either one.
""" """
sha256, md5, size = hashlib.sha256(), hashlib.md5(), 0 sha256, md5, size = hashlib.sha256(), hashlib.md5(usedforsecurity=False), 0
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
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310 with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
for chunk in iter(lambda: response.read(1024 * 1024), b""): for chunk in iter(lambda: response.read(1024 * 1024), b""):
sha256.update(chunk) sha256.update(chunk)
+12 -1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import logging
import math import math
import os import os
import platform import platform
@@ -23,6 +24,8 @@ import time
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
logger = logging.getLogger(__name__)
PROTOCOL_VERSION = "1.0.0" PROTOCOL_VERSION = "1.0.0"
_VMRSS_RE = re.compile(r"^VmRSS:\s+(\d+)\s+kB", re.MULTILINE) _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]]: def vram_mib() -> Optional[tuple[int, int]]:
"""Return ``(total_mib, free_mib)`` of the first NVIDIA GPU, if any.""" """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: try:
proc = subprocess.run( proc = subprocess.run(
[ [
@@ -127,8 +133,10 @@ def vram_mib() -> Optional[tuple[int, int]]:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=30, timeout=30,
env=host_env(),
) )
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError) as error:
logger.debug("nvidia-smi unavailable: %s", error)
return None return None
if proc.returncode != 0: if proc.returncode != 0:
return None return None
@@ -441,6 +449,8 @@ def cmd_inject_probe(args: argparse.Namespace) -> int:
if wayland: if wayland:
import shutil import shutil
from vocalinux.utils.host_process import host_env
if shutil.which("wtype") is not None and backend != "x11": if shutil.which("wtype") is not None and backend != "x11":
for _ in range(5): for _ in range(5):
begin = time.monotonic() begin = time.monotonic()
@@ -449,6 +459,7 @@ def cmd_inject_probe(args: argparse.Namespace) -> int:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10, timeout=10,
env=host_env(),
) )
wtype_roundtrips.append(time.monotonic() - begin) wtype_roundtrips.append(time.monotonic() - begin)
try: 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]: def pypi_artifacts(name: str, version: str) -> list[dict]:
url = PYPI_JSON.format(name=name, version=version) 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 with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
return json.load(response)["urls"] return json.load(response)["urls"]
+1
View File
@@ -188,6 +188,7 @@ def check_pypi(version: str, stored: dict[str, str]) -> list[str]:
try: try:
url = f"https://pypi.org/pypi/{PYPI_PROJECT}/{version}/json" 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: with urllib.request.urlopen(url, timeout=PYPI_TIMEOUT) as response:
payload = json.load(response) payload = json.load(response)
except urllib.error.HTTPError as error: except urllib.error.HTTPError as error:
+2
View File
@@ -103,6 +103,7 @@ def smoke(root: Path) -> None:
site = audit(root, manifest["packages"]) site = audit(root, manifest["packages"])
sys.path.insert(0, str(site)) sys.path.insert(0, str(site))
for name in ("numpy", "requests", "psutil", "pyaudio", "cairo", "evdev", "vocalinux"): 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) module = importlib.import_module(name)
if not module.__file__ or not Path(module.__file__).resolve().is_relative_to( if not module.__file__ or not Path(module.__file__).resolve().is_relative_to(
site.resolve() site.resolve()
@@ -118,6 +119,7 @@ def smoke(root: Path) -> None:
"vocalinux.speech_recognition.recognition_manager", "vocalinux.speech_recognition.recognition_manager",
"vocalinux.text_injection.text_injector", "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) importlib.import_module(name)
import gi import gi
+31 -26
View File
@@ -2,45 +2,50 @@
Ticket #18 rename: every user-visible identity string that is hardcoded in 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 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 own name everywhere a user can see it. The Python import package name
package name is retained (see :data:`RETAINED_SURFACES`) and unrelated to ``vocalinux`` is retained internally (see :data:`RETAINED_SURFACES`) and is
these values. unrelated to these values.
One-time legacy import of a previous Voculinux install lives in One-time legacy import of a previous Voculinux install lives in
:mod:`vocalinux.legacy_import` and keys off :data:`LEGACY_APP_DIR_NAME`. :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_NAME: str = "Voculinux"
APP_ID: str = "voculinux" APP_ID: str = _SLUG
APP_DIR_NAME: str = "voculinux" APP_DIR_NAME: str = _SLUG
CLI_PROG: str = "voculinux" CLI_PROG: str = _SLUG
BUS_NAME: str = "com.voculinux.Voculinux" BUS_NAME: str = f"com.{_SLUG}.Voculinux"
OBJECT_PATH: str = "/com/voculinux/Voculinux" OBJECT_PATH: str = f"/com/{_SLUG}/Voculinux"
INTERFACE_NAME: str = "com.voculinux.Voculinux" INTERFACE_NAME: str = f"com.{_SLUG}.Voculinux"
DESKTOP_FILENAME: str = "voculinux.desktop" DESKTOP_FILENAME: str = f"{_SLUG}.desktop"
DESKTOP_ICON_NAME: str = "voculinux" DESKTOP_ICON_NAME: str = _SLUG
TRAY_TITLE: str = APP_NAME TRAY_TITLE: str = APP_NAME
ICON_DEFAULT: str = "voculinux-microphone-off" ICON_DEFAULT: str = f"{_SLUG}-microphone-off"
ICON_ACTIVE: str = "voculinux-microphone" ICON_ACTIVE: str = f"{_SLUG}-microphone"
ICON_PROCESSING: str = "voculinux-microphone-process" ICON_PROCESSING: str = f"{_SLUG}-microphone-process"
ICON_BASE_NAMES: tuple[str, ...] = ( ICON_BASE_NAMES: tuple[str, ...] = (
"voculinux", _SLUG,
ICON_ACTIVE, ICON_ACTIVE,
ICON_DEFAULT, ICON_DEFAULT,
ICON_PROCESSING, ICON_PROCESSING,
) )
ENGINE_NAME: str = "voculinux" ENGINE_NAME: str = _SLUG
ENGINE_LONGNAME: str = "Voculinux" ENGINE_LONGNAME: str = APP_NAME
ENGINE_DESCRIPTION: str = "Voculinux voice dictation (use as default input method)" ENGINE_DESCRIPTION: str = f"{APP_NAME} voice dictation (use as default input method)"
COMPONENT_NAME: str = "org.freedesktop.IBus.Voculinux" COMPONENT_NAME: str = f"org.freedesktop.IBus.{APP_NAME}"
IBUS_DIR_NAME: str = "voculinux-ibus" IBUS_DIR_NAME: str = f"{_SLUG}-ibus"
IBUS_TEXTDOMAIN: str = "voculinux" 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: def user_agent(version: str) -> str:
@@ -48,11 +53,11 @@ def user_agent(version: str) -> str:
return f"{APP_ID}/{version}" return f"{APP_ID}/{version}"
RESOURCE_DIR_NAME: str = "voculinux" RESOURCE_DIR_NAME: str = _SLUG
VERIFICATION_STAMP_NAME: str = ".voculinux_verified" VERIFICATION_STAMP_NAME: str = f".{_SLUG}_verified"
LOG_FILE_PREFIX: str = "voculinux_logs_" LOG_FILE_PREFIX: str = f"{_SLUG}_logs_"
PORTAL_TOKEN_PREFIX: str = "voculinux_" PORTAL_TOKEN_PREFIX: str = f"{_SLUG}_"
ENV_FORCE_BACKEND: str = "VOCULINUX_FORCE_BACKEND" ENV_FORCE_BACKEND: str = "VOCULINUX_FORCE_BACKEND"
ENV_INJECT_MODIFIER_WAIT: str = "VOCULINUX_INJECT_MODIFIER_WAIT" ENV_INJECT_MODIFIER_WAIT: str = "VOCULINUX_INJECT_MODIFIER_WAIT"
+4 -1
View File
@@ -7,6 +7,8 @@ import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from .urls import require_http_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_BASE_URL = "http://127.0.0.1:8765" 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]: def _get_status(url: str, *, timeout: float) -> tuple[int | None, str]:
request = urllib.request.Request(url, method="GET")
try: 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: with urllib.request.urlopen(request, timeout=timeout) as response:
return int(response.status), "" return int(response.status), ""
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
+3 -2
View File
@@ -10,7 +10,7 @@ from dataclasses import dataclass
from typing import Any, Optional from typing import Any, Optional
from ..common_types import Readable 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__) logger = logging.getLogger(__name__)
@@ -61,7 +61,8 @@ def _authorized_get(
headers = {} headers = {}
if token: if token:
headers["Authorization"] = f"Bearer {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: with urllib.request.urlopen(request, timeout=timeout) as response:
return _read_capped(response, limit) 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): if is_unusable_phone_url(url):
return None return None
return str(url).strip() 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__) 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" CONFIG_FILENAME: str = "config.json"
TERMS_FILENAME: str = "dictionary.txt" TERMS_FILENAME: str = "dictionary.txt"
CORRECTIONS_FILENAME: str = "custom-dictionary-corrections.json" CORRECTIONS_FILENAME: str = "custom-dictionary-corrections.json"
RESTORE_TOKEN_FILENAME: str = "remote_desktop_restore_token" RESTORE_TOKEN_FILENAME: str = "remote_desktop_restore_token"
#: ``(subdirectory, filename)`` pairs copied ``config -> config`` or #: Files copied from the legacy configuration directory. Models are
#: ``data -> data``. Models are deliberately absent: they re-resolve through #: deliberately absent: they re-resolve through the pinned-checksum download
#: the pinned-checksum download path instead of being migrated. #: path instead of being migrated.
CONFIG_PAYLOAD: tuple[tuple[str, str], ...] = ( CONFIG_PAYLOAD: tuple[str, ...] = (CONFIG_FILENAME, TERMS_FILENAME, CORRECTIONS_FILENAME)
("", CONFIG_FILENAME), #: Files copied from the legacy data directory.
("", TERMS_FILENAME), DATA_PAYLOAD: tuple[str, ...] = (RESTORE_TOKEN_FILENAME,)
("", CORRECTIONS_FILENAME),
)
DATA_PAYLOAD: tuple[tuple[str, str], ...] = (("", RESTORE_TOKEN_FILENAME),)
def _xdg_dir(env_var: str, fallback: str) -> str: def _app_dir(xdg_env_var: str, xdg_fallback: str, app_dir_name: str) -> str:
"""Return an XDG base directory (empty values treated as unset).""" """Return an application directory under an XDG base (empty values unset)."""
return os.environ.get(env_var) or os.path.expanduser(fallback) 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: def legacy_config_dir() -> str:
"""Return the previous Vocalinux configuration directory.""" """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: def new_config_dir() -> str:
"""Return the Voculinux configuration directory.""" """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: def legacy_data_dir() -> str:
"""Return the previous Vocalinux data directory.""" """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: def new_data_dir() -> str:
"""Return the Voculinux data directory.""" """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: def _copy_once(src: str, dst: str) -> bool:
@@ -103,14 +96,14 @@ def import_legacy_once() -> list[str]:
return [] return []
imported: list[str] = [] imported: list[str] = []
for _subdir, filename in CONFIG_PAYLOAD: for filename in CONFIG_PAYLOAD:
src = os.path.join(legacy_config, filename) src = os.path.join(legacy_config, filename)
dst = os.path.join(new_config_dir(), filename) dst = os.path.join(new_config_dir(), filename)
if _copy_once(src, dst): if _copy_once(src, dst):
imported.append(dst) imported.append(dst)
legacy_data = legacy_data_dir() legacy_data = legacy_data_dir()
if os.path.isdir(legacy_data): if os.path.isdir(legacy_data):
for _subdir, filename in DATA_PAYLOAD: for filename in DATA_PAYLOAD:
src = os.path.join(legacy_data, filename) src = os.path.join(legacy_data, filename)
dst = os.path.join(new_data_dir(), filename) dst = os.path.join(new_data_dir(), filename)
if _copy_once(src, dst): if _copy_once(src, dst):
+1 -1
View File
@@ -394,7 +394,7 @@ def main():
# Register cleanup to release lock on exit # Register cleanup to release lock on exit
atexit.register(single_instance.release_lock) 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 # install's config, dictionary, and portal token into the new locations
# before anything reads them. No-op on every later run. # before anything reads them. No-op on every later run.
try: 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 large-v3-turbo-q8_0 -> large-v3-turbo-q5_0 -> small.en / small
Thresholds derive from ticket #19's measured transcribe peaks (1132 MiB The thresholds are PROVISIONAL. They are the rule
for q8_0, 846 MiB for q5_0) plus the 512 MiB desktop headroom from the ``scripts/measure_perf_baseline.py::recommend_thresholds`` applies (measured
locked baseline protocol footprint plus the 512 MiB desktop headroom) to the transcribe peaks
(``scripts/measure_perf_baseline.py::recommend_thresholds``). The recorded for ticket #19 (1132 MiB for q8_0, 846 MiB for q5_0). Ticket #19
derivation is pinned by ``tests/test_model_ladder.py`` so a re-measure is still open and its measurement is not recorded in this repository, so
forces a threshold review. 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 Backend order follows the same readiness rule: CUDA ranks before Vulkan
only when a complete architecture-matching CUDA toolkit is installed 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. #: the baseline protocol adds to measured footprints.
HEADROOM_MIB: float = 512.0 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_Q8_0_PEAK_MIB: float = 1132.0
_MEASURED_Q5_0_PEAK_MIB: float = 846.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) 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: def select_ladder_rung(language_is_english: bool, free_mib: float) -> str:
"""Return the ladder rung that fits ``free_mib`` MiB of free memory. """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 return DEFAULT_RUNG
if free_mib >= PREFER_Q5_0_ABOVE_MIB: if free_mib >= PREFER_Q5_0_ABOVE_MIB:
return LOW_MEMORY_RUNG 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( def resolve_startup_rung(
@@ -89,7 +101,7 @@ def next_rung_down(rung: str, language_is_english: bool) -> str | None:
if rung == DEFAULT_RUNG: if rung == DEFAULT_RUNG:
return LOW_MEMORY_RUNG return LOW_MEMORY_RUNG
if rung == 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 return None
@@ -197,6 +209,21 @@ def free_system_ram_mib() -> float | None:
return 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: def free_memory_mib() -> float | None:
"""Return free memory in MiB: discrete-GPU VRAM when present, else RAM.""" """Return free memory in MiB: discrete-GPU VRAM when present, else RAM."""
vram = free_vram_mib() vram = free_vram_mib()
@@ -242,12 +269,14 @@ __all__ = [
"PREFER_Q8_0_ABOVE_MIB", "PREFER_Q8_0_ABOVE_MIB",
"PREFER_Q5_0_ABOVE_MIB", "PREFER_Q5_0_ABOVE_MIB",
"MIN_CUDA_COMPUTE_CAPABILITY", "MIN_CUDA_COMPUTE_CAPABILITY",
"GPU_BACKENDS",
"select_ladder_rung", "select_ladder_rung",
"resolve_startup_rung", "resolve_startup_rung",
"next_rung_down", "next_rung_down",
"is_ladder_rung", "is_ladder_rung",
"cuda_toolkit_ready_from_probe", "cuda_toolkit_ready_from_probe",
"cuda_toolkit_ready", "cuda_toolkit_ready",
"free_memory_for_backend",
"free_memory_mib", "free_memory_mib",
"preferred_backend_order", "preferred_backend_order",
"describe_environment", "describe_environment",
+42 -13
View File
@@ -100,6 +100,23 @@ def _is_kde_plasma_session() -> bool:
return "kde" in desktop or "plasma" in desktop 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: def _kde_wayland_ibus_hint() -> str:
"""Return the KDE Plasma Wayland IBus setup hint.""" """Return the KDE Plasma Wayland IBus setup hint."""
return ( return (
@@ -442,6 +459,11 @@ class TextInjector:
if self.environment != DesktopEnvironment.WAYLAND: if self.environment != DesktopEnvironment.WAYLAND:
# X11 / XWayland: IBus reaches apps through XIM regardless of DE. # X11 / XWayland: IBus reaches apps through XIM regardless of DE.
return True 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( desktop = " ".join(
os.environ.get(var, "") os.environ.get(var, "")
for var in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION") 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]: def _resolve_wayland_key_tool(self) -> Optional[str]:
"""Pick (or reuse) the Wayland virtual-keyboard tool for key events. """Pick (or reuse) the Wayland virtual-keyboard tool for key events.
Mirrors the preference ``_check_dependencies`` already applies: prefer Uses the same rung order as ``_check_dependencies`` (spec #16, ticket
ydotool via uinput once its daemon is (or can be made) ready, and only #31): direct keysym typing (wtype) first, then the virtual-input tool
fall back to wtype. Callers that resolve a tool after startup -- IBus (ydotool, whose daemon is started when it is the tool in use). Callers
shortcut routing, BackSpace key events -- use this instead of picking that resolve a tool after startup -- IBus shortcut routing, BackSpace
their own order. key events -- use this instead of picking their own order.
Returns: Returns:
The tool name, or None if neither is installed. The tool name, or None if neither is installed.
@@ -679,16 +701,17 @@ class TextInjector:
if getattr(self, "wayland_tool", None): if getattr(self, "wayland_tool", None):
return self.wayland_tool return self.wayland_tool
ydotool_available = shutil.which("ydotool") is not None
wtype_available = shutil.which("wtype") 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 wtype_available:
if ydotool_available and self._ensure_ydotoold():
tool = "ydotool"
elif ydotool_available and not wtype_available:
tool = "ydotool"
elif wtype_available:
tool = "wtype" 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: else:
return None return None
@@ -1002,7 +1025,11 @@ class TextInjector:
pinned_tool_present = (forced == "wtype" and wtype_available) or ( pinned_tool_present = (forced == "wtype" and wtype_available) or (
forced == "ydotool" and ydotool_available 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: if ydotool_available:
_warn_if_ydotool_globally_enabled() _warn_if_ydotool_globally_enabled()
@@ -2975,6 +3002,7 @@ class TextInjector:
type_timeout = max(5, len(chunk) * self._key_delay_seconds(key_delay) * 4) type_timeout = max(5, len(chunk) * self._key_delay_seconds(key_delay) * 4)
try: try:
subprocess.run( 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, cmd,
check=True, check=True,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
@@ -3420,6 +3448,7 @@ class TextInjector:
try: try:
subprocess.run( 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, cmd,
check=True, check=True,
stderr=subprocess.PIPE, 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]]: def _read_kde_layout_memory(path: str) -> tuple[str, list[str]]:
"""Return ``(SwitchMode, currentLayout tokens)`` from a layout_memory.xml.""" """Return ``(SwitchMode, currentLayout tokens)`` from a layout_memory.xml."""
try: 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) tree = ET.parse(path)
except FileNotFoundError: except FileNotFoundError:
return "", [] return "", []
+2 -3
View File
@@ -32,6 +32,7 @@ import logging
import os import os
import re import re
from functools import lru_cache from functools import lru_cache
from importlib.resources import files # nosemgrep: python37-compatibility-importlib2 -- py>=3.11
from typing import Dict, NamedTuple, Optional from typing import Dict, NamedTuple, Optional
from ..app_identity import VERIFICATION_STAMP_NAME 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. :func:`verify_model_file`, which is the safe direction to fail.
""" """
try: try:
from importlib.resources import files
return files(__package__).joinpath(_MANIFEST_NAME).read_text(encoding="utf-8") return files(__package__).joinpath(_MANIFEST_NAME).read_text(encoding="utf-8")
except (ImportError, TypeError, ModuleNotFoundError): except (TypeError, ModuleNotFoundError):
pass pass
except (FileNotFoundError, OSError): except (FileNotFoundError, OSError):
return "" 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. # VOSK model metadata for display and download path resolution.
# When Alphacephei only ships a small model, medium/large point at that same # When Alphacephei only ships a small model, medium/large point at that same
# zip so size selection never resolves to None (issue #550). # zip so size selection never resolves to None (issue #550).
@@ -231,58 +259,12 @@ VOSK_MODEL_INFO = {
"medium": { "medium": {
"size_mb": 1500, "size_mb": 1500,
"desc": "Balanced accuracy/speed", "desc": "Balanced accuracy/speed",
"languages": { "languages": dict(_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",
},
}, },
"large": { "large": {
"size_mb": 1500, "size_mb": 1500,
"desc": "Same as medium (best available)", "desc": "Same as medium (best available)",
"languages": { "languages": dict(_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",
},
}, },
} }
+25 -64
View File
@@ -7,7 +7,6 @@ for whisper.cpp, supporting Vulkan, CUDA, and CPU backends.
import ctypes import ctypes
import logging import logging
import math
import os import os
import re import re
import subprocess import subprocess
@@ -541,75 +540,37 @@ def detect_cpu_info() -> str:
return "CPU" 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]: def get_recommended_model() -> tuple[str, str]:
""" """Recommend a whisper.cpp model from the installed build and free memory.
Get the recommended whisper.cpp model based on system configuration.
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: Returns:
Tuple of (model_name, reason) Tuple of (model_name, reason)
""" """
try: from ..speech_recognition.model_ladder import (
import psutil 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)) gpu_backend = detect_pywhispercpp_gpu_backend()
free_mib, source = free_memory_for_backend(gpu_backend)
# Detect available compute backends if free_mib is None:
backend, backend_info = detect_compute_backend() logger.info("Free memory unreadable; recommending the floor model")
return FLOOR_RUNG_MULTILINGUAL, "Free memory unreadable; floor model"
if backend == ComputeBackend.VULKAN: rung = resolve_startup_rung("", False, free_mib)
# Vulkan can handle larger models efficiently return rung, f"{rung}: {free_mib:.0f} MiB free {source} ({gpu_backend} build)"
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"
def _model_file_path(model_name: str) -> str: def _model_file_path(model_name: str) -> str:
+3
View File
@@ -41,6 +41,9 @@ for _var in (
"XDG_SESSION_DESKTOP", "XDG_SESSION_DESKTOP",
"DESKTOP_SESSION", "DESKTOP_SESSION",
"WAYLAND_DISPLAY", "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) os.environ.pop(_var, None)
atexit.register(shutil.rmtree, _XDG_ISOLATION_DIR, ignore_errors=True) 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] REPO_ROOT = Path(__file__).resolve().parents[1]
SOURCE = REPO_ROOT / "src" / "vocalinux" 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"} 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 dies on the wrong GLib, and our own interpreter stripped of it cannot find
its stdlib or GI stack.""" its stdlib or GI stack."""
inherit, stripped = [], [] 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"))): for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if not _is_subprocess_call(node): if not _is_subprocess_call(node):
continue continue
+104 -1
View File
@@ -36,7 +36,11 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from vocalinux.text_injection.remote_desktop_portal import RemoteDesktopPortalError 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" _TI = "vocalinux.text_injection.text_injector"
@@ -187,6 +191,105 @@ class TestNiriLadder:
assert runs == [["wtype", "--", "hi"]] 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: class TestPortalFirstWhereImplemented:
"""GNOME/KDE implement RemoteDesktop, so the portal leads there.""" """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 All behavior is exercised with mocked probes and loaders: no GPU, no
network, no hardware. The threshold-derivation test pins the rung network, no hardware. The threshold-derivation test pins the rung
thresholds to ticket #19's measured transcribe peaks (1132 MiB for q8_0, thresholds to the provisional transcribe peaks (1132 MiB for q8_0, 846 MiB
846 MiB for q5_0) plus the 512 MiB protocol headroom, so any re-measure for q5_0, ticket #19, not yet recorded in the repo) plus the 512 MiB
forces a threshold review. protocol headroom. Replacing them with a recorded measurement is a
deliberate change to this test.
""" """
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -294,27 +295,33 @@ class TestStartupRungSelection:
assert manager.get_model_size_for_engine("whisper_cpp") == "medium.en" assert manager.get_model_size_for_engine("whisper_cpp") == "medium.en"
class TestRecommendationGate: class TestFreeMemoryForBackend:
"""Driver-only CUDA demotes to CPU logic; toolkit keeps the VRAM ladder.""" """The memory reading follows the installed build (spec #16, ticket #20)."""
def _recommend(self, backend, toolkit): def test_vulkan_build_reads_discrete_vram_when_present(self) -> None:
from vocalinux.utils import whispercpp_model_info as info 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() def test_cuda_build_reads_discrete_vram_when_present(self) -> None:
mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) with patch.object(model_ladder, "free_vram_mib", return_value=1500.0):
with patch.dict("sys.modules", {"psutil": mock_psutil}): assert model_ladder.free_memory_for_backend("cuda") == (1500.0, "VRAM")
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_driver_only_cuda_demotes_to_cpu(self) -> None: def test_gpu_build_without_an_nvidia_reading_reads_ram(self) -> None:
model, _reason = self._recommend(("cuda", "NVIDIA RTX 2050 (4GB)"), False) """Integrated graphics: no discrete VRAM reading, so system RAM decides."""
assert model == "base" 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: def test_cpu_build_never_reads_vram(self) -> None:
model, reason = self._recommend(("cuda", "NVIDIA RTX 2050 (4GB)"), True) vram = MagicMock(return_value=2600.0)
assert model == "small" with (
assert "VRAM" in reason 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",
"publish-aur-bin", "publish-aur-bin",
"publish-flatpak-remote", "publish-flatpak-remote",
"build-xbps",
): ):
assert "build-and-release" in _needs(jobs[name]), name assert "build-and-release" in _needs(jobs[name]), name
checksums = jobs["publish-checksums"] checksums = jobs["publish-checksums"]
assert "needs.build-and-release.result == 'success'" in 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 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" assert checksum_line, "publish-checksums does not generate SHA256SUMS"
for pattern in ("*.whl", "*.tar.gz", "*.AppImage", "*.flatpak", "*.snap"): for pattern in ("*.whl", "*.tar.gz", "*.AppImage", "*.flatpak", "*.snap"):
assert pattern in checksum_line.group(1), f"{pattern} is published but unchecksummed" 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 # The signed xbps repository is published by the Gitea tag workflow, not
# as the flat files the release attaches), alongside the client key. # attached to this release, so the manifest must not require or hash it:
for pattern in ("*.x86_64.xbps", "*-repodata*", "FINGERPRINT", "*.plist"): # a pattern for files that never arrive would fail every release.
assert pattern in block, f"{pattern} is published but unchecksummed" 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(): 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 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), - upstream provenance: ``VocaHQ/vocalinux`` and ``jatinkrmalik/vocalinux``
- the retained internal Python module name (``src/vocalinux`` paths, slugs, and prose that labels the old name as upstream or as the fork's
``vocalinux.*`` imports/attributes, ``-m vocalinux.main``), origin ("Vocalinux (upstream)", "a hard fork of Vocalinux");
- retained internal class names (``VocalinuxEngine*``, ``VocalinuxDBusService``), - the retained internal Python package (ticket #17): ``src/vocalinux`` paths,
- one-time-legacy import sources (old config/dictionary/bus literals), 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``, - generated or historical files (``uv.lock``, ``requirements/*.txt``,
``CHANGELOG.md``), ``CHANGELOG.md``) and the git worktree pointer file ``.git``;
- web upstream surfaces (ticket #24): upstream GitHub/raw/nix flake URLs, - web upstream surfaces (ticket #24): the nix flake snippets on the nixos page.
nix flake API identifiers, and the nixos-page fork-status note that names
the upstream flake as installing Vocalinux rather than Voculinux. ``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 os
import re import re
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 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) OLD_NAME = re.compile(r"vocalinux", re.IGNORECASE)
@@ -34,7 +44,9 @@ SKIP_DIRS = {
"out", "out",
} }
SKIP_SUFFIXES = (".egg-info",) 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/",) SKIP_PREFIXES = ("requirements/",)
BINARY_EXTS = ( BINARY_EXTS = (
".wav", ".wav",
@@ -60,98 +72,119 @@ BINARY_EXTS = (
#: (matched in _allowed) may still spell the old name there. #: (matched in _allowed) may still spell the old name there.
WEB_OWNED = {"web/src/app/page.tsx", "web/package.json"} 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: def _allowed(path: str, line: str) -> bool:
"""Return True when an old-name match is an allowlisted surface.""" """Return True when an old-name match is an allowlisted retained surface."""
low = line.lower() if path == "tests/test_rename_sweep.py":
if "vocahq/" in low or "jatinkrmalik/vocalinux" in low: # This file defines the allowlist and quotes the old name to do so.
return True return True
if "vocalinux" in low and "voculinux" in low: if _UPSTREAM_SLUG.search(line) or _UPSTREAM_LABEL.search(line):
# Migration-compat shims that name both installs at once.
return True return True
if "upstream" in low: if _MODULE_PATH.search(line) or _DOTTED_MODULE.search(line):
# Upstream-provenance mentions that explicitly label the old name
# as upstream (fork-status notes, provenance cards).
return True return True
if "/{name}/vocalinux" in line: if _IMPORT_FORM.search(line) or _CAMEL_CLASS.search(line) or _RETAINED_PROSE.search(line):
# Upstream repo-name template (retained provenance).
return True return True
if path == "snap/local/check_runtime.py" and '"vocalinux"' in line: if _LEGACY_PROSE.search(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.
return True return True
if path == "web/src/app/install/nixos/page.tsx": if path == "web/src/app/install/nixos/page.tsx":
# The Nix flake is upstream's: flake URL, input/output names, module # The Nix flake is upstream's: flake URL, input and output names and
# paths, and programs.* options spell the retained upstream API. # programs.* options spell the retained upstream API. The page carries
# The page carries a fork-status note stating those snippets install # a fork-status note saying those snippets install Vocalinux, not
# Vocalinux (upstream), not Voculinux. # 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 return True
if path == "tests/test_rename_sweep.py": if path.startswith(_TOOLING_PREFIXES) and _PACKAGE_SEGMENT.search(line):
return True 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 return True
if path == "pyproject.toml" and "vocalinux.version" in line: if path == "src/vocalinux/utils/update_checker.py" and (
return True 'casefold() == "vocalinux"' in line or '"/{name}/vocalinux"' in line
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
): ):
# Pre-rename sdist compat (ticket #28): the payload guard names the # Upstream repository identity the update checker recognises.
# 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.
return True return True
return False 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: with open(os.path.join(REPO_ROOT, rel), encoding="utf-8") as handle:
text = handle.read() text = handle.read()
assert "Voculinux" in text or "voculinux" in text, rel 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) self.assertFalse(injector._wayland_compositor_bridges_ibus(), desktop)
def test_unbridged_compositors_use_ibus_when_bridge_running(self): 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() injector = self._bare_injector()
for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "niri", "river"): for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "river"):
with patch.dict( with patch.dict(
"os.environ", "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_xdotool.assert_called_once_with("ctrl+z")
obj._inject_shortcut_with_wayland_tool.assert_not_called() obj._inject_shortcut_with_wayland_tool.assert_not_called()
def test_wayland_ibus_prefers_ydotool(self): def test_wayland_ibus_prefers_wtype_over_ready_ydotool(self):
"""Reaches the real Wayland helper: a stub would pass while it's broken.""" """Direct typing outranks the virtual-input tool (spec #16, ticket #31)."""
obj = _make_injector(self._env("WAYLAND_IBUS")) obj = _make_injector(self._env("WAYLAND_IBUS"))
obj._inject_shortcut_with_xdotool = MagicMock(return_value=True) obj._inject_shortcut_with_xdotool = MagicMock(return_value=True)
obj._ydotool_legacy_named_keys = False obj._ydotool_legacy_named_keys = False
obj._ensure_ydotoold = MagicMock(return_value=True) obj._ensure_ydotoold = MagicMock(return_value=True)
obj._wait_for_modifiers_released = MagicMock() 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: with patch("subprocess.run") as mock_run:
self.assertTrue(obj._inject_keyboard_shortcut("ctrl+a")) self.assertTrue(obj._inject_keyboard_shortcut("ctrl+a"))
# _check_dependencies prefers the uinput helper once its daemon is ready. self.assertEqual(obj.wayland_tool, "wtype")
self.assertTrue(which.called)
self.assertEqual(obj.wayland_tool, "ydotool")
self.assertEqual( self.assertEqual(
mock_run.call_args[0][0], 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() obj._inject_shortcut_with_xdotool.assert_not_called()
def test_wayland_ibus_falls_back_to_wtype(self): def test_wayland_ibus_falls_back_to_wtype(self):
@@ -1638,14 +1638,15 @@ class TestPressBackspace(unittest.TestCase):
self.assertTrue(obj.press_backspace(1)) self.assertTrue(obj.press_backspace(1))
self.assertEqual(mock_run.call_args[0][0], ["wtype", "-k", "BackSpace"]) self.assertEqual(mock_run.call_args[0][0], ["wtype", "-k", "BackSpace"])
def test_prefers_ydotool_when_both_tools_are_present(self): def test_prefers_wtype_when_both_tools_are_present(self):
"""_check_dependencies prefers the uinput helper; match that order.""" """Key events use the same rung order as startup: wtype before ydotool."""
obj = _make_backspace_injector("WAYLAND_IBUS", legacy=False) obj = _make_backspace_injector("WAYLAND_IBUS", legacy=False)
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
with patch("shutil.which", return_value="/usr/bin/x"): with patch("shutil.which", return_value="/usr/bin/x"):
self.assertTrue(obj.press_backspace(1)) self.assertTrue(obj.press_backspace(1))
self.assertEqual(obj.wayland_tool, "ydotool") self.assertEqual(obj.wayland_tool, "wtype")
self.assertEqual(mock_run.call_args[0][0][:2], ["ydotool", "key"]) 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): def test_waits_for_modifiers_before_resolving_the_tool(self):
"""A held PTT modifier turns BackSpace into Ctrl+BackSpace.""" """A held PTT modifier turns BackSpace into Ctrl+BackSpace."""
+80 -31
View File
@@ -60,10 +60,32 @@ EXTRA_VOID_DEPS = (
"python3", "python3",
"gtk+3", "gtk+3",
"libayatana-appindicator", "libayatana-appindicator",
"ibus",
"gobject-introspection", "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 #: Every package name the app template may reference, verified to exist in
#: void-packages master on 2026-11-06 via the srcpkgs/<name>/template #: void-packages master on 2026-11-06 via the srcpkgs/<name>/template
#: paths (python3-devel is a subpackage of the python3 template, as #: paths (python3-devel is a subpackage of the python3 template, as
@@ -78,8 +100,12 @@ VERIFIED_VOID_PACKAGES = frozenset(
"python3-cairo", "python3-cairo",
"gtk+3", "gtk+3",
"libayatana-appindicator", "libayatana-appindicator",
"ibus",
"gobject-introspection", "gobject-introspection",
"pipewire",
"wtype",
"wl-clipboard",
"xdotool",
"pulseaudio-utils",
"python3-numpy", "python3-numpy",
"python3-requests", "python3-requests",
"python3-pysocks", "python3-pysocks",
@@ -208,7 +234,7 @@ def test_template_metadata_shape() -> None:
assert re.fullmatch(r".+ <[^@<>\s]+@[^@<>\s]+>", variables["maintainer"]) assert re.fullmatch(r".+ <[^@<>\s]+@[^@<>\s]+>", variables["maintainer"])
assert "noreply" not in variables["maintainer"] assert "noreply" not in variables["maintainer"]
assert variables["license"] == "AGPL-3.0-only" 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 len(variables["short_desc"]) <= 72
assert re.fullmatch(r"[0-9][A-Za-z0-9.]*", variables["version"]) 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: 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() variables = _template_vars()
distfiles = variables["distfiles"] distfiles = variables["distfiles"]
version = variables["version"] version = variables["version"]
assert ( assert distfiles == (
"https://github.com/VocaHQ/vocalinux/releases/download/v${version}/" in distfiles f"{FORK_RELEASE_HOST}/releases/download/v${{version}}/"
), "distfiles must use the canonical release distribution for this version" f"{variables['pkgname']}-${{version}}.tar.gz"
assert distfiles.rstrip().endswith(".tar.gz"), "the release payload is the sdist tarball" ), "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+"): for moving in ("/master", "/main", "/archive/", "git+"):
assert moving not in distfiles, f"distfiles must not track a moving ref: {moving}" 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" assert distfiles.rstrip().endswith(".tar.gz"), "the release payload is the sdist tarball"
# The unpacked top-level directory must be where the template builds. # The sdist's top-level directory is pkgname-version, so xbps-src needs no
# xbps-src moves a single-top-dir archive to pkgname-version itself # wrksrc override (deprecated; xlint rejects it).
# (common/hooks/do-extract/00-distfiles.sh), so the pre-rename archive root = distfiles.rsplit("/", 1)[1].replace("${version}", version)
# root needs no override — and wrksrc is deprecated, xlint rejects it. assert root == f"{variables['pkgname']}-{version}.tar.gz", "archive name must match pkgname"
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 "wrksrc" not in variables, "deprecated wrksrc override: xlint fails the template" 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: def test_every_project_dependency_is_covered() -> None:
"""pyproject.toml runtime deps plus substrate must all resolve to depends.""" """pyproject.toml runtime deps plus substrate must all resolve to depends."""
depends = set(_template_vars()["depends"].split()) 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" assert "model" not in code, "models are not package payload"
def test_post_install_installs_the_renamed_paths_from_the_old_sdist() -> None: def test_post_install_installs_the_fork_names_without_renaming() -> None:
"""The 0.18.5 sdist predates the rename, so post_install promotes the """The fork's sdist already carries the new names, so post_install installs
old names at install time: the entry lands as voculinux.desktop with them as they are: no sed rewrite and no mv of upstream launchers."""
rewritten Name/Exec/Icon, and old-prefixed icons land renamed."""
body = _post_install_body() body = _post_install_body()
assert "vlicense LICENSE" in body assert "vlicense LICENSE" in body
assert "vocalinux.desktop" in body, "the sdist entry still carries the old name" assert "vinstall voculinux.desktop 644 usr/share/applications" in body
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 "resources/icons/scalable" in body assert "resources/icons/scalable" in body
assert "usr/share/icons/hicolor/scalable/apps" 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 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: 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: 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() variables = _template_vars()
for key in FORBIDDEN_VARS: for key in FORBIDDEN_VARS:
assert key not in variables, f"template sets {key}" 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: 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"): for engine in ("vosk", "whisper", "parakeet", "faster-whisper", "onnxruntime"):
assert engine in text, f"optional-engine status must name {engine}" assert engine in text, f"optional-engine status must name {engine}"
assert "ydotool" in text, "the absent injection helper must be named" 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: 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. pipeline, same shape as the other packaging gates.
- ``packaging/void/README.md`` — the documented maintainer and client - ``packaging/void/README.md`` — the documented maintainer and client
flow, including the CI secret name. 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 owns the template set (a renamed or added template must not silently
drop out), ``packaging/void/tool_checksums.txt`` owns the pins, drop out), ``packaging/void/tool_checksums.txt`` owns the pins,
``packaging/void/smoke.sh`` owns the dependency order the signed repo ``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. itself is the proof; these tests are the wiring guard.
""" """
import base64
import hashlib
import plistlib
import re import re
import struct
from pathlib import Path from pathlib import Path
from typing import Tuple
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml" 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" ROOT_README = REPO_ROOT / "README.md"
RELEASE_PROCESS = REPO_ROOT / "docs" / "RELEASE_PROCESS.md" RELEASE_PROCESS = REPO_ROOT / "docs" / "RELEASE_PROCESS.md"
VOID = REPO_ROOT / "packaging" / "void" VOID = REPO_ROOT / "packaging" / "void"
@@ -208,79 +222,51 @@ def _release_workflow_text() -> str:
return RELEASE_WORKFLOW.read_text(encoding="utf-8") return RELEASE_WORKFLOW.read_text(encoding="utf-8")
def _xbps_job_text() -> str: def _gitea_workflow_text() -> str:
"""The release xbps job block only — other jobs have their own runtimes.""" assert GITEA_WORKFLOW.is_file(), f"missing Gitea xbps release workflow: {GITEA_WORKFLOW}"
text = _release_workflow_text() return GITEA_WORKFLOW.read_text(encoding="utf-8")
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 test_release_workflow_builds_signed_xbps_on_tag() -> None: def test_the_xbps_job_lives_in_the_gitea_workflow_not_the_github_release() -> None:
"""The tag pipeline builds the signed repo and attaches it to the release.""" """One signing path: the tag-triggered Gitea workflow runs repo.sh."""
text = _xbps_job_text() github = _release_workflow_text()
assert ( for marker in (
"packaging/void/repo.sh" in text "build-xbps",
), "the xbps job must run the signed-repo pipeline, not a second inline flow" "xbps-repo",
assert "podman" in text, "the release xbps job must install podman (never Docker)" "XBPS_SIGNING_KEY",
assert "docker" not in text.lower().replace( "packaging/void/repo.sh",
"never docker", "" ):
), "the release xbps job must never invoke Docker" 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: #: Registration URL every client snippet names: the rolling ``latest``
"""An unset XBPS_SIGNING_KEY fails the xbps job — never a silent skip.""" #: release the Gitea workflow keeps current, so the URL serves the newest
text = _xbps_job_text() #: release's downloads (the tag itself is not part of the client config).
assert SIGNING_SECRET in text, "the xbps job must read the XBPS_SIGNING_KEY secret" CONCRETE_REPO_DOWNLOAD = "https://git.bongbetic.com/xavierk/voculinux/releases/download/latest"
assert re.search( REGISTRATION_LINE = f"repository={CONCRETE_REPO_DOWNLOAD}"
r"XBPS_SIGNING_KEY.{0,400}?exit 1", text, re.DOTALL
), "an empty XBPS_SIGNING_KEY must exit 1, not skip the publish"
def test_release_workflow_xbps_job_gates_tag_against_version() -> None: def _void_section(readme: str) -> str:
"""The xbps job only ships the repo when the tag matches version.py.""" """The root README's Void Linux section, up to the next ``###`` heading."""
text = _xbps_job_text() start = readme.index("### Void Linux")
assert "version.py" in text, "the xbps job must gate on version.py" end = readme.find("\n### ", start + 1)
assert re.search( return readme[start : end if end != -1 else len(readme)]
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 test_release_workflow_attaches_signed_repo_to_release() -> None: def test_client_registration_names_the_rolling_latest_release_url() -> None:
"""Packages, signatures, repodata, and the client key land on the release.""" """No placeholder, no per-tag or GitHub URL: the registration line is the
text = _xbps_job_text() fork's Gitea `latest` release, which the workflow republishes every tag."""
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()
root = ROOT_README.read_text(encoding="utf-8") root = ROOT_README.read_text(encoding="utf-8")
void_readme = README.read_text(encoding="utf-8") void_readme = README.read_text(encoding="utf-8")
for name, text in ( for name, text in (
("release.yml", workflow), ("release.yml", _release_workflow_text()),
("xbps-release.yml", _gitea_workflow_text()),
("README.md", root), ("README.md", root),
("packaging/void/README.md", void_readme), ("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 "<repo-url-from-release-notes>" not in text
), f"{name} still carries the self-referential repo-URL placeholder" ), f"{name} still carries the self-referential repo-URL placeholder"
assert "repository=<" not in text, f"{name} still carries a placeholder repository URL" 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" for name, text in (
assert ( ("README.md", _void_section(root)),
CONCRETE_REPO_DOWNLOAD in void_readme ("packaging/void/README.md", void_readme),
), "void README must name the concrete release download URL" ):
assert ( assert REGISTRATION_LINE in text, f"{name} must carry the registration line"
"releases/download/${{ github.ref_name }}" in workflow assert "releases/download/${TAG}" not in text, f"{name} must not pin a per-tag URL"
), "release.yml must render the concrete per-tag download URL at publish time" 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: 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" 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: 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.""" """glibc x86_64 only, and this is a third-party repo outside upstream Void."""
text = ROOT_README.read_text(encoding="utf-8").lower() 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( assert re.search(
r"[Pp]ull request", text r"[Pp]ull request", text
), "docs/RELEASE_PROCESS.md must require the bump as a pull request" ), "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): class TestGetRecommendedModel(unittest.TestCase):
"""Test cases for get_recommended_model function.""" """The recommendation is a ladder rung keyed to the installed build and free memory.
def test_get_recommended_model_returns_tuple(self): Spec #16 / ticket #20. Every reading is mocked: the installed build's
"""Test that get_recommended_model returns a tuple.""" backend, free VRAM from nvidia-smi and free system RAM. Total memory is
mock_psutil = MagicMock() never consulted, so no test here passes a total as a free reading.
mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3)) """
with patch.dict("sys.modules", {"psutil": mock_psutil}):
with patch( LADDER = {"large-v3-turbo-q8_0", "large-v3-turbo-q5_0", "small"}
"vocalinux.utils.whispercpp_model_info.detect_compute_backend",
return_value=("cpu", "Intel Core i7"), def _recommend(self, build_backend, free_vram=None, free_ram_mib=10 * 1024.0):
): from vocalinux.speech_recognition import model_ladder, recognition_manager
import importlib from vocalinux.utils import whispercpp_model_info as info
import vocalinux.utils.whispercpp_model_info as mod with (
patch.object(
importlib.reload(mod) recognition_manager,
result = mod.get_recommended_model() "detect_pywhispercpp_gpu_backend",
self.assertIsInstance(result, tuple) return_value=build_backend,
self.assertEqual(len(result), 2) ),
patch.object(model_ladder, "free_vram_mib", return_value=free_vram),
def test_get_recommended_model_with_vulkan_high_ram(self): patch.object(model_ladder, "free_system_ram_mib", return_value=free_ram_mib),
"""Test model recommendation with Vulkan and 8GB+ RAM.""" ):
import sys return info.get_recommended_model()
# Create a mock psutil module def test_returns_tuple(self):
mock_psutil = MagicMock() model, reason = self._recommend("vulkan", free_vram=2600.0)
mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3)) self.assertIsInstance(model, str)
self.assertIsInstance(reason, str)
with patch.dict("sys.modules", {"psutil": mock_psutil}):
with patch( def test_ample_free_vram_on_vulkan_build_selects_default_rung(self):
"vocalinux.utils.whispercpp_model_info.detect_compute_backend" model, reason = self._recommend("vulkan", free_vram=2600.0)
) as mock_backend: self.assertEqual(model, "large-v3-turbo-q8_0")
mock_backend.return_value = ("vulkan", "Intel Arc A770") self.assertIn("VRAM", reason)
with patch( def test_constrained_free_vram_steps_to_q5_rung(self):
"vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", model, reason = self._recommend("vulkan", free_vram=1500.0)
return_value=True, self.assertEqual(model, "large-v3-turbo-q5_0")
): self.assertIn("1500 MiB free VRAM", reason)
from vocalinux.utils.whispercpp_model_info import get_recommended_model
def test_tight_free_vram_selects_floor(self):
model, reason = get_recommended_model() model, _reason = self._recommend("vulkan", free_vram=900.0)
self.assertEqual(model, "small")
self.assertEqual(model, "small")
def test_total_vram_is_not_the_reading(self):
def test_get_recommended_model_with_cuda_high_vram(self): """A 4 GB card whose VRAM is mostly taken by the desktop gets the floor."""
"""Test model recommendation with CUDA and high VRAM.""" model, _reason = self._recommend("vulkan", free_vram=512.0)
import sys self.assertEqual(model, "small")
mock_psutil = MagicMock() def test_integrated_graphics_read_free_ram(self):
mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) """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)
with patch.dict("sys.modules", {"psutil": mock_psutil}): self.assertEqual(model, "large-v3-turbo-q8_0")
with patch( self.assertIn("RAM", reason)
"vocalinux.utils.whispercpp_model_info.detect_compute_backend"
) as mock_backend: def test_cpu_build_ignores_a_visible_gpu_and_reads_ram(self):
mock_backend.return_value = ("cuda", "NVIDIA RTX 4090 (24GB)") """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)
with patch( self.assertEqual(model, "small")
"vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", self.assertIn("RAM", reason)
return_value=True, self.assertNotIn("VRAM", reason)
):
from vocalinux.utils.whispercpp_model_info import get_recommended_model def test_keys_to_the_installed_build_not_the_hardware_probe(self):
"""The informational probe reporting CPU must not hide an installed GPU build."""
model, reason = get_recommended_model() from vocalinux.speech_recognition import model_ladder, recognition_manager
from vocalinux.utils import whispercpp_model_info as info
# High VRAM should recommend medium or higher
self.assertIn(model, ["medium", "large", "small"]) with (
patch.object(
def test_get_recommended_model_with_driver_only_cuda_demotes(self): info, "detect_compute_backend", return_value=(info.ComputeBackend.CPU, "x")
"""A CUDA driver without the toolkit cannot run the build: CPU logic.""" ),
import sys patch.object(
recognition_manager,
mock_psutil = MagicMock() "detect_pywhispercpp_gpu_backend",
mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) return_value="vulkan",
),
with patch.dict("sys.modules", {"psutil": mock_psutil}): patch.object(model_ladder, "free_vram_mib", return_value=2600.0),
with patch( patch.object(model_ladder, "free_system_ram_mib", return_value=512.0),
"vocalinux.utils.whispercpp_model_info.detect_compute_backend" ):
) as mock_backend: model, reason = info.get_recommended_model()
mock_backend.return_value = ("cuda", "NVIDIA RTX 4090 (24GB)") self.assertEqual(model, "large-v3-turbo-q8_0")
self.assertIn("VRAM", reason)
with patch(
"vocalinux.speech_recognition.model_ladder.cuda_toolkit_ready", def test_unreadable_memory_recommends_the_floor(self):
return_value=False, model, reason = self._recommend("vulkan", free_vram=None, free_ram_mib=None)
): self.assertEqual(model, "small")
from vocalinux.utils.whispercpp_model_info import get_recommended_model self.assertIn("unreadable", reason)
model, _reason = get_recommended_model() def test_recommendation_stays_inside_the_ladder_for_any_reading(self):
"""Unquantized turbo, the large family and medium never come back as a default."""
# 16 GB system RAM under CPU logic recommends base. for backend in ("vulkan", "cuda", "cpu"):
self.assertEqual(model, "base") 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):
def test_get_recommended_model_with_cpu_high_ram(self): model, _reason = self._recommend(backend, free_vram, free_ram)
"""Test model recommendation with CPU and 16GB+ RAM.""" self.assertIn(model, self.LADDER, (backend, free_vram, free_ram))
import sys
def test_reason_names_the_backend_the_build_has(self):
mock_psutil = MagicMock() _model, reason = self._recommend("cuda", free_vram=2600.0)
mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) self.assertIn("cuda build", reason)
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)
class TestGetModelPath(unittest.TestCase): 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'", "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]] [[package]]
name = "alabaster" name = "alabaster"
version = "1.0.0" 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 - Long URLs wrap (`break-all` / `overflow-wrap`) so mobile never overflows
### Navigation ### 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 - Mobile menu keeps Install Voculinux as the conversion action
- Real mobile disclosure with `aria-expanded` - Real mobile disclosure with `aria-expanded`
- Resource-rich footer, not a second mega-menu in the header - 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 - 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 - Dictation into terminals, browsers, IDEs, office apps
- Engines and models chosen for hardware (CPU, optional Vulkan GPU with discrete-device preference) - 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 ## Capabilities and constraints
+1 -28
View File
@@ -1,5 +1,3 @@
import { PHASE_PRODUCTION_BUILD } from 'next/constants.js';
// @ts-ignore // @ts-ignore
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
@@ -73,29 +71,4 @@ const config = {
}, },
}; };
// The header star count is fetched once per production build and inlined, so export default config;
// 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() },
};
}
+2
View File
@@ -86,9 +86,11 @@ describe("SEO Assets", () => {
continue; continue;
} }
const routePath = `${prefix}/${entry.name}`; 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"))) { if (fs.existsSync(path.join(dir, entry.name, "page.tsx"))) {
routes.push(`https://voculinux.com${routePath}/`); 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); walk(path.join(dir, entry.name), routePath);
} }
}; };
@@ -38,6 +38,7 @@ const CHROME =
async function resolveBaseUrl(): Promise<string | null> { async function resolveBaseUrl(): Promise<string | null> {
if (process.env.BASE_URL) return process.env.BASE_URL.replace(/\/$/, ""); if (process.env.BASE_URL) return process.env.BASE_URL.replace(/\/$/, "");
try { 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) }); const res = await fetch("http://127.0.0.1:3456/", { signal: AbortSignal.timeout(2000) });
if (res.ok) return "http://127.0.0.1:3456"; if (res.ok) return "http://127.0.0.1:3456";
} catch { } catch {
+12 -3
View File
@@ -11,7 +11,11 @@ import {
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell"; import { SeoSubpageShell } from "@/components/seo-subpage-shell";
import { absoluteUrl, buildPageMetadata } from "@/lib/seo"; import {
absoluteUrl,
buildPageMetadata,
UPSTREAM_REPO_URL,
} from "@/lib/seo";
const releases = [ const releases = [
{ {
@@ -574,6 +578,11 @@ export default function ChangelogPage() {
Track every release and see how Voculinux has evolved. From initial Track every release and see how Voculinux has evolved. From initial
alpha to stable releases, follow the journey of Linux voice dictation. alpha to stable releases, follow the journey of Linux voice dictation.
</p> </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>
<section className="space-y-6"> <section className="space-y-6">
@@ -644,13 +653,13 @@ export default function ChangelogPage() {
<div className="mt-4 border-t border-border pt-4"> <div className="mt-4 border-t border-border pt-4">
<a <a
href={`https://github.com/VocaHQ/vocalinux/releases/tag/${release.version}`} href={`${UPSTREAM_REPO_URL}/releases/tag/${release.version}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline" className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
View release on GitHub View upstream release on GitHub
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</a> </a>
</div> </div>
+5 -5
View File
@@ -12,7 +12,7 @@ import {
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell"; import { SeoSubpageShell } from "@/components/seo-subpage-shell";
import { buildPageMetadata } from "@/lib/seo"; import { buildPageMetadata, SOURCE_REPO_URL } from "@/lib/seo";
const faqCategories = [ const faqCategories = [
{ {
@@ -41,11 +41,11 @@ const faqCategories = [
questions: [ questions: [
{ {
q: "Does Voculinux work offline?", 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?", 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?", q: "Is my voice data stored anywhere?",
@@ -239,12 +239,12 @@ export default function FaqPage() {
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<ChevronRight className="h-4 w-4 text-primary" /> <ChevronRight className="h-4 w-4 text-primary" />
<a <a
href="https://github.com/VocaHQ/vocalinux/issues" href={`${SOURCE_REPO_URL}/issues`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-semibold text-primary hover:underline" className="font-semibold text-primary hover:underline"
> >
Open an issue on GitHub Open an issue in the Voculinux repository
</a> </a>
</span> </span>
</li> </li>
+3 -2
View File
@@ -457,8 +457,9 @@ flatpak update`}</Commands>
Need a different package? Need a different package?
</h2> </h2>
<p className="mb-6 text-muted-foreground"> <p className="mb-6 text-muted-foreground">
The install script covers every engine, the AUR and Nix flake fit Arch The install script covers every engine, the Nix flake fits NixOS, and
and NixOS, and the .deb and .rpm suit Debian, Ubuntu and Fedora. the .deb and .rpm suit Debian, Ubuntu and Fedora. The AUR packages for
Arch are not published yet.
</p> </p>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<Link <Link
+5 -5
View File
@@ -167,14 +167,14 @@ const distroConfig: Record<DistroSlug, DistroConfig> = {
name: "Arch Linux", name: "Arch Linux",
title: "Install Voice Dictation on Arch Linux", title: "Install Voice Dictation on Arch Linux",
description: 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: 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: [ prerequisites: [
"Arch Linux, Manjaro or EndeavourOS", "Arch Linux, Manjaro or EndeavourOS",
"Python 3.11+ with python-gobject", "Python 3.11+ with python-gobject",
"Working microphone", "Working microphone",
"curl installed (sudo pacman -Sy --needed curl), or an AUR helper such as yay", "curl installed (sudo pacman -Sy --needed curl)",
], ],
supportedOn: [ supportedOn: [
"Arch Linux (rolling): good support", "Arch Linux (rolling): good support",
@@ -184,7 +184,7 @@ const distroConfig: Record<DistroSlug, DistroConfig> = {
installCommand: INSTALL_SCRIPT_COMMAND, installCommand: INSTALL_SCRIPT_COMMAND,
otherInstallOptions: [ 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.", 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 command: `yay -S voculinux # canonical release sdist, built against Arch dependencies
yay -S voculinux-git # community package; tracks main`, 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.", "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.", "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.", "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: [ postInstallChecks: [
...COMMON_CHECKS, ...COMMON_CHECKS,
+3 -2
View File
@@ -391,8 +391,9 @@ export default function NixosInstallPage() {
Other ways to install Other ways to install
</h2> </h2>
<p className="mb-6 text-muted-foreground"> <p className="mb-6 text-muted-foreground">
Not on Nix? The install script, AUR, .deb, .rpm, AppImage and the Not on Nix? The install script and the upstream .deb, .rpm and
VocaHQ Flatpak remote cover most other systems. AppImage downloads cover most other systems. The AUR packages and the
fork Flatpak remote are not published yet.
</p> </p>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<Link <Link
+25 -29
View File
@@ -3,7 +3,11 @@ import { type Metadata } from "next";
import { ChevronRight, Cpu, Laptop, Terminal } from "lucide-react"; import { ChevronRight, Cpu, Laptop, Terminal } from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell"; import { SeoSubpageShell } from "@/components/seo-subpage-shell";
import { TerminalBlock } from "@/components/terminal-block"; import { TerminalBlock } from "@/components/terminal-block";
import { buildPageMetadata } from "@/lib/seo"; import {
buildPageMetadata,
SOURCE_REPO_URL,
UPSTREAM_REPO_URL,
} from "@/lib/seo";
const installCommand = const installCommand =
"curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh -o /tmp/vl.sh && bash /tmp/vl.sh --interactive"; "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 && \\ -o /tmp/vl.sh && \\
bash /tmp/vl.sh --interactive`; bash /tmp/vl.sh --interactive`;
const codeClass =
"rounded bg-muted px-1.5 py-0.5 font-mono text-sm text-foreground";
const distroGuides = [ const distroGuides = [
{ {
name: "Ubuntu", name: "Ubuntu",
@@ -113,7 +120,7 @@ export default function InstallGuidesPage() {
<div className="install-alts"> <div className="install-alts">
<a <a
className="install-alt" className="install-alt"
href="https://github.com/VocaHQ/vocalinux/releases" href={`${UPSTREAM_REPO_URL}/releases`}
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<p className="install-alt-kicker">No installer</p> <p className="install-alt-kicker">No installer</p>
@@ -125,7 +132,7 @@ export default function InstallGuidesPage() {
</a> </a>
<a <a
className="install-alt" 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" rel="noopener noreferrer"
> >
<p className="install-alt-kicker">New</p> <p className="install-alt-kicker">New</p>
@@ -144,11 +151,7 @@ export default function InstallGuidesPage() {
sideload the .flatpak bundle. sideload the .flatpak bundle.
</p> </p>
</a> </a>
<a <div className="install-alt">
className="install-alt"
href="https://snapcraft.io/voculinux"
rel="noopener noreferrer"
>
<p className="install-alt-kicker">Snap Store</p> <p className="install-alt-kicker">Snap Store</p>
<h2>Snap</h2> <h2>Snap</h2>
<p> <p>
@@ -156,36 +159,33 @@ export default function InstallGuidesPage() {
published (not published yet). amd64 only; a .snap will be published (not published yet). amd64 only; a .snap will be
attached to fork releases too. attached to fork releases too.
</p> </p>
</a> </div>
<a <div className="install-alt">
className="install-alt"
href="https://aur.archlinux.org/packages/voculinux"
rel="noopener noreferrer"
>
<p className="install-alt-kicker">Arch</p> <p className="install-alt-kicker">Arch</p>
<h2>AUR</h2> <h2>AUR</h2>
<p> <p>
<code>yay -S voculinux</code>, or voculinux-bin for the prebuilt <code>yay -S voculinux</code>, or voculinux-bin for the prebuilt
package. Fork packages are not published yet. package. Fork packages are not published yet.
</p> </p>
</a> </div>
<a <a
className="install-alt" 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" rel="noopener noreferrer"
> >
<p className="install-alt-kicker">From source</p> <p className="install-alt-kicker">From source</p>
<h2>Build it yourself</h2> <h2>Build it yourself</h2>
<p> <p>
Clone the repo and run <code>./install.sh</code>. The upstream Clone the repo and run <code>./install.sh</code>. The install
install guide has the full source path. guide in the Voculinux repository has the full source path.
</p> </p>
</a> </a>
</div> </div>
<p className="mt-4 text-sm text-muted-foreground"> <p className="mt-4 text-sm text-muted-foreground">
Fork status: packaged artefacts (AppImage, .deb/.rpm, Flatpak, Snap, Fork status: packaged artefacts (AppImage, .deb/.rpm, Flatpak, Snap,
AUR) are not published yet — the cards above link upstream artefacts AUR) are not published yet. The AppImage card links upstream
or reserved fork names until the first fork release lands. artefacts; the Snap and AUR cards name the planned fork packages and
do not link anywhere until the first fork release lands.
</p> </p>
</section> </section>
@@ -218,15 +218,11 @@ export default function InstallGuidesPage() {
<h2 className="mb-4">After installation</h2> <h2 className="mb-4">After installation</h2>
<ul className="space-y-3 text-muted-foreground"> <ul className="space-y-3 text-muted-foreground">
<li> <li>
Launch with{" "} Launch with <code className={codeClass}>voculinux</code> once fork
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm text-foreground"> releases are published. The install command above fetches the
vocalinux upstream script, so today it puts{" "}
</code>{" "} <code className={codeClass}>vocalinux</code> (upstream) on your PATH
(the upstream binary; fork releases will use{" "} instead.
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm text-foreground">
voculinux
</code>
).
</li> </li>
<li> <li>
Hold Right Alt (Option) to dictate, or switch to toggle mode in Hold Right Alt (Option) to dictate, or switch to toggle mode in
+3 -8
View File
@@ -6,7 +6,7 @@ import {
absoluteUrl, absoluteUrl,
DEFAULT_OG_IMAGE_ALT, DEFAULT_OG_IMAGE_ALT,
DEFAULT_OG_IMAGE_PATH, DEFAULT_OG_IMAGE_PATH,
GITHUB_REPO_URL, SOURCE_REPO_URL,
SITE_NAME, SITE_NAME,
SITE_URL, SITE_URL,
TWITTER_HANDLE, TWITTER_HANDLE,
@@ -128,12 +128,7 @@ const organizationJsonLd = {
url: absoluteUrl("/"), url: absoluteUrl("/"),
logo: absoluteUrl("/voculinux.png"), logo: absoluteUrl("/voculinux.png"),
description: "Open-source, offline voice dictation software for Linux.", description: "Open-source, offline voice dictation software for Linux.",
sameAs: [GITHUB_REPO_URL, "https://bongbetic.com"], sameAs: [SOURCE_REPO_URL, "https://bongbetic.com"],
founder: {
"@type": "Person",
name: "Jatin K Malik",
url: "https://github.com/jatinkrmalik",
},
}; };
const webSiteJsonLd = { const webSiteJsonLd = {
@@ -152,7 +147,7 @@ const webSiteJsonLd = {
"@type": "SearchAction", "@type": "SearchAction",
target: { target: {
"@type": "EntryPoint", "@type": "EntryPoint",
urlTemplate: `${GITHUB_REPO_URL}/issues?q={search_term_string}`, urlTemplate: `${SOURCE_REPO_URL}/issues?q={search_term_string}`,
}, },
"query-input": { "query-input": {
"@type": "PropertyValueSpecification", "@type": "PropertyValueSpecification",
+8 -8
View File
@@ -11,7 +11,7 @@ import {
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell"; import { SeoSubpageShell } from "@/components/seo-subpage-shell";
import { absoluteUrl, buildPageMetadata } from "@/lib/seo"; import { absoluteUrl, buildPageMetadata, SOURCE_REPO_URL } from "@/lib/seo";
const benefits = [ const benefits = [
{ {
@@ -23,9 +23,9 @@ const benefits = [
iconBg: "bg-primary/10", iconBg: "bg-primary/10",
}, },
{ {
title: "Zero Data Collection", title: "No usage telemetry",
description: 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, icon: Database,
iconColor: "text-primary", iconColor: "text-primary",
iconBg: "bg-primary/10", iconBg: "bg-primary/10",
@@ -61,7 +61,7 @@ const sensitiveUseCases = [
{ {
title: "Local engines keep audio on the machine", title: "Local engines keep audio on the machine",
description: 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", 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"> <ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 flex-shrink-0" /> <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 <a
href="https://github.com/VocaHQ/vocalinux" href={SOURCE_REPO_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-semibold hover:underline" className="font-semibold hover:underline"
> >
GitHub Voculinux repository
</a> </a>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
@@ -281,7 +281,7 @@ export default function OfflinePage() {
</section> </section>
<section className="rounded-[12px] border border-border bg-muted p-8"> <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"> <p className="mb-6 text-muted-foreground">
Install Voculinux and use a local engine if you want speech recognition on the machine. Install Voculinux and use a local engine if you want speech recognition on the machine.
Remote API stays optional. Remote API stays optional.
+8 -8
View File
@@ -14,7 +14,7 @@ import {
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell"; import { SeoSubpageShell } from "@/components/seo-subpage-shell";
import { absoluteUrl, buildPageMetadata } from "@/lib/seo"; import { absoluteUrl, buildPageMetadata, SOURCE_REPO_URL } from "@/lib/seo";
const benefits = [ const benefits = [
{ {
@@ -58,7 +58,7 @@ const vsProprietary = [
{ feature: "Linux Support", voculinux: "Native", proprietary: "Limited or none" }, { feature: "Linux Support", voculinux: "Native", proprietary: "Limited or none" },
{ feature: "Customization", voculinux: "Unlimited", proprietary: "Limited to vendor options" }, { feature: "Customization", voculinux: "Unlimited", proprietary: "Limited to vendor options" },
{ feature: "Vendor Dependency", voculinux: "None", proprietary: "High" }, { 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 = [ const techStack = [
@@ -87,7 +87,7 @@ const techStack = [
export const metadata: Metadata = buildPageMetadata({ export const metadata: Metadata = buildPageMetadata({
title: "Open Source Dictation Software for Linux (AGPL-3.0)", title: "Open Source Dictation Software for Linux (AGPL-3.0)",
description: 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", path: "/open-source",
keywords: [ keywords: [
"open source voice dictation", "open source voice dictation",
@@ -141,14 +141,14 @@ export default function OpenSourcePage() {
Free & Open Source Voice Dictation Free & Open Source Voice Dictation
</h1> </h1>
<p className="mb-8 max-w-4xl text-lg text-muted-foreground"> <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 <a
href="https://vocahq.com" href="https://github.com/VocaHQ/vocalinux"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-medium text-primary hover:underline" className="font-medium text-primary hover:underline"
> >
VocaHQ Vocalinux (upstream, by VocaHQ)
</a> </a>
. No subscriptions, no vendor lock-in, no hidden costs. Local engines keep . No subscriptions, no vendor lock-in, no hidden costs. Local engines keep
audio on the machine. Remote API is optional. audio on the machine. Remote API is optional.
@@ -289,13 +289,13 @@ export default function OpenSourcePage() {
translations - join the community. translations - join the community.
</p> </p>
<a <a
href="https://github.com/VocaHQ/vocalinux" href={SOURCE_REPO_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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" /> <Zap className="h-4 w-4" />
View on GitHub View the Voculinux source
</a> </a>
</div> </div>
</div> </div>
+35 -39
View File
@@ -5,20 +5,22 @@ import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { SiteChrome } from "@/components/site-chrome"; import { SiteChrome } from "@/components/site-chrome";
import { TerminalBlock, TerminalPrompt } from "@/components/terminal-block"; 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/", ""); // The install and uninstall scripts below are fetched from upstream: the fork
const GITHUB_RAW_BASE = `https://raw.githubusercontent.com/${GITHUB_REPO_PATH}`; // 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 = { 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 \\ interactiveInstallDisplayCommand: `curl -fsSL \\
${GITHUB_RAW_BASE}/main/install.sh \\ ${UPSTREAM_RAW_BASE}/main/install.sh \\
-o /tmp/vl.sh && \\ -o /tmp/vl.sh && \\
bash /tmp/vl.sh --interactive`, 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 \\ uninstallDisplayCommand: `curl -fsSL \\
${GITHUB_RAW_BASE}/main/uninstall.sh \\ ${UPSTREAM_RAW_BASE}/main/uninstall.sh \\
-o /tmp/vul.sh && \\ -o /tmp/vul.sh && \\
bash /tmp/vul.sh`, bash /tmp/vul.sh`,
}; };
@@ -32,7 +34,7 @@ const homeJsonLd = [
applicationCategory: "UtilitiesApplication", applicationCategory: "UtilitiesApplication",
operatingSystem: "Linux", operatingSystem: "Linux",
isAccessibleForFree: true, isAccessibleForFree: true,
license: `${GITHUB_REPO_URL}/blob/main/LICENSE`, license: `${SOURCE_REPO_URL}/src/branch/main/LICENSE`,
offers: { offers: {
"@type": "Offer", "@type": "Offer",
price: "0", price: "0",
@@ -47,7 +49,7 @@ const homeJsonLd = [
url: "https://github.com/jatinkrmalik", url: "https://github.com/jatinkrmalik",
}, },
url: "https://voculinux.com/", url: "https://voculinux.com/",
downloadUrl: GITHUB_REPO_URL, downloadUrl: SOURCE_REPO_URL,
screenshot: "https://voculinux.com/og-image.png", screenshot: "https://voculinux.com/og-image.png",
featureList: [ featureList: [
"Local speech recognition with whisper.cpp, Faster Whisper, Whisper, VOSK, and Parakeet", "Local speech recognition with whisper.cpp, Faster Whisper, Whisper, VOSK, and Parakeet",
@@ -96,7 +98,7 @@ const homeJsonLd = [
name: "Does Voculinux collect usage telemetry?", name: "Does Voculinux collect usage telemetry?",
acceptedAnswer: { acceptedAnswer: {
"@type": "Answer", "@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", "no account",
"system tray", "system tray",
"whisper.cpp", "whisper.cpp",
"no telemetry", "no app telemetry",
]; ];
export default function HomePage() { export default function HomePage() {
@@ -305,7 +307,7 @@ export default function HomePage() {
</a> </a>
<a <a
className="btn btn-secondary" className="btn btn-secondary"
href={GITHUB_REPO_URL} href={SOURCE_REPO_URL}
rel="noopener noreferrer" rel="noopener noreferrer"
> >
View the source{" "} View the source{" "}
@@ -318,7 +320,7 @@ export default function HomePage() {
</div> </div>
<div className="hero-proof"> <div className="hero-proof">
<span>X11 and Wayland</span> <span>X11 and Wayland</span>
<span>No telemetry</span> <span>No app telemetry</span>
<span>AGPL-3.0</span> <span>AGPL-3.0</span>
</div> </div>
</div> </div>
@@ -714,8 +716,9 @@ export default function HomePage() {
<article className="boundary"> <article className="boundary">
<h3>What uses a network</h3> <h3>What uses a network</h3>
<p> <p>
Model and app downloads, plus any Remote API host you add. Model downloads, the update check against GitHub Releases, and
Nothing is sent to a Voca speech cloud. any Remote API host you add. Nothing is sent to a Voca speech
cloud.
</p> </p>
</article> </article>
<article className="boundary"> <article className="boundary">
@@ -759,7 +762,7 @@ export default function HomePage() {
<div className="install-alts"> <div className="install-alts">
<a <a
className="install-alt" className="install-alt"
href={`${GITHUB_REPO_URL}/releases`} href={`${SOURCE_REPO_URL}/releases`}
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<p className="install-alt-kicker">Void Linux</p> <p className="install-alt-kicker">Void Linux</p>
@@ -772,7 +775,7 @@ export default function HomePage() {
</a> </a>
<a <a
className="install-alt" className="install-alt"
href={`${GITHUB_REPO_URL}/releases`} href={`${UPSTREAM_REPO_URL}/releases`}
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<p className="install-alt-kicker">No installer</p> <p className="install-alt-kicker">No installer</p>
@@ -784,7 +787,7 @@ export default function HomePage() {
</a> </a>
<a <a
className="install-alt" 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" rel="noopener noreferrer"
> >
<p className="install-alt-kicker">New</p> <p className="install-alt-kicker">New</p>
@@ -796,7 +799,7 @@ export default function HomePage() {
</a> </a>
<a <a
className="install-alt" 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" rel="noopener noreferrer"
> >
<p className="install-alt-kicker">Auto-updates</p> <p className="install-alt-kicker">Auto-updates</p>
@@ -807,11 +810,7 @@ export default function HomePage() {
sideload the .flatpak bundle. sideload the .flatpak bundle.
</p> </p>
</a> </a>
<a <div className="install-alt">
className="install-alt"
href="https://snapcraft.io/voculinux"
rel="noopener noreferrer"
>
<p className="install-alt-kicker">Snap Store</p> <p className="install-alt-kicker">Snap Store</p>
<h3>Snap</h3> <h3>Snap</h3>
<p> <p>
@@ -819,36 +818,33 @@ export default function HomePage() {
is published (not published yet). amd64 only; a .snap will be is published (not published yet). amd64 only; a .snap will be
attached to fork releases too. attached to fork releases too.
</p> </p>
</a> </div>
<a <div className="install-alt">
className="install-alt"
href="https://aur.archlinux.org/packages/voculinux"
rel="noopener noreferrer"
>
<p className="install-alt-kicker">Arch</p> <p className="install-alt-kicker">Arch</p>
<h3>AUR</h3> <h3>AUR</h3>
<p> <p>
<code>yay -S voculinux</code>, or voculinux-bin for the prebuilt <code>yay -S voculinux</code>, or voculinux-bin for the prebuilt
package. Fork packages are not published yet. package. Fork packages are not published yet.
</p> </p>
</a> </div>
<a <a
className="install-alt" 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" rel="noopener noreferrer"
> >
<p className="install-alt-kicker">From source</p> <p className="install-alt-kicker">From source</p>
<h3>Build it yourself</h3> <h3>Build it yourself</h3>
<p> <p>
Clone the repo and run <code>./install.sh</code>. The upstream Clone the repo and run <code>./install.sh</code>. The install
install guide has the full source path. guide in the Voculinux repository has the full source path.
</p> </p>
</a> </a>
</div> </div>
<p className="mt-4 text-sm text-muted-foreground"> <p className="mt-4 text-sm text-muted-foreground">
Fork status: packaged artefacts (AppImage, .deb/.rpm, Flatpak, Snap, Fork status: packaged artefacts (AppImage, .deb/.rpm, Flatpak, Snap,
AUR, xbps) are not published yet — the cards above link upstream AUR, xbps) are not published yet. The AppImage card links upstream
artefacts or reserved fork names until the first fork release lands. artefacts; the Snap and AUR cards name the planned fork packages and
do not link anywhere until the first fork release lands.
</p> </p>
<p className="section-link" style={{ marginTop: "1.6rem" }}> <p className="section-link" style={{ marginTop: "1.6rem" }}>
<Link href="/install/ubuntu/">Ubuntu guide</Link> <Link href="/install/ubuntu/">Ubuntu guide</Link>
@@ -900,7 +896,7 @@ export default function HomePage() {
{ {
question: "Does Voculinux collect usage telemetry?", question: "Does Voculinux collect usage telemetry?",
answer: 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?", question: "Which Linux distributions are supported?",
@@ -1016,7 +1012,7 @@ export default function HomePage() {
</div> </div>
<a <a
className="family-card" className="family-card"
href={GITHUB_REPO_URL} href={UPSTREAM_REPO_URL}
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<div className="family-card-top"> <div className="family-card-top">
@@ -1081,7 +1077,7 @@ export default function HomePage() {
</a> </a>
<a <a
className="btn btn-secondary" className="btn btn-secondary"
href={GITHUB_REPO_URL} href={SOURCE_REPO_URL}
rel="noopener noreferrer" rel="noopener noreferrer"
> >
View the source View the source
+13 -8
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import { type Metadata } from "next"; import { type Metadata } from "next";
import { CheckCircle2, Lock, Server, Shield, Eye, Database, ExternalLink } from "lucide-react"; import { CheckCircle2, Lock, Server, Shield, Eye, Database, ExternalLink } from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell"; 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({ export const metadata: Metadata = buildPageMetadata({
title: "Privacy Policy - Local Voice Dictation", title: "Privacy Policy - Local Voice Dictation",
@@ -92,8 +92,8 @@ export default function PrivacyPage() {
</div> </div>
<h2 className="subpage-card-title mb-2 text-xl font-semibold">Open Source</h2> <h2 className="subpage-card-title mb-2 text-xl font-semibold">Open Source</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Fully open source under AGPL-3.0. Inspect the code yourself on GitHub. The installed Fully open source under AGPL-3.0. Inspect the code yourself in the Voculinux source
app does not send usage telemetry. repository. The installed app does not send usage telemetry.
</p> </p>
</article> </article>
</section> </section>
@@ -139,6 +139,11 @@ export default function PrivacyPage() {
Local engines keep audio on your device. Remote API sends audio only Local engines keep audio on your device. Remote API sends audio only
to the server you configure. to the server you configure.
</p> </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> <p>
Those limits apply to the installed app. They do not describe this Those limits apply to the installed app. They do not describe this
website. website.
@@ -179,7 +184,7 @@ export default function PrivacyPage() {
</li> </li>
<li className="border-l-2 border-primary pl-4"> <li className="border-l-2 border-primary pl-4">
<strong className="text-foreground">2. Local Processing</strong> - Audio is <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>
<li className="border-l-2 border-primary pl-4"> <li className="border-l-2 border-primary pl-4">
<strong className="text-foreground">3. Text Output</strong> - Transcribed text is <strong className="text-foreground">3. Text Output</strong> - Transcribed text is
@@ -242,7 +247,7 @@ export default function PrivacyPage() {
</thead> </thead>
<tbody className="text-sm text-muted-foreground"> <tbody className="text-sm text-muted-foreground">
<tr className="border-b border-border"> <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"> <td className="py-3 text-primary">
Only with Remote API Only with Remote API
</td> </td>
@@ -251,7 +256,7 @@ export default function PrivacyPage() {
<tr className="border-b border-border"> <tr className="border-b border-border">
<td className="py-3 font-medium text-foreground">Internet required</td> <td className="py-3 font-medium text-foreground">Internet required</td>
<td className="py-3 text-primary"> <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>
<td className="py-3 text-primary">Yes</td> <td className="py-3 text-primary">Yes</td>
</tr> </tr>
@@ -283,12 +288,12 @@ export default function PrivacyPage() {
please reach out: please reach out:
</p> </p>
<a <a
href="https://github.com/VocaHQ/vocalinux/issues" href={`${SOURCE_REPO_URL}/issues`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-primary hover:underline" 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" /> <ExternalLink className="h-4 w-4" />
</a> </a>
</section> </section>
+5 -5
View File
@@ -10,7 +10,7 @@ import {
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
import { SeoSubpageShell } from "@/components/seo-subpage-shell"; import { SeoSubpageShell } from "@/components/seo-subpage-shell";
import { absoluteUrl, buildPageMetadata } from "@/lib/seo"; import { absoluteUrl, buildPageMetadata, SOURCE_REPO_URL } from "@/lib/seo";
const troubleshootingItems = [ const troubleshootingItems = [
{ {
@@ -221,7 +221,7 @@ export default function TroubleshootingPage() {
</h1> </h1>
<p className="mb-8 max-w-4xl text-lg text-muted-foreground"> <p className="mb-8 max-w-4xl text-lg text-muted-foreground">
Having issues with Voculinux? Find solutions to common problems below. 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> </p>
</section> </section>
@@ -320,12 +320,12 @@ export default function TroubleshootingPage() {
<ChevronRight className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" /> <ChevronRight className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
<span> <span>
<a <a
href="https://github.com/VocaHQ/vocalinux/issues" href={`${SOURCE_REPO_URL}/issues`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-semibold text-primary hover:underline" 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" /> <ExternalLink className="h-3.5 w-3.5" />
</a> </a>
</span> </span>
@@ -334,7 +334,7 @@ export default function TroubleshootingPage() {
<ChevronRight className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" /> <ChevronRight className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
<span> <span>
<a <a
href="https://github.com/VocaHQ/vocalinux/issues/new" href={`${SOURCE_REPO_URL}/issues/new`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-semibold text-primary hover:underline" 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 React, { useEffect, useId, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Star } from "lucide-react"; import { Code } from "lucide-react";
import { VoculinuxLogo } from "@/components/optimized-image"; import { VoculinuxLogo } from "@/components/optimized-image";
import { GITHUB_REPO_URL } from "@/lib/seo"; import { SOURCE_REPO_URL } from "@/lib/seo";
import { import {
footerGroups, footerGroups,
homeNav, homeNav,
@@ -14,10 +14,6 @@ import {
type NavLink, type NavLink,
} from "@/lib/site-nav"; } 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({ function SmartLink({
href, href,
className, className,
@@ -65,7 +61,6 @@ export function SiteChrome({
variant?: "home" | "page"; variant?: "home" | "page";
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const stars = GITHUB_STARS;
const navId = useId(); const navId = useId();
const nav = variant === "home" ? homeNav : pageNav; const nav = variant === "home" ? homeNav : pageNav;
const ctaHref = variant === "home" ? "#install" : "/#install"; const ctaHref = variant === "home" ? "#install" : "/#install";
@@ -141,22 +136,17 @@ export function SiteChrome({
<div className="header-actions"> <div className="header-actions">
<a <a
className="header-stars" className="header-source"
href={GITHUB_REPO_URL} href={SOURCE_REPO_URL}
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label={ aria-label="Voculinux source repository"
stars !== null
? `Voculinux on GitHub, ${stars.toLocaleString("en-US")} stars`
: "Voculinux on GitHub"
}
> >
<Star <Code
className="h-4 w-4" className="h-4 w-4"
strokeWidth={1.75} strokeWidth={1.75}
fill="currentColor"
aria-hidden="true" aria-hidden="true"
/> />
<span>{stars !== null ? stars.toLocaleString("en-US") : "GitHub"}</span> <span>Source</span>
</a> </a>
<button <button
className="nav-toggle" className="nav-toggle"
@@ -1,6 +1,11 @@
import fs from "fs"; import fs from "fs";
import path from "path"; 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 => const readPage = (...segments: string[]): string =>
fs.readFileSync(path.join(process.cwd(), "src", "app", ...segments), "utf-8"); 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."; "Dictate locally. In any app. Offline voice dictation for Linux.";
export const TWITTER_HANDLE = "@vocahq"; export const TWITTER_HANDLE = "@vocahq";
export const VOCAHQ_URL = "https://vocahq.com"; 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 X_URL = "https://x.com/vocahq";
export const DISCORD_URL = "https://discord.gg/t6muquAJbm"; export const DISCORD_URL = "https://discord.gg/t6muquAJbm";
export const EMAIL_URL = "mailto:hello@vocahq.com"; export const EMAIL_URL = "mailto:hello@vocahq.com";
+7 -3
View File
@@ -1,7 +1,7 @@
import { import {
DISCORD_URL, DISCORD_URL,
EMAIL_URL, EMAIL_URL,
GITHUB_REPO_URL, SOURCE_REPO_URL,
X_URL, X_URL,
} from "@/lib/seo"; } from "@/lib/seo";
@@ -35,7 +35,11 @@ export const mobileExtraNav: NavLink[] = [
export const socialLinks: { href: string; label: string; icon: string }[] = [ export const socialLinks: { href: string; label: string; icon: string }[] = [
{ href: X_URL, label: "X", icon: "/brand/social/x.svg" }, { href: X_URL, label: "X", icon: "/brand/social/x.svg" },
{ href: DISCORD_URL, label: "Discord", icon: "/brand/social/discord.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" }, { 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: "/troubleshooting/", label: "Troubleshooting" },
{ href: "/privacy/", label: "Privacy" }, { href: "/privacy/", label: "Privacy" },
{ href: "/changelog/", label: "Changelog" }, { href: "/changelog/", label: "Changelog" },
{ href: GITHUB_REPO_URL, label: "Source" }, { href: SOURCE_REPO_URL, label: "Source" },
{ href: "https://bongbetic.com", label: "Bongbetic" }, { href: "https://bongbetic.com", label: "Bongbetic" },
], ],
}, },
+2 -2
View File
@@ -357,7 +357,7 @@
margin-left: auto; margin-left: auto;
} }
.header-stars { .header-source {
display: inline-flex; display: inline-flex;
gap: 0.4rem; gap: 0.4rem;
align-items: center; align-items: center;
@@ -373,7 +373,7 @@
text-decoration: none; text-decoration: none;
} }
.header-stars:hover { .header-source:hover {
color: var(--brand-dark); color: var(--brand-dark);
background: var(--brand-softer); background: var(--brand-softer);
border-color: var(--brand); border-color: var(--brand);