5 Commits
Author SHA1 Message Date
xavierk 61e8206c67 docs(cuda): record owner decisions and phase 0 result (#58)
CI / security (push) Successful in 1m11s
CI / lint (push) Successful in 22s
CI / ai-review (push) Skipped
2026-11-07 03:24:37 +00:00
xavierk 594686d0f7 docs(cuda): integration plan for an optional CUDA backend on Void (#57)
CI / security (push) Successful in 1m10s
CI / lint (push) Successful in 21s
CI / ai-review (push) Skipped
2026-11-07 03:17:22 +00:00
xavierk 808bd17704 docs(readme): current usage, GPU and CUDA section, single upstream credit (#56)
CI / security (push) Successful in 1m10s
CI / lint (push) Successful in 22s
CI / ai-review (push) Skipped
2026-11-07 03:00:25 +00:00
xavierk d1a80e6ead chore(release): prepare v0.19.1 (#55)
CI / security (push) Successful in 1m21s
CI / lint (push) Successful in 22s
CI / ai-review (push) Skipped
2026-11-06 21:18:15 +00:00
xavierk 8b842e5712 fix(legacy): flag leftover upstream Vocalinux launchers (#54)
CI / security (push) Successful in 1m12s
CI / lint (push) Successful in 23s
CI / ai-review (push) Skipped
After the tray is up, Voculinux looks for a user-level vocalinux.desktop or ~/.local/bin/vocalinux(-gui) whose target is gone. It logs them and shows one notification naming the files to remove. It never changes the user's files, and a working upstream install is left alone. The README gains a migration note.

Fixes #52
2026-11-06 21:03:29 +00:00
16 changed files with 913 additions and 32 deletions
+7 -1
View File
@@ -12,7 +12,13 @@ Release history for Vocalinux.
## Current stable
**[v0.19.0](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0)** (2026-11-07)
**[v0.19.1](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.1)** (2026-11-07)
A patch on 0.19.0. A leftover upstream launcher (`vocalinux.desktop`, `vocalinux`, `vocalinux-gui`) is now reported at startup, because it hid the Voculinux entry in the launcher (#52, #54). The release pipeline docs now say `bash` for `publish-release.sh`.
### v0.19.0 (2026-11-07)
[Release page](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0)
The first Voculinux release: a hard fork of 0.18.5 for Void Linux (glibc, x86_64), published as a signed xbps repository from the fork's Gitea host. Changes since v0.18.5, from the commit subjects and PR titles on `main`:
+68 -14
View File
@@ -11,7 +11,7 @@
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Privacy: on-device](https://img.shields.io/badge/privacy-on--device-success)](#privacy-and-security)
[![X11 & Wayland](https://img.shields.io/badge/display-X11%20%7C%20Wayland-lightgrey)](#features)
[![Release](https://img.shields.io/badge/release-v0.19.0-478061)](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0)
[![Release](https://img.shields.io/badge/release-v0.19.1-478061)](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.1)
[Website](https://voculinux.com) · [Install](#install) · [Docs](#documentation) · [Releases](https://git.bongbetic.com/xavierk/voculinux/releases) · [Issues](https://git.bongbetic.com/xavierk/voculinux/issues)
@@ -19,16 +19,16 @@
Voculinux turns speech into typed text in whatever app has focus. It is a free, AGPL-3.0-licensed desktop app for X11 and Wayland. After you download a model, local engines (whisper.cpp by default, plus OpenAI Whisper, Faster Whisper, VOSK, and Parakeet) run speech-to-text on your machine. An optional remote HTTP API is off unless you configure it.
No account is required. Models download once. After that, speech-to-text stays on your machine.
No account is required. Models download once. After that, speech-to-text stays on your machine. It is built for Void Linux, with niri and the Dank Material Shell in mind, and is inspired by an earlier project credited under [Origin](#origin).
**Current release:** [v0.19.0](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0). The first Voculinux release: a Void Linux package from the [signed xbps repository](#void-linux-signed-xbps-repo), niri and Dank Material Shell support, a model default chosen by free GPU memory, and a fix that keeps system-audio capture from ever opening the microphone. Details: [docs/UPDATE.md](docs/UPDATE.md).
**Current release:** [v0.19.1](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.1), a patch on v0.19.0 that warns when a leftover launcher from the upstream project shadows Voculinux. v0.19.0 was the first Voculinux release: a Void Linux package from the [signed xbps repository](#void-linux-signed-xbps-repo), niri and Dank Material Shell support, a model default chosen by free GPU memory, and a fix that keeps system-audio capture from ever opening the microphone. Details: [docs/UPDATE.md](docs/UPDATE.md).
## Features
- **On-device after model download**: Local engines; speech-to-text stays on your machine
- **X11 and Wayland**: Text injection via xdotool, IBus, wtype, ydotool, or clipboard fallback
- **Several engines**: whisper.cpp (default), OpenAI Whisper, Faster Whisper, VOSK, Parakeet, plus optional remote HTTP API or local gateway from Settings (not on-device)
- **GPU acceleration**: Vulkan for AMD, Intel, and NVIDIA with whisper.cpp
- **GPU acceleration**: Vulkan for AMD, Intel, and NVIDIA with whisper.cpp, with a CPU fallback. CUDA is a local-build option, not part of the package; see [GPU and CUDA](#gpu-and-cuda)
- **Toggle or push-to-talk**: New installs default to hold Right Alt; existing configs keep their shortcut
- **System tray + settings**: Searchable sidebar, Speech Model simple setup with Advanced as an island, status icons, audio feedback
- **Start on login**: XDG autostart (desktop session, not a systemd service)
@@ -139,6 +139,13 @@ echo 'repository=https://git.bongbetic.com/xavierk/voculinux/releases/download/l
xbps-install -S voculinux
```
After that, Voculinux upgrades with the rest of the system:
```bash
sudo xbps-install -Su # upgrades Voculinux together with everything else
xbps-query -R voculinux # shows the version the repository serves
```
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
@@ -149,8 +156,8 @@ sync: it would accept an unknown key without asking.
| Engine | When to use |
|--------|-------------|
| **whisper.cpp** (default) | Best default; Vulkan GPU on AMD, Intel, and NVIDIA |
| **Whisper** (OpenAI) | PyTorch path; NVIDIA/CUDA |
| **whisper.cpp** (default) | Best default; Vulkan GPU on AMD, Intel, and NVIDIA, CPU otherwise |
| **Whisper** (OpenAI) | PyTorch path; NVIDIA/CUDA with a CUDA PyTorch you install yourself |
| **Faster Whisper** | CPU-friendly Whisper via CTranslate2 / INT8 |
| **VOSK** | Low RAM / minimal footprint |
| **Parakeet** | CPU; NVIDIA NeMo ASR via sherpa-onnx; 25 European languages |
@@ -164,10 +171,32 @@ on first run.
### After installation
```bash
voculinux # from the xbps package, on your PATH
voculinux # start the tray app; it also appears in your application menu
voculinux --toggle # start or stop dictation from a keybind or script
```
You can also launch Voculinux from your application menu.
1. **First run:** the tray icon appears first, then the speech model loads in the
background. If no model is present, it downloads once (a few hundred MB to
about 1 GB, checksum-verified). The default is chosen from your free memory:
`large-v3-turbo-q8_0`, then `q5_0`, then `small` as memory gets tighter.
2. **Dictate:** focus any text field, hold **Right Alt**, speak, release. The text is
typed at the cursor.
3. **Hands-free:** switch to toggle mode in Settings, or bind `voculinux --toggle`.
4. **On niri:** copy the example keybind pack and read [docs/NIRI.md](docs/NIRI.md).
Typing goes straight to the cursor (`wtype`), with no permission prompt. The tray
needs a StatusNotifier host, which Dank Material Shell provides.
5. **Something wrong?** Run `voculinux --debug`, then see
[Troubleshooting](docs/TROUBLESHOOTING.md).
Quit and relaunch Voculinux after an upgrade so the running tray uses the new version.
### Leftover launchers from an earlier install
Launchers left by an earlier install of the upstream project can point at a Python
environment that no longer exists. They start nothing, yet they appear next to
Voculinux in the application menu. Voculinux logs and shows one notice that names
those files. It never deletes or changes them, so remove the ones it names
yourself. Keep any launcher that still starts a working install.
### From a source checkout (developers)
@@ -186,10 +215,35 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the two-venv layout and the dev workf
| **OS** | Linux. The published package targets Void Linux (glibc, x86_64) |
| **Python** | 3.11 or newer |
| **Display** | X11 or Wayland |
| **Hardware** | Microphone; GPU optional (Vulkan) |
| **Hardware** | Microphone; GPU optional (Vulkan, any vendor) |
Python 3.11+ is required, with PyGObject from the system (`python3-gi` or the Void `python3-gobject` package). Ubuntu 22.04 (Python 3.10) and Debian 11 (3.9) are below that floor. Source-checkout distribution notes: [docs/DISTRO_COMPATIBILITY.md](docs/DISTRO_COMPATIBILITY.md).
## GPU and CUDA
Voculinux is not a CUDA build. The published Void package accelerates whisper.cpp
with **Vulkan**, plus CPU code paths, and it does not ship CUDA. Void has no CUDA
toolkit package, so the package cannot be built against one.
| Where | What runs | Notes |
|-------|-----------|-------|
| Published Void package, NVIDIA GPU | Vulkan | Needs the NVIDIA driver, which provides the Vulkan device. This is the supported NVIDIA path, tested on an RTX 2050 (4 GB) |
| Published Void package, AMD or Intel GPU | Vulkan | Needs the Mesa Vulkan driver. Software Vulkan devices are skipped |
| Published Void package, no usable GPU | CPU | The best instruction-set variant (AVX2 and others) is loaded at run time |
| Local rebuild of `pywhispercpp` with `GGML_CUDA=1` and a toolkit you installed by hand | CUDA | Works, but unsupported: you maintain it, and the package will not replace it on upgrade without a fresh rebuild |
| OpenAI Whisper engine (PyTorch) in your own Python environment | CUDA | Not included in the package; you install a CUDA PyTorch yourself |
| Faster Whisper engine in your own Python environment | CPU (INT8), CUDA optional | Not included in the package |
How the whisper.cpp engine decides: it uses the backends the installed
`pywhispercpp` was actually built with, not what the machine could run. When a
CUDA-built `pywhispercpp` is installed and a complete toolkit is found (`nvcc`,
the runtime header and `libcudart`, with GPU compute capability 7.0 or newer),
CUDA ranks first; otherwise the order is Vulkan, then CPU. The model size then
follows the free memory of the chosen backend, with its own thresholds (CUDA needs
somewhat more than Vulkan for the same model, per the measurements in
[docs/PERF_BASELINE_PROTOCOL.md](docs/PERF_BASELINE_PROTOCOL.md)). To see which
backend loaded, run `voculinux --debug` and read the startup log.
## Usage
### Dictation
@@ -263,7 +317,7 @@ Neural VAD (Silero) is used when `onnxruntime` is available. The xbps package do
## Privacy and security
- Local engines process audio on-device after you download a model; no Voca account required
- Local engines process audio on-device after you download a model; no account required
- Optional remote API is off by default and only used when you configure a server
- Model downloads are checked against pinned checksums
@@ -277,7 +331,7 @@ cd voculinux
./install.sh --dev
source venv/bin/activate
pytest
python -m vocalinux.main --debug
voculinux --debug
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for the two-venv layout, `just` recipes, and PR guidelines.
@@ -293,10 +347,10 @@ Planned:
## Origin
Voculinux is a hard fork of Vocalinux by VocaHQ: https://github.com/VocaHQ/vocalinux
Voculinux is inspired by Vocalinux by VocaHQ: https://github.com/VocaHQ/vocalinux
Voculinux is that project's fork for Void Linux. Its source, issues and releases
live on the fork's own host.
It is an independent project for Void Linux. Its source, issues and releases live
on the fork's own host.
## Contributing
+1
View File
@@ -9,6 +9,7 @@ User and maintainer documentation for Voculinux.
| [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | Users | Common install and runtime failures |
| [USER_GUIDE.md](USER_GUIDE.md) | Users | Dictation, engines, models, shortcuts, tips |
| [NIRI.md](NIRI.md) | Users | niri keybind pack and Dank Material Shell notes |
| [cuda-vocu.md](cuda-vocu.md) | Maintainers | Plan: optional CUDA backend in the Void repository |
| [UPDATE.md](UPDATE.md) | Users | How to upgrade; release notes by version |
| [DISTRO_COMPATIBILITY.md](DISTRO_COMPATIBILITY.md) | Users | Distro matrix, session caveats |
| [HTTP_REMOTE.md](HTTP_REMOTE.md) | Power users | Remote HTTP transcription |
+3 -2
View File
@@ -574,10 +574,10 @@ from a clean checkout of the tag, in this order. Replace `vX.Y.Z` with the relea
that is not the committed one, both before any container starts. It writes `sdist/`,
`xbps/`, `SDIST_SHA256` and `BUILT_FROM` (the tag and the commit it was built from), and
prints `checksum=<sha256>`. Remove the key file afterwards.
5. **Dry-run the publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
5. **Dry-run the publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
It runs every check (the tag is on `origin` at the built commit, the digest, the key)
and prints the API calls it would make. It sends no request and reads no token.
6. **Publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. It attaches the
6. **Publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. It attaches the
sdist, packages, signatures, `FINGERPRINT` and the client `.plist` to the tag's release, then
replaces the assets of the rolling `latest` release that the client registration URL serves.
The repodata is uploaded last. The token comes from `GITEA_TOKEN`, or from the `tea` login
@@ -642,6 +642,7 @@ git push origin v0.5.1-beta
| Version | Date | Phase | Notes |
|---------|------|-------|-------|
| 0.19.1 | 2026-11-07 | Stable | Patch: a leftover upstream Vocalinux launcher is reported at startup (#52) |
| 0.19.0 | 2026-11-07 | Stable | First Voculinux release: a hard fork for Void Linux (glibc, x86_64) with a signed xbps repository, niri and Dank Material Shell support, a per-backend memory-aware model default, and PipeWire system audio that never falls back to the microphone |
| 0.2.0-alpha | 2024 | Alpha | Initial alpha |
| 0.3.0-alpha | 2024 | Alpha | - |
+10 -1
View File
@@ -25,7 +25,7 @@ installer as below.
```bash
cd voculinux
git fetch origin
git checkout v0.19.0
git checkout v0.19.1
./install.sh
```
@@ -90,6 +90,15 @@ Missing system packages: see [INSTALL.md](INSTALL.md) or [DISTRO_COMPATIBILITY.m
---
## What's New in v0.19.1
0.19.1 is a patch on 0.19.0 for Void Linux. If a launcher from the upstream project
is still installed, Voculinux now says so
on startup instead of leaving a stale upstream entry in the launcher. It does not remove
the files; the startup message names them. Update with `sudo xbps-install -Su`.
---
## What's New in v0.19.0
0.19.0 is the first Voculinux release: a hard fork of the 0.18.5 line for Void Linux
+365
View File
@@ -0,0 +1,365 @@
# CUDA for Voculinux on Void Linux: integration plan
Status: plan, not started. Target release: 0.20.0. Owner decisions taken 2026-11-07 (section 9);
phase 0 is complete.
This plan adds an optional CUDA backend for the whisper.cpp engine to the signed Void
xbps repository, without changing what users who do not opt in receive. Each phase
lists the files it touches, the tests that gate it, and its acceptance check, so it can
be split into tickets as written (section 8).
## 1. Goal and non-goals
Goal: `sudo xbps-install -S voculinux-cuda` on Void Linux (glibc, x86_64) with an NVIDIA
GPU of compute capability 7.5 or newer gives whisper.cpp a CUDA backend. The app
selects it automatically and falls back to Vulkan, then CPU, when it cannot run.
Non-goals:
- No change to the default `voculinux` package. It stays Vulkan plus CPU and pulls in
nothing from NVIDIA.
- No CUDA for the optional Python engines (OpenAI Whisper, Faster Whisper). They stay in
the user's own Python environment.
- No musl, no aarch64. Voculinux publishes glibc x86_64 only.
- No user-installed toolkit. Users never install `nvcc` or a CUDA toolkit.
## 2. Facts this plan is built on
Measured or checked on 2026-11-07, on the maintainer host (Void, RTX 2050 4 GB, compute
capability 8.6, driver 595.112.07, local CUDA 13.2 toolkit):
| Fact | Value | Consequence |
|------|-------|-------------|
| Void CUDA toolkit package | none, in main or nonfree (`xbps-query -Rs cuda` is empty) | the toolkit must come from NVIDIA's redistributable archives, at build time only |
| NVIDIA driver library `libcuda.so.1` | in Void nonfree package `nvidia-libs` | the CUDA package depends on `nvidia-libs`; users need the nonfree repo |
| Performance on RTX 2050, `large-v3-turbo-q8_0`, warm real-time factor | Vulkan 0.013, CUDA 0.016 (local build) | CUDA is not faster on this card. Value is for larger NVIDIA GPUs, still unmeasured |
| VRAM to prefer q8_0 / q5_0 | Vulkan 1644 / 1359 MiB, CUDA 1690 / 1404 MiB | already in `model_ladder.py` (`_PREFER_ABOVE_MIB`) |
| `libggml-cuda.so` from the local build (sm_86 only) | 57 MiB, 36 MiB zstd | one architecture; a multi-architecture build is larger (phase 1 measures it) |
| `libcublasLt.so.13` / `libcublas.so.13` / `libcudart.so.13` | 482 / 52 / 0.7 MiB, about 365 MiB zstd together | the runtime package is large; check the release upload limit (phase 0) |
| Gitea `[attachment] MAX_SIZE` reported by `/api/v1/settings/attachment` | 100 MB | that is the issue-attachment limit; the release-asset limit must be checked separately (phase 0) |
| Host GCC | 14.2.1 | nvcc 13.2 must accept the container's GCC without `-allow-unsupported-compiler` (phase 1 checks) |
| CUDA EULA, Attachment A | `libcudart.so`, `libcublas.so`, `libcublasLt.so` are redistributable with an application, unmodified; `nvcc` is not | ship those three libraries, never the compiler |
The backend is a loadable library. The `python3-pywhispercpp` template already builds
whisper.cpp with `GGML_BACKEND_DL=ON`, and the carried patch
`packaging/void/srcpkgs/python3-pywhispercpp/patches/load-ggml-backends-beside-module.patch`
loads every `libggml-*.so` that sits beside the extension. A `libggml-cuda.so` dropped into
that directory is picked up without changing the extension. This is what makes a separate,
optional CUDA package possible.
## 3. Target package layout
| Package | New | Contents | Depends on |
|---------|-----|----------|------------|
| `python3-pywhispercpp` | no | extension, `libggml-base`, CPU variants, `libggml-vulkan.so` (unchanged) | as today |
| `voculinux-cuda-libs` | yes | the unmodified `libcudart.so.13`, `libcublas.so.13`, `libcublasLt.so.13` in the private directory `/usr/lib/voculinux/cuda/`, plus the NVIDIA EULA as its license | nothing |
| `python3-pywhispercpp-cuda` | yes | only `libggml-cuda.so`, installed beside the extension in `${py3_sitelib}` | `python3-pywhispercpp-${version}_${revision}` (exact), `voculinux-cuda-libs`, `nvidia-libs` |
| `voculinux-cuda` | yes | meta package, no files | `voculinux`, `python3-pywhispercpp-cuda` |
Rules that come out of the EULA and xbps:
- The NVIDIA libraries live in a private directory that is not on the loader path, so only
Voculinux's backend loads them ("accessed only by your application"). `libggml-cuda.so`
finds them through its rpath: `$ORIGIN` for `libggml-base`, then `/usr/lib/voculinux/cuda`.
- `voculinux-cuda-libs` sets `noshlibprovides=yes`, so no other Void package can link
against its copies, and keeps the files byte-identical to NVIDIA's archive (no strip:
`nostrip=yes`).
- Both new build templates set `restricted=yes`: Void's builders must never build or
publish them. The release container sets `XBPS_ALLOW_RESTRICTED=yes` for these two only.
- `python3-pywhispercpp-cuda` must be built from the identical pywhispercpp sdist,
version and revision as `python3-pywhispercpp`. A ggml backend built from a different
ggml source is refused at load time or, worse, crashes. The exact `depends` pin and a
test enforce this.
- `libcuda.so.1` and the CUDA libraries are not in Void's `common/shlibs`, so the CUDA
template lists them in `skiprdeps`, or sets `noverifyrdeps=yes`, and declares the real
dependencies in `depends`. Phase 1 decides which xbps-src accepts.
## 4. Phases
### Phase 0: preflight (maintainer, no code) — done 2026-11-07
Result: Gitea 1.27.1 on xServer accepted a 419,430,400-byte release asset (HTTP 201) in a
private scratch repository, and the authenticated download matched its sha256. The
scratch repository was deleted. No server limit change is needed, and the Coolify service
`gitea` carries no upload-limit overrides. The owner accepted the EULA terms (section 9).
Original steps, kept for re-checking after a Gitea upgrade:
1. Check the release upload limit on git.bongbetic.com: upload a 400 MiB file to a draft
release in a scratch repository with `tea`. If refused, raise
`[repository.release] FILE_MAX_SIZE` in the Gitea server config (infra change, needs
the owner's OK). Delete the draft release afterwards.
2. The owner accepts redistributing the NVIDIA libraries under the CUDA EULA (section 9).
Acceptance: a 400 MiB asset uploads and downloads anonymously from a public release.
### Phase 1: spike, measured before any template is committed
Work in a throwaway worktree, never on `main`.
1. In the pinned build image (`packaging/void/tool_checksums.txt`, `base-image`), unpack
the redistributable archives from section 5, build pywhispercpp 1.5.0 with:
`GGML_CUDA=1`, `-DGGML_BACKEND_DL=ON -DBUILD_SHARED_LIBS=ON -DGGML_NATIVE=OFF`,
`-DCMAKE_CUDA_ARCHITECTURES="75-real;86-real;89-real;120-real;120-virtual"`
(consumer GPUs only, section 9)
and fatbin compression, if ggml's CMake offers it for this nvcc
(`GGML_CUDA_COMPRESSION_MODE=size`; confirm the option exists in the vendored ggml).
2. Record: build time, `libggml-cuda.so` size (raw and zstd), and whether nvcc accepts
the image's GCC.
3. On the RTX 2050, with the packaged `python3-pywhispercpp` installed, copy the built
`libggml-cuda.so` beside the extension, put the three runtime libraries in a private
directory reached through rpath, and check:
- the extension registers the CUDA device (`voculinux --debug` shows `CUDA0`);
- with both `libggml-cuda.so` and `libggml-vulkan.so` present, which device whisper.cpp
takes as GPU device 0 (registration order in `ggml_backend_load_all_from_path`);
- `CUDA_VISIBLE_DEVICES=` (empty) set before the first import makes it use Vulkan;
- with `nvidia-libs` absent (any non-NVIDIA box or a container), loading the CUDA
backend fails quietly and Vulkan or CPU still works.
4. Measure the real-time factor with `scripts/measure_perf_baseline.py`, CUDA against
Vulkan, for `large-v3-turbo-q8_0`, `q5_0` and `small`.
Acceptance: a short report in the ticket with the numbers from steps 2 to 4. If the
backend does not load beside the packaged extension, stop and revise this plan before
phase 2.
### Phase 2: `voculinux-cuda-libs` template
Files:
- `packaging/void/srcpkgs/voculinux-cuda-libs/template` (new): `distfiles` = the
`cuda_cudart` and `libcublas` archives, `checksum` from section 5, `do_install` copies
only `lib/libcudart.so.13*`, `lib/libcublas.so.13*`, `lib/libcublasLt.so.13*` to
`/usr/lib/voculinux/cuda/` (symlinks kept), `vlicense` the archive's `LICENSE`;
`restricted=yes`, `nostrip=yes`, `noshlibprovides=yes`, `archs="x86_64"`,
`license="custom:NVIDIA-CUDA-EULA"`.
- `tests/test_void_cuda_libs_template.py` (new): checksums equal the pinned manifest
values, only the three libraries are installed, the install path is private, the flags
above are set.
Acceptance: `xbps-src pkg voculinux-cuda-libs` in the release container; a test that the
installed files hash equal to the files inside the NVIDIA archive (unmodified).
### Phase 3: `python3-pywhispercpp-cuda` template
Files:
- `packaging/void/srcpkgs/python3-pywhispercpp-cuda/template` (new). Same `version`,
`distfiles` (the pywhispercpp sdist) and `checksum` as `python3-pywhispercpp`, plus the
build-only CUDA archives (`cuda_nvcc`, `cuda_crt`, `libnvvm`, `cuda_cudart`,
`cuda_cccl`, `libcublas`). `pre_build` assembles a toolkit root under `$wrksrc/cuda`
from those archives and sets `CUDACXX`, `CUDAToolkit_ROOT`, the `CMAKE_ARGS` from phase
1 and `-DCMAKE_INSTALL_RPATH='$ORIGIN:/usr/lib/voculinux/cuda'`. Same
`patches/load-ggml-backends-beside-module.patch` (symlink or copy; the test checks they
are identical). `do_install` installs only `libggml-cuda.so` into `${py3_sitelib}`.
`depends="python3-pywhispercpp-${version}_${revision} voculinux-cuda-libs nvidia-libs"`.
`restricted=yes`, `archs="x86_64"`.
- `tests/test_void_pywhispercpp_template.py`: extend to assert that the CUDA template's
version, revision, sdist checksum and patch match `python3-pywhispercpp` exactly.
- `tests/test_void_pywhispercpp_cuda_template.py` (new): architecture list, rpath, only
one installed file, pinned CUDA checksums, exact-version dependency.
Acceptance: the package builds in the release container, contains exactly one file, and
`readelf -d` shows the rpath and `NEEDED` entries for `libcudart.so.13`,
`libcublas.so.13`, `libcuda.so.1` and `libggml-base.so.0`.
### Phase 4: `voculinux-cuda` meta package and install message
Files:
- `packaging/void/srcpkgs/voculinux-cuda/template` (new): `build_style=meta`,
`depends="voculinux>=${version}_1 python3-pywhispercpp-cuda"`, its `version` kept equal
to `voculinux`'s.
- `packaging/void/srcpkgs/voculinux-cuda/INSTALL.msg` (new): needs the nonfree repository
(`xbps-install -S void-repo-nonfree`) and an NVIDIA driver that supports CUDA 13.2;
GPUs below compute capability 7.5 keep using Vulkan; how to check the backend
(`voculinux --debug`).
- `tests/test_void_app_template.py`: version of `voculinux-cuda` equals `voculinux`'s.
- `packaging/void/build-release.sh` `check-tag`: refuse a tag where the two differ.
Acceptance: `xbps-install -S voculinux-cuda` from a local staged repository pulls all
four packages.
### Phase 5: application backend selection
Today `detect_pywhispercpp_gpu_backend()`
(`src/vocalinux/speech_recognition/recognition_manager.py`) returns the first match of
`libggml-vulkan*` then `libggml-cuda*`, so with both installed it says `vulkan`. And
`cuda_toolkit_ready()` (`src/vocalinux/speech_recognition/model_ladder.py`) requires
`nvcc` or a complete toolkit root, which a packaged install never has.
Changes:
1. `detect_pywhispercpp_gpu_backends() -> frozenset[str]`: every GPU backend library
present beside the extension (`{"cuda", "vulkan"}`, `{"vulkan"}`, or empty). Keep
`detect_pywhispercpp_gpu_backend()` as a thin wrapper that returns the selected one,
for current callers.
2. `cuda_backend_ready(installed: frozenset[str]) -> bool` in `model_ladder.py`, pure
over its probe inputs: `"cuda"` is installed, `nvidia-smi` answers, compute capability
at least `MIN_CUDA_COMPUTE_CAPABILITY`, and the CUDA runtime is reachable, meaning
`/usr/lib/voculinux/cuda/libcudart.so.13` exists or a complete toolkit root exists
(the current probe, kept for source builds).
3. `MIN_CUDA_COMPUTE_CAPABILITY` becomes `(7, 5)`: CUDA 13 does not build for older
GPUs, so a 7.0 card must not be offered CUDA.
4. Selection, one function, `select_gpu_backend(installed, cuda_ready, preference)`:
preference `auto` gives CUDA when ready, else Vulkan when installed, else CPU;
`vulkan` and `cpu` force those; `cuda` falls back with a logged warning when not ready.
5. Enforcement: when the selection is not CUDA, set `CUDA_VISIBLE_DEVICES=""` in
`os.environ` before the first `pywhispercpp` import, so ggml registers no CUDA device
and GPU device 0 is the Vulkan one. When it is CUDA and both are present, rely on the
registration order measured in phase 1; if Vulkan registers first, also set
`GGML_VK_VISIBLE_DEVICES` to hide the NVIDIA Vulkan device. The import happens in
`src/vocalinux/utils/pywhispercpp_loader.py`; set the variables there, once.
6. Config key `whispercpp_gpu_backend` (`auto` default; `cuda`, `vulkan`, `cpu`) in
`src/vocalinux/ui/config_manager.py`. A Settings control is a follow-up, not part of
this plan.
7. The model ladder already takes the backend name; pass the selected one, so CUDA uses
its own thresholds (1690 / 1404 MiB).
8. Log once at load: installed backends, selected backend, reason, device name.
Tests:
- `tests/test_model_ladder.py`: `cuda_backend_ready` truth table (no driver, low
capability, packaged runtime only, toolkit only, neither); 7.0 now refused, 7.5 allowed.
- `tests/test_recognition_manager.py` (or a new `tests/test_gpu_backend_selection.py`):
detection with `cuda` only, `vulkan` only, both, none; selection for each preference;
the environment variables are set before import and only when needed.
- `tests/test_host_process.py` keeps passing: `nvidia-smi` probes run with
`env=host_env()`.
Acceptance: unit tests pass; on the RTX 2050 with the CUDA package installed, `auto`
picks CUDA, `vulkan` picks Vulkan, and removing `python3-pywhispercpp-cuda` returns to
Vulkan with no error.
### Phase 6: release pipeline
Files:
- `packaging/void/smoke.sh`: add the three new packages to `PKGS`, in dependency order
(`voculinux-cuda-libs python3-pywhispercpp-cuda voculinux-cuda` after
`python3-pywhispercpp` and `voculinux`).
- `packaging/void/container-release.sh`: `XBPS_ALLOW_RESTRICTED=yes` in the build
container's `etc/conf`; payload checks for the new packages (one file in
`python3-pywhispercpp-cuda`, three libraries plus license in `voculinux-cuda-libs`);
install smoke: install `voculinux-cuda` from the staged signed repository and check
that, with no GPU and no driver in the container, `voculinux` still imports and selects
CPU without error. The container has no GPU, so CUDA itself is verified by hand
(phase 7).
- `packaging/void/build-release.sh`: nothing beyond `check-tag` (phase 4), unless phase 1
shows the build needs more time or memory limits.
- `packaging/void/publish-release.sh`: no change in calls; add a size preflight that
fails before any upload when an asset exceeds the limit found in phase 0.
- `packaging/void/tool_checksums.txt`: one `version` row naming the CUDA redist manifest
(`redistrib_13.2.0.json`) the archive checksums come from.
- Tests: `tests/test_void_smoke_gate.py`, `tests/test_void_release_build.py`,
`tests/test_void_publish_release.py` for the above.
Acceptance: `just void-release vX.Y.Z` builds and signs all eight packages; the dry run of
`bash packaging/void/publish-release.sh` lists every asset and passes the size preflight.
### Phase 7: documentation and hardware acceptance
Docs:
- `README.md`, section "GPU and CUDA": add the `voculinux-cuda` row and the install line.
- `packaging/void/README.md`: the CUDA packages, the private library directory, the EULA
rules, how to bump the CUDA version (new manifest, new checksums, rebuild both).
- `docs/RELEASE_PROCESS.md`: build time and size expectations; restricted packages.
- `docs/TROUBLESHOOTING.md`: "CUDA not used" checklist (nonfree repo, driver, capability,
`whispercpp_gpu_backend`, debug log line).
- `docs/UPDATE.md`, `CHANGELOG.md`: 0.20.0 entry.
Hardware acceptance on the RTX 2050, from the published repository:
1. `sudo xbps-install -Su voculinux-cuda` installs from git.bongbetic.com with the
pinned key and no prompt.
2. `voculinux --debug` logs the selected backend `cuda` and device `CUDA0`; dictation
types text.
3. Real-time factor and VRAM recorded with `scripts/measure_perf_baseline.py` and
compared with the Vulkan numbers in section 2.
4. `sudo xbps-remove voculinux-cuda python3-pywhispercpp-cuda voculinux-cuda-libs`, then
relaunch: Vulkan, no error.
5. On the AMD iGPU only (`CUDA_VISIBLE_DEVICES=` and the NVIDIA Vulkan device hidden),
Vulkan still works with the CUDA package installed.
## 5. Pinned CUDA inputs
From NVIDIA's `redistrib_13.2.0.json` (release date 2026-03-09), the version of the toolkit
already proven on the maintainer host. Base URL:
`https://developer.download.nvidia.com/compute/cuda/redist/`.
| Component | Version | sha256 | Used for |
|-----------|---------|--------|----------|
| `cuda_nvcc` | 13.2.51 | `706b996fefc59dc8d64d317fdf48d0aa84c4ae004eff43009dd918f40c5cc66a` | build only |
| `cuda_crt` | 13.2.51 | `fbc31fed55b7255591f3a19f575ca078827f5e6757d317d009f7ec1e69fcde4b` | build only |
| `libnvvm` | 13.2.51 | `e013fce38130d2337ea695aadc5ddd5dcfb78f9107903d72492b9819539749bb` | build only |
| `cuda_cccl` | 13.2.27 | `56e1bafb29faa87375b0484814870046530b88c0a421909096892f027ec1927b` | build only (headers) |
| `cuda_cudart` | 13.2.51 | `539edc1056e44d319f2112e9971c6415d78d4dde04b3f6ffbd20ec808e718526` | build and shipped (`libcudart.so.13`) |
| `libcublas` | 13.3.0.5 | `9f3c23d55b098d9159c435db59f228df79493152a778d4d367e7d60470d8c64b` | build and shipped (`libcublas.so.13`, `libcublasLt.so.13`) |
Each archive path is `<component>/linux-x86_64/<component>-linux-x86_64-<version>-archive.tar.xz`.
The `libcublas` archive is about 765 MiB to download, most of it static libraries that are
never shipped. xbps-src caches distfiles, so the two templates fetch it once per host.
Bumping CUDA: take the new `redistrib_<version>.json`, replace the six rows here and in
both templates in one pull request, rebuild both packages together, and re-run phase 7.
## 6. Size and time budget
| Item | Estimate | Source |
|------|----------|--------|
| `voculinux-cuda-libs` package | about 365 MiB compressed, 535 MiB installed | zstd of the 13.2 libraries |
| `python3-pywhispercpp-cuda` package | 36 MiB per architecture before fatbin compression; total measured in phase 1 | local sm_86 build |
| Build time of the CUDA backend | measured in phase 1 | four architectures plus PTX with nvcc |
| User download for `voculinux-cuda` | the two packages above | |
Architecture set (consumer GPUs only, owner decision):
| Target | GPUs |
|--------|------|
| `75-real` | Turing: GeForce GTX 16 series, RTX 20 series |
| `86-real` | Ampere consumer: RTX 30 series and RTX 2050 |
| `89-real` | Ada: RTX 40 series |
| `120-real` | Blackwell consumer: RTX 50 series |
| `120-virtual` | PTX, so a future consumer architecture JIT-compiles on first load |
Left out on purpose: `80` (A100), `87` (Jetson Orin), `90` (H100), `100` and `103`
(data-center Blackwell). Those GPUs are not Void desktop targets. An Ampere data-center
card would JIT from no matching PTX and fail to load the backend, so the app falls back
to Vulkan there.
## 7. Risks
| Risk | Mitigation |
|------|------------|
| Release asset upload limit below the package size | closed: phase 0 uploaded 400 MiB; `publish-release.sh` still gets a size preflight (phase 6) |
| ggml backend ABI mismatch between the two pywhispercpp builds | exact `version_revision` dependency plus a template-equality test |
| nvcc 13.2 rejects the image's GCC | phase 1 finds out; if so, use a GCC the image provides and nvcc supports, never `-allow-unsupported-compiler` |
| Driver too old for CUDA 13.2 | `INSTALL.msg` states the minimum driver from NVIDIA's CUDA 13.2 release notes; selection falls back when `cudaGetDeviceCount` fails |
| Vulkan and CUDA both register the same NVIDIA GPU | phase 1 measures order; phase 5 hides the unused one with environment variables |
| No speed gain on small GPUs | owner chose CUDA first regardless; the `vulkan` preference is one config key away; phase 7 records numbers |
| Void's `nvidia-libs` upgrade changes the driver ABI | `libcuda.so.1` is the stable driver ABI; phase 7 re-run after major driver updates |
## 8. Ticket breakdown
Dependencies in brackets.
| # | Ticket | Type |
|---|--------|------|
| 1 | Phase 0: release upload limit and EULA decision (done 2026-11-07) | human / infra |
| 2 | Phase 1: CUDA backend spike and measurements [1] | spike |
| 3 | Phase 2: `voculinux-cuda-libs` template [2] | AFK |
| 4 | Phase 3: `python3-pywhispercpp-cuda` template [2, 3] | AFK |
| 5 | Phase 4: `voculinux-cuda` meta package [4] | AFK |
| 6 | Phase 5: backend detection, selection and config key [2] | AFK, parallel with 3 to 5 |
| 7 | Phase 6: release pipeline [3, 4, 5] | AFK |
| 8 | Phase 7: docs, 0.20.0 release and hardware acceptance [6, 7] | human-in-the-loop |
## 9. Owner decisions (taken 2026-11-07)
1. **EULA: accepted.** `libcudart`, `libcublas` and `libcublasLt` are redistributed from
this repository under the NVIDIA CUDA EULA, unmodified, with the license shipped.
2. **Gitea upload limit: may be raised.** Phase 0 showed no change is needed.
3. **Default preference: CUDA first.** `auto` selects CUDA whenever it is ready, then
Vulkan, then CPU, even though Vulkan matched CUDA on the RTX 2050.
4. **Architectures: consumer GPUs only.** `75-real;86-real;89-real;120-real;120-virtual`
(section 6).
+3 -3
View File
@@ -198,7 +198,7 @@ just void-smoke # the gate: build, sign with a throwaway k
just void-release v0.19.0 # build the release set, with XBPS_SIGNING_KEY_FILE set
packaging/void/build-release.sh names v0.19.0-beta # offline: the names a tag maps to
packaging/void/build-release.sh check-tag v0.19.0-beta # offline: the tag gate
packaging/void/publish-release.sh v0.19.0 --out-dir DIR --dry-run # every check, the planned calls, no request
bash packaging/void/publish-release.sh v0.19.0 --out-dir DIR --dry-run # every check, the planned calls, no request
```
Podman only, never Docker. The base image digest and the void-packages commit
@@ -315,11 +315,11 @@ cannot build this release. The sequence is:
then, in that worktree,
`packaging/void/build-release.sh release vX.Y.Z --key-file /dev/shm/xbps-sign-key.pem --out-dir /path/to/out`.
Remove the key file when the run ends.
4. **Dry-run the publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
4. **Dry-run the publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
It checks that origin's `vX.Y.Z` peels to the commit in `BUILT_FROM`, that the sdist
matches `SDIST_SHA256`, and that the client key is the committed one, then prints the
calls it would make. It sends no request and reads no token.
5. **Publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. The tag's
5. **Publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. The tag's
release gets the sdist, packages, signatures, `FINGERPRINT` and the client `.plist`, and
the rolling `latest` release that the client registration URL serves is replaced, with the
repodata last. The token comes from `GITEA_TOKEN`, or from the `tea` login for
+2 -2
View File
@@ -1,11 +1,11 @@
# Template file for 'voculinux'
pkgname=voculinux
version=0.19.0
version=0.19.1
revision=1
# The release tag this template names: v + __version__, with the pre-release
# hyphen kept (the tag is v0.19.0-beta while version is 0.19.0beta). The release
# gate (packaging/void/build-release.sh check-tag) refuses any mismatch.
_tag=v0.19.0
_tag=v0.19.1
# The release sdist ships no setup.py (setuptools pyproject-only), so the
# build goes through the PEP 517 wheel (cf. Solaar, PhotoCollage).
build_style=python3-pep517
+177 -1
View File
@@ -14,13 +14,22 @@ the payload files above) is rewritten in the copied configuration to that file's
new location, whether or not this run copied it. Edits then never land in the
legacy file. Any other path, including a user-chosen file elsewhere or another
file inside the legacy directory, is kept as stored.
Ticket #52: a previous install can also leave user-level launcher files behind
(a desktop entry and two wrapper scripts). When their target is gone they start
nothing yet still show up as a second app. :func:`report_stale_upstream_launchers`
logs and shows one notice naming them, then records a marker. It never removes
or changes a file. It runs from the GTK idle loop after the tray is up and does
only stat calls and small reads.
"""
import json
import logging
import os
import re
import shlex
import shutil
from typing import Optional
from typing import Callable, Optional
from .app_identity import APP_DIR_NAME, LEGACY_APP_DIR_NAME
@@ -31,6 +40,17 @@ TERMS_FILENAME: str = "dictionary.txt"
CORRECTIONS_FILENAME: str = "custom-dictionary-corrections.json"
RESTORE_TOKEN_FILENAME: str = "remote_desktop_restore_token"
#: Leftover upstream launcher files, reported and never removed (ticket #52).
LEGACY_DESKTOP_FILENAME: str = f"{LEGACY_APP_DIR_NAME}.desktop"
LEGACY_LAUNCHER_NAMES: tuple[str, ...] = (LEGACY_APP_DIR_NAME, f"{LEGACY_APP_DIR_NAME}-gui")
#: Written in the new data directory once the stale-launcher notice has been shown.
STALE_LAUNCHER_MARKER: str = "stale-upstream-launcher-notified"
#: Upper bound on what is read from a launcher. Wrappers are a few KiB.
_MAX_SCRIPT_BYTES: int = 64 * 1024
_ENV_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
_ABSOLUTE_PATH = re.compile(r"/[A-Za-z0-9._@+%~-]+(?:/[A-Za-z0-9._@+%~-]+)+")
_INTERPRETER_NAME = re.compile(r"python[\d.]*")
#: Files copied from the legacy configuration directory. Models are
#: deliberately absent: they re-resolve through the pinned-checksum download
#: path instead of being migrated.
@@ -170,3 +190,159 @@ def import_legacy_once() -> list[str]:
if imported:
logger.info("Legacy Vocalinux import complete: %d file(s)", len(imported))
return imported
def _read_head(path: str) -> Optional[str]:
"""Return the first ``_MAX_SCRIPT_BYTES`` of a text file, or None when unreadable."""
try:
with open(path, encoding="utf-8", errors="replace") as handle:
return handle.read(_MAX_SCRIPT_BYTES)
except OSError:
return None
def _is_interpreter_or_entry(path: str) -> bool:
"""True for a path to an interpreter or a legacy entry script inside a bin directory."""
name = os.path.basename(path)
if os.path.basename(os.path.dirname(path)) != "bin":
return False
return _INTERPRETER_NAME.fullmatch(name) is not None or name in LEGACY_LAUNCHER_NAMES
def _names_missing_interpreter(path: str) -> bool:
"""True when ``path`` is a script whose shebang or interpreter or venv entry is gone.
Only a regular file that starts with ``#!`` counts as a script, so a compiled
binary that happens to contain a dead path is never flagged.
"""
if not os.path.isfile(path):
return False
text = _read_head(path)
if text is None or not text.startswith("#!"):
return False
shebang = text.split("\n", 1)[0][2:].split()
if shebang and shebang[0].startswith("/") and not os.path.exists(shebang[0]):
return True
return any(
_is_interpreter_or_entry(token) and not os.path.exists(token)
for token in _ABSOLUTE_PATH.findall(text)
)
def _launcher_is_stale(path: str) -> bool:
"""True when a launcher file is missing (a dangling link) or names a missing interpreter."""
if not os.path.exists(path):
return True
return _names_missing_interpreter(path)
def _desktop_exec(path: str) -> Optional[str]:
"""Return the ``Exec=`` value of the ``[Desktop Entry]`` group, or None."""
text = _read_head(path)
if text is None:
return None
in_entry = False
for raw in text.splitlines():
line = raw.strip()
if line.startswith("["):
in_entry = line == "[Desktop Entry]"
elif in_entry and line.startswith("Exec="):
return line[len("Exec=") :]
return None
def _exec_target(exec_value: str) -> Optional[str]:
"""Return the program an Exec value starts, after any ``env`` and ``VAR=`` prefix."""
try:
words = shlex.split(exec_value)
except ValueError:
return None
if words and os.path.basename(words[0]) == "env":
words = words[1:]
while words and _ENV_ASSIGNMENT.match(words[0]):
words = words[1:]
return words[0] if words else None
def _desktop_entry_is_stale(path: str) -> bool:
"""True when a desktop entry starts a target that is missing or stale."""
if not os.path.exists(path):
return True
exec_value = _desktop_exec(path)
target = _exec_target(exec_value) if exec_value is not None else None
if target is None:
return False
if "/" in target or target.startswith("~"):
return _launcher_is_stale(os.path.expanduser(target))
# A bare name is looked up on PATH with stat calls only; no subprocess.
resolved = shutil.which(target)
return resolved is None or _launcher_is_stale(resolved)
def find_stale_upstream_launchers() -> list[str]:
"""Return the leftover upstream launcher files that no longer start Voculinux.
Checks three paths: the user-level desktop entry and the two wrappers in
``~/.local/bin``. Only positive evidence counts: a missing target, a dangling
link, or a script that names a missing interpreter or venv entry. A working
install is never reported.
"""
stale: list[str] = []
desktop = os.path.join(
_app_dir("XDG_DATA_HOME", "~/.local/share", "applications"), LEGACY_DESKTOP_FILENAME
)
if os.path.lexists(desktop) and _desktop_entry_is_stale(desktop):
stale.append(desktop)
bin_dir = os.path.expanduser("~/.local/bin")
for name in LEGACY_LAUNCHER_NAMES:
launcher = os.path.join(bin_dir, name)
if os.path.lexists(launcher) and _launcher_is_stale(launcher):
stale.append(launcher)
return stale
def _record_notice(marker: str) -> None:
"""Write the marker that says the stale-launcher notice has been shown."""
try:
os.makedirs(os.path.dirname(marker), exist_ok=True)
with open(marker, "w", encoding="utf-8") as handle:
handle.write("shown\n")
except OSError as error:
logger.warning("Could not record the stale launcher notice at %s: %s", marker, error)
def report_stale_upstream_launchers(
notifier: Optional[Callable[..., object]] = None,
) -> list[str]:
"""Log and show, once, leftover upstream launchers that no longer start Voculinux.
Nothing is scanned once the marker exists. A clean run records nothing, so a
stale launcher that appears later is still reported. The marker is written
after the notice. No file is removed or modified.
``notifier`` takes the signature of :func:`vocalinux.ui.notifications.notify`
and defaults to it. Returns the stale paths reported this run.
"""
marker = os.path.join(new_data_dir(), STALE_LAUNCHER_MARKER)
if os.path.exists(marker):
return []
stale = find_stale_upstream_launchers()
if not stale:
return []
for path in stale:
logger.warning(
"Old Vocalinux launcher %s starts nothing: its install is gone; remove it yourself",
path,
)
if notifier is None:
from .ui import notifications
notifier = notifications.notify
notifier(
"Old Vocalinux launcher found",
"These files from a previous Vocalinux install point at an install that is gone "
"and start nothing. Remove them; Voculinux leaves them in place.\n" + "\n".join(stale),
icon="dialog-warning",
)
_record_notice(marker)
return stale
+16
View File
@@ -344,6 +344,20 @@ def check_appindicator_support():
return True
def _report_stale_upstream_launchers() -> bool:
"""GLib idle callback: show the one-time stale-launcher notice (#52), then stop.
Returns False so GLib drops the callback after one run.
"""
try:
from .legacy_import import report_stale_upstream_launchers
report_stale_upstream_launchers()
except Exception as error:
logger.debug("Stale launcher check skipped: %s", error)
return False
def main():
"""Main entry point for the application."""
# Parse arguments first so flags like --version work even when
@@ -1388,6 +1402,8 @@ def main():
# callback has put the icon up. The load runs on its own thread, so the
# tray is usable while it loads (#50).
GLib.idle_add(speech_engine.load_model_in_background)
# Leftover upstream launchers (#52): checked after the tray is up, once.
GLib.idle_add(_report_stale_upstream_launchers)
# Start the GTK main loop
indicator.run()
+2 -2
View File
@@ -2,8 +2,8 @@
Version information for Voculinux.
"""
__version__ = "0.19.0"
__version_info__ = (0, 19, 0)
__version__ = "0.19.1"
__version_info__ = (0, 19, 1)
__author__ = "Jatin K Malik"
__email__ = "jatinkrmalik@gmail.com"
__license__ = "AGPL-3.0-only"
+250
View File
@@ -277,3 +277,253 @@ def test_existing_new_dictionary_takes_the_edit_and_legacy_stays_put(monkeypatch
assert legacy_terms.read_bytes() == legacy_bytes
assert legacy_terms.stat().st_mtime_ns == legacy_mtime
assert (new_config_dir / "dictionary.txt").read_text(encoding="utf-8") == "Mine\nKubernetes\n"
# --- #52: leftover upstream launchers -----------------------------------------
#
# A previous install can leave a user-level desktop entry and two wrapper
# scripts behind. When their venv is gone they start nothing, yet they show up
# as a second app in launchers. Every path here lives under a fake HOME, XDG
# roots and PATH, so the real home is never read.
_LEGACY_DESKTOP = "vocalinux.desktop" # legacy launcher file name
_LEGACY_LAUNCH = "vocalinux" # legacy CLI wrapper name
_LEGACY_GUI = "vocalinux-gui" # legacy GUI wrapper name
_LEGACY_TITLE = "Vocalinux" # legacy launcher display name
_GI_PREFIX = "env GI_TYPELIB_PATH=/usr/lib/girepository-1.0"
def _isolate_launchers(monkeypatch, tmp_path: Path) -> tuple[Path, Path, Path]:
"""Point HOME, XDG and PATH at scratch dirs; return (home, data_root, path_bin)."""
home = tmp_path / "home"
home.mkdir()
data_root = tmp_path / "data"
path_bin = tmp_path / "path-bin"
path_bin.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setenv("XDG_DATA_HOME", str(data_root))
monkeypatch.setenv("PATH", str(path_bin))
return home, data_root, path_bin
def _bin_dir(home: Path) -> Path:
return home / ".local" / "bin"
def _write_desktop(data_root: Path, exec_value: str, extra: str = "") -> Path:
"""Write a desktop entry under the fake XDG data home."""
entry = data_root / "applications" / _LEGACY_DESKTOP
entry.parent.mkdir(parents=True, exist_ok=True)
entry.write_text(
f"[Desktop Entry]\nType=Application\nName={_LEGACY_TITLE}\nExec={exec_value}\n{extra}",
encoding="utf-8",
)
return entry
def _make_venv(root: Path, entry: str) -> Path:
"""Create a live venv with an interpreter and the entry script."""
venv = root / "venv"
(venv / "bin").mkdir(parents=True)
for name in ("python", entry):
script = venv / "bin" / name
script.write_text("#!/bin/sh\n", encoding="utf-8")
script.chmod(0o755)
return venv
def _write_wrapper(path: Path, venv: Path, entry: str) -> Path:
"""Write a wrapper shaped like the installer's, bound to ``venv``."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"#!/bin/bash\n"
"export GI_TYPELIB_PATH=/usr/lib/girepository-1.0\n"
f'PY_SITE_PATHS=$("{venv / "bin" / "python"}" - <<\'PY\' 2>/dev/null\n'
"PY\n"
f'EXEC_CMD="{venv / "bin" / entry} \\$*"\n'
'exec sg input -c "$EXEC_CMD"\n',
encoding="utf-8",
)
path.chmod(0o755)
return path
def _recorder(calls: list):
"""Return a notifier stand-in that records (title, message, icon)."""
def _notify(title: str, message: str, icon: str = "") -> None:
calls.append((title, message, icon))
return _notify
def test_no_upstream_launchers_means_no_notice(monkeypatch, tmp_path) -> None:
"""A clean machine has nothing to report and writes no marker."""
_, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
calls: list = []
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls)) == []
assert calls == []
assert not (data_root / "voculinux" / legacy_import.STALE_LAUNCHER_MARKER).exists()
def test_missing_exec_target_is_flagged_named_and_shown_once(monkeypatch, tmp_path, caplog) -> None:
"""A desktop entry whose target is gone is logged, notified once, and left untouched."""
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
entry = _write_desktop(data_root, str(_bin_dir(home) / _LEGACY_GUI)) # never created
before = entry.read_bytes()
calls: list = []
with caplog.at_level("WARNING", logger="vocalinux.legacy_import"):
stale = legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls))
assert stale == [str(entry)]
assert any(str(entry) in r.getMessage() for r in caplog.records if r.levelname == "WARNING")
assert len(calls) == 1
title, message, _icon = calls[0]
assert title == "Old Vocalinux launcher found"
assert str(entry) in message
assert entry.read_bytes() == before
again: list = []
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(again)) == []
assert again == []
def test_env_prefixed_exec_into_wrapper_with_missing_venv_is_flagged(monkeypatch, tmp_path) -> None:
"""The reported case: a GI_TYPELIB_PATH env prefix, then a wrapper whose venv is gone."""
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
gone_venv = tmp_path / "gone" / "venv"
gui = _write_wrapper(_bin_dir(home) / _LEGACY_GUI, gone_venv, _LEGACY_GUI)
entry = _write_desktop(data_root, f"{_GI_PREFIX} {gui}")
assert legacy_import.find_stale_upstream_launchers() == [str(entry), str(gui)]
def test_both_wrappers_are_flagged_when_their_venv_is_gone(monkeypatch, tmp_path) -> None:
"""Without a desktop entry, each stale wrapper is still named."""
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
gone_venv = tmp_path / "gone" / "venv"
launch = _write_wrapper(_bin_dir(home) / _LEGACY_LAUNCH, gone_venv, _LEGACY_LAUNCH)
gui = _write_wrapper(_bin_dir(home) / _LEGACY_GUI, gone_venv, _LEGACY_GUI)
assert legacy_import.find_stale_upstream_launchers() == [str(launch), str(gui)]
def test_working_upstream_install_is_not_flagged(monkeypatch, tmp_path) -> None:
"""A launcher whose target and venv exist is the user's choice, never reported."""
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
venv = _make_venv(tmp_path / "live", _LEGACY_GUI)
gui = _write_wrapper(_bin_dir(home) / _LEGACY_GUI, venv, _LEGACY_GUI)
_write_desktop(data_root, f"{_GI_PREFIX} {gui}")
calls: list = []
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls)) == []
assert calls == []
assert not (data_root / "voculinux" / legacy_import.STALE_LAUNCHER_MARKER).exists()
def test_dangling_wrapper_symlink_is_flagged(monkeypatch, tmp_path) -> None:
"""A symlink whose target was removed starts nothing, so it is stale."""
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
link = _bin_dir(home) / _LEGACY_LAUNCH
link.parent.mkdir(parents=True)
link.symlink_to(tmp_path / "removed" / "bin" / _LEGACY_LAUNCH)
assert legacy_import.find_stale_upstream_launchers() == [str(link)]
def test_compiled_binary_is_not_read_as_a_wrapper(monkeypatch, tmp_path) -> None:
"""Only a script with a shebang is a wrapper; a binary that names a dead path is left alone."""
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
binary = _bin_dir(home) / _LEGACY_LAUNCH
binary.parent.mkdir(parents=True)
binary.write_bytes(b"\x7fELF\x02\x01\x01" + str(tmp_path / "gone" / "bin" / "python").encode())
assert legacy_import.find_stale_upstream_launchers() == []
def test_bare_exec_name_is_resolved_through_path(monkeypatch, tmp_path) -> None:
"""A bare Exec name is stale when PATH cannot find it, and fine when a working copy is there."""
_, data_root, path_bin = _isolate_launchers(monkeypatch, tmp_path)
entry = _write_desktop(data_root, _LEGACY_LAUNCH)
assert legacy_import.find_stale_upstream_launchers() == [str(entry)]
venv = _make_venv(tmp_path / "live", _LEGACY_LAUNCH)
_write_wrapper(path_bin / _LEGACY_LAUNCH, venv, _LEGACY_LAUNCH)
assert legacy_import.find_stale_upstream_launchers() == []
@pytest.mark.parametrize("exec_value", ["", "env", '"unterminated /usr/bin/x'])
def test_exec_that_names_no_usable_target_is_not_flagged(monkeypatch, tmp_path, exec_value) -> None:
"""Empty, env-only and unparseable Exec lines give no evidence, so they are not flagged."""
_, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
_write_desktop(data_root, exec_value)
assert legacy_import.find_stale_upstream_launchers() == []
def test_only_the_main_entry_exec_counts(monkeypatch, tmp_path) -> None:
"""An Exec inside a Desktop Action group is not the launcher's target."""
_, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
live = tmp_path / "live-launcher"
live.write_text("#!/bin/sh\n", encoding="utf-8")
_write_desktop(
data_root,
str(live),
extra=f"\n[Desktop Action Old]\nName=Old\nExec={tmp_path / 'gone'}\n",
)
assert legacy_import.find_stale_upstream_launchers() == []
def test_existing_marker_skips_the_scan_and_the_notice(monkeypatch, tmp_path) -> None:
"""Once the notice has been recorded, nothing is re-scanned or shown again."""
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
_write_desktop(data_root, str(_bin_dir(home) / _LEGACY_GUI))
marker = data_root / "voculinux" / legacy_import.STALE_LAUNCHER_MARKER
marker.parent.mkdir(parents=True)
marker.write_text("shown\n", encoding="utf-8")
calls: list = []
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls)) == []
assert calls == []
def test_empty_xdg_data_home_falls_back_to_the_default(monkeypatch, tmp_path) -> None:
"""An empty XDG_DATA_HOME means ~/.local/share, as the rest of the module reads it."""
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
monkeypatch.setenv("XDG_DATA_HOME", "")
entry = _write_desktop(home / ".local" / "share", str(_bin_dir(home) / _LEGACY_GUI))
assert legacy_import.find_stale_upstream_launchers() == [str(entry)]
def test_idle_callback_runs_the_check_once_and_then_stops(monkeypatch) -> None:
"""GLib repeats an idle callback while it returns truthy, so the wrapper must return False."""
from vocalinux import main as voculinux_main
calls: list[int] = []
monkeypatch.setattr(
legacy_import, "report_stale_upstream_launchers", lambda: calls.append(1) or []
)
assert voculinux_main._report_stale_upstream_launchers() is False
assert calls == [1]
def test_idle_callback_swallows_a_failing_check(monkeypatch) -> None:
"""A failing check is logged and never escapes into the GTK main loop."""
from vocalinux import main as voculinux_main
def _boom() -> list[str]:
raise OSError("disk gone")
monkeypatch.setattr(legacy_import, "report_stale_upstream_launchers", _boom)
assert voculinux_main._report_stale_upstream_launchers() is False
+5 -2
View File
@@ -96,8 +96,11 @@ RETAINED_MODULE_HEADS = frozenset(
#: The one upstream credit per fork surface, matched as whole stripped lines.
CREDIT_LINE = "Voculinux is a hard fork of Vocalinux by VocaHQ: https://github.com/VocaHQ/vocalinux"
README_CREDIT_LINE = (
"Voculinux is inspired by Vocalinux by VocaHQ: https://github.com/VocaHQ/vocalinux"
)
FORK_SURFACE_CREDITS: dict[str, frozenset[str]] = {
"README.md": frozenset({CREDIT_LINE}),
"README.md": frozenset({README_CREDIT_LINE}),
"CONTRIBUTING.md": frozenset({CREDIT_LINE}),
"src/vocalinux/ui/settings_about.py": frozenset(
{
@@ -434,7 +437,7 @@ def test_allowlist_denies_user_facing_old_names() -> None:
def test_allowlist_keeps_the_retained_surfaces() -> None:
"""Upstream provenance, the retained package and the legacy import stay allowed."""
kept = [
("README.md", CREDIT_LINE),
("README.md", README_CREDIT_LINE),
("CONTRIBUTING.md", CREDIT_LINE),
(
"src/vocalinux/ui/settings_about.py",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "voculinux-website",
"version": "0.19.0",
"version": "0.19.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "voculinux-website",
"version": "0.19.0",
"version": "0.19.1",
"dependencies": {
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "voculinux-website",
"version": "0.19.0",
"version": "0.19.1",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -29,7 +29,7 @@ const homeJsonLd = [
},
description:
"Offline voice dictation and speech-to-text for Linux with whisper.cpp, Faster Whisper, Whisper, VOSK, and Parakeet.",
softwareVersion: "0.19.0",
softwareVersion: "0.19.1",
author: {
"@type": "Person",
name: "Xavier Karma",