From e88afd224fa25ca49bf247c0fb5c8b96ae1052bb Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 19:51:57 +0530 Subject: [PATCH 1/6] fix(model): detect CUDA toolkit outside PATH and rank by installed build cuda_toolkit_ready() only accepted nvcc on PATH, so a session started before the CUDA profile snippet ranked Vulkan above a CUDA-compiled build. A complete toolkit is now also recognised under CUDA_HOME, CUDA_PATH or /usr/local/cuda*, requiring nvcc, include/cuda_runtime.h and libcudart in the architecture-matching target. The backend order only names backends the installed build compiled in, so a Vulkan-only build never lists CUDA. Refs #20 --- .../speech_recognition/model_ladder.py | 108 +++++++++++-- .../speech_recognition/recognition_manager.py | 2 +- tests/test_model_ladder.py | 144 ++++++++++++++++++ 3 files changed, 242 insertions(+), 12 deletions(-) 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/tests/test_model_ladder.py b/tests/test_model_ladder.py index 296b8ff..e553618 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,146 @@ 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_cuda_build_without_toolkit_has_no_cuda_rung(self, no_env_toolkit: Path) -> None: + with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)): + assert preferred_backend_order("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.""" -- 2.54.0 From 5a0e16da899c37868730c8a960db6606ca174bec Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 19:53:04 +0530 Subject: [PATCH 2/6] fix(inject): explain the silent-drop risk of an explicit IBus pin on niri An explicit ibus pin (VOCULINUX_FORCE_BACKEND or text_injection.backend) is still honoured on niri, as the documented escape hatch. The one warning it logs now names niri and says IBus there can report success while text is silently dropped, instead of the generic compositor wording. Refs #16 --- src/vocalinux/text_injection/text_injector.py | 25 ++++++++++++----- tests/test_text_injector_ext.py | 28 ++++++++++++++++++- 2 files changed, 45 insertions(+), 8 deletions(-) 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/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 -- 2.54.0 From 51ecd686d88b31fc6f5b88d0b2a98ef670e61e70 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 19:56:53 +0530 Subject: [PATCH 3/6] fix(update): check the Voculinux release server, never upstream Source installs look up releases on the Gitea repository (git.bongbetic.com/xavierk/voculinux) through its JSON API and parse tag_name, html_url and body. The GitHub API and the HTML scrape fallback are removed, and only that Gitea repository's release pages are trusted links. Packaged installs (the xbps marker) make no release request at all: fetch_latest_release returns before importing requests, and Settings -> About shows that updates arrive via sudo xbps-install -Su. Refs #16, #27 --- docs/UPDATE.md | 4 +- src/vocalinux/ui/settings_about.py | 6 +- src/vocalinux/ui/settings_dialog.py | 12 +- src/vocalinux/ui/tray_indicator.py | 2 +- src/vocalinux/utils/packaging.py | 17 +- src/vocalinux/utils/update_checker.py | 141 +++++----------- src/vocalinux/utils/update_monitor.py | 2 +- tests/test_packaged_install.py | 6 +- tests/test_settings_dialog.py | 12 +- tests/test_update_checker.py | 231 +++++++++++--------------- 10 files changed, 186 insertions(+), 247 deletions(-) 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/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_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_update_checker.py b/tests/test_update_checker.py
index 90607c5..fd7767d 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,
@@ -72,7 +74,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 +88,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 +105,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 +154,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 +171,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 +201,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 +210,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 +236,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 +248,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 +277,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 +323,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=""):
-- 
2.54.0


From c3c7f981177d8541823c568c44a4695ced2011a8 Mon Sep 17 00:00:00 2001
From: Xavier Karma 
Date: Fri, 6 Nov 2026 19:58:32 +0530
Subject: [PATCH 4/6] fix(legacy): point a migrated dictionary path at the
 copied file

The one-time import copied config.json and dictionary.txt, but a stored
dictionary path that still named the legacy file kept pointing there, so
dictionary edits were written back to the legacy location. After the copy,
the new configuration copy now has its dictionary path rewritten when it
names a legacy file copied in this run. Only the new copy is written, and
a path chosen outside the legacy directory is kept as stored.

