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:
Jatin K Malik
2026-08-08 00:04:39 -07:00
committed by GitHub
co-authored by Cursor Agent Jatin K Malik
parent 4a1d3ed9df
commit 06e8809c2a
8 changed files with 685 additions and 16 deletions
+2
View File
@@ -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": "",
},
}
+80
View File
@@ -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)
+108 -16
View File
@@ -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
+197
View File
@@ -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()