diff --git a/docs/UPDATE.md b/docs/UPDATE.md index 13d2b0a..cf310bc 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -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 diff --git a/src/vocalinux/legacy_import.py b/src/vocalinux/legacy_import.py index 760b71e..466535f 100644 --- a/src/vocalinux/legacy_import.py +++ b/src/vocalinux/legacy_import.py @@ -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 diff --git a/src/vocalinux/speech_recognition/model_ladder.py b/src/vocalinux/speech_recognition/model_ladder.py index dc4150a..a410036 100644 --- a/src/vocalinux/speech_recognition/model_ladder.py +++ b/src/vocalinux/speech_recognition/model_ladder.py @@ -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: diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index edb5c84..7b45704 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -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 diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 2de0d23..6836b9e 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -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( diff --git a/src/vocalinux/ui/settings_about.py b/src/vocalinux/ui/settings_about.py index fa9b454..d1277f2 100644 --- a/src/vocalinux/ui/settings_about.py +++ b/src/vocalinux/ui/settings_about.py @@ -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) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 5ec4393..8596c52 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -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: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index d9a9535..0dbaf67 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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, diff --git a/src/vocalinux/utils/packaging.py b/src/vocalinux/utils/packaging.py index 82704cd..21e191c 100644 --- a/src/vocalinux/utils/packaging.py +++ b/src/vocalinux/utils/packaging.py @@ -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" diff --git a/src/vocalinux/utils/update_checker.py b/src/vocalinux/utils/update_checker.py index 38724f7..fb5d971 100644 --- a/src/vocalinux/utils/update_checker.py +++ b/src/vocalinux/utils/update_checker.py @@ -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[^/]+)/(?P[^/]+?)(?:\.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[^/]+)/(?P[^/]+)/(?P[^/]+?)(?:\.git)?/?$" +) _PRE_RELEASE_SUFFIX_RE = re.compile( r"^(?P
\d+(?:\.\d+)*)(?:[-.](?P
(?: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()
diff --git a/src/vocalinux/utils/update_monitor.py b/src/vocalinux/utils/update_monitor.py
index 6663be3..d9d7438 100644
--- a/src/vocalinux/utils/update_monitor.py
+++ b/src/vocalinux/utils/update_monitor.py
@@ -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.
diff --git a/tests/test_legacy_import.py b/tests/test_legacy_import.py
index 6e99ced..721dae5 100644
--- a/tests/test_legacy_import.py
+++ b/tests/test_legacy_import.py
@@ -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"
diff --git a/tests/test_model_ladder.py b/tests/test_model_ladder.py
index 296b8ff..674be9f 100644
--- a/tests/test_model_ladder.py
+++ b/tests/test_model_ladder.py
@@ -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."""
 
diff --git a/tests/test_packaged_install.py b/tests/test_packaged_install.py
index 80eb6fb..c645183 100644
--- a/tests/test_packaged_install.py
+++ b/tests/test_packaged_install.py
@@ -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
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
index 36f9706..f0739b4 100644
--- a/tests/test_settings_dialog.py
+++ b/tests/test_settings_dialog.py
@@ -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(
diff --git a/tests/test_text_injector_ext.py b/tests/test_text_injector_ext.py
index 98d5518..c083787 100644
--- a/tests/test_text_injector_ext.py
+++ b/tests/test_text_injector_ext.py
@@ -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
diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py
index 90607c5..77daca6 100644
--- a/tests/test_update_checker.py
+++ b/tests/test_update_checker.py
@@ -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=""):