Refs #18
---
 src/vocalinux/legacy_import.py | 56 +++++++++++++++++++++++++
 tests/test_legacy_import.py    | 76 ++++++++++++++++++++++++++++++++++
 2 files changed, 132 insertions(+)

diff --git a/src/vocalinux/legacy_import.py b/src/vocalinux/legacy_import.py
index 760b71e..1383874 100644
--- a/src/vocalinux/legacy_import.py
+++ b/src/vocalinux/legacy_import.py
@@ -8,11 +8,17 @@ 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 a legacy file copied in this run is
+rewritten in the copied configuration to the new location, so edits never land
+in the legacy file. Any other path (a location the user chose elsewhere) is kept.
 """
 
+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 +83,50 @@ def _copy_once(src: str, dst: str) -> bool:
     return True
 
 
+def _copied_destination(stored: str, copied: dict[str, str]) -> Optional[str]:
+    """Return the new location of the copied legacy file ``stored`` names, if any."""
+    try:
+        target = os.path.realpath(os.path.expanduser(stored))
+    except (OSError, ValueError):
+        return None
+    for source, destination in copied.items():
+        if target == os.path.realpath(source):
+            return destination
+    return None
+
+
+def _repoint_dictionary_path(config_file: str, copied: dict[str, str]) -> None:
+    """Rewrite the copied config's dictionary path when it names a copied legacy 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 = _copied_destination(stored, copied)
+    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,11 +146,13 @@ def import_legacy_once() -> list[str]:
         return []
 
     imported: list[str] = []
+    copied: dict[str, str] = {}
     for filename in CONFIG_PAYLOAD:
         src = os.path.join(legacy_config, filename)
         dst = os.path.join(new_config_dir(), filename)
         if _copy_once(src, dst):
             imported.append(dst)
+            copied[src] = dst
     legacy_data = legacy_data_dir()
     if os.path.isdir(legacy_data):
         for filename in DATA_PAYLOAD:
@@ -108,6 +160,10 @@ def import_legacy_once() -> list[str]:
             dst = os.path.join(new_data_dir(), filename)
             if _copy_once(src, dst):
                 imported.append(dst)
+                copied[src] = dst
+    copied_config = os.path.join(new_config_dir(), CONFIG_FILENAME)
+    if copied_config in imported:
+        _repoint_dictionary_path(copied_config, copied)
     if imported:
         logger.info("Legacy Vocalinux import complete: %d file(s)", len(imported))
     return imported
diff --git a/tests/test_legacy_import.py b/tests/test_legacy_import.py
index 6e99ced..a0a4bf6 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,76 @@ 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()
+
+    new_config_dir = config_root / "voculinux"
+    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"))
+    return seeded["dictionary.txt"]
+
+
+@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"
-- 
2.54.0


From 15a527beb1e50db4d69b36c73e46aee2ff4a760d Mon Sep 17 00:00:00 2001
From: Xavier Karma 
Date: Fri, 6 Nov 2026 20:02:44 +0530
Subject: [PATCH 5/6] fix(legacy): repoint a stored dictionary path even when
 no file was copied

A stored dictionary path to an importer-owned legacy file was rewritten
only when this run copied that file. A user who never created a legacy
dictionary, or who already has a new dictionary.txt, kept the legacy path,
and the first edit was written back into the legacy directory. The path is
now rewritten to the file's new location whenever it names a payload file,
copied this run or not. Paths outside the legacy payload are kept as stored.

Refs #18
---
 src/vocalinux/legacy_import.py | 41 ++++++++++++-----------
 tests/test_legacy_import.py    | 60 ++++++++++++++++++++++++++++++++--
 2 files changed, 80 insertions(+), 21 deletions(-)

