feat(updates): notify when a newer GitHub release is available (#645)
* feat(updates): notify when a newer GitHub release is available Check the configured release channel shortly after startup and every six hours. When an update exists, show a tray menu item and a green New badge on Settings → About, plus one desktop notification per version, so users can open release notes and update themselves. Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> * refactor(updates): slim UpdateMonitor after ponytail review Drop unused APIs (tick/check_now/version injector/last_* state), integer-only GLib scheduling, and the local demo launcher. Keep tray notify-send local to avoid coupling to recognition_manager. Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> * fix(updates): address Bugbot and harden notify persistence Persist last_notified_version only after notify-send spawns successfully, and sync the tray Update Available item from About-page checks without re-notifying. Also merge latest main and cover the new paths in tests. Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> * test(tray): pin config mock for update-notification assertions CI on 3.9/3.10 showed empty MagicMock set call history after a successful notify-send spawn. Pin tray.config_manager to the test mock and record set() via side_effect so persistence checks are reliable across Python versions. Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> * fix(updates): clear pending About update on failed lookup A failed About check hid the New badge but left _pending_update set, so clearing settings search could revive the badge while status still showed the failed lookup. Clear dialog pending state on failure (tray unchanged) and restore the badge via _set_about_update_badge. * fix(updates): ignore stale UpdateMonitor results after channel switch Bind the channel for each background check and discard the callback when the configured channel changed mid-flight, then re-check immediately — matching About-page stale-channel handling so the tray cannot show the wrong channel's update state. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
co-authored by
Cursor Agent
Jatin K Malik
parent
4a1d3ed9df
commit
06e8809c2a
@@ -369,6 +369,8 @@ See the [full changelog](https://github.com/jatinkrmalik/vocalinux/releases/tag/
|
||||
|
||||
## Quick Update
|
||||
|
||||
Vocalinux 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.
|
||||
|
||||
### If You Installed via curl (Recommended)
|
||||
|
||||
Simply re-run the installation command:
|
||||
|
||||
@@ -99,6 +99,8 @@ DEFAULT_CONFIG = {
|
||||
"updates": {
|
||||
# "stable" follows GitHub /releases/latest; "nightly" follows nightly-YYYY-MM-DD tags.
|
||||
"channel": "stable",
|
||||
# Tag last announced via desktop notification (avoids re-notifying every 6h).
|
||||
"last_notified_version": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ from ..speech_recognition.silero_vad import is_silero_available # noqa: E402
|
||||
from ..utils.paths import models_dir # noqa: E402
|
||||
from ..utils.update_checker import ( # noqa: E402
|
||||
DEFAULT_UPDATE_CHANNEL,
|
||||
ReleaseInfo,
|
||||
fetch_latest_release,
|
||||
format_release_notes,
|
||||
is_trusted_release_url,
|
||||
@@ -413,6 +414,15 @@ SETTINGS_CSS = """
|
||||
background-color: alpha(@theme_selected_fg_color, 0.2);
|
||||
}
|
||||
|
||||
.sidebar-update-badge {
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
background-color: #26a269;
|
||||
border-radius: 10px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
.settings-search {
|
||||
margin: 8px 6px 14px 6px;
|
||||
border-radius: 8px;
|
||||
@@ -984,6 +994,7 @@ class SettingsPage:
|
||||
self.extras = []
|
||||
self.sidebar_row = None
|
||||
self.match_count_label = None
|
||||
self.update_badge_label = None
|
||||
|
||||
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
self.box.set_margin_top(16)
|
||||
@@ -1155,6 +1166,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
speech_engine: "SpeechRecognitionManager",
|
||||
shortcut_update_callback: callable = None,
|
||||
initial_page: Optional[str] = None,
|
||||
pending_update: Optional[ReleaseInfo] = None,
|
||||
update_status_callback: callable = None,
|
||||
):
|
||||
super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0)
|
||||
# Force window decorations (title-bar close) on all WMs. An in-window
|
||||
@@ -1166,6 +1179,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.config_manager = config_manager
|
||||
self.speech_engine = speech_engine
|
||||
self.shortcut_update_callback = shortcut_update_callback
|
||||
self.update_status_callback = update_status_callback
|
||||
self._test_active = False
|
||||
self._test_result = ""
|
||||
self._initializing = True # Flag to prevent auto-apply during initialization
|
||||
@@ -1180,6 +1194,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self._update_check_generation = 0
|
||||
self._update_auto_checked = False
|
||||
self._initial_page = initial_page
|
||||
self._pending_update = pending_update
|
||||
|
||||
# Setup CSS styling
|
||||
_setup_css()
|
||||
@@ -1308,6 +1323,10 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.show_all()
|
||||
# Release notes stay hidden until a successful update check.
|
||||
self.release_notes_group.hide()
|
||||
# Re-hide the About "New" badge if show_all revealed it without a pending update.
|
||||
self._set_about_update_badge(self._pending_update is not None)
|
||||
# Seed About from a tray background check (opens with notes already filled).
|
||||
self._seed_pending_update_ui()
|
||||
if self._initial_page:
|
||||
self.navigate_to_page(self._initial_page)
|
||||
else:
|
||||
@@ -1350,6 +1369,12 @@ class SettingsDialog(Gtk.Dialog):
|
||||
label = Gtk.Label(label=page.title, xalign=0)
|
||||
hbox.pack_start(label, True, True, 0)
|
||||
|
||||
update_badge = Gtk.Label(label="New")
|
||||
update_badge.get_style_context().add_class("sidebar-update-badge")
|
||||
update_badge.set_no_show_all(True)
|
||||
update_badge.hide()
|
||||
hbox.pack_end(update_badge, False, False, 0)
|
||||
|
||||
match_label = Gtk.Label(label="")
|
||||
match_label.get_style_context().add_class("sidebar-match-count")
|
||||
match_label.set_no_show_all(True)
|
||||
@@ -1358,6 +1383,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
row.add(hbox)
|
||||
page.sidebar_row = row
|
||||
page.match_count_label = match_label
|
||||
page.update_badge_label = update_badge
|
||||
if page.name == "about" and self._pending_update is not None:
|
||||
update_badge.show()
|
||||
return row
|
||||
|
||||
def _build_search_empty_page(self) -> Gtk.Widget:
|
||||
@@ -1462,6 +1490,10 @@ class SettingsDialog(Gtk.Dialog):
|
||||
page.sidebar_row.set_sensitive(True)
|
||||
page.sidebar_row.show()
|
||||
|
||||
# Badge visibility follows _pending_update (cleared on failed About checks
|
||||
# so a stale New badge cannot reappear when search is cleared).
|
||||
self._set_about_update_badge(self._pending_update is not None)
|
||||
|
||||
# Engine-driven visibility is authoritative; re-apply it in case a
|
||||
# control changed while the filter was active.
|
||||
self._update_engine_specific_ui()
|
||||
@@ -1514,12 +1546,16 @@ class SettingsDialog(Gtk.Dialog):
|
||||
extra.hide()
|
||||
|
||||
if page_matches > 0:
|
||||
if page.update_badge_label is not None:
|
||||
page.update_badge_label.hide()
|
||||
page.match_count_label.set_text(str(page_matches))
|
||||
page.match_count_label.show()
|
||||
page.sidebar_row.set_sensitive(True)
|
||||
if first_match_page is None:
|
||||
first_match_page = page
|
||||
else:
|
||||
if page.update_badge_label is not None:
|
||||
page.update_badge_label.hide()
|
||||
page.match_count_label.hide()
|
||||
page.sidebar_row.set_sensitive(False)
|
||||
|
||||
@@ -3202,6 +3238,30 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.about_tab.pack_start(self.release_notes_group, False, False, 0)
|
||||
self.release_notes_group.hide()
|
||||
|
||||
def _set_about_update_badge(self, visible: bool) -> None:
|
||||
"""Show or hide the green New badge on the About sidebar row."""
|
||||
about_page = next((page for page in self._pages if page.name == "about"), None)
|
||||
if about_page is None or about_page.update_badge_label is None:
|
||||
return
|
||||
# Don't fight the search filter's match-count badges.
|
||||
searching = bool(self.search_entry.get_text().strip())
|
||||
if visible and not searching:
|
||||
about_page.update_badge_label.show()
|
||||
else:
|
||||
about_page.update_badge_label.hide()
|
||||
|
||||
def _seed_pending_update_ui(self) -> None:
|
||||
"""Apply a tray-discovered update to the About page without another fetch."""
|
||||
if self._pending_update is None:
|
||||
return
|
||||
# Avoid an immediate re-check flicker; user can still press Check.
|
||||
self._update_auto_checked = True
|
||||
self._apply_update_check_result(
|
||||
self._pending_update,
|
||||
channel=self._current_update_channel(),
|
||||
generation=None,
|
||||
)
|
||||
|
||||
def _current_update_channel(self) -> str:
|
||||
"""Return the selected update channel id."""
|
||||
channel_id = self.update_channel_combo.get_active_id()
|
||||
@@ -3252,6 +3312,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
def _dialog_is_alive(self) -> bool:
|
||||
"""Return False when the settings dialog has been destroyed."""
|
||||
try:
|
||||
# During __init__ (before map) widgets exist but are not realized yet.
|
||||
if self._initializing:
|
||||
return True
|
||||
return bool(self.get_realized())
|
||||
except Exception:
|
||||
return False
|
||||
@@ -3293,6 +3356,12 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.open_release_btn.set_tooltip_text("Open the Vocalinux project page")
|
||||
self.open_release_btn.get_style_context().remove_class("suggested-action")
|
||||
self.release_notes_group.hide()
|
||||
# Clear dialog pending state so search restore cannot revive the New
|
||||
# badge while About still shows the failed-lookup message. Do not
|
||||
# call update_status_callback — tray state stays as-is (same as
|
||||
# UpdateMonitor on a failed lookup).
|
||||
self._pending_update = None
|
||||
self._set_about_update_badge(False)
|
||||
return False
|
||||
|
||||
self._about_release_url = (
|
||||
@@ -3306,13 +3375,24 @@ class SettingsDialog(Gtk.Dialog):
|
||||
release_label = f"{release_label} (nightly)"
|
||||
|
||||
if update_available:
|
||||
self._pending_update = release
|
||||
self.update_status_row.set_subtitle(
|
||||
f"Update available on {channel} (running {__version__})"
|
||||
)
|
||||
self.open_release_btn.get_style_context().add_class("suggested-action")
|
||||
self._set_about_update_badge(True)
|
||||
else:
|
||||
self._pending_update = None
|
||||
self.update_status_row.set_subtitle(f"Up to date on {channel}")
|
||||
self.open_release_btn.get_style_context().remove_class("suggested-action")
|
||||
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:
|
||||
try:
|
||||
self.update_status_callback(update_available, release if update_available else None)
|
||||
except Exception:
|
||||
logger.error("Update status callback failed", exc_info=True)
|
||||
|
||||
self.latest_release_row.set_subtitle(release_label)
|
||||
self.open_release_btn.set_sensitive(True)
|
||||
|
||||
@@ -38,6 +38,8 @@ from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, T
|
||||
from ..model_keepalive import DEFAULT_IDLE_TIMEOUT_SECONDS, ModelKeepAlive
|
||||
from ..suspend_handler import SuspendHandler
|
||||
from ..utils.resource_manager import ResourceManager
|
||||
from ..utils.update_checker import ReleaseInfo
|
||||
from ..utils.update_monitor import UpdateMonitor
|
||||
from .config_manager import ConfigManager
|
||||
from .keyboard_shortcuts import KeyboardShortcutManager
|
||||
from .settings_dialog import SettingsDialog
|
||||
@@ -141,6 +143,10 @@ class TrayIndicator:
|
||||
self._init_icons()
|
||||
self._validate_resources()
|
||||
|
||||
# Must exist before _init_indicator: tests run idle_add synchronously.
|
||||
self._pending_update: Optional[ReleaseInfo] = None
|
||||
self._update_menu_item = None
|
||||
|
||||
# Initialize the indicator (in the GTK main thread)
|
||||
GLib.idle_add(self._init_indicator)
|
||||
|
||||
@@ -166,6 +172,13 @@ class TrayIndicator:
|
||||
self._model_keepalive.start()
|
||||
self._model_keepalive.bump()
|
||||
|
||||
# Background GitHub update checks (tray menu + optional notification)
|
||||
self._update_monitor = UpdateMonitor(
|
||||
get_channel=self._get_update_channel,
|
||||
on_result=self._on_update_check_result,
|
||||
)
|
||||
self._update_monitor.start()
|
||||
|
||||
# Set up keyboard shortcuts with mode support
|
||||
self._setup_keyboard_shortcuts()
|
||||
|
||||
@@ -287,6 +300,12 @@ class TrayIndicator:
|
||||
self._add_menu_item("Settings", self._on_settings_clicked)
|
||||
self._add_menu_item("View Logs", self._on_logs_clicked)
|
||||
self._add_menu_separator()
|
||||
# Hidden until a background check finds a newer release.
|
||||
self._update_menu_item = self._add_menu_item(
|
||||
"Update Available…", self._on_update_available_clicked
|
||||
)
|
||||
self._update_menu_item.set_no_show_all(True)
|
||||
self._update_menu_item.hide()
|
||||
self._add_menu_item("About", self._on_about_clicked)
|
||||
self._add_menu_item("Quit", self._on_quit_clicked)
|
||||
|
||||
@@ -295,6 +314,11 @@ class TrayIndicator:
|
||||
|
||||
# Show the menu
|
||||
self.menu.show_all()
|
||||
# show_all() would re-show the update item; keep it hidden until needed.
|
||||
if self._pending_update is None:
|
||||
self._update_menu_item.hide()
|
||||
else:
|
||||
self._show_update_menu_item(self._pending_update)
|
||||
|
||||
# Update the UI based on the initial state
|
||||
self._update_ui(RecognitionState.IDLE)
|
||||
@@ -539,33 +563,98 @@ class TrayIndicator:
|
||||
def _on_settings_clicked(self, widget):
|
||||
"""Handle click on the Settings menu item."""
|
||||
logger.debug("Settings clicked")
|
||||
self._show_settings_page(None)
|
||||
|
||||
# Create the settings dialog
|
||||
dialog = SettingsDialog(
|
||||
parent=None, # Or get the main window if available
|
||||
config_manager=self.config_manager,
|
||||
speech_engine=self.speech_engine,
|
||||
shortcut_update_callback=self.update_shortcut,
|
||||
)
|
||||
|
||||
# Connect to the response signal
|
||||
dialog.connect("response", self._on_settings_dialog_response)
|
||||
|
||||
# Show the dialog (non-modal)
|
||||
dialog.show()
|
||||
|
||||
def _show_settings_page(self, page_name: str):
|
||||
"""Open settings focused on a specific sidebar page."""
|
||||
def _show_settings_page(self, page_name: Optional[str]):
|
||||
"""Open settings, optionally focused on a specific sidebar page."""
|
||||
dialog = SettingsDialog(
|
||||
parent=None,
|
||||
config_manager=self.config_manager,
|
||||
speech_engine=self.speech_engine,
|
||||
shortcut_update_callback=self.update_shortcut,
|
||||
initial_page=page_name,
|
||||
pending_update=self._pending_update,
|
||||
# About checks should refresh the tray item without re-notifying.
|
||||
update_status_callback=lambda available, release: self._apply_update_status(
|
||||
available, release, notify=False
|
||||
),
|
||||
)
|
||||
dialog.connect("response", self._on_settings_dialog_response)
|
||||
dialog.show()
|
||||
|
||||
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 _on_update_check_result(self, available: bool, release: Optional[ReleaseInfo]):
|
||||
"""Handle a background update-check result (already on the GLib main loop)."""
|
||||
self._apply_update_status(available, release, notify=True)
|
||||
|
||||
def _apply_update_status(
|
||||
self,
|
||||
available: bool,
|
||||
release: Optional[ReleaseInfo],
|
||||
*,
|
||||
notify: bool,
|
||||
) -> None:
|
||||
"""Update tray pending-update state and optional desktop notification."""
|
||||
self._pending_update = release if available else None
|
||||
if not hasattr(self, "menu"):
|
||||
return
|
||||
if available and release is not None:
|
||||
self._show_update_menu_item(release)
|
||||
if notify:
|
||||
self._maybe_notify_update(release)
|
||||
elif self._update_menu_item is not None:
|
||||
self._update_menu_item.hide()
|
||||
|
||||
def _show_update_menu_item(self, release: ReleaseInfo) -> None:
|
||||
"""Reveal the tray menu entry that opens About / release notes."""
|
||||
if self._update_menu_item is None:
|
||||
return
|
||||
label = f"Update Available ({release.tag_name})…"
|
||||
self._update_menu_item.set_label(label)
|
||||
self._update_menu_item.set_tooltip_text(
|
||||
"Open Settings → About for release notes and download links"
|
||||
)
|
||||
self._update_menu_item.show()
|
||||
|
||||
def _maybe_notify_update(self, release: ReleaseInfo) -> None:
|
||||
"""Send one desktop notification per new version when notifications are on."""
|
||||
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:
|
||||
return
|
||||
# Persist only after notify-send is successfully spawned so a missing
|
||||
# binary does not permanently suppress the alert for this version.
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
subprocess.Popen(
|
||||
[
|
||||
"notify-send",
|
||||
"-i",
|
||||
"software-update-available",
|
||||
"-a",
|
||||
"Vocalinux",
|
||||
"Vocalinux update available",
|
||||
f"{release.tag_name} is ready. Open the tray menu or "
|
||||
"Settings → About for release notes.",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except (FileNotFoundError, OSError) as exc:
|
||||
logger.debug("Could not show update notification: %s", exc)
|
||||
return
|
||||
self.config_manager.set("updates", "last_notified_version", release.tag_name)
|
||||
self.config_manager.save_settings()
|
||||
|
||||
def _on_update_available_clicked(self, widget):
|
||||
"""Open Settings on About so the user can read notes and open the release."""
|
||||
logger.debug("Update Available clicked")
|
||||
self._show_settings_page("about")
|
||||
|
||||
def _on_logs_clicked(self, widget):
|
||||
"""Handle click on the View Logs menu item."""
|
||||
logger.debug("View Logs clicked")
|
||||
@@ -802,6 +891,9 @@ class TrayIndicator:
|
||||
if getattr(self, "_model_keepalive", None) is not None:
|
||||
self._model_keepalive.shutdown()
|
||||
|
||||
if getattr(self, "_update_monitor", None) is not None:
|
||||
self._update_monitor.shutdown()
|
||||
|
||||
self._cleanup_input_monitor()
|
||||
|
||||
# Stop the keyboard shortcut manager
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Background GitHub release checks for Vocalinux.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
|
||||
from ..version import __version__
|
||||
from .update_checker import (
|
||||
DEFAULT_UPDATE_CHANNEL,
|
||||
ReleaseInfo,
|
||||
fetch_latest_release,
|
||||
is_update_available,
|
||||
normalize_channel,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check once shortly after launch, then every six hours while the app runs.
|
||||
DEFAULT_STARTUP_DELAY_SECONDS = 45
|
||||
DEFAULT_CHECK_INTERVAL_SECONDS = 6 * 60 * 60
|
||||
|
||||
|
||||
class UpdateMonitor:
|
||||
"""Periodic update check owned by the tray.
|
||||
|
||||
Args:
|
||||
get_channel: Returns ``stable`` / ``nightly`` (re-read each check).
|
||||
on_result: ``on_result(available, release)`` on the GLib main loop.
|
||||
``release`` is set only when ``available`` is True.
|
||||
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``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
get_channel: Callable[[], str],
|
||||
on_result: Optional[Callable[[bool, Optional[ReleaseInfo]], None]] = None,
|
||||
startup_delay_seconds: float = DEFAULT_STARTUP_DELAY_SECONDS,
|
||||
check_interval_seconds: float = DEFAULT_CHECK_INTERVAL_SECONDS,
|
||||
use_glib: bool = True,
|
||||
):
|
||||
self._get_channel = get_channel
|
||||
self._on_result = on_result
|
||||
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
|
||||
self._running = False
|
||||
self._check_in_progress = False
|
||||
self._timeout_id: Optional[int] = None
|
||||
self._generation = 0
|
||||
|
||||
def start(self) -> None:
|
||||
"""Arm the first check after the startup delay."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
logger.info(
|
||||
"Update monitor started (first check in %.0fs, then every %.0fs)",
|
||||
self._startup_delay,
|
||||
self._check_interval,
|
||||
)
|
||||
self._schedule(self._startup_delay)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Cancel timers and ignore in-flight results."""
|
||||
self._running = False
|
||||
self._cancel_timeout()
|
||||
self._generation += 1
|
||||
logger.info("Update monitor stopped")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Alias for :meth:`stop` (matches other tray helpers)."""
|
||||
self.stop()
|
||||
|
||||
def _schedule(self, delay_seconds: float) -> None:
|
||||
self._cancel_timeout()
|
||||
if not self._use_glib:
|
||||
return
|
||||
try:
|
||||
from gi.repository import GLib
|
||||
except Exception:
|
||||
logger.debug("GLib unavailable; update monitor will not auto-schedule")
|
||||
return
|
||||
# ponytail: integer seconds only — startup/interval are never sub-second.
|
||||
self._timeout_id = GLib.timeout_add_seconds(max(1, int(delay_seconds)), self._on_timer)
|
||||
|
||||
def _cancel_timeout(self) -> None:
|
||||
if self._timeout_id is None:
|
||||
return
|
||||
if self._use_glib:
|
||||
try:
|
||||
from gi.repository import GLib
|
||||
|
||||
GLib.source_remove(self._timeout_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._timeout_id = None
|
||||
|
||||
def _on_timer(self):
|
||||
self._timeout_id = None
|
||||
if self._running:
|
||||
self._run_check()
|
||||
return False
|
||||
|
||||
def _run_check(self) -> None:
|
||||
if self._check_in_progress:
|
||||
return
|
||||
self._check_in_progress = True
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
channel = self._current_channel()
|
||||
threading.Thread(
|
||||
target=self._worker,
|
||||
args=(channel, __version__, generation),
|
||||
daemon=True,
|
||||
name="vocalinux-update-check",
|
||||
).start()
|
||||
|
||||
def _worker(self, channel: str, current: str, generation: int) -> None:
|
||||
try:
|
||||
release = fetch_latest_release(channel=channel)
|
||||
except Exception:
|
||||
logger.warning("Update check failed", exc_info=True)
|
||||
release = None
|
||||
|
||||
if release is None:
|
||||
# Network/API miss — keep any previously shown update affordance.
|
||||
self._finish_check(generation, channel, callback=False)
|
||||
return
|
||||
|
||||
available = is_update_available(current, release, channel)
|
||||
self._finish_check(
|
||||
generation,
|
||||
channel,
|
||||
callback=True,
|
||||
available=available,
|
||||
release=release if available else None,
|
||||
)
|
||||
|
||||
def _current_channel(self) -> str:
|
||||
"""Read and normalize the live channel preference."""
|
||||
try:
|
||||
return normalize_channel(self._get_channel())
|
||||
except Exception:
|
||||
logger.error("Failed to read update channel", exc_info=True)
|
||||
return DEFAULT_UPDATE_CHANNEL
|
||||
|
||||
def _finish_check(
|
||||
self,
|
||||
generation: int,
|
||||
channel: str,
|
||||
*,
|
||||
callback: bool,
|
||||
available: bool = False,
|
||||
release: Optional[ReleaseInfo] = None,
|
||||
) -> None:
|
||||
def _apply() -> bool:
|
||||
self._check_in_progress = False
|
||||
if not self._running or generation != self._generation:
|
||||
return False
|
||||
# Drop results from an earlier channel (About-page checks do the same).
|
||||
if callback:
|
||||
current_channel = self._current_channel()
|
||||
if current_channel != channel:
|
||||
logger.debug(
|
||||
"Ignoring stale update result for channel %s (now %s)",
|
||||
channel,
|
||||
current_channel,
|
||||
)
|
||||
if self._running:
|
||||
self._run_check()
|
||||
return False
|
||||
if callback and self._on_result is not None:
|
||||
try:
|
||||
self._on_result(available, release)
|
||||
except Exception:
|
||||
logger.error("Update monitor callback failed", exc_info=True)
|
||||
if self._running:
|
||||
self._schedule(self._check_interval)
|
||||
return False
|
||||
|
||||
if self._use_glib:
|
||||
try:
|
||||
from gi.repository import GLib
|
||||
|
||||
GLib.idle_add(_apply)
|
||||
return
|
||||
except Exception:
|
||||
logger.debug("GLib idle_add unavailable; applying update result inline")
|
||||
_apply()
|
||||
@@ -773,6 +773,19 @@ class TestSettingsSearch(unittest.TestCase):
|
||||
self.assertIn("self.sidebar_listbox.select_row(page.sidebar_row)", source)
|
||||
self.assertIn("self.settings_stack.set_visible_child_name(page.name)", source)
|
||||
|
||||
def test_failed_update_check_clears_pending_before_hiding_badge(self):
|
||||
"""Failed About lookup must clear _pending_update so search cannot revive New."""
|
||||
source = self._settings_source()
|
||||
fail_block = source.split("if release is None:")[1].split("return False")[0]
|
||||
self.assertIn("self._pending_update = None", fail_block)
|
||||
self.assertIn("self._set_about_update_badge(False)", fail_block)
|
||||
# Search restore must follow _pending_update via the badge helper.
|
||||
restore_body = source.split("def _restore_search_baseline")[1].split("def ")[0]
|
||||
self.assertIn(
|
||||
"self._set_about_update_badge(self._pending_update is not None)",
|
||||
restore_body,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _settings_source():
|
||||
import os
|
||||
|
||||
@@ -277,6 +277,156 @@ 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):
|
||||
"""Background update check reveals tray item and opens About with release."""
|
||||
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/jatinkrmalik/vocalinux/releases/tag/v0.99.0",
|
||||
body="Notes",
|
||||
published_at="2026-08-06T00:00:00Z",
|
||||
prerelease=False,
|
||||
channel="stable",
|
||||
)
|
||||
menu_item = MagicMock()
|
||||
self.tray_indicator._update_menu_item = menu_item
|
||||
self.tray_indicator.menu = MagicMock()
|
||||
# Pin the mock config used by the tray (avoids any patch indirection).
|
||||
self.tray_indicator.config_manager = self.mock_config_manager
|
||||
self.mock_config_manager.get_bool.side_effect = None
|
||||
self.mock_config_manager.get_bool.return_value = True
|
||||
self.mock_config_manager.get_str.side_effect = None
|
||||
self.mock_config_manager.get_str.return_value = ""
|
||||
recorded_sets = []
|
||||
|
||||
def _record_set(section, key, value):
|
||||
recorded_sets.append((section, key, value))
|
||||
return True
|
||||
|
||||
self.mock_config_manager.set.side_effect = _record_set
|
||||
|
||||
with patch("subprocess.Popen") as mock_popen:
|
||||
self.tray_indicator._on_update_check_result(True, release)
|
||||
|
||||
menu_item.set_label.assert_called()
|
||||
self.assertIn("v0.99.0", menu_item.set_label.call_args[0][0])
|
||||
menu_item.show.assert_called_once()
|
||||
mock_popen.assert_called_once()
|
||||
self.assertEqual(self.tray_indicator._pending_update, release)
|
||||
self.assertIn(("updates", "last_notified_version", "v0.99.0"), recorded_sets)
|
||||
self.mock_config_manager.save_settings.assert_called()
|
||||
|
||||
with patch("vocalinux.ui.tray_indicator.SettingsDialog") as mock_dialog_class:
|
||||
mock_dialog_instance = MagicMock()
|
||||
mock_dialog_class.return_value = mock_dialog_instance
|
||||
self.tray_indicator._on_update_available_clicked(None)
|
||||
kwargs = mock_dialog_class.call_args.kwargs
|
||||
self.assertEqual(kwargs.get("initial_page"), "about")
|
||||
self.assertEqual(kwargs.get("pending_update"), release)
|
||||
self.assertIn("update_status_callback", kwargs)
|
||||
|
||||
def test_update_notification_not_marked_when_notify_send_missing(self):
|
||||
"""Do not record last_notified_version if notify-send cannot be spawned."""
|
||||
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/jatinkrmalik/vocalinux/releases/tag/v0.99.0",
|
||||
body="Notes",
|
||||
published_at="2026-08-06T00:00:00Z",
|
||||
prerelease=False,
|
||||
channel="stable",
|
||||
)
|
||||
self.tray_indicator.config_manager = self.mock_config_manager
|
||||
self.tray_indicator._update_menu_item = MagicMock()
|
||||
self.tray_indicator.menu = MagicMock()
|
||||
self.mock_config_manager.get_bool.side_effect = None
|
||||
self.mock_config_manager.get_bool.return_value = True
|
||||
self.mock_config_manager.get_str.side_effect = None
|
||||
self.mock_config_manager.get_str.return_value = ""
|
||||
recorded_sets = []
|
||||
self.mock_config_manager.set.side_effect = (
|
||||
lambda section, key, value: recorded_sets.append((section, key, value)) or True
|
||||
)
|
||||
|
||||
with patch("subprocess.Popen", side_effect=FileNotFoundError("notify-send")):
|
||||
self.tray_indicator._maybe_notify_update(release)
|
||||
|
||||
self.assertEqual(recorded_sets, [])
|
||||
|
||||
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
|
||||
|
||||
release = ReleaseInfo(
|
||||
tag_name="v0.99.0",
|
||||
version="0.99.0",
|
||||
name="v0.99.0",
|
||||
html_url="https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.99.0",
|
||||
body="Notes",
|
||||
published_at="2026-08-06T00:00:00Z",
|
||||
prerelease=False,
|
||||
channel="stable",
|
||||
)
|
||||
self.tray_indicator.config_manager = self.mock_config_manager
|
||||
self.mock_config_manager.get_bool.side_effect = None
|
||||
self.mock_config_manager.get_bool.return_value = True
|
||||
self.mock_config_manager.get_str.side_effect = None
|
||||
self.mock_config_manager.get_str.return_value = ""
|
||||
recorded_sets = []
|
||||
self.mock_config_manager.set.side_effect = (
|
||||
lambda section, key, value: recorded_sets.append((section, key, value)) or True
|
||||
)
|
||||
|
||||
with patch("subprocess.Popen") as mock_popen:
|
||||
self.tray_indicator._maybe_notify_update(release)
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
self.assertEqual(recorded_sets, [("updates", "last_notified_version", "v0.99.0")])
|
||||
self.mock_config_manager.save_settings.assert_called()
|
||||
|
||||
def test_update_cleared_hides_menu_item(self):
|
||||
"""Clearing an update hides the tray menu entry."""
|
||||
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_check_result(False, None)
|
||||
|
||||
self.assertIsNone(self.tray_indicator._pending_update)
|
||||
menu_item.hide.assert_called_once()
|
||||
|
||||
def test_about_callback_syncs_tray_without_notification(self):
|
||||
"""About-page checks update the tray item but do not re-notify."""
|
||||
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/jatinkrmalik/vocalinux/releases/tag/v0.99.0",
|
||||
body="Notes",
|
||||
published_at="2026-08-06T00:00:00Z",
|
||||
prerelease=False,
|
||||
channel="stable",
|
||||
)
|
||||
menu_item = MagicMock()
|
||||
self.tray_indicator._update_menu_item = menu_item
|
||||
self.tray_indicator.menu = MagicMock()
|
||||
|
||||
with patch.object(self.tray_indicator, "_maybe_notify_update") as mock_notify:
|
||||
self.tray_indicator._apply_update_status(True, release, notify=False)
|
||||
|
||||
menu_item.show.assert_called_once()
|
||||
mock_notify.assert_not_called()
|
||||
self.assertEqual(self.tray_indicator._pending_update, release)
|
||||
|
||||
def test_validate_resources_missing_resources_dir(self):
|
||||
"""Test validation when resources directory doesn't exist."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator, _resource_manager
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for the background GitHub update monitor."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vocalinux.utils.update_checker import ReleaseInfo
|
||||
from vocalinux.utils.update_monitor import UpdateMonitor
|
||||
|
||||
|
||||
def _release(tag: str = "v0.99.0") -> ReleaseInfo:
|
||||
return ReleaseInfo(
|
||||
tag_name=tag,
|
||||
version=tag.lstrip("v"),
|
||||
name=tag,
|
||||
html_url=f"https://github.com/jatinkrmalik/vocalinux/releases/tag/{tag}",
|
||||
body="Notes",
|
||||
published_at="2026-08-06T00:00:00Z",
|
||||
prerelease=False,
|
||||
channel="stable",
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateMonitor:
|
||||
def test_start_is_idempotent(self):
|
||||
monitor = UpdateMonitor(
|
||||
get_channel=lambda: "stable",
|
||||
on_result=MagicMock(),
|
||||
use_glib=False,
|
||||
)
|
||||
monitor.start()
|
||||
monitor.start()
|
||||
assert monitor._running
|
||||
monitor.stop()
|
||||
assert not monitor._running
|
||||
|
||||
def test_reports_available_update(self):
|
||||
on_result = MagicMock()
|
||||
release = _release("v0.99.0")
|
||||
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
||||
monitor.start()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=release,
|
||||
),
|
||||
patch("vocalinux.utils.update_monitor.__version__", "0.15.0"),
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", monitor._generation)
|
||||
|
||||
on_result.assert_called_once_with(True, release)
|
||||
monitor.stop()
|
||||
|
||||
def test_reports_up_to_date(self):
|
||||
on_result = MagicMock()
|
||||
release = _release("v0.15.0")
|
||||
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
||||
monitor.start()
|
||||
|
||||
with patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=release,
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", monitor._generation)
|
||||
|
||||
on_result.assert_called_once_with(False, None)
|
||||
monitor.stop()
|
||||
|
||||
def test_failed_fetch_keeps_prior_callback_state(self):
|
||||
on_result = MagicMock()
|
||||
release = _release("v0.99.0")
|
||||
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
||||
monitor.start()
|
||||
|
||||
with patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=release,
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", monitor._generation)
|
||||
on_result.reset_mock()
|
||||
|
||||
with patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=None,
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", monitor._generation)
|
||||
|
||||
on_result.assert_not_called()
|
||||
monitor.stop()
|
||||
|
||||
def test_stale_generation_is_ignored(self):
|
||||
on_result = MagicMock()
|
||||
monitor = UpdateMonitor(get_channel=lambda: "stable", on_result=on_result, use_glib=False)
|
||||
monitor.start()
|
||||
stale = monitor._generation
|
||||
monitor._generation += 1
|
||||
|
||||
with patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=_release(),
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", stale)
|
||||
|
||||
on_result.assert_not_called()
|
||||
monitor.stop()
|
||||
|
||||
def test_stale_channel_result_is_ignored_and_rechecks(self):
|
||||
"""A result for an earlier channel must not update the tray."""
|
||||
on_result = MagicMock()
|
||||
channel = {"value": "stable"}
|
||||
monitor = UpdateMonitor(
|
||||
get_channel=lambda: channel["value"],
|
||||
on_result=on_result,
|
||||
use_glib=False,
|
||||
)
|
||||
monitor.start()
|
||||
generation = monitor._generation
|
||||
|
||||
channel["value"] = "nightly"
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.utils.update_monitor.fetch_latest_release",
|
||||
return_value=_release(),
|
||||
) as fetch_mock,
|
||||
patch.object(monitor, "_run_check") as run_check,
|
||||
):
|
||||
monitor._worker("stable", "0.15.0", generation)
|
||||
|
||||
on_result.assert_not_called()
|
||||
run_check.assert_called_once_with()
|
||||
# Worker itself should not have re-fetched for nightly — only the
|
||||
# deferred _run_check path does that.
|
||||
fetch_mock.assert_called_once_with(channel="stable")
|
||||
monitor.stop()
|
||||
Reference in New Issue
Block a user