fix(updates): follow the build channel and allow turning update checks off (#999)

Use the running build channel for background checks and add a preference to disable them.

Fixes #977
This commit is contained in:
Jatin K Malik
2026-10-21 00:26:09 -07:00
committed by GitHub
parent 1c299d2dc7
commit e53fc2ceb0
11 changed files with 422 additions and 32 deletions
+2 -2
View File
@@ -239,8 +239,8 @@ DEFAULT_CONFIG = {
"whispercpp_gpu_device": None, # None = auto-select discrete GPU; int = specific device index
},
"updates": {
# "stable" follows GitHub /releases/latest; "nightly" follows nightly-YYYY-MM-DD tags.
"channel": "stable",
# Background checks control the tray entry and desktop notification.
"check_automatically": True,
# Tag last announced via desktop notification (avoids re-notifying every 6h).
"last_notified_version": "",
},
+29 -7
View File
@@ -9,7 +9,11 @@ import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa: E402
from ..utils.update_checker import is_trusted_release_url, normalize_channel
from ..utils.update_checker import (
DEFAULT_CHECK_AUTOMATICALLY,
build_channel,
is_trusted_release_url,
)
from ..version import __copyright__, __url__, __version__
logger = logging.getLogger(__name__)
@@ -283,6 +287,7 @@ class AboutSectionMixin(Gtk.Dialog):
about_tab = getattr(self, "about_tab")
config_manager = getattr(self, "config_manager")
on_check_updates_clicked = getattr(self, "_on_check_updates_clicked")
on_update_checks_toggled = getattr(self, "_on_update_checks_toggled")
on_update_channel_changed = getattr(self, "_on_update_channel_changed")
on_open_release_clicked = getattr(self, "_on_open_release_clicked")
@@ -291,7 +296,6 @@ class AboutSectionMixin(Gtk.Dialog):
from ..utils.resource_manager import ResourceManager
from .settings_dialog import (
_CONTROL_WIDTH,
DEFAULT_UPDATE_CHANNEL,
PreferenceRow,
PreferencesGroup,
_combo_chrome,
@@ -427,12 +431,33 @@ class AboutSectionMixin(Gtk.Dialog):
)
about_tab.pack_start(talk_group, False, False, 0)
build = build_channel()
updates_group = PreferencesGroup(
title="Updates",
description="Stable for numbered releases. Nightly for dated development builds.",
description=(
f"This is a {build} build, so background checks only look for "
f"{build} releases. Pick a channel below to look one up by hand."
),
keywords=("update", "version", "release", "upgrade", "appimage", "nightly", "channel"),
)
self.update_checks_switch = Gtk.Switch()
self.update_checks_switch.set_active(
config_manager.get_bool("updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY)
)
self.update_checks_switch.connect("state-set", on_update_checks_toggled)
updates_group.add_row(
PreferenceRow(
title="Check for updates automatically",
subtitle=(
"Look for new releases in the background and show a tray entry and a "
"notification when one is out"
),
widget=self.update_checks_switch,
keywords=("update", "notification", "automatic", "check", "disable"),
)
)
self.update_channel_combo = Gtk.ComboBoxText()
self.update_channel_combo.append("stable", "Stable")
self.update_channel_combo.append("nightly", "Nightly")
@@ -443,10 +468,7 @@ class AboutSectionMixin(Gtk.Dialog):
"nightly-YYYY-MM-DD build."
),
)
saved_channel = normalize_channel(
config_manager.get_str("updates", "channel", DEFAULT_UPDATE_CHANNEL)
)
self.update_channel_combo.set_active_id(saved_channel)
self.update_channel_combo.set_active_id(build_channel())
self.check_updates_btn = Gtk.Button(label="Check")
self.check_updates_btn.get_style_context().add_class("suffix-button")
+40 -11
View File
@@ -74,8 +74,10 @@ from ..utils.system_language import ( # noqa: E402
language_for_active_layout,
)
from ..utils.update_checker import ( # noqa: E402
DEFAULT_CHECK_AUTOMATICALLY,
DEFAULT_UPDATE_CHANNEL,
ReleaseInfo,
build_channel,
fetch_latest_release,
format_release_notes,
is_trusted_release_url,
@@ -2452,12 +2454,13 @@ class SettingsDialog(AboutSectionMixin):
initial_page: Optional[str] = None,
pending_update: Optional[ReleaseInfo] = None,
update_status_callback: Optional[Callable[..., Any]] = None,
update_checks_callback: Optional[Callable[[bool], None]] = None,
overlay_enabled_callback: Optional[Callable[[bool], None]] = None,
hotkey_listener_update_callback: Optional[Callable[[], None]] = None,
language_shortcuts_update_callback: Optional[Callable[[], None]] = None,
history_update_callback: Optional[Callable[[], None]] = None,
history_clear_callback: Optional[Callable[[], None]] = None,
):
) -> None:
super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0)
# Force window decorations (title-bar close) on all WMs. An in-window
# Close button also lives in the sidebar footer so the dialog always
@@ -2470,6 +2473,7 @@ class SettingsDialog(AboutSectionMixin):
self.dictionary_manager = getattr(speech_engine, "dictionary_manager", None)
self.shortcut_update_callback = shortcut_update_callback
self.update_status_callback = update_status_callback
self.update_checks_callback = update_checks_callback
self.overlay_enabled_callback = overlay_enabled_callback
self.hotkey_listener_update_callback = hotkey_listener_update_callback
self.language_shortcuts_update_callback = language_shortcuts_update_callback
@@ -2836,14 +2840,21 @@ class SettingsDialog(AboutSectionMixin):
if row is not None:
self.settings_stack.set_visible_child_name(row.page_name)
def _on_settings_page_changed(self, stack, pspec):
def _on_settings_page_changed(self, stack: Gtk.Stack, pspec: Any) -> None:
"""Persist deferred edits when the visible settings page changes."""
visible = stack.get_visible_child_name()
if visible != "advanced":
self._flush_advanced_prompt_if_dirty()
if visible == "about" and not self._update_auto_checked:
self._update_auto_checked = True
self._start_update_check()
if self.config_manager.get_bool(
"updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY
):
self._start_update_check()
else:
self.update_status_row.set_subtitle(
"Automatic checks are off. Press Check to look now."
)
def _open_web_url(self, url: str) -> None:
"""Open a trusted project URL in the user's default browser."""
@@ -6345,13 +6356,25 @@ class SettingsDialog(AboutSectionMixin):
channel_id = self.update_channel_combo.get_active_id()
return normalize_channel(channel_id or DEFAULT_UPDATE_CHANNEL)
def _on_update_channel_changed(self, widget):
"""Persist channel choice and refresh the update check."""
def _on_update_checks_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
"""Persist automatic update-check preference and notify the tray."""
if _handlers_suppressed(self):
return False
enabled = bool(state)
self.config_manager.set("updates", "check_automatically", enabled)
self.config_manager.save_settings()
if self.update_checks_callback is not None:
try:
self.update_checks_callback(enabled)
except Exception:
logger.error("Automatic update checks callback failed", exc_info=True)
return False
def _on_update_channel_changed(self, widget: Gtk.ComboBoxText) -> None:
"""Refresh the manual update lookup for the selected channel."""
if _handlers_suppressed(self):
return
channel = self._current_update_channel()
self.config_manager.set("updates", "channel", channel)
self.config_manager.save_settings()
self._start_update_check()
def _on_check_updates_clicked(self, widget):
@@ -6399,10 +6422,10 @@ class SettingsDialog(AboutSectionMixin):
def _apply_update_check_result(
self,
release,
release: Optional[ReleaseInfo],
channel: Optional[str] = None,
generation: Optional[int] = None,
):
) -> bool:
"""Update About-page UI from a release lookup result."""
if not self._dialog_is_alive():
return False
@@ -6467,7 +6490,13 @@ class SettingsDialog(AboutSectionMixin):
self._set_about_update_badge(False)
# Keep the tray menu in sync with an About-page check (no extra notification).
if self.update_status_callback is not None:
if (
channel == build_channel()
and self.config_manager.get_bool(
"updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY
)
and self.update_status_callback is not None
):
try:
self.update_status_callback(update_available, release if update_available else None)
except Exception:
+17 -5
View File
@@ -49,7 +49,7 @@ from ..speech_recognition.diarization import (
from ..suspend_handler import SuspendHandler
from ..utils.host_process import host_env
from ..utils.resource_manager import ResourceManager
from ..utils.update_checker import ReleaseInfo
from ..utils.update_checker import DEFAULT_CHECK_AUTOMATICALLY, ReleaseInfo
from ..utils.update_monitor import UpdateMonitor
from ..utils.whispercpp_model_info import TDRZ_MODEL, WHISPERCPP_MODEL_INFO, is_model_downloaded
from . import notifications
@@ -288,7 +288,7 @@ class TrayIndicator:
# Background GitHub update checks (tray menu + optional notification)
self._update_monitor = UpdateMonitor(
get_channel=self._get_update_channel,
get_enabled=self._update_checks_enabled,
on_result=self._on_update_check_result,
)
self._update_monitor.start()
@@ -1343,6 +1343,7 @@ class TrayIndicator:
update_status_callback=lambda available, release: self._apply_update_status(
available, release, notify=False
),
update_checks_callback=self._on_update_checks_toggled,
overlay_enabled_callback=self.set_overlay_enabled,
hotkey_listener_update_callback=self._setup_keyboard_shortcuts,
language_shortcuts_update_callback=self.refresh_language_shortcuts,
@@ -1364,9 +1365,18 @@ class TrayIndicator:
if self._settings_dialog is dialog:
self._settings_dialog = None
def _get_update_channel(self) -> str:
"""Return the configured release channel for background update checks."""
return self.config_manager.get_str("updates", "channel", "stable")
def _update_checks_enabled(self) -> bool:
"""Return whether background update checks are enabled."""
return self.config_manager.get_bool(
"updates", "check_automatically", DEFAULT_CHECK_AUTOMATICALLY
)
def _on_update_checks_toggled(self, enabled: bool) -> None:
"""Update tray state when automatic update checks change."""
if not enabled:
self._apply_update_status(False, None, notify=False)
else:
self._update_monitor.check_now()
def _on_update_check_result(self, available: bool, release: Optional[ReleaseInfo]):
"""Handle a background update-check result (already on the GLib main loop)."""
@@ -1403,6 +1413,8 @@ class TrayIndicator:
def _maybe_notify_update(self, release: ReleaseInfo) -> None:
"""Send one desktop notification per new version when notifications are on."""
if not self._update_checks_enabled():
return
if not self.config_manager.get_bool("ui", "show_notifications", True):
return
if self.config_manager.get_str("updates", "last_notified_version", "") == release.tag_name:
+9
View File
@@ -6,6 +6,7 @@ from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlparse
from .. import version as _version_module
from ..version import __url__
from .proxy import requests_proxies
@@ -13,6 +14,7 @@ logger = logging.getLogger(__name__)
UPDATE_CHANNELS = ("stable", "nightly")
DEFAULT_UPDATE_CHANNEL = "stable"
DEFAULT_CHECK_AUTOMATICALLY = True
_GITHUB_REPO_RE = re.compile(r"https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$")
_PRE_RELEASE_SUFFIX_RE = re.compile(
@@ -111,6 +113,13 @@ def _nightly_date(tag_or_version: str) -> Optional[str]:
return f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}"
def build_channel(version: Optional[str] = None) -> str:
"""Return the channel for this build; nightly builds carry .devYYYYMMDD from CI."""
if version is None:
version = _version_module.__version__
return "nightly" if _nightly_date(version) is not None else "stable"
def is_update_available(
current: str, latest: ReleaseInfo, channel: str = DEFAULT_UPDATE_CHANNEL
) -> bool:
+40 -4
View File
@@ -12,8 +12,10 @@ from typing import Callable, Optional
from ..version import __version__
from .update_checker import (
DEFAULT_CHECK_AUTOMATICALLY,
DEFAULT_UPDATE_CHANNEL,
ReleaseInfo,
build_channel,
fetch_latest_release,
is_update_available,
normalize_channel,
@@ -31,8 +33,11 @@ class UpdateMonitor:
Args:
get_channel: Returns ``stable`` / ``nightly`` (re-read each check).
When None, the running build determines the channel.
on_result: ``on_result(available, release)`` on the GLib main loop.
``release`` is set only when ``available`` is True.
get_enabled: Returns whether automatic update checks are enabled.
When None, automatic checks are always enabled.
startup_delay_seconds: Delay before the first check.
check_interval_seconds: Delay between subsequent checks.
use_glib: Schedule via GLib (False = tests drive ``_run_check``).
@@ -40,14 +45,16 @@ class UpdateMonitor:
def __init__(
self,
get_channel: Callable[[], str],
get_channel: Optional[Callable[[], str]] = None,
on_result: Optional[Callable[[bool, Optional[ReleaseInfo]], None]] = None,
get_enabled: Optional[Callable[[], bool]] = None,
startup_delay_seconds: float = DEFAULT_STARTUP_DELAY_SECONDS,
check_interval_seconds: float = DEFAULT_CHECK_INTERVAL_SECONDS,
use_glib: bool = True,
):
) -> None:
self._get_channel = get_channel
self._on_result = on_result
self._get_enabled = get_enabled
self._startup_delay = max(0.0, float(startup_delay_seconds))
self._check_interval = max(60.0, float(check_interval_seconds))
self._use_glib = use_glib
@@ -103,7 +110,7 @@ class UpdateMonitor:
pass
self._timeout_id = None
def _on_timer(self):
def _on_timer(self) -> bool:
self._timeout_id = None
if self._running:
self._run_check()
@@ -112,6 +119,11 @@ class UpdateMonitor:
def _run_check(self) -> None:
if self._check_in_progress:
return
if not self._is_enabled():
logger.debug("Automatic update checks are off")
if self._running:
self._schedule(self._check_interval)
return
self._check_in_progress = True
self._generation += 1
generation = self._generation
@@ -145,13 +157,32 @@ class UpdateMonitor:
)
def _current_channel(self) -> str:
"""Read and normalize the live channel preference."""
"""Read the live channel preference or infer the running build's channel."""
try:
if self._get_channel is None:
return build_channel(__version__)
return normalize_channel(self._get_channel())
except Exception:
logger.error("Failed to read update channel", exc_info=True)
return DEFAULT_UPDATE_CHANNEL
def _is_enabled(self) -> bool:
"""Return whether automatic update checks are enabled."""
if self._get_enabled is None:
return True
try:
return bool(self._get_enabled())
except Exception:
logger.error("Failed to read automatic update setting", exc_info=True)
return DEFAULT_CHECK_AUTOMATICALLY
def check_now(self) -> None:
"""Run an update check now when the monitor is active and enabled."""
if not self._running or self._check_in_progress or not self._is_enabled():
return
self._cancel_timeout()
self._run_check()
def _finish_check(
self,
generation: int,
@@ -165,6 +196,11 @@ class UpdateMonitor:
self._check_in_progress = False
if not self._running or generation != self._generation:
return False
if callback and not self._is_enabled():
logger.debug("Automatic update checks are off")
if self._running:
self._schedule(self._check_interval)
return False
# Drop results from an earlier channel (About-page checks do the same).
if callback:
current_channel = self._current_channel()
+10 -1
View File
@@ -77,15 +77,24 @@ class TestConfigManager(unittest.TestCase):
self.logger_patcher.stop()
self.temp_dir.cleanup()
def test_init_default_config(self):
def test_init_default_config(self) -> None:
"""Test initialization with default configuration."""
config_manager = ConfigManager()
self.assertEqual(config_manager.config, DEFAULT_CONFIG)
self.assertTrue(DEFAULT_CONFIG["updates"]["check_automatically"])
self.mock_logger.info.assert_called_with(
f"Config file not found at {self.temp_config_file}. Using defaults."
)
self.assertTrue(os.path.exists(self.temp_config_dir))
def test_old_update_channel_config_loads_with_automatic_checks_enabled(self) -> None:
self._write_config({"updates": {"channel": "nightly"}})
config_manager = ConfigManager()
self.assertTrue(config_manager.get_bool("updates", "check_automatically"))
self.assertEqual(config_manager.config["updates"]["channel"], "nightly")
def test_paste_long_text_defaults_to_off_and_can_be_changed(self) -> None:
config_manager = ConfigManager()
self.assertFalse(DEFAULT_CONFIG["text_injection"]["paste_long_text"])
+64 -1
View File
@@ -1328,7 +1328,7 @@ class TestAboutPage(unittest.TestCase):
cls.source += cls.about_source
cls.about = cls.about_source.split("def _build_about_section")[1].split("\n def ")[0]
def test_three_groups_keep_updater(self):
def test_three_groups_keep_updater(self) -> None:
self.assertIn('title="Vocalinux"', self.about)
self.assertIn('title="Part of VocaHQ"', self.about)
self.assertIn('title="Talk to us"', self.about)
@@ -1336,6 +1336,7 @@ class TestAboutPage(unittest.TestCase):
self.assertIn("What's New", self.about)
self.assertIn("_on_check_updates_clicked", self.about)
self.assertIn("self.update_channel_combo", self.about)
self.assertIn("self.update_checks_switch", self.about)
def test_product_status_and_honest_network_copy(self):
self.assertIn("Available now on Linux (X11 and Wayland)", self.about)
@@ -1582,6 +1583,68 @@ class TestAboutPage(unittest.TestCase):
self.assertIn("_FAMILY_ICON_PX", self.source)
class TestUpdatePreferences(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
source_dir = os.path.join(os.path.dirname(__file__), "..", "src", "vocalinux", "ui")
with open(os.path.join(source_dir, "settings_dialog.py"), encoding="utf-8") as source_file:
cls.settings_source = source_file.read()
with open(os.path.join(source_dir, "settings_about.py"), encoding="utf-8") as source_file:
cls.about_source = source_file.read()
@classmethod
def _method_source(cls, source: str, name: str) -> str:
start = source.index(f" def {name}(")
end = source.find("\n def ", start + 1)
return source[start:end] if end != -1 else source[start:]
def test_update_checks_toggle_persists_and_calls_callback(self) -> None:
method = self._method_source(self.settings_source, "_on_update_checks_toggled")
self.assertIn('self.config_manager.set("updates", "check_automatically", enabled)', method)
self.assertIn("self.config_manager.save_settings()", method)
self.assertIn("self.update_checks_callback(enabled)", method)
self.assertIn(
'logger.error("Automatic update checks callback failed", exc_info=True)', method
)
def test_channel_change_runs_manual_lookup_without_persisting(self) -> None:
method = self._method_source(self.settings_source, "_on_update_channel_changed")
self.assertIn("self._start_update_check()", method)
self.assertNotIn('self.config_manager.set("updates", "channel"', method)
self.assertNotIn("self.config_manager.save_settings()", method)
def test_manual_about_check_syncs_tray_only_for_enabled_build_channel(self) -> None:
method = self._method_source(self.settings_source, "_apply_update_check_result")
tray_sync = method.split("# Keep the tray menu in sync", 1)[1]
guard = tray_sync.split("):", 1)[0]
self.assertIn("channel == build_channel()", guard)
self.assertIn('"check_automatically", DEFAULT_CHECK_AUTOMATICALLY', guard)
self.assertIn("self.update_status_callback(update_available", tray_sync)
def test_about_auto_check_is_skipped_when_disabled(self) -> None:
method = self._method_source(self.settings_source, "_on_settings_page_changed")
self.assertIn('"check_automatically", DEFAULT_CHECK_AUTOMATICALLY', method)
self.assertIn("self._start_update_check()", method)
self.assertIn("Automatic checks are off. Press Check to look now.", method)
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(
"self.update_checks_switch.set_active(", switch_start
)
connect_position = self.about_source.index(
'self.update_checks_switch.connect("state-set", on_update_checks_toggled)',
switch_start,
)
self.assertLess(active_position, connect_position)
self.assertIn('"check_automatically", DEFAULT_CHECK_AUTOMATICALLY', self.about_source)
class TestAboutInk(unittest.TestCase):
"""Ink for About marks follows light vs dark surfaces."""
+54 -1
View File
@@ -325,7 +325,7 @@ class TestTrayIndicator(unittest.TestCase):
self.assertEqual(kwargs.get("initial_page"), "about")
mock_dialog_instance.show.assert_called_once()
def test_update_available_shows_menu_and_opens_about(self):
def test_update_available_shows_menu_and_opens_about(self) -> None:
"""Background update check reveals tray item and opens About with release."""
from vocalinux.utils.update_checker import ReleaseInfo
@@ -375,6 +375,32 @@ class TestTrayIndicator(unittest.TestCase):
self.assertEqual(kwargs.get("initial_page"), "about")
self.assertEqual(kwargs.get("pending_update"), release)
self.assertIn("update_status_callback", kwargs)
self.assertIn("update_checks_callback", kwargs)
def test_update_monitor_follows_build_without_channel_getter(self) -> None:
monitor = self.tray_indicator._update_monitor
self.assertIsNone(monitor._get_channel)
self.assertIs(monitor._get_enabled.__self__, self.tray_indicator)
self.assertIs(
monitor._get_enabled.__func__,
type(self.tray_indicator)._update_checks_enabled,
)
def test_automatic_update_checks_toggle_updates_tray(self) -> None:
menu_item = MagicMock()
self.tray_indicator._update_menu_item = menu_item
self.tray_indicator.menu = MagicMock()
self.tray_indicator._pending_update = MagicMock()
self.tray_indicator._on_update_checks_toggled(False)
self.assertIsNone(self.tray_indicator._pending_update)
menu_item.hide.assert_called_once()
self.tray_indicator._update_monitor.check_now = MagicMock()
self.tray_indicator._on_update_checks_toggled(True)
self.tray_indicator._update_monitor.check_now.assert_called_once_with()
def test_update_notification_not_marked_when_notify_send_missing(self):
"""Do not record last_notified_version if notify-send cannot be spawned."""
@@ -407,6 +433,33 @@ class TestTrayIndicator(unittest.TestCase):
self.assertEqual(recorded_sets, [])
def test_disabled_automatic_checks_do_not_send_notification(self) -> None:
from vocalinux.utils.update_checker import ReleaseInfo
release = ReleaseInfo(
tag_name="v0.99.0",
version="0.99.0",
name="v0.99.0",
html_url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.99.0",
body="Notes",
published_at="2026-08-06T00:00:00Z",
prerelease=False,
channel="stable",
)
def get_bool(section: str, key: str, default: bool = False) -> bool:
if section == "updates" and key == "check_automatically":
return False
return default
self.tray_indicator.config_manager = self.mock_config_manager
self.mock_config_manager.get_bool.side_effect = get_bool
with patch("subprocess.Popen") as mock_popen:
self.tray_indicator._maybe_notify_update(release)
mock_popen.assert_not_called()
def test_update_notification_persists_after_successful_spawn(self):
"""Record last_notified_version only after notify-send is spawned."""
from vocalinux.utils.update_checker import ReleaseInfo
+20
View File
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
from vocalinux.utils.update_checker import (
ReleaseInfo,
build_channel,
fetch_latest_release,
format_release_notes,
is_newer_version,
@@ -42,6 +43,25 @@ class TestNormalizeAndCompare:
class TestChannels:
def test_build_channel_from_version(self) -> None:
cases = (
("0.18.3", "stable"),
("v0.18.3", "stable"),
("0.18.3+flatpak", "stable"),
("0.14.0-beta", "stable"),
("0.18.3.dev20261020+4799f06", "nightly"),
("0.18.3.dev20261020", "nightly"),
)
for version, expected in cases:
assert build_channel(version) == expected
def test_build_channel_reads_version_module_at_call_time(self) -> None:
with patch(
"vocalinux.version.__version__",
"0.18.3.dev20261020+4799f06",
):
assert build_channel() == "nightly"
def test_normalize_channel(self):
assert normalize_channel("stable") == "stable"
assert normalize_channel("NIGHTLY") == "nightly"
+137
View File
@@ -2,6 +2,8 @@
from unittest.mock import MagicMock, patch
import pytest
from vocalinux.utils.update_checker import ReleaseInfo
from vocalinux.utils.update_monitor import UpdateMonitor
@@ -20,6 +22,141 @@ def _release(tag: str = "v0.99.0") -> ReleaseInfo:
class TestUpdateMonitor:
def test_default_channel_follows_running_build(self) -> None:
cases = (
("0.18.3", "stable", _release("v0.99.0")),
(
"0.18.3.dev20261019+abc1234",
"nightly",
_release("nightly-2026-10-20"),
),
)
for version, expected_channel, release in cases:
on_result = MagicMock()
monitor = UpdateMonitor(
get_channel=None,
on_result=on_result,
use_glib=False,
)
monitor.start()
thread_factory = MagicMock()
def start_inline() -> None:
call_args = thread_factory.call_args.kwargs
call_args["target"](*call_args["args"])
thread_factory.return_value.start.side_effect = start_inline
with (
patch("vocalinux.utils.update_monitor.__version__", version),
patch(
"vocalinux.utils.update_monitor.fetch_latest_release",
return_value=release,
) as fetch_mock,
patch("vocalinux.utils.update_monitor.threading.Thread", thread_factory),
):
monitor._run_check()
fetch_mock.assert_called_once_with(channel=expected_channel)
on_result.assert_called_once_with(True, release)
monitor.stop()
def test_disabled_check_does_not_start_worker_or_callback(self) -> None:
on_result = MagicMock()
monitor = UpdateMonitor(
get_enabled=lambda: False,
on_result=on_result,
use_glib=False,
)
monitor.start()
with (
patch("vocalinux.utils.update_monitor.fetch_latest_release") as fetch_mock,
patch("vocalinux.utils.update_monitor.threading.Thread") as thread_mock,
patch.object(monitor, "_schedule") as schedule_mock,
):
monitor._run_check()
fetch_mock.assert_not_called()
thread_mock.assert_not_called()
on_result.assert_not_called()
schedule_mock.assert_called_once_with(monitor._check_interval)
assert not monitor._check_in_progress
monitor.stop()
def test_disabled_after_fetch_drops_result(self) -> None:
enabled = {"value": True}
on_result = MagicMock()
monitor = UpdateMonitor(
get_enabled=lambda: enabled["value"],
on_result=on_result,
use_glib=False,
)
monitor.start()
monitor._check_in_progress = True
enabled["value"] = False
with patch.object(monitor, "_schedule") as schedule_mock:
monitor._finish_check(
monitor._generation,
"stable",
callback=True,
available=True,
release=_release(),
)
on_result.assert_not_called()
schedule_mock.assert_called_once_with(monitor._check_interval)
assert not monitor._check_in_progress
monitor.stop()
def test_enabled_getter_error_defaults_to_enabled(
self, caplog: pytest.LogCaptureFixture
) -> None:
def broken_getter() -> bool:
raise RuntimeError("config unavailable")
monitor = UpdateMonitor(get_enabled=broken_getter, use_glib=False)
monitor.start()
with patch("vocalinux.utils.update_monitor.threading.Thread") as thread_mock:
monitor._run_check()
thread_mock.assert_called_once()
thread_mock.return_value.start.assert_called_once()
assert "Failed to read automatic update setting" in caplog.text
assert caplog.records[0].exc_info is not None
monitor.stop()
def test_check_now_requires_running_and_enabled_monitor(self) -> None:
stopped = UpdateMonitor(use_glib=False)
with patch.object(stopped, "_run_check") as run_check:
stopped.check_now()
run_check.assert_not_called()
disabled = UpdateMonitor(get_enabled=lambda: False, use_glib=False)
disabled.start()
with patch.object(disabled, "_run_check") as run_check:
disabled.check_now()
run_check.assert_not_called()
disabled.stop()
running = UpdateMonitor(use_glib=False)
running.start()
running._check_in_progress = True
running._timeout_id = 456
with patch.object(running, "_run_check") as run_check:
running.check_now()
run_check.assert_not_called()
assert running._timeout_id == 456
running._check_in_progress = False
running._timeout_id = 123
with patch.object(running, "_run_check") as run_check:
running.check_now()
run_check.assert_called_once_with()
assert running._timeout_id is None
running.stop()
def test_start_is_idempotent(self):
monitor = UpdateMonitor(
get_channel=lambda: "stable",