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()