diff --git a/docs/UPDATE.md b/docs/UPDATE.md index 69e3e57..c111900 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -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: diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 020c579..b8c3179 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -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": "", }, } diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index bac1517..7a3903e 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -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) diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 1619180..16b3f8d 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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 diff --git a/src/vocalinux/utils/update_monitor.py b/src/vocalinux/utils/update_monitor.py new file mode 100644 index 0000000..bd40295 --- /dev/null +++ b/src/vocalinux/utils/update_monitor.py @@ -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() diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 426a066..73008bd 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -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 diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 71fa64f..0d6ccc3 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -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 diff --git a/tests/test_update_monitor.py b/tests/test_update_monitor.py new file mode 100644 index 0000000..445b853 --- /dev/null +++ b/tests/test_update_monitor.py @@ -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()