fix(app): CUDA toolkit detection, niri IBus pin warning, Gitea update checks, legacy dictionary path #36

Merged
xavierk merged 6 commits from fix/app-post-merge into main 2026-11-06 14:38:44 +00:00
17 changed files with 672 additions and 273 deletions
+3 -1
View File
@@ -4,7 +4,9 @@ How to upgrade an existing install, plus release notes for the current series.
## Quick update
Voculinux checks GitHub Releases in the background about every six hours (and shortly after startup). When a newer build is on your selected channel, the tray menu gains an **Update Available...** entry and Settings -> About shows a green **New** badge; open that page for release notes and download links. Updates are not installed automatically. Re-run the installer (or your package manager) as below.
Voculinux checks its own release server, the Gitea repository at `git.bongbetic.com/xavierk/voculinux`, in the background about every six hours (and shortly after startup). Upstream VocaHQ is never queried. When a newer build is on your selected channel, the tray menu gains an **Update Available...** entry and Settings -> About shows a green **New** badge; open that page for release notes and download links. Updates are not installed automatically. Re-run the installer (or your package manager) as below.
A package-managed install (the Void `voculinux` package) makes no update check at all. Its updates arrive with `sudo xbps-install -Su`, and Settings -> About says so.
### Official installer
+65 -6
View File
@@ -8,11 +8,19 @@ always wins: no merging, no write-back to the legacy location.
Exactly-once falls out of existence checks — a copied file is never copied
again, and a run that already has new locations imports nothing.
A stored custom-dictionary path that names an importer-owned legacy file (one of
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.
"""
import json
import logging
import os
import shutil
from typing import Optional
from .app_identity import APP_DIR_NAME, LEGACY_APP_DIR_NAME
@@ -77,6 +85,50 @@ def _copy_once(src: str, dst: str) -> bool:
return True
def _payload_destination(stored: str, payload: dict[str, str]) -> Optional[str]:
"""Return the new location of the importer-owned legacy file ``stored`` names."""
try:
target = os.path.realpath(os.path.expanduser(stored))
except (OSError, ValueError):
return None
for source, destination in payload.items():
if target == os.path.realpath(source):
return destination
return None
def _repoint_dictionary_path(config_file: str, payload: dict[str, str]) -> None:
"""Rewrite the copied config's dictionary path when it names a payload file.
Only the new configuration copy is written; the legacy file is only read.
"""
try:
with open(config_file, encoding="utf-8") as handle:
config = json.load(handle)
except (OSError, ValueError) as error:
logger.warning("Legacy import could not read the copied configuration: %s", error)
return
dictionary = config.get("dictionary") if isinstance(config, dict) else None
if not isinstance(dictionary, dict):
return
stored = dictionary.get("file_path")
if not isinstance(stored, str) or not stored.strip():
return
destination = _payload_destination(stored, payload)
if destination is None:
return
dictionary["file_path"] = destination
temporary = config_file + ".tmp"
try:
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(config, handle, indent=4)
os.replace(temporary, config_file)
except OSError as error:
logger.warning("Legacy import could not point the dictionary at %s: %s", destination, error)
return
logger.info("Legacy import pointed the custom dictionary at %s", destination)
def import_legacy_once() -> list[str]:
"""Copy legacy Vocalinux user data into the new locations, once.
@@ -96,18 +148,25 @@ def import_legacy_once() -> list[str]:
return []
imported: list[str] = []
# Every importer-owned legacy file and its new location, copied this run or
# not: a stored path to one of them must never resolve to the legacy file.
payload: dict[str, str] = {}
for filename in CONFIG_PAYLOAD:
src = os.path.join(legacy_config, filename)
dst = os.path.join(new_config_dir(), filename)
payload[src] = dst
if _copy_once(src, dst):
imported.append(dst)
legacy_data = legacy_data_dir()
if os.path.isdir(legacy_data):
for filename in DATA_PAYLOAD:
src = os.path.join(legacy_data, filename)
dst = os.path.join(new_data_dir(), filename)
if _copy_once(src, dst):
imported.append(dst)
for filename in DATA_PAYLOAD:
src = os.path.join(legacy_data, filename)
dst = os.path.join(new_data_dir(), filename)
payload[src] = dst
if os.path.isdir(legacy_data) and _copy_once(src, dst):
imported.append(dst)
copied_config = os.path.join(new_config_dir(), CONFIG_FILENAME)
if copied_config in imported:
_repoint_dictionary_path(copied_config, payload)
if imported:
logger.info("Legacy Vocalinux import complete: %d file(s)", len(imported))
return imported
@@ -19,14 +19,20 @@ that change is deliberate.
Backend order follows the same readiness rule: CUDA ranks before Vulkan
only when a complete architecture-matching CUDA toolkit is installed
(``nvidia-smi`` sees a GPU, ``nvcc`` answers, compute capability >= 7.0);
otherwise the order is Vulkan, then CPU. A driver without the toolkit is
(``nvidia-smi`` sees a GPU, compute capability >= 7.0, and nvcc answers on
PATH or a complete toolkit root under ``CUDA_HOME``, ``CUDA_PATH`` or
``/usr/local/cuda*``); otherwise the order is Vulkan, then CPU. The order only
names backends the installed build compiled in. A driver without the toolkit is
exactly what made the old VRAM-aware recommendation unreachable.
"""
import logging
import os
import platform
import shutil
import subprocess
from collections.abc import Mapping
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -59,6 +65,14 @@ MIN_CUDA_COMPUTE_CAPABILITY: tuple[int, int] = (7, 0)
#: discrete card. A CPU build keeps its model in system RAM.
GPU_BACKENDS: tuple[str, ...] = ("vulkan", "cuda")
#: Environment variables that may name a CUDA toolkit root, in probe order.
_CUDA_ROOT_ENV_VARS: tuple[str, ...] = ("CUDA_HOME", "CUDA_PATH")
#: Directory whose ``cuda*`` entries are probed as toolkit roots. Tests redirect it.
_CUDA_USR_LOCAL_ROOT: Path = Path("/usr/local")
#: Toolkit ``targets/`` directory for each host machine. Voculinux publishes
#: only glibc x86_64, so any other machine has no matching toolkit target.
_CUDA_TARGET_FOR_MACHINE: dict[str, str] = {"x86_64": "x86_64-linux"}
def _floor_rung(language_is_english: bool) -> str:
"""Return the floor rung for the language: English-only pairs with ``.en``."""
@@ -168,6 +182,67 @@ def cuda_toolkit_ready_from_probe(
return compute_cap >= MIN_CUDA_COMPUTE_CAPABILITY
def _toolkit_is_complete(root: Path, machine: str) -> bool:
"""Return whether ``root`` holds an nvcc, the runtime header and libcudart.
``machine`` must have a toolkit target: the libraries are looked up in that
target's ``lib`` directory (and ``lib64`` on x86_64, where it is a link to
the same place in the runfile layout).
"""
target = _CUDA_TARGET_FOR_MACHINE.get(machine)
if target is None:
return False
nvcc = root / "bin" / "nvcc"
if not (nvcc.is_file() and os.access(nvcc, os.X_OK)):
return False
if not (root / "include" / "cuda_runtime.h").is_file():
return False
lib_dirs = [root / "targets" / target / "lib"]
if machine == "x86_64":
lib_dirs.append(root / "lib64")
return any(lib.is_dir() and any(lib.glob("libcudart.so*")) for lib in lib_dirs)
def _cuda_toolkit_roots(environ: Mapping[str, str]) -> list[Path]:
"""Return candidate toolkit roots: CUDA_HOME, CUDA_PATH, then /usr/local/cuda*."""
roots: list[Path] = []
for name in _CUDA_ROOT_ENV_VARS:
value = environ.get(name, "").strip()
if value:
roots.append(Path(value))
if _CUDA_USR_LOCAL_ROOT.is_dir():
roots.extend(
sorted(
(path for path in _CUDA_USR_LOCAL_ROOT.glob("cuda*") if path.is_dir()),
reverse=True,
)
)
return roots
def _toolkit_root_is_complete(environ: Mapping[str, str], machine: str) -> bool:
"""Return whether any candidate root is a complete toolkit for ``machine``."""
checked: set[Path] = set()
for root in _cuda_toolkit_roots(environ):
try:
resolved = root.resolve()
except OSError:
continue
if resolved in checked:
continue
checked.add(resolved)
if _toolkit_is_complete(resolved, machine):
return True
return False
def _cuda_toolkit_installed() -> bool:
"""Return whether a CUDA toolkit is usable: nvcc on PATH, or a complete root."""
if _run_probe(["nvcc", "--version"]) is not None:
return True
return _toolkit_root_is_complete(os.environ, platform.machine())
def cuda_toolkit_ready() -> bool:
"""Probe the host for a complete architecture-matching CUDA toolkit."""
smi = _run_probe(
@@ -175,10 +250,9 @@ def cuda_toolkit_ready() -> bool:
)
if not smi:
return False
nvcc = _run_probe(["nvcc", "--version"])
return cuda_toolkit_ready_from_probe(
has_nvidia_gpu=True,
nvcc_ok=nvcc is not None,
nvcc_ok=_cuda_toolkit_installed(),
compute_cap=_parse_first_compute_cap(smi),
)
@@ -232,15 +306,27 @@ def free_memory_mib() -> float | None:
return free_system_ram_mib()
def preferred_backend_order() -> tuple[str, ...]:
"""Return the backend attempt order for this host.
def preferred_backend_order(build_backend: str | None = None) -> tuple[str, ...]:
"""Return the backend attempt order for this host and installed build.
CUDA ranks before Vulkan only when a complete architecture-matching
toolkit is installed; otherwise Vulkan, then CPU.
``build_backend`` is the GPU backend the installed pywhispercpp build
compiled in (``vulkan``, ``cuda`` or ``cpu``). The runtime can only use
those, so the order names only them: CUDA first when a complete
architecture-matching toolkit is installed, then Vulkan, then CPU. A
Vulkan-only build never lists CUDA. ``None`` (build unknown) keeps the
unfiltered order: CUDA, Vulkan, CPU with a toolkit, otherwise Vulkan, CPU.
"""
if cuda_toolkit_ready():
return ("cuda", "vulkan", "cpu")
return ("vulkan", "cpu")
if build_backend is None:
if cuda_toolkit_ready():
return ("cuda", "vulkan", "cpu")
return ("vulkan", "cpu")
order: list[str] = []
if build_backend == "cuda" and cuda_toolkit_ready():
order.append("cuda")
if build_backend == "vulkan":
order.append("vulkan")
order.append("cpu")
return tuple(order)
def describe_environment() -> str:
@@ -1801,7 +1801,7 @@ class SpeechRecognitionManager:
ggml_devices = detect_ggml_gpu_devices()
logger.info(
"whisper.cpp backend selection priority: %s",
" -> ".join(preferred_backend_order()),
" -> ".join(preferred_backend_order(actual_gpu_backend)),
)
selected_gpu_device = None
+18 -7
View File
@@ -117,6 +117,14 @@ def _is_niri_session() -> bool:
return any("niri" in value.lower() for value in desktop_values)
#: Logged once when an explicit ibus pin is honoured on niri (spec #16).
_NIRI_IBUS_PIN_WARNING = (
"IBus on niri can report success while text is silently dropped: niri does not "
"relay IBus commits to native Wayland windows. The explicit ibus pin is honoured; "
"remove it to type directly with wtype or ydotool instead."
)
def _kde_wayland_ibus_hint() -> str:
"""Return the KDE Plasma Wayland IBus setup hint."""
return (
@@ -989,13 +997,16 @@ class TextInjector:
# reports the commit as delivered and the text never arrives.
# The other two guards are out of scope for this warning.
if force_ibus and not self._wayland_compositor_bridges_ibus():
logger.warning(
"Compositor '%s' does not bridge IBus to native Wayland apps, but an "
"explicit ibus pin overrides that check. Dictation may silently do "
"nothing in native Wayland windows; remove the pin to fall back to "
"wtype/ydotool.",
os.environ.get("XDG_CURRENT_DESKTOP", "unknown"),
)
if _is_niri_session():
logger.warning(_NIRI_IBUS_PIN_WARNING)
else:
logger.warning(
"Compositor '%s' does not bridge IBus to native Wayland apps, but an "
"explicit ibus pin overrides that check. Dictation may silently do "
"nothing in native Wayland windows; remove the pin to fall back to "
"wtype/ydotool.",
os.environ.get("XDG_CURRENT_DESKTOP", "unknown"),
)
try:
if wayland_scoped_ibus and not ibus_active:
logger.info(
+3 -3
View File
@@ -31,7 +31,7 @@ VOCAHQ_DISCORD_URL = "https://discord.gg/t6muquAJbm"
VOCAHQ_X_URL = "https://x.com/vocahq"
VOCAHQ_MAILTO_URL = "mailto:hello@vocahq.com"
# About page links that are not GitHub release URLs.
# About page links that are not release URLs.
_ABOUT_OPEN_URLS = frozenset(
{
VOCULINUX_SITE_URL,
@@ -169,7 +169,7 @@ _VOCAHQ_FAMILY_LINKS = (
def _can_open_url(url: str) -> bool:
"""Return True for GitHub project URLs or the About page allowlist."""
"""Return True for this project's release URLs or the About page allowlist."""
return bool(url) and (url in _ABOUT_OPEN_URLS or is_trusted_release_url(url))
@@ -472,7 +472,7 @@ class AboutSectionMixin(Gtk.Dialog):
self.check_updates_btn.get_style_context().add_class("suffix-button")
_style_action_button(self.check_updates_btn, width=80)
self.check_updates_btn.set_tooltip_text(
"Look up the latest release for this channel on GitHub"
"Look up the latest Voculinux release for this channel"
)
self.check_updates_btn.connect("clicked", on_check_updates_clicked)
+10 -2
View File
@@ -70,6 +70,10 @@ from ..utils.model_choice import (
priority_for_size,
size_for_priority,
)
from ..utils.packaging import ( # noqa: E402
PACKAGED_UPDATE_HINT,
is_packaged_install,
)
from ..utils.paths import models_dir # noqa: E402
from ..utils.proxy import ( # noqa: E402
DEFAULT_PROXY_MODE,
@@ -6542,13 +6546,17 @@ class SettingsDialog(AboutSectionMixin):
self._open_web_url(self._about_release_url)
def _start_update_check(self):
"""Kick off a background GitHub release lookup."""
"""Kick off a background release lookup, or explain packaged updates."""
channel = self._current_update_channel()
if is_packaged_install():
# Packaged installs never query a release server (ticket #27).
self.update_status_row.set_subtitle(PACKAGED_UPDATE_HINT)
return
self._update_check_generation += 1
generation = self._update_check_generation
self.check_updates_btn.set_sensitive(False)
self.check_updates_btn.set_label("…")
self.update_status_row.set_subtitle(f"Checking GitHub ({channel})…")
self.update_status_row.set_subtitle(f"Checking Voculinux releases ({channel})…")
# Invalidate any in-flight result; that worker will restart on completion.
if self._update_check_in_progress:
+1 -1
View File
@@ -335,7 +335,7 @@ class TrayIndicator:
self._model_keepalive.start()
self._model_keepalive.bump()
# Background GitHub update checks (tray menu + optional notification)
# Background update checks (tray menu + optional notification)
self._update_monitor = UpdateMonitor(
get_enabled=self._update_checks_enabled,
on_result=self._on_update_check_result,
+9 -8
View File
@@ -1,11 +1,10 @@
"""Facts about installs managed by a system package manager.
Ticket #27 (Void app template): the xbps template installs an empty
``usr/share/voculinux/packaged`` marker. When it exists, background
update checks stay available as a signal, but every user-visible update
affordance presents the package-manager path instead of release
downloads, so packaged users are never misdirected away from
package-manager updates.
``usr/share/voculinux/packaged`` marker. When it exists, the install never
queries a release server (``fetch_latest_release`` returns None without a
network request), and the update affordances say that updates arrive via
``xbps-install -Su``, never a release download.
"""
from pathlib import Path
@@ -14,10 +13,12 @@ from typing import Optional
#: Installed by the xbps template's post_install; absent everywhere else.
PACKAGED_MARKER_PATH = Path("/usr/share/voculinux/packaged")
#: The Void command that delivers updates to a packaged install.
PACKAGED_UPDATE_COMMAND = "sudo xbps-install -Su"
#: Presented wherever a packaged install would otherwise offer a download.
PACKAGED_UPDATE_HINT = (
"Installed from a system package: update with your package manager "
"(on Void: sudo xbps-install -Syu voculinux)."
f"Installed from a system package: updates arrive via {PACKAGED_UPDATE_COMMAND}."
)
@@ -38,7 +39,7 @@ def update_menu_tooltip(*, packaged: bool) -> str:
if packaged:
return (
"Open Settings → About for release notes; "
"update with your package manager (on Void: sudo xbps-install -Syu voculinux)"
f"updates arrive via {PACKAGED_UPDATE_COMMAND}"
)
return "Open Settings → About for release notes and download links"
+44 -97
View File
@@ -1,4 +1,9 @@
"""Check GitHub Releases for newer Voculinux versions."""
"""Check the Voculinux release server (Gitea) for newer versions.
Voculinux is a fork: its releases live on the personal Gitea instance. The
upstream VocaHQ repository is never queried, and packaged installs never query
a release server at all (their updates arrive through xbps).
"""
import logging
import re
@@ -8,7 +13,7 @@ from urllib.parse import urlparse
from .. import version as _version_module
from ..app_identity import UPDATE_CHECKER_USER_AGENT
from ..version import __url__
from .packaging import is_packaged_install
from .proxy import requests_proxies
logger = logging.getLogger(__name__)
@@ -17,7 +22,12 @@ UPDATE_CHANNELS = ("stable", "nightly")
DEFAULT_UPDATE_CHANNEL = "stable"
DEFAULT_CHECK_AUTOMATICALLY = True
_GITHUB_REPO_RE = re.compile(r"https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$")
#: Release source for source installs: this fork's repository on Gitea.
UPDATE_REPO_URL = "https://git.bongbetic.com/xavierk/voculinux"
_REPO_URL_RE = re.compile(
r"^https://(?P<host>[^/]+)/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
)
_PRE_RELEASE_SUFFIX_RE = re.compile(
r"^(?P<main>\d+(?:\.\d+)*)(?:[-.](?P<pre>(?:alpha|beta|rc|pre|dev)\.?\d*))?$",
re.IGNORECASE,
@@ -29,7 +39,7 @@ _DEV_DATE_RE = re.compile(r"(?:^|[.+-])dev(\d{8})(?:[.+-]|$)", re.IGNORECASE)
@dataclass(frozen=True)
class ReleaseInfo:
"""Metadata for a GitHub release."""
"""Metadata for a release published on the release server."""
tag_name: str
version: str
@@ -47,16 +57,17 @@ def normalize_channel(channel: Optional[str]) -> str:
return value if value in UPDATE_CHANNELS else DEFAULT_UPDATE_CHANNEL
def _repo_parts(repo_url: str = __url__) -> tuple[str, str]:
match = _GITHUB_REPO_RE.match(repo_url.strip())
def _repo_parts(repo_url: str = UPDATE_REPO_URL) -> tuple[str, str, str]:
"""Return ``(host, owner, repo)`` for an https Gitea repository URL."""
match = _REPO_URL_RE.match(repo_url.strip())
if not match:
raise ValueError(f"Unsupported repository URL: {repo_url}")
return match.group("owner"), match.group("repo")
return match.group("host"), match.group("owner"), match.group("repo")
def _repo_api_base(repo_url: str = __url__) -> str:
owner, repo = _repo_parts(repo_url)
return f"https://api.github.com/repos/{owner}/{repo}"
def _repo_api_base(repo_url: str = UPDATE_REPO_URL) -> str:
host, owner, repo = _repo_parts(repo_url)
return f"https://{host}/api/v1/repos/{owner}/{repo}"
def normalize_version(version: str) -> str:
@@ -139,44 +150,24 @@ def is_update_available(
return is_newer_version(current, latest.version)
# github.com/jatinkrmalik/vocalinux redirects here after the org transfer.
_VOCULINUX_GITHUB_OWNERS = frozenset({"VocaHQ", "jatinkrmalik"})
def _trusted_path_prefixes(repo_url: str = __url__) -> tuple[str, ...]:
"""Return GitHub path prefixes that belong to this project."""
owner, repo = _repo_parts(repo_url)
prefixes = [f"/{owner}/{repo}"]
known_owners = {name.casefold() for name in _VOCULINUX_GITHUB_OWNERS}
if repo.casefold() == "vocalinux" and owner.casefold() in known_owners:
prefixes.extend(f"/{name}/vocalinux" for name in sorted(_VOCULINUX_GITHUB_OWNERS))
seen: set[str] = set()
unique: list[str] = []
for prefix in prefixes:
key = prefix.casefold()
if key not in seen:
seen.add(key)
unique.append(prefix)
return tuple(unique)
def is_trusted_release_url(url: str, repo_url: str = __url__) -> bool:
"""Return True when ``url`` points at this project's GitHub pages."""
def is_trusted_release_url(url: str, repo_url: str = UPDATE_REPO_URL) -> bool:
"""Return True when ``url`` points at this project's release pages on Gitea."""
if not url:
return False
try:
host, owner, repo = _repo_parts(repo_url)
parsed = urlparse(url)
prefixes = _trusted_path_prefixes(repo_url)
except ValueError:
return False
if parsed.scheme != "https" or parsed.netloc.lower() != "github.com":
if parsed.scheme != "https" or parsed.netloc.lower() != host.lower():
return False
path = parsed.path.rstrip("/")
return any(path == prefix or path.startswith(prefix + "/") for prefix in prefixes)
path = parsed.path.rstrip("/").casefold()
prefix = f"/{owner}/{repo}".casefold()
return path == prefix or path.startswith(prefix + "/")
def _release_from_payload(
data: dict, channel: str, repo_url: str = __url__
data: dict, channel: str, repo_url: str = UPDATE_REPO_URL
) -> Optional[ReleaseInfo]:
tag_name = data.get("tag_name") or ""
if not tag_name:
@@ -199,71 +190,26 @@ def _release_from_payload(
)
def _html_url_from_relative_update_url(update_url: object, repo_url: str) -> str:
"""Return a trusted tag URL from a relative GitHub ``update_url``, or empty."""
if not isinstance(update_url, str):
return ""
if not update_url.startswith("/") or update_url.startswith("//"):
return ""
if ".." in update_url or "\\" in update_url or "?" in update_url or "#" in update_url:
return ""
if ":" in update_url or any(ch.isspace() for ch in update_url):
return ""
candidate = "https://github.com" + update_url
if not is_trusted_release_url(candidate, repo_url):
return ""
return candidate
def _fetch_stable_from_releases_page(
requests, repo_url: str, timeout: float
) -> Optional[ReleaseInfo]:
"""Look up the latest stable tag from github.com when the REST API is blocked."""
owner, repo = _repo_parts(repo_url)
url = f"https://github.com/{owner}/{repo}/releases/latest"
headers = {
"Accept": "application/json",
"User-Agent": UPDATE_CHECKER_USER_AGENT,
}
try:
response = requests.get(url, headers=headers, timeout=timeout, proxies=requests_proxies())
final_url = getattr(response, "url", "") or ""
parsed = urlparse(final_url)
if (
parsed.scheme != "https"
or parsed.netloc.lower() != "github.com"
or not is_trusted_release_url(final_url, repo_url)
or response.status_code != 200
):
logger.debug("Stable release HTML fallback unsuccessful for %s", url)
return None
data = response.json()
if not isinstance(data, dict) or not (data.get("tag_name") or ""):
logger.debug("Stable release HTML fallback unsuccessful for %s", url)
return None
html_url = _html_url_from_relative_update_url(data.get("update_url"), repo_url)
return _release_from_payload(
{"tag_name": data.get("tag_name"), "html_url": html_url},
"stable",
repo_url,
)
except (requests.exceptions.RequestException, ValueError, TypeError) as exc:
logger.debug("Stable release HTML fallback failed for %s: %s", url, exc)
return None
def fetch_latest_release(
repo_url: str = __url__,
repo_url: str = UPDATE_REPO_URL,
timeout: float = 10.0,
channel: str = DEFAULT_UPDATE_CHANNEL,
) -> Optional[ReleaseInfo]:
"""Fetch the newest GitHub release for ``channel`` (``stable`` or ``nightly``)."""
"""Fetch the newest Gitea release for ``channel`` (``stable`` or ``nightly``).
A packaged install returns None without importing ``requests`` or opening a
connection: its updates arrive through the system package manager.
"""
if is_packaged_install():
logger.debug("Packaged install: release check skipped, updates come from xbps")
return None
import requests
channel = normalize_channel(channel)
api_base = _repo_api_base(repo_url)
headers = {
"Accept": "application/vnd.github+json",
"Accept": "application/json",
"User-Agent": UPDATE_CHECKER_USER_AGENT,
}
@@ -279,7 +225,8 @@ def fetch_latest_release(
logger.warning("No latest release found for %s", api_base)
return None
if response.status_code in (403, 429):
return _fetch_stable_from_releases_page(requests, repo_url, timeout)
logger.debug("Release lookup rate-limited or forbidden for %s", api_base)
return None
response.raise_for_status()
data = response.json()
if not isinstance(data, dict):
@@ -293,12 +240,12 @@ def fetch_latest_release(
response = requests.get(
f"{api_base}/releases",
headers=headers,
params={"per_page": 30, "page": page},
params={"limit": 30, "page": page},
timeout=timeout,
proxies=requests_proxies(),
)
if response.status_code in (403, 429):
logger.debug("GitHub API rate-limited or forbidden for %s", api_base)
logger.debug("Release lookup rate-limited or forbidden for %s", api_base)
return None
response.raise_for_status()
releases = response.json()
+1 -1
View File
@@ -1,4 +1,4 @@
"""Background GitHub release checks for Voculinux.
"""Background release checks for Voculinux.
Schedules a delayed first check, then repeats on an interval. Networking runs on
a worker thread; the result callback is delivered on the GLib main loop.
+132
View File
@@ -11,8 +11,11 @@ Seams under test (all offline, no hardware):
"""
import json
import os
from pathlib import Path
import pytest
from vocalinux import legacy_import
from vocalinux.legacy_import import import_legacy_once
@@ -145,3 +148,132 @@ def test_new_dictionary_never_overwritten_by_legacy(monkeypatch, tmp_path) -> No
# dictionary file must survive untouched.
assert (new_config / "dictionary.txt").read_text() == "Mine\n"
assert legacy_import.CONFIG_FILENAME == "config.json"
#: A fixed old timestamp, so any write to the legacy file moves its mtime visibly.
_OLD_MTIME_NS = 1_000_000_000_000_000_000
def _import_with_stored_dictionary_path(
monkeypatch, config_root: Path, data_root: Path, stored_path: str
) -> Path:
"""Seed a legacy install whose config stores ``stored_path``, then import it.
Returns the legacy dictionary file. The ConfigManager paths are pointed at
the copied configuration, so nothing outside the scratch directories is read
or written.
"""
seeded = _seed_legacy(config_root, data_root)
seeded["config.json"].write_text(
json.dumps({"dictionary": {"enabled": True, "file_path": stored_path}}),
encoding="utf-8",
)
import_legacy_once()
_point_config_manager_at(monkeypatch, config_root / "voculinux")
return seeded["dictionary.txt"]
def _point_config_manager_at(monkeypatch, new_config_dir: Path) -> None:
"""Make ConfigManager read and write only the given (scratch) configuration dir."""
from vocalinux.ui import config_manager
monkeypatch.setattr(config_manager, "CONFIG_DIR", str(new_config_dir))
monkeypatch.setattr(config_manager, "CONFIG_FILE", str(new_config_dir / "config.json"))
def _add_term_through_migrated_config(monkeypatch, new_config_dir: Path, term: str) -> None:
"""Add a dictionary term exactly as the app does, using the migrated configuration."""
_point_config_manager_at(monkeypatch, new_config_dir)
from vocalinux.custom_dictionary import CustomDictionaryManager
from vocalinux.ui.config_manager import ConfigManager
assert CustomDictionaryManager(ConfigManager()).add_term(term) is True
@pytest.mark.parametrize("form", ["absolute-legacy", "tilde-legacy"])
def test_migrated_dictionary_edit_never_writes_the_legacy_file(monkeypatch, tmp_path, form) -> None:
"""The copied config points the dictionary at the copy, so an edit leaves legacy alone."""
if form == "tilde-legacy":
# The tilde form names the legacy file only when XDG_CONFIG_HOME is ~/.config.
home = tmp_path / "home"
monkeypatch.setenv("HOME", str(home))
config_root = home / ".config"
monkeypatch.setenv("XDG_CONFIG_HOME", str(config_root))
data_root = tmp_path / "data"
monkeypatch.setenv("XDG_DATA_HOME", str(data_root))
stored = "~/.config/vocalinux/dictionary.txt"
else:
config_root, data_root = _isolate(monkeypatch, tmp_path)
stored = str(config_root / "vocalinux" / "dictionary.txt")
legacy_terms = _import_with_stored_dictionary_path(monkeypatch, config_root, data_root, stored)
os.utime(legacy_terms, ns=(_OLD_MTIME_NS, _OLD_MTIME_NS))
legacy_bytes = legacy_terms.read_bytes()
legacy_mtime = legacy_terms.stat().st_mtime_ns
from vocalinux.custom_dictionary import CustomDictionaryManager
from vocalinux.ui.config_manager import ConfigManager
dictionary = CustomDictionaryManager(ConfigManager())
assert dictionary.add_term("Kubernetes") is True
assert legacy_terms.read_bytes() == legacy_bytes
assert legacy_terms.stat().st_mtime_ns == legacy_mtime
new_terms = config_root / "voculinux" / "dictionary.txt"
assert "Kubernetes" in new_terms.read_text(encoding="utf-8")
def test_dictionary_path_outside_the_legacy_directory_is_kept(monkeypatch, tmp_path) -> None:
"""A path the user chose outside the legacy directory is not rewritten."""
chosen = tmp_path / "shared" / "terms.txt"
chosen.parent.mkdir(parents=True)
chosen.write_text("Chosen\n", encoding="utf-8")
config_root, data_root = _isolate(monkeypatch, tmp_path)
_import_with_stored_dictionary_path(monkeypatch, config_root, data_root, str(chosen))
migrated = json.loads((config_root / "voculinux" / "config.json").read_text(encoding="utf-8"))
assert migrated["dictionary"]["file_path"] == str(chosen)
assert chosen.read_text(encoding="utf-8") == "Chosen\n"
def test_absent_legacy_dictionary_edit_lands_in_the_new_file(monkeypatch, tmp_path) -> None:
"""The default dictionary path was saved but the file was never created."""
config_root, _data_root = _isolate(monkeypatch, tmp_path)
legacy_config = config_root / "vocalinux"
legacy_config.mkdir(parents=True)
legacy_terms = legacy_config / "dictionary.txt"
(legacy_config / "config.json").write_text(
json.dumps({"dictionary": {"enabled": True, "file_path": str(legacy_terms)}}),
encoding="utf-8",
)
import_legacy_once()
_add_term_through_migrated_config(monkeypatch, config_root / "voculinux", "Kubernetes")
assert not legacy_terms.exists()
assert "Kubernetes" in (config_root / "voculinux" / "dictionary.txt").read_text(
encoding="utf-8"
)
def test_existing_new_dictionary_takes_the_edit_and_legacy_stays_put(monkeypatch, tmp_path) -> None:
"""A new dictionary already present wins; the legacy file is never written."""
config_root, data_root = _isolate(monkeypatch, tmp_path)
seeded = _seed_legacy(config_root, data_root)
legacy_terms = seeded["dictionary.txt"]
seeded["config.json"].write_text(
json.dumps({"dictionary": {"enabled": True, "file_path": str(legacy_terms)}}),
encoding="utf-8",
)
new_config_dir = config_root / "voculinux"
new_config_dir.mkdir(parents=True)
(new_config_dir / "dictionary.txt").write_text("Mine\n", encoding="utf-8")
os.utime(legacy_terms, ns=(_OLD_MTIME_NS, _OLD_MTIME_NS))
legacy_bytes = legacy_terms.read_bytes()
legacy_mtime = legacy_terms.stat().st_mtime_ns
import_legacy_once()
_add_term_through_migrated_config(monkeypatch, new_config_dir, "Kubernetes")
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"
+140
View File
@@ -8,8 +8,12 @@ protocol headroom. Replacing them with a recorded measurement is a
deliberate change to this test.
"""
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from vocalinux.speech_recognition import model_ladder
from vocalinux.speech_recognition.model_ladder import (
DEFAULT_RUNG,
@@ -130,6 +134,142 @@ class TestCudaToolkitReadiness:
assert cuda_toolkit_ready_from_probe(True, True, None) is False
NVCC_VERSION_OUTPUT = "Cuda compilation tools, release 13.2, V13.2.51\n"
NVIDIA_SMI_OUTPUT = "3711, 8.6\n"
def _write_toolkit(
root: Path,
*,
nvcc: bool = True,
header: bool = True,
libcudart: bool = True,
target: str = "x86_64-linux",
) -> Path:
"""Lay out a CUDA toolkit tree shaped like the NVIDIA runfile install."""
if nvcc:
(root / "bin").mkdir(parents=True, exist_ok=True)
binary = root / "bin" / "nvcc"
binary.write_text("#!/bin/sh\n")
binary.chmod(0o755)
if header:
(root / "include").mkdir(parents=True, exist_ok=True)
(root / "include" / "cuda_runtime.h").write_text("/* header */\n")
if libcudart:
lib = root / "targets" / target / "lib"
lib.mkdir(parents=True, exist_ok=True)
(lib / "libcudart.so.13").write_text("")
if target == "x86_64-linux":
(root / "lib64").symlink_to(lib, target_is_directory=True)
return root
def _probe_stub(nvcc_output: str | None):
"""Stand in for ``_run_probe``: nvidia-smi sees an RTX-class GPU."""
def probe(argv, timeout=10):
if argv[0] == "nvidia-smi":
return NVIDIA_SMI_OUTPUT
if argv[0] == "nvcc":
return nvcc_output
return None
return probe
@pytest.fixture
def no_env_toolkit(tmp_path: Path):
"""Isolate detection from the host: no CUDA env vars, empty /usr/local."""
usr_local = tmp_path / "usr-local"
usr_local.mkdir()
with (
patch.dict(os.environ, {}, clear=True),
patch.object(model_ladder, "_CUDA_USR_LOCAL_ROOT", usr_local),
patch.object(model_ladder.platform, "machine", return_value="x86_64"),
):
yield usr_local
class TestCudaToolkitDetection:
"""A toolkit counts from PATH, CUDA_HOME, CUDA_PATH or /usr/local/cuda*/."""
def test_toolkit_found_via_path(self, no_env_toolkit: Path) -> None:
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(NVCC_VERSION_OUTPUT)):
assert model_ladder.cuda_toolkit_ready() is True
@pytest.mark.parametrize("variable", ["CUDA_HOME", "CUDA_PATH"])
def test_toolkit_found_via_cuda_env_root(
self, tmp_path: Path, no_env_toolkit: Path, variable: str
) -> None:
root = _write_toolkit(tmp_path / "opt" / "cuda")
with (
patch.dict(os.environ, {variable: str(root)}, clear=True),
patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)),
):
assert model_ladder.cuda_toolkit_ready() is True
def test_toolkit_found_only_under_usr_local_cuda(self, no_env_toolkit: Path) -> None:
"""The real target: /usr/local/cuda -> cuda-13.2 with nvcc off PATH."""
real = _write_toolkit(no_env_toolkit / "cuda-13.2")
(no_env_toolkit / "cuda").symlink_to(real, target_is_directory=True)
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
assert model_ladder.cuda_toolkit_ready() is True
def test_no_toolkit_anywhere_is_not_ready(self, no_env_toolkit: Path) -> None:
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
assert model_ladder.cuda_toolkit_ready() is False
@pytest.mark.parametrize(
"layout",
[
{"nvcc": False},
{"header": False},
{"libcudart": False},
{"target": "sbsa-linux"},
],
ids=["no-nvcc", "no-cuda-runtime-header", "no-libcudart", "other-architecture"],
)
def test_incomplete_toolkit_is_rejected(
self, tmp_path: Path, no_env_toolkit: Path, layout: dict
) -> None:
root = _write_toolkit(tmp_path / "partial", **layout)
with (
patch.dict(os.environ, {"CUDA_HOME": str(root)}, clear=True),
patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)),
):
assert model_ladder.cuda_toolkit_ready() is False
def test_driver_without_toolkit_stays_not_ready_with_partial_tree(
self, no_env_toolkit: Path
) -> None:
"""A /usr/local/cuda with headers only is not a toolkit."""
_write_toolkit(no_env_toolkit / "cuda-13.2", nvcc=False, libcudart=False)
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
assert model_ladder.cuda_toolkit_ready() is False
class TestBuildAwareBackendOrder:
"""The order names only backends the installed build compiled in."""
def test_vulkan_only_build_never_picks_cuda_even_with_toolkit(
self, no_env_toolkit: Path
) -> None:
_write_toolkit(no_env_toolkit / "cuda-13.2")
(no_env_toolkit / "cuda").symlink_to(no_env_toolkit / "cuda-13.2")
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
assert model_ladder.cuda_toolkit_ready() is True
assert preferred_backend_order("vulkan") == ("vulkan", "cpu")
def test_cuda_build_picks_cuda_with_nvcc_off_path(self, no_env_toolkit: Path) -> None:
_write_toolkit(no_env_toolkit / "cuda-13.2")
(no_env_toolkit / "cuda").symlink_to(no_env_toolkit / "cuda-13.2")
with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
assert preferred_backend_order("cuda") == ("cuda", "cpu")
def test_cpu_build_is_cpu_only(self) -> None:
assert preferred_backend_order("cpu") == ("cpu",)
class TestPreferredBackendOrder:
"""CUDA ranks first only with a complete toolkit."""
+3 -3
View File
@@ -38,8 +38,8 @@ def test_packaged_install_when_the_marker_exists(tmp_path: Path) -> None:
def test_packaged_hint_names_the_package_manager_path() -> None:
"""The presented path is xbps, not a release download."""
assert "package manager" in PACKAGED_UPDATE_HINT
assert "xbps-install -Syu voculinux" in PACKAGED_UPDATE_HINT
assert "xbps-install -Su" in PACKAGED_UPDATE_HINT
assert "updates arrive via sudo xbps-install -Su" in PACKAGED_UPDATE_HINT
def test_unpackaged_menu_label_is_unchanged() -> None:
@@ -90,4 +90,4 @@ def test_packaged_updates_description_presents_the_package_manager() -> None:
"""The About page tells packaged users to update through xbps."""
description = updates_description("stable", packaged=True)
assert "stable" in description
assert "package manager" in description
assert "xbps-install -Su" in description
+11 -1
View File
@@ -1476,9 +1476,10 @@ class TestAboutPage(unittest.TestCase):
VOCAHQ_DISCORD_URL,
VOCAHQ_X_URL,
VOCAHQ_MAILTO_URL,
"https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
):
self.assertTrue(_can_open_url(url), url)
self.assertFalse(_can_open_url("https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0"))
self.assertFalse(_can_open_url("https://evil.example/payload"))
self.assertFalse(_can_open_url("mailto:evil@example.com"))
self.assertFalse(_can_open_url(""))
@@ -1644,6 +1645,15 @@ class TestUpdatePreferences(unittest.TestCase):
self.assertIn("self._start_update_check()", method)
self.assertIn("Automatic checks are off. Press Check to look now.", method)
def test_packaged_check_now_explains_xbps_before_any_lookup(self) -> None:
"""A packaged install's About page shows the xbps path and starts no thread."""
method = self._method_source(self.settings_source, "_start_update_check")
guard = method.index("if is_packaged_install():")
packaged_branch = method[guard : method.index("return", guard)]
self.assertIn("self.update_status_row.set_subtitle(PACKAGED_UPDATE_HINT)", packaged_branch)
self.assertLess(guard, method.index("threading.Thread"))
def test_update_switch_loads_state_before_connecting(self) -> None:
switch_start = self.about_source.index("self.update_checks_switch = Gtk.Switch()")
active_position = self.about_source.index(
+27 -1
View File
@@ -497,6 +497,7 @@ class TestCheckDependencies(unittest.TestCase):
desktop: str = "Hyprland",
env: Any = None,
config_pin: bool = False,
ibus_class: Any = None,
) -> tuple[list[str], int]:
"""Drive _check_dependencies with an ibus pin; return (logs, bridge_call_count)."""
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
@@ -536,7 +537,10 @@ class TestCheckDependencies(unittest.TestCase):
patch(
"vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True
),
patch("vocalinux.text_injection.text_injector.IBusTextInjector"),
patch(
"vocalinux.text_injection.text_injector.IBusTextInjector",
new=ibus_class if ibus_class is not None else MagicMock(),
),
patch.object(TextInjector, "_start_ibus_initialization", lambda s: None),
# shutil.which is patched to claim every tool exists, so without this
# the real probe shells out to a ydotoold that may not be installed
@@ -595,6 +599,28 @@ class TestCheckDependencies(unittest.TestCase):
_, call_count = self._run_ibus_pin(bridges=bridges)
self.assertEqual(call_count, 1, f"bridges={bridges}: expected 1 call, got {call_count}")
def test_ibus_pin_on_niri_is_honoured_with_one_silent_drop_warning(self):
"""An explicit IBus pin stays an escape hatch on niri, with one clear warning.
Both pin sources must construct the IBus injector (the pin is honoured)
and log exactly one WARNING saying IBus on niri can report success while
text is silently dropped.
"""
for config_pin in (False, True):
with self.subTest(config_pin=config_pin):
ibus_class = MagicMock()
output, _ = self._run_ibus_pin(
bridges=False,
desktop="niri",
config_pin=config_pin,
ibus_class=ibus_class,
)
ibus_class.assert_called_once()
warnings = [line for line in output if line.startswith("WARNING:")]
self.assertEqual(len(warnings), 1, f"expected one WARNING, got: {warnings}")
self.assertIn("niri", warnings[0])
self.assertIn("can report success while text is silently dropped", warnings[0])
def test_ibus_pin_on_x11_does_not_warn(self):
"""The denylist is a Wayland concern; X11 reaches apps through XIM."""
from vocalinux.text_injection.text_injector import DesktopEnvironment
+107 -130
View File
@@ -1,8 +1,10 @@
"""Tests for the GitHub Releases update checker."""
"""Tests for the Voculinux release checker (Gitea, never upstream VocaHQ)."""
import logging
from unittest.mock import MagicMock, patch
import pytest
from vocalinux.utils.update_checker import (
ReleaseInfo,
build_channel,
@@ -16,6 +18,12 @@ from vocalinux.utils.update_checker import (
)
@pytest.fixture(autouse=True)
def _unpackaged_install(monkeypatch):
"""Keep these tests independent of a packaged marker on the host (ticket #27)."""
monkeypatch.setattr("vocalinux.utils.update_checker.is_packaged_install", lambda: False)
class TestNormalizeAndCompare:
def test_normalize_version_strips_v_prefix(self):
assert normalize_version("v0.15.0") == "0.15.0"
@@ -72,7 +80,7 @@ class TestChannels:
tag_name="v0.15.0",
version="0.15.0",
name="v0.15.0",
html_url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
body="",
published_at="2026-08-02T00:00:00Z",
prerelease=False,
@@ -86,7 +94,7 @@ class TestChannels:
tag_name="nightly-2026-08-02",
version="nightly-2026-08-02",
name="Nightly Build - 0.14.2.dev20260802+27ecf88",
html_url="https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-02",
html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
body="",
published_at="2026-08-02T00:00:00Z",
prerelease=True,
@@ -103,20 +111,22 @@ class TestChannels:
class TestTrustedUrls:
def test_accepts_project_urls(self):
assert is_trusted_release_url("https://github.com/VocaHQ/vocalinux")
assert is_trusted_release_url("https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0")
# Pre-transfer URLs still redirect and must stay trusted.
assert is_trusted_release_url("https://github.com/jatinkrmalik/vocalinux")
assert is_trusted_release_url("https://git.bongbetic.com/xavierk/voculinux")
assert is_trusted_release_url(
"https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.15.0"
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0"
)
def test_rejects_untrusted_urls(self):
assert not is_trusted_release_url("https://evil.example/payload")
assert not is_trusted_release_url("http://github.com/VocaHQ/vocalinux")
assert not is_trusted_release_url("https://github.com/evil/voculinux")
def test_rejects_upstream_and_other_urls(self):
assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux")
assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux/releases/tag/v1")
assert not is_trusted_release_url("https://github.com/jatinkrmalik/vocalinux")
assert not is_trusted_release_url("https://git.bongbetic.com/xavierk/other")
assert not is_trusted_release_url("https://evil.example/xavierk/voculinux")
assert not is_trusted_release_url("")
def test_rejects_non_https_urls(self):
assert not is_trusted_release_url("http://git.bongbetic.com/xavierk/voculinux")
class TestFormatReleaseNotes:
def test_empty_body(self):
@@ -150,7 +160,7 @@ class TestFetchLatestRelease:
mock_response.json.return_value = {
"tag_name": "v0.15.0",
"name": "v0.15.0",
"html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
"body": "Notes",
"published_at": "2026-08-02T22:29:58Z",
"prerelease": False,
@@ -167,7 +177,7 @@ class TestFetchLatestRelease:
assert release.html_url.endswith("/v0.15.0")
mock_requests.get.assert_called_once()
api_url = mock_requests.get.call_args[0][0]
assert api_url == "https://api.github.com/repos/VocaHQ/vocalinux/releases/latest"
assert api_url == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
def test_drops_untrusted_html_url(self):
mock_response = MagicMock()
@@ -197,7 +207,7 @@ class TestFetchLatestRelease:
{
"tag_name": "v0.15.0",
"name": "v0.15.0",
"html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
"body": "Stable",
"published_at": "2026-08-02T22:29:58Z",
"prerelease": False,
@@ -206,7 +216,7 @@ class TestFetchLatestRelease:
{
"tag_name": "nightly-2026-08-02",
"name": "nightly-2026-08-02",
"html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-02",
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
"body": "Nightly",
"published_at": "2026-08-02T05:00:00Z",
"prerelease": True,
@@ -232,7 +242,7 @@ class TestFetchLatestRelease:
{
"tag_name": f"v0.15.{i}",
"name": f"v0.15.{i}",
"html_url": f"https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.{i}",
"html_url": f"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.{i}",
"body": "",
"published_at": "2026-08-03T00:00:00Z",
"prerelease": False,
@@ -244,7 +254,9 @@ class TestFetchLatestRelease:
{
"tag_name": "nightly-2026-08-01",
"name": "nightly-2026-08-01",
"html_url": ("https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-01"),
"html_url": (
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-01"
),
"body": "Nightly",
"published_at": "2026-08-01T05:00:00Z",
"prerelease": True,
@@ -271,92 +283,45 @@ class TestFetchLatestRelease:
assert mock_requests.get.call_args_list[0].kwargs["params"]["page"] == 1
assert mock_requests.get.call_args_list[1].kwargs["params"]["page"] == 2
def test_stable_api_403_uses_html_fallback(self, caplog):
release = self._fetch_stable_with_rate_limit(403, caplog)
assert isinstance(release, ReleaseInfo)
assert release.version == "0.17.0"
assert release.channel == "stable"
assert release.html_url == "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
def test_stable_api_429_uses_html_fallback(self, caplog):
release = self._fetch_stable_with_rate_limit(429, caplog)
assert isinstance(release, ReleaseInfo)
assert release.version == "0.17.0"
assert release.channel == "stable"
assert release.html_url == "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
def test_stable_api_403_fallback_failures_return_none_quietly(self, caplog):
api_response = _mock_response(403)
failures = [
_mock_response(
500,
url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
),
Exception("offline"),
_mock_response(
200,
json_value=["not", "a", "dict"],
url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
),
_mock_response(
200,
json_value={"update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0"},
url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
),
]
def test_stable_404_means_no_release_and_warns(self, caplog):
"""A missing or private repo answers 404 anonymously: no release, one warning."""
mock_requests = _requests_mock([_mock_response(404)])
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
for failure in failures:
mock_requests = _requests_mock([api_response, failure])
with patch.dict("sys.modules", {"requests": mock_requests}):
assert fetch_latest_release() is None
assert _checker_warnings(caplog) == []
caplog.clear()
with patch.dict("sys.modules", {"requests": mock_requests}):
assert fetch_latest_release() is None
mock_requests.get.assert_called_once()
assert len(_checker_warnings(caplog)) == 1
def test_evil_update_url_does_not_become_html_url(self, caplog):
tag_url = "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
evil_paths = [
"/evil/voculinux/releases/tag/v9",
"https://evil.example/x",
"/VocaHQ/vocalinux/releases/../evil",
]
@pytest.mark.parametrize("status_code", [403, 429])
def test_stable_403_and_429_return_none_without_a_second_request(self, caplog, status_code):
mock_requests = _requests_mock([_mock_response(status_code)])
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
for update_url in evil_paths:
api_response = _mock_response(403)
fallback = _mock_response(
200,
json_value={"tag_name": "v0.17.0", "update_url": update_url},
url=tag_url,
)
mock_requests = _requests_mock([api_response, fallback])
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release()
assert release is not None
assert release.version == "0.17.0"
assert release.html_url == ""
assert _checker_warnings(caplog) == []
caplog.clear()
with patch.dict("sys.modules", {"requests": mock_requests}):
assert fetch_latest_release() is None
mock_requests.get.assert_called_once()
assert _checker_warnings(caplog) == []
def test_untrusted_fallback_final_url_is_rejected(self, caplog):
def test_prerelease_tag_is_compared_not_filtered(self):
"""Existing comparison rules apply to a pre-release tag: no new filter."""
payload = {
"tag_name": "v0.17.0",
"update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
"tag_name": "v0.18.0-beta.1",
"name": "v0.18.0-beta.1",
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.0-beta.1",
"body": "Beta",
"published_at": "2026-08-02T22:29:58Z",
"prerelease": True,
}
untrusted_urls = (
"https://evil.example/VocaHQ/vocalinux/releases/tag/v0.17.0",
"https://github.com/evil/voculinux/releases/tag/v0.17.0",
"http://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
)
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
for final_url in untrusted_urls:
api_response = _mock_response(403)
fallback = _mock_response(200, json_value=payload, url=final_url)
mock_requests = _requests_mock([api_response, fallback])
with patch.dict("sys.modules", {"requests": mock_requests}):
assert fetch_latest_release() is None
assert _checker_warnings(caplog) == []
caplog.clear()
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release()
def test_nightly_api_403_returns_none_without_html_fallback(self, caplog):
assert release is not None
assert release.prerelease is True
assert release.version == "0.18.0-beta.1"
assert is_update_available("0.17.0", release) is True
assert is_update_available("0.18.0", release) is False
def test_nightly_api_403_returns_none(self, caplog):
api_response = _mock_response(403)
mock_requests = _requests_mock([api_response])
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
@@ -364,46 +329,58 @@ class TestFetchLatestRelease:
assert fetch_latest_release(channel="nightly") is None
mock_requests.get.assert_called_once()
requested = mock_requests.get.call_args[0][0]
assert requested == "https://api.github.com/repos/VocaHQ/vocalinux/releases"
assert requested == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases"
assert _checker_warnings(caplog) == []
def _fetch_stable_with_rate_limit(self, status_code: int, caplog):
tag_url = "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
api_response = _mock_response(status_code)
fallback = _mock_response(
200,
json_value={
"tag_name": "v0.17.0",
"update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
"id": 1,
"edit_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0/edit",
"delete_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
"update_authenticity_token": "csrf-update",
"delete_authenticity_token": "csrf-delete",
},
url=tag_url,
)
mock_requests = _requests_mock([api_response, fallback])
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
def test_stable_lookup_parses_gitea_payload_from_gitea_only(self, caplog):
"""The stable lookup reads Gitea's JSON and never contacts GitHub."""
payload = {
"tag_name": "v0.18.6",
"name": "v0.18.6",
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.6",
"body": "Notes",
"published_at": "2026-11-06T10:00:00Z",
"prerelease": False,
}
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release()
assert mock_requests.get.call_count == 2
first, second = mock_requests.get.call_args_list
assert first[0][0] == "https://api.github.com/repos/VocaHQ/vocalinux/releases/latest"
assert first.kwargs["headers"]["Accept"] == "application/vnd.github+json"
assert release is not None
assert release.tag_name == "v0.18.6"
assert release.html_url == payload["html_url"]
assert release.body == "Notes"
mock_requests.get.assert_called_once()
first = mock_requests.get.call_args
assert (
first[0][0]
== "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
)
assert first.kwargs["headers"]["Accept"] == "application/json"
assert first.kwargs["headers"]["User-Agent"] == "Voculinux-UpdateChecker"
assert "Authorization" not in first.kwargs["headers"]
assert second[0][0] == "https://github.com/VocaHQ/vocalinux/releases/latest"
assert second.kwargs["headers"]["Accept"] == "application/json"
assert second.kwargs["headers"]["User-Agent"] == "Voculinux-UpdateChecker"
assert "Authorization" not in second.kwargs["headers"]
assert fallback.url == tag_url
assert _checker_warnings(caplog) == []
assert release is not None
assert "csrf-update" not in repr(release)
assert "csrf-delete" not in repr(release)
return release
@pytest.mark.parametrize("channel", ["stable", "nightly"])
def test_source_install_never_queries_upstream(self, channel):
payload = [] if channel == "nightly" else {"tag_name": "v0.18.6"}
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
with patch.dict("sys.modules", {"requests": mock_requests}):
fetch_latest_release(channel=channel)
for call in mock_requests.get.call_args_list:
assert call[0][0].startswith(
"https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/"
)
def test_packaged_install_makes_no_network_request(self):
"""Packaged installs get updates from xbps, so the checker stays off the network."""
mock_requests = _requests_mock([_mock_response(200, json_value={"tag_name": "v9.9.9"})])
with (
patch("vocalinux.utils.update_checker.is_packaged_install", return_value=True),
patch.dict("sys.modules", {"requests": mock_requests}),
):
assert fetch_latest_release() is None
assert fetch_latest_release(channel="nightly") is None
mock_requests.get.assert_not_called()
def _mock_response(status_code, json_value=None, url=""):