fix(spec16): close the review findings for the Void port (#34)
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:
@@ -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"
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"**/dist/**",
|
"**/dist/**",
|
||||||
"**/build/**",
|
"**/build/**",
|
||||||
"**/*.lock",
|
"**/*.lock",
|
||||||
|
"requirements/*.txt",
|
||||||
"**/package-lock.json",
|
"**/package-lock.json",
|
||||||
"**/*.md",
|
"**/*.md",
|
||||||
"docs/**"
|
"docs/**"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -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 <soubarna@live.in></string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 "", []
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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() },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user