diff --git a/src/vocalinux/legacy_import.py b/src/vocalinux/legacy_import.py
index 1383874..466535f 100644
--- a/src/vocalinux/legacy_import.py
+++ b/src/vocalinux/legacy_import.py
@@ -9,9 +9,11 @@ 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 a legacy file copied in this run is
-rewritten in the copied configuration to the new location, so edits never land
-in the legacy file. Any other path (a location the user chose elsewhere) is kept.
+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
@@ -83,20 +85,20 @@ def _copy_once(src: str, dst: str) -> bool:
     return True
 
 
-def _copied_destination(stored: str, copied: dict[str, str]) -> Optional[str]:
-    """Return the new location of the copied legacy file ``stored`` names, if any."""
+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 copied.items():
+    for source, destination in payload.items():
         if target == os.path.realpath(source):
             return destination
     return None
 
 
-def _repoint_dictionary_path(config_file: str, copied: dict[str, str]) -> None:
-    """Rewrite the copied config's dictionary path when it names a copied legacy file.
+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.
     """
@@ -112,7 +114,7 @@ def _repoint_dictionary_path(config_file: str, copied: dict[str, str]) -> None:
     stored = dictionary.get("file_path")
     if not isinstance(stored, str) or not stored.strip():
         return
-    destination = _copied_destination(stored, copied)
+    destination = _payload_destination(stored, payload)
     if destination is None:
         return
     dictionary["file_path"] = destination
@@ -146,24 +148,25 @@ def import_legacy_once() -> list[str]:
         return []
 
     imported: list[str] = []
-    copied: dict[str, 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)
-            copied[src] = 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)
-                copied[src] = 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, copied)
+        _repoint_dictionary_path(copied_config, payload)
     if imported:
         logger.info("Legacy Vocalinux import complete: %d file(s)", len(imported))
     return imported
diff --git a/tests/test_legacy_import.py b/tests/test_legacy_import.py
index a0a4bf6..721dae5 100644
--- a/tests/test_legacy_import.py
+++ b/tests/test_legacy_import.py
@@ -169,13 +169,25 @@ def _import_with_stored_dictionary_path(
         encoding="utf-8",
     )
     import_legacy_once()
+    _point_config_manager_at(monkeypatch, config_root / "voculinux")
+    return seeded["dictionary.txt"]
 
-    new_config_dir = config_root / "voculinux"
+
+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"))
-    return seeded["dictionary.txt"]
+
+
+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"])
@@ -221,3 +233,47 @@ def test_dictionary_path_outside_the_legacy_directory_is_kept(monkeypatch, tmp_p
     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"
-- 
2.54.0


From 474b52228102d65dccaf7e9f8beddaab0bf324af Mon Sep 17 00:00:00 2001
From: Xavier Karma 
Date: Fri, 6 Nov 2026 20:02:44 +0530
Subject: [PATCH 6/6] test(update): isolate the update checker tests from a
 packaged marker

The update checker tests now patch the packaged-install check, so they do
not depend on /usr/share/voculinux/packaged being absent on the host. The
model ladder test for an unrequested cuda-without-toolkit order is dropped.

Refs #16, #20
---
 tests/test_model_ladder.py   | 4 ----
 tests/test_update_checker.py | 6 ++++++
 2 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/tests/test_model_ladder.py b/tests/test_model_ladder.py
index e553618..674be9f 100644
--- a/tests/test_model_ladder.py
+++ b/tests/test_model_ladder.py
@@ -266,10 +266,6 @@ class TestBuildAwareBackendOrder:
         with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
             assert preferred_backend_order("cuda") == ("cuda", "cpu")
 
-    def test_cuda_build_without_toolkit_has_no_cuda_rung(self, no_env_toolkit: Path) -> None:
-        with patch.object(model_ladder, "_run_probe", side_effect=_probe_stub(None)):
-            assert preferred_backend_order("cuda") == ("cpu",)
-
     def test_cpu_build_is_cpu_only(self) -> None:
         assert preferred_backend_order("cpu") == ("cpu",)
 
diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py
index fd7767d..77daca6 100644
--- a/tests/test_update_checker.py
+++ b/tests/test_update_checker.py
@@ -18,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"
-- 
2.54.0