diff --git a/README.md b/README.md index 14a12d5..2296d01 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Vocalinux in action. Settings gallery shots may lag the searchable sidebar UI fr About View
- About dialog with version info + About & Updates in Settings Log Viewer
diff --git a/resources/screenshots/about-updates/about-nightly-up-to-date.png b/resources/screenshots/about-updates/about-nightly-up-to-date.png new file mode 100644 index 0000000..51927f1 Binary files /dev/null and b/resources/screenshots/about-updates/about-nightly-up-to-date.png differ diff --git a/resources/screenshots/about-updates/about-nightly-update-available.png b/resources/screenshots/about-updates/about-nightly-update-available.png new file mode 100644 index 0000000..b277cea Binary files /dev/null and b/resources/screenshots/about-updates/about-nightly-update-available.png differ diff --git a/resources/screenshots/about-updates/about-stable-up-to-date.png b/resources/screenshots/about-updates/about-stable-up-to-date.png new file mode 100644 index 0000000..3da47e8 Binary files /dev/null and b/resources/screenshots/about-updates/about-stable-up-to-date.png differ diff --git a/resources/screenshots/about-updates/about-stable-update-available.png b/resources/screenshots/about-updates/about-stable-update-available.png new file mode 100644 index 0000000..4ecae8e Binary files /dev/null and b/resources/screenshots/about-updates/about-stable-update-available.png differ diff --git a/resources/screenshots/about-updates/about-update-states-grid.png b/resources/screenshots/about-updates/about-update-states-grid.png new file mode 100644 index 0000000..04af2c3 Binary files /dev/null and b/resources/screenshots/about-updates/about-update-states-grid.png differ diff --git a/src/vocalinux/ui/about_dialog.py b/src/vocalinux/ui/about_dialog.py deleted file mode 100644 index 962b2a9..0000000 --- a/src/vocalinux/ui/about_dialog.py +++ /dev/null @@ -1,369 +0,0 @@ -""" -About Dialog for Vocalinux. - -A modern, GNOME HIG-compliant about dialog with: -- Clean card-based layout -- App icon and branding -- Version and description -- Credits section with tabs -- Links to GitHub and license - -UX Design Notes: -- Follows GNOME Human Interface Guidelines -- Uses standard Gtk.AboutDialog as base (GNOME apps do this) -- Customizes with better styling and organization -- Close-only button pattern -""" - -import logging -import os - -import gi - -gi.require_version("Gtk", "3.0") -from gi.repository import GdkPixbuf, Gtk # noqa: E402 - -logger = logging.getLogger(__name__) - - -def show_about_dialog(parent: Gtk.Window = None): - """ - Show the About dialog for Vocalinux. - - Uses Gtk.AboutDialog which is the GNOME-standard way to show app info. - This is the recommended approach per GNOME HIG. - - Args: - parent: Parent window for the dialog - """ - from ..utils.resource_manager import ResourceManager - from ..version import __copyright__, __description__, __url__, __version__ - - resource_manager = ResourceManager() - - about_dialog = Gtk.AboutDialog() - about_dialog.set_transient_for(parent) - about_dialog.set_modal(True) - - # Basic info - about_dialog.set_program_name("Vocalinux") - about_dialog.set_version(__version__) - about_dialog.set_copyright(__copyright__) - - # Description with cleaner formatting (no emojis in main comments) - about_dialog.set_comments(__description__) - - # Website - about_dialog.set_website(__url__) - about_dialog.set_website_label("Star on GitHub") - - # License - about_dialog.set_license_type(Gtk.License.GPL_3_0) - - # Credits - about_dialog.set_authors(["Jatin K Malik "]) - about_dialog.set_documenters(["Jatin K Malik"]) - about_dialog.set_artists(["Jatin K Malik"]) - - # Additional credits section - about_dialog.add_credit_section( - "Contributors", - [ - "Open to contributions!", - "github.com/jatinkrmalik/vocalinux", - ], - ) - - about_dialog.add_credit_section( - "Built With", - [ - "VOSK Speech Recognition", - "OpenAI Whisper", - "GTK 3 / PyGObject", - ], - ) - - # Set the logo - logo_path = resource_manager.get_icon_path("vocalinux") - if os.path.exists(logo_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo_path) - # Scale to appropriate size for about dialog - scaled_pixbuf = pixbuf.scale_simple(128, 128, GdkPixbuf.InterpType.BILINEAR) - about_dialog.set_logo(scaled_pixbuf) - except Exception as e: - logger.warning(f"Failed to load or scale logo: {e}") - - # Run and clean up - about_dialog.run() - about_dialog.destroy() - - -class AboutDialog(Gtk.Dialog): - """ - Custom About Dialog with modern GNOME styling. - - This is an alternative to Gtk.AboutDialog for apps that want more - customization. Uses card-based layout with tabs for different sections. - """ - - # CSS for modern styling - ABOUT_CSS = """ - .about-dialog { - background-color: @theme_bg_color; - } - - .about-header { - padding: 32px 24px 24px 24px; - } - - .about-icon { - margin-bottom: 16px; - } - - .about-title { - font-size: 1.5em; - font-weight: bold; - margin-bottom: 4px; - } - - .about-version { - font-size: 0.9em; - color: @theme_unfocused_fg_color; - margin-bottom: 12px; - } - - .about-description { - color: @theme_unfocused_fg_color; - margin-bottom: 16px; - } - - .about-links { - margin-top: 8px; - } - - .about-link-button { - padding: 8px 16px; - border-radius: 8px; - background-color: @theme_selected_bg_color; - color: @theme_selected_fg_color; - } - - .about-link-button:hover { - background-color: shade(@theme_selected_bg_color, 1.1); - } - - .about-section { - background-color: @theme_base_color; - border-radius: 12px; - padding: 16px; - margin: 8px 24px; - border: 1px solid alpha(@borders, 0.5); - } - - .about-section-title { - font-weight: 600; - font-size: 0.85em; - color: @theme_unfocused_fg_color; - margin-bottom: 8px; - } - - .about-credit { - padding: 4px 0; - } - - .about-credit-name { - font-weight: 500; - } - - .about-credit-role { - font-size: 0.85em; - color: @theme_unfocused_fg_color; - } - - .about-footer { - padding: 16px 24px; - color: @theme_unfocused_fg_color; - font-size: 0.85em; - } - - .about-dialog button { - min-height: 32px; - padding: 6px 14px; - } - """ - - def __init__(self, parent: Gtk.Window = None): - from ..utils.resource_manager import ResourceManager - from ..version import __copyright__, __description__, __url__, __version__ - - super().__init__( - title="About Vocalinux", - transient_for=parent, - flags=Gtk.DialogFlags.MODAL, - ) - - self.resource_manager = ResourceManager() - self.version = __version__ - self.description = __description__ - self.url = __url__ - self.copyright = __copyright__ - - # Setup - self._setup_css() - self.set_default_size(400, -1) - self.set_resizable(False) - self.get_style_context().add_class("about-dialog") - - # Only Close button - self.add_button("_Close", Gtk.ResponseType.CLOSE) - - # Build UI - self._build_ui() - - # Connect response - self.connect("response", lambda d, r: d.destroy()) - - self.show_all() - - def _setup_css(self): - """Set up CSS styling.""" - from gi.repository import Gdk - - css_provider = Gtk.CssProvider() - css_provider.load_from_data(self.ABOUT_CSS.encode()) - Gtk.StyleContext.add_provider_for_screen( - Gdk.Screen.get_default(), - css_provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ) - - def _build_ui(self): - """Build the dialog UI.""" - content_area = self.get_content_area() - - # Main container - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - content_area.add(main_box) - - # Header section (icon, name, version, description) - header = self._build_header() - main_box.pack_start(header, False, False, 0) - - # Links section - links = self._build_links() - main_box.pack_start(links, False, False, 0) - - # Credits section - credits_section = self._build_credits() - main_box.pack_start(credits_section, False, False, 0) - - # Footer (copyright, license) - footer = self._build_footer() - main_box.pack_start(footer, False, False, 0) - - def _build_header(self): - """Build the header with icon, name, version.""" - header = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - header.get_style_context().add_class("about-header") - header.set_halign(Gtk.Align.CENTER) - - # App icon - logo_path = self.resource_manager.get_icon_path("vocalinux") - if os.path.exists(logo_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo_path) - scaled = pixbuf.scale_simple(96, 96, GdkPixbuf.InterpType.BILINEAR) - icon = Gtk.Image.new_from_pixbuf(scaled) - icon.get_style_context().add_class("about-icon") - header.pack_start(icon, False, False, 0) - except Exception as e: - logger.warning(f"Failed to load logo: {e}") - - # App name - name_label = Gtk.Label(label="Vocalinux") - name_label.get_style_context().add_class("about-title") - header.pack_start(name_label, False, False, 0) - - # Version - version_label = Gtk.Label(label=self.version) - version_label.get_style_context().add_class("about-version") - header.pack_start(version_label, False, False, 0) - - # Description - desc_label = Gtk.Label(label=self.description) - desc_label.get_style_context().add_class("about-description") - desc_label.set_line_wrap(True) - desc_label.set_max_width_chars(40) - desc_label.set_justify(Gtk.Justification.CENTER) - header.pack_start(desc_label, False, False, 0) - - return header - - def _build_links(self): - """Build the links section.""" - links_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - links_box.set_halign(Gtk.Align.CENTER) - links_box.get_style_context().add_class("about-links") - links_box.set_margin_bottom(16) - - # GitHub button - github_button = Gtk.LinkButton.new_with_label(self.url, "Star on GitHub") - github_button.get_style_context().add_class("about-link-button") - links_box.pack_start(github_button, False, False, 0) - - return links_box - - def _build_credits(self): - """Build the credits section.""" - section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) - section.get_style_context().add_class("about-section") - - # Title - title = Gtk.Label(label="CREDITS", xalign=0) - title.get_style_context().add_class("about-section-title") - section.pack_start(title, False, False, 0) - - # Author - author_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - author_box.get_style_context().add_class("about-credit") - - author_name = Gtk.Label(label="Jatin K Malik", xalign=0) - author_name.get_style_context().add_class("about-credit-name") - author_box.pack_start(author_name, False, False, 0) - - author_role = Gtk.Label(label="Author & Maintainer", xalign=0) - author_role.get_style_context().add_class("about-credit-role") - author_box.pack_start(author_role, False, False, 0) - - section.pack_start(author_box, False, False, 0) - - # Technologies - tech_label = Gtk.Label(xalign=0) - tech_label.set_markup("Built with VOSK, OpenAI Whisper, GTK 3, and Python") - tech_label.get_style_context().add_class("about-credit-role") - tech_label.set_margin_top(8) - section.pack_start(tech_label, False, False, 0) - - return section - - def _build_footer(self): - """Build the footer with copyright and license.""" - footer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - footer.get_style_context().add_class("about-footer") - footer.set_halign(Gtk.Align.CENTER) - - # Copyright - copyright_label = Gtk.Label(label=self.copyright) - copyright_label.set_line_wrap(True) - footer.pack_start(copyright_label, False, False, 0) - - # License - license_label = Gtk.Label() - license_label.set_markup( - '' - "GNU General Public License v3.0" - ) - footer.pack_start(license_label, False, False, 0) - - return footer diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index e9be88f..020c579 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -96,6 +96,10 @@ DEFAULT_CONFIG = { "whispercpp_n_threads": 0, # 0 = auto-detect optimal thread count; set to override "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", + }, } diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 9a37a32..bac1517 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -34,6 +34,14 @@ from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 from ..common_types import RecognitionState # noqa: E402 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, + fetch_latest_release, + format_release_notes, + is_trusted_release_url, + is_update_available, + normalize_channel, +) from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO # noqa: E402 from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES from ..utils.whispercpp_model_info import ( @@ -47,6 +55,7 @@ from ..utils.whispercpp_model_info import get_model_variants as get_whispercpp_m from ..utils.whispercpp_model_info import get_recommended_model as get_recommended_whispercpp_model from ..utils.whispercpp_model_info import is_english_only_model as is_english_only_whispercpp_model from ..utils.whispercpp_model_info import is_model_downloaded as is_whispercpp_model_downloaded +from ..version import __copyright__, __description__, __url__, __version__ # noqa: E402 from .config_manager import DEFAULT_CONFIG # noqa: E402 from .keyboard_backends import ( # noqa: E402 SHORTCUT_DISPLAY_NAMES, @@ -837,7 +846,13 @@ def _row_matches_query(query: str, title: str, subtitle: str = "", keywords=()) class PreferencesGroup(Gtk.Box): """A card-style group of preferences, similar to libadwaita's AdwPreferencesGroup.""" - def __init__(self, title: str = "", description: str = "", keywords=()): + def __init__( + self, + title: str = "", + description: str = "", + keywords=(), + header_icon: Optional[Gtk.Widget] = None, + ): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0) self.get_style_context().add_class("preferences-group") self.title = title @@ -845,17 +860,13 @@ class PreferencesGroup(Gtk.Box): self.keywords = tuple(keywords) self.rows = [] - # Header with title + # Header with title (optional icon aligned to the top-right) if title: - header_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - header_box.set_margin_top(12) - header_box.set_margin_bottom(4) - header_box.set_margin_start(16) - header_box.set_margin_end(16) + text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) title_label = Gtk.Label(label=title, xalign=0) title_label.get_style_context().add_class("preferences-group-title") - header_box.pack_start(title_label, False, False, 0) + text_box.pack_start(title_label, False, False, 0) if description: desc_label = Gtk.Label(label=description, xalign=0, wrap=True) @@ -863,7 +874,23 @@ class PreferencesGroup(Gtk.Box): # Align with the title, which carries 16px CSS padding. desc_label.set_margin_start(16) desc_label.set_margin_end(16) - header_box.pack_start(desc_label, False, False, 0) + text_box.pack_start(desc_label, False, False, 0) + + if header_icon is not None: + header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + header_box.set_margin_top(12) + header_box.set_margin_bottom(4) + header_box.set_margin_start(16) + header_box.set_margin_end(16) + header_box.pack_start(text_box, True, True, 0) + header_icon.set_valign(Gtk.Align.CENTER) + header_box.pack_end(header_icon, False, False, 0) + else: + header_box = text_box + header_box.set_margin_top(12) + header_box.set_margin_bottom(4) + header_box.set_margin_start(16) + header_box.set_margin_end(16) self.pack_start(header_box, False, False, 0) @@ -1127,6 +1154,7 @@ class SettingsDialog(Gtk.Dialog): config_manager: "ConfigManager", speech_engine: "SpeechRecognitionManager", shortcut_update_callback: callable = None, + initial_page: Optional[str] = None, ): super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0) # Force window decorations (title-bar close) on all WMs. An in-window @@ -1147,6 +1175,11 @@ class SettingsDialog(Gtk.Dialog): ) self._applying_settings = False # Flag to prevent recursive settings application self._advanced_prompt_dirty = False + self._about_release_url = "" + self._update_check_in_progress = False + self._update_check_generation = 0 + self._update_auto_checked = False + self._initial_page = initial_page # Setup CSS styling _setup_css() @@ -1183,6 +1216,7 @@ class SettingsDialog(Gtk.Dialog): SettingsPage("performance", "Performance", "power-profile-performance-symbolic"), SettingsPage("application", "Application", "preferences-system-symbolic"), SettingsPage("advanced", "Advanced", "applications-engineering-symbolic"), + SettingsPage("about", "About", "help-about-symbolic"), ] pages_by_name = {page.name: page for page in self._pages} @@ -1195,6 +1229,7 @@ class SettingsDialog(Gtk.Dialog): self.power_tab = pages_by_name["performance"].box self.general_tab = pages_by_name["application"].box self.advanced_tab = pages_by_name["advanced"].box + self.about_tab = pages_by_name["about"].box # Each page is wrapped in a vertical ScrolledWindow: without one, the # stack's minimum height is the tallest page's full content, which @@ -1256,6 +1291,7 @@ class SettingsDialog(Gtk.Dialog): self._build_gpu_section() self._build_general_section() self._build_advanced_section() + self._build_about_section() self._build_sidebar_footer(sidebar_box) # Record searchable groups vs. loose extras per page @@ -1270,7 +1306,12 @@ class SettingsDialog(Gtk.Dialog): # Show everything first self.show_all() - self.sidebar_listbox.select_row(self.sidebar_listbox.get_row_at_index(0)) + # Release notes stay hidden until a successful update check. + self.release_notes_group.hide() + if self._initial_page: + self.navigate_to_page(self._initial_page) + else: + self.sidebar_listbox.select_row(self.sidebar_listbox.get_row_at_index(0)) # Then update visibility of engine-specific elements self._update_engine_specific_ui() @@ -1288,6 +1329,14 @@ class SettingsDialog(Gtk.Dialog): # Navigation: sidebar, stack, and settings search # ------------------------------------------------------------------ + def navigate_to_page(self, page_name: str) -> bool: + """Select a settings page by its internal name (e.g. ``about``).""" + for page in self._pages: + if page.name == page_name and page.sidebar_row is not None: + self.sidebar_listbox.select_row(page.sidebar_row) + return True + return False + def _build_sidebar_row(self, page: SettingsPage) -> Gtk.ListBoxRow: """Build one sidebar navigation row (icon + title + match badge).""" row = Gtk.ListBoxRow() @@ -1337,8 +1386,25 @@ class SettingsDialog(Gtk.Dialog): def _on_settings_page_changed(self, stack, pspec): """Persist deferred edits when the visible settings page changes.""" - if stack.get_visible_child_name() != "advanced": + 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() + + def _open_web_url(self, url: str) -> None: + """Open a trusted project URL in the user's default browser.""" + if not url or not is_trusted_release_url(url): + logger.warning("Refusing to open untrusted URL: %s", url) + return + try: + if hasattr(Gtk, "show_uri_on_window"): + Gtk.show_uri_on_window(self, url, Gdk.CURRENT_TIME) + else: + Gtk.show_uri(None, url, Gdk.CURRENT_TIME) + except Exception as exc: + logger.warning("Failed to open URL %s: %s", url, exc) def _on_dialog_key_press(self, widget, event): """Dialog-level shortcuts: Ctrl+F focuses search, Ctrl+W closes, Esc clears search.""" @@ -3007,6 +3073,261 @@ class SettingsDialog(Gtk.Dialog): self.power_user_switch.connect("state-set", self._on_power_user_toggled) + def _build_about_section(self): + """Build the About page using the same PreferenceRow cards as other pages.""" + from gi.repository import GdkPixbuf + + from ..utils.resource_manager import ResourceManager + + about_icon = None + logo_path = ResourceManager().get_icon_path("vocalinux") + if os.path.exists(logo_path): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo_path) + scaled = pixbuf.scale_simple(48, 48, GdkPixbuf.InterpType.BILINEAR) + about_icon = Gtk.Image.new_from_pixbuf(scaled) + about_icon.set_pixel_size(48) + except Exception as exc: + logger.warning("Failed to load About icon: %s", exc) + + app_group = PreferencesGroup( + title="Vocalinux", + description=__description__, + keywords=("about", "version", "app"), + header_icon=about_icon, + ) + + self.about_version_label = Gtk.Label(label=__version__) + self.about_version_label.set_selectable(True) + self.about_version_label.get_style_context().add_class("preference-row-subtitle") + version_row = PreferenceRow( + title="Version", + subtitle="Currently installed Vocalinux build", + widget=self.about_version_label, + keywords=("version", "build"), + ) + app_group.add_row(version_row) + + website_btn = Gtk.Button(label="Open") + website_btn.set_size_request(100, -1) + website_btn.set_tooltip_text("Open the Vocalinux GitHub page") + website_btn.connect("clicked", lambda *_: self._open_web_url(__url__)) + website_row = PreferenceRow( + title="Website", + subtitle="Source code, issues, and documentation on GitHub", + widget=website_btn, + keywords=("github", "website", "repo"), + ) + app_group.add_row(website_row) + + license_row = PreferenceRow( + title="License", + subtitle=f"GNU GPL v3 · {__copyright__}", + keywords=("license", "gpl", "copyright"), + ) + app_group.add_row(license_row) + self.about_tab.pack_start(app_group, False, False, 0) + + updates_group = PreferencesGroup( + title="Updates", + description="Choose a release channel, then check GitHub for a newer build.", + keywords=("update", "version", "release", "upgrade", "appimage", "nightly", "channel"), + ) + + self.update_channel_combo = Gtk.ComboBoxText() + self.update_channel_combo.set_size_request(_CONTROL_WIDTH, -1) + self.update_channel_combo.append("stable", "Stable") + self.update_channel_combo.append("nightly", "Nightly") + self.update_channel_combo.set_tooltip_text( + "Stable uses the latest numbered release. Nightly uses the newest nightly-YYYY-MM-DD build." + ) + _prevent_scroll_on_hover(self.update_channel_combo) + saved_channel = normalize_channel( + self.config_manager.get_str("updates", "channel", DEFAULT_UPDATE_CHANNEL) + ) + self.update_channel_combo.set_active_id(saved_channel) + channel_row = PreferenceRow( + title="Channel", + subtitle="Stable for releases, Nightly for dated development builds", + widget=self.update_channel_combo, + keywords=("channel", "stable", "nightly", "beta"), + ) + updates_group.add_row(channel_row) + + self.check_updates_btn = Gtk.Button(label="Check") + self.check_updates_btn.set_size_request(100, -1) + self.check_updates_btn.set_tooltip_text( + "Look up the latest release for this channel on GitHub" + ) + self.check_updates_btn.connect("clicked", self._on_check_updates_clicked) + self.update_status_row = PreferenceRow( + title="Status", + subtitle="Not checked yet", + widget=self.check_updates_btn, + keywords=("status", "check"), + ) + updates_group.add_row(self.update_status_row) + + self.open_release_btn = Gtk.Button(label="Open") + self.open_release_btn.set_size_request(100, -1) + self.open_release_btn.set_sensitive(False) + self.open_release_btn.set_tooltip_text( + "Open the release page in your browser for download links and install steps" + ) + self.open_release_btn.connect("clicked", self._on_open_release_clicked) + self.latest_release_row = PreferenceRow( + title="Latest Release", + subtitle="-", + widget=self.open_release_btn, + keywords=("latest", "download", "changelog"), + ) + updates_group.add_row(self.latest_release_row) + self.about_tab.pack_start(updates_group, False, False, 0) + + self.update_channel_combo.connect("changed", self._on_update_channel_changed) + + self.release_notes_group = PreferencesGroup( + title="What's New", + keywords=("changelog", "what's new", "release notes"), + ) + self.release_notes_row = PreferenceRow( + title="Release notes", + subtitle="Notes appear here after a successful update check.", + keywords=("notes", "changelog"), + ) + # Allow a longer wrap than typical preference subtitles. + if self.release_notes_row.subtitle_label is not None: + self.release_notes_row.subtitle_label.set_max_width_chars(72) + self.release_notes_group.add_row(self.release_notes_row) + self.about_tab.pack_start(self.release_notes_group, False, False, 0) + self.release_notes_group.hide() + + def _current_update_channel(self) -> str: + """Return the selected update channel id.""" + 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.""" + if self._initializing or self._applying_settings: + 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): + """Manual re-check from the About page.""" + self._start_update_check() + + def _on_open_release_clicked(self, widget): + """Open the latest or pending release page in the browser.""" + self._open_web_url(self._about_release_url) + + def _start_update_check(self): + """Kick off a background GitHub release lookup.""" + channel = self._current_update_channel() + self._update_check_generation += 1 + generation = self._update_check_generation + self.check_updates_btn.set_sensitive(False) + self.check_updates_btn.set_label("…") + self.update_status_row.set_subtitle(f"Checking GitHub ({channel})…") + + # Invalidate any in-flight result; that worker will restart on completion. + if self._update_check_in_progress: + return + + self._update_check_in_progress = True + threading.Thread( + target=self._update_check_worker, + args=(channel, generation), + daemon=True, + ).start() + + def _update_check_worker(self, channel: str, generation: int): + """Worker thread: fetch latest release for ``channel`` and compare versions.""" + release = fetch_latest_release(channel=channel) + GLib.idle_add(self._apply_update_check_result, release, channel, generation) + + def _dialog_is_alive(self) -> bool: + """Return False when the settings dialog has been destroyed.""" + try: + return bool(self.get_realized()) + except Exception: + return False + + def _apply_update_check_result( + self, + release, + channel: Optional[str] = None, + generation: Optional[int] = None, + ): + """Update About-page UI from a release lookup result.""" + if not self._dialog_is_alive(): + return False + + channel = normalize_channel(channel or self._current_update_channel()) + current_channel = self._current_update_channel() + # Drop stale results after a channel switch (or a newer check request). + if generation is not None and generation != self._update_check_generation: + self._update_check_in_progress = False + self._start_update_check() + return False + if channel != current_channel: + self._update_check_in_progress = False + self._start_update_check() + return False + + self._update_check_in_progress = False + self.check_updates_btn.set_sensitive(True) + self.check_updates_btn.set_label("Check") + + if release is None: + self._about_release_url = __url__ + missing = "nightly build" if channel == "nightly" else "stable release" + self.update_status_row.set_subtitle( + f"Could not find a {missing}. Check your connection, or try again later." + ) + self.latest_release_row.set_subtitle("Unavailable") + self.open_release_btn.set_sensitive(True) + 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() + return False + + self._about_release_url = ( + release.html_url if is_trusted_release_url(release.html_url) else __url__ + ) + update_available = is_update_available(__version__, release, channel) + release_label = release.tag_name + if release.published_at: + release_label = f"{release.tag_name} · {release.published_at[:10]}" + if channel == "nightly" and release.prerelease: + release_label = f"{release_label} (nightly)" + + if update_available: + self.update_status_row.set_subtitle( + f"Update available on {channel} (running {__version__})" + ) + self.open_release_btn.get_style_context().add_class("suggested-action") + else: + self.update_status_row.set_subtitle(f"Up to date on {channel}") + self.open_release_btn.get_style_context().remove_class("suggested-action") + + self.latest_release_row.set_subtitle(release_label) + self.open_release_btn.set_sensitive(True) + self.open_release_btn.set_tooltip_text( + "Open the release page in your browser for download links and install steps" + ) + + notes = format_release_notes(release.body) + # Keep the card readable; full notes remain on the release page. + if len(notes) > 900: + notes = notes[:900].rstrip() + "…" + self.release_notes_row.set_subtitle(notes) + self.release_notes_group.show() + return False + def _build_gpu_section(self): """Build the GPU device group on the Performance page. diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 62e8360..7e95262 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -549,6 +549,18 @@ class TrayIndicator: # Show the dialog (non-modal) dialog.show() + def _show_settings_page(self, page_name: str): + """Open settings 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, + ) + dialog.connect("response", self._on_settings_dialog_response) + dialog.show() + def _on_logs_clicked(self, widget): """Handle click on the View Logs menu item.""" logger.debug("View Logs clicked") @@ -605,10 +617,8 @@ class TrayIndicator: def _on_about_clicked(self, widget): """Handle click on the About menu item.""" - from .about_dialog import show_about_dialog - logger.debug("About clicked") - show_about_dialog(parent=None) + self._show_settings_page("about") def _get_auto_pause_config(self): """Return (enabled, apps, poll_interval_seconds) for AutoPauseMonitor.""" diff --git a/src/vocalinux/utils/update_checker.py b/src/vocalinux/utils/update_checker.py new file mode 100644 index 0000000..6afa88b --- /dev/null +++ b/src/vocalinux/utils/update_checker.py @@ -0,0 +1,260 @@ +"""Check GitHub Releases for newer Vocalinux versions.""" + +import logging +import re +from dataclasses import dataclass +from typing import Optional +from urllib.parse import urlparse + +from ..version import __url__ + +logger = logging.getLogger(__name__) + +UPDATE_CHANNELS = ("stable", "nightly") +DEFAULT_UPDATE_CHANNEL = "stable" + +_GITHUB_REPO_RE = re.compile(r"https?://github\.com/(?P[^/]+)/(?P[^/]+?)(?:\.git)?/?$") +_PRE_RELEASE_SUFFIX_RE = re.compile( + r"^(?P
\d+(?:\.\d+)*)(?:[-.](?P
(?:alpha|beta|rc|pre|dev)\.?\d*))?$",
+    re.IGNORECASE,
+)
+_NIGHTLY_TAG_RE = re.compile(r"^nightly-(\d{4}-\d{2}-\d{2})$", re.IGNORECASE)
+# Nightly CI embeds the build day as ``.devYYYYMMDD[+sha]`` (see release names).
+_DEV_DATE_RE = re.compile(r"(?:^|[.+-])dev(\d{8})(?:[.+-]|$)", re.IGNORECASE)
+
+
+@dataclass(frozen=True)
+class ReleaseInfo:
+    """Metadata for a GitHub release."""
+
+    tag_name: str
+    version: str
+    name: str
+    html_url: str
+    body: str
+    published_at: str
+    prerelease: bool
+    channel: str = DEFAULT_UPDATE_CHANNEL
+
+
+def normalize_channel(channel: Optional[str]) -> str:
+    """Return a supported update channel id."""
+    value = (channel or DEFAULT_UPDATE_CHANNEL).strip().casefold()
+    return value if value in UPDATE_CHANNELS else DEFAULT_UPDATE_CHANNEL
+
+
+def _repo_parts(repo_url: str = __url__) -> tuple[str, str]:
+    match = _GITHUB_REPO_RE.match(repo_url.strip())
+    if not match:
+        raise ValueError(f"Unsupported repository URL: {repo_url}")
+    return match.group("owner"), match.group("repo")
+
+
+def _repo_api_base(repo_url: str = __url__) -> str:
+    owner, repo = _repo_parts(repo_url)
+    return f"https://api.github.com/repos/{owner}/{repo}"
+
+
+def normalize_version(version: str) -> str:
+    """Strip a leading ``v`` and surrounding whitespace."""
+    return version.strip().lstrip("vV")
+
+
+def _version_key(version: str) -> tuple:
+    """Build a sortable key for PEP-440-like version strings."""
+    version = normalize_version(version)
+    if not version:
+        return (0, 0, 0, 0, "")
+
+    match = _PRE_RELEASE_SUFFIX_RE.match(version)
+    if match:
+        main = match.group("main")
+        prerelease = match.group("pre") or ""
+    else:
+        main, _, prerelease = version.partition("-")
+        # Drop build metadata (+flatpak) so it does not pollute comparison.
+        main = main.split("+", 1)[0]
+        prerelease = prerelease.split("+", 1)[0]
+
+    parts: list[int] = []
+    for part in main.split("."):
+        if part.isdigit():
+            parts.append(int(part))
+        elif part:
+            parts.append(0)
+    while len(parts) < 3:
+        parts.append(0)
+
+    # Stable releases outrank pre-releases with the same numeric prefix.
+    release_rank = 1 if not prerelease else 0
+    return tuple(parts) + (release_rank, prerelease.casefold())
+
+
+def is_newer_version(current: str, latest: str) -> bool:
+    """Return True when ``latest`` is newer than ``current`` (stable semver tags)."""
+    return _version_key(latest) > _version_key(current)
+
+
+def _nightly_date(tag_or_version: str) -> Optional[str]:
+    """Return YYYY-MM-DD from a nightly tag or ``.devYYYYMMDD`` version, or None."""
+    text = tag_or_version.strip()
+    match = _NIGHTLY_TAG_RE.match(text) or _NIGHTLY_TAG_RE.match(normalize_version(text))
+    if match:
+        return match.group(1)
+
+    # Installed nightlies report versions like ``0.14.2.dev20260802+27ecf88``.
+    dev_match = _DEV_DATE_RE.search(text)
+    if not dev_match:
+        return None
+    raw = dev_match.group(1)
+    return f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}"
+
+
+def is_update_available(
+    current: str, latest: ReleaseInfo, channel: str = DEFAULT_UPDATE_CHANNEL
+) -> bool:
+    """Return whether ``latest`` is an update for ``current`` on the given channel."""
+    channel = normalize_channel(channel)
+    if channel == "nightly":
+        current_tag = current.strip()
+        if current_tag == latest.tag_name or normalize_version(current_tag) == latest.version:
+            return False
+        current_nightly = _nightly_date(current_tag)
+        latest_nightly = _nightly_date(latest.tag_name) or _nightly_date(latest.name)
+        if current_nightly and latest_nightly:
+            return latest_nightly > current_nightly
+        # Stable (or other) install following the nightly channel → offer latest nightly.
+        return True
+    return is_newer_version(current, latest.version)
+
+
+def is_trusted_release_url(url: str, repo_url: str = __url__) -> bool:
+    """Return True when ``url`` points at this project's GitHub pages."""
+    if not url:
+        return False
+    try:
+        owner, repo = _repo_parts(repo_url)
+        parsed = urlparse(url)
+    except ValueError:
+        return False
+    if parsed.scheme != "https" or parsed.netloc.lower() != "github.com":
+        return False
+    path = parsed.path.rstrip("/")
+    prefix = f"/{owner}/{repo}"
+    return path == prefix or path.startswith(prefix + "/")
+
+
+def _release_from_payload(
+    data: dict, channel: str, repo_url: str = __url__
+) -> Optional[ReleaseInfo]:
+    tag_name = data.get("tag_name") or ""
+    if not tag_name:
+        return None
+
+    html_url = data.get("html_url") or ""
+    if html_url and not is_trusted_release_url(html_url, repo_url):
+        logger.warning("Ignoring untrusted release URL: %s", html_url)
+        html_url = ""
+
+    return ReleaseInfo(
+        tag_name=str(tag_name),
+        version=normalize_version(str(tag_name)),
+        name=str(data.get("name") or tag_name),
+        html_url=html_url,
+        body=str(data.get("body") or ""),
+        published_at=str(data.get("published_at") or ""),
+        prerelease=bool(data.get("prerelease")),
+        channel=channel,
+    )
+
+
+def fetch_latest_release(
+    repo_url: str = __url__,
+    timeout: float = 10.0,
+    channel: str = DEFAULT_UPDATE_CHANNEL,
+) -> Optional[ReleaseInfo]:
+    """Fetch the newest GitHub release for ``channel`` (``stable`` or ``nightly``)."""
+    import requests
+
+    channel = normalize_channel(channel)
+    api_base = _repo_api_base(repo_url)
+    headers = {
+        "Accept": "application/vnd.github+json",
+        "User-Agent": "Vocalinux-UpdateChecker",
+    }
+
+    try:
+        if channel == "stable":
+            response = requests.get(f"{api_base}/releases/latest", headers=headers, timeout=timeout)
+            if response.status_code == 404:
+                logger.warning("No latest release found for %s", api_base)
+                return None
+            if response.status_code == 403:
+                logger.warning("GitHub API rate-limited or forbidden for %s", api_base)
+                return None
+            response.raise_for_status()
+            data = response.json()
+            if not isinstance(data, dict):
+                return None
+            return _release_from_payload(data, channel, repo_url)
+
+        # Page through releases so a burst of non-nightly tags cannot hide the
+        # newest nightly beyond the first response page.
+        max_pages = 10
+        for page in range(1, max_pages + 1):
+            response = requests.get(
+                f"{api_base}/releases",
+                headers=headers,
+                params={"per_page": 30, "page": page},
+                timeout=timeout,
+            )
+            if response.status_code == 403:
+                logger.warning("GitHub API rate-limited or forbidden for %s", api_base)
+                return None
+            response.raise_for_status()
+            releases = response.json()
+            if not isinstance(releases, list) or not releases:
+                break
+
+            for item in releases:
+                if not isinstance(item, dict) or item.get("draft"):
+                    continue
+                tag_name = str(item.get("tag_name") or "")
+                if _NIGHTLY_TAG_RE.match(tag_name):
+                    return _release_from_payload(item, channel, repo_url)
+
+        logger.warning("No nightly release found for %s", api_base)
+        return None
+    except (requests.exceptions.RequestException, ValueError) as exc:
+        logger.warning("Failed to fetch latest release: %s", exc)
+        return None
+
+
+def format_release_notes(body: str) -> str:
+    """Convert common GitHub-flavored markdown to readable plain text."""
+    if not body:
+        return "No release notes were published for this version."
+
+    text = body.replace("\r\n", "\n").replace("\r", "\n")
+    text = re.sub(r"]*>", "", text, flags=re.IGNORECASE)
+    text = re.sub(r"]*>", "", text, flags=re.IGNORECASE)
+    text = re.sub(r"]*>", "", text, flags=re.IGNORECASE)
+    # Keep fenced code contents (install commands) but drop the fences.
+    text = re.sub(r"```[^\n]*\n(.*?)```", r"\1", text, flags=re.DOTALL)
+    text = re.sub(r"`([^`]+)`", r"\1", text)
+    text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text)
+    text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
+    text = re.sub(r"^>\s?", "", text, flags=re.MULTILINE)
+    text = re.sub(r"[*_]{1,2}([^*_]+)[*_]{1,2}", r"\1", text)
+    # Flatten markdown tables into "Area: Description" lines.
+    text = re.sub(r"^\|[-:| ]+\|\s*$", "", text, flags=re.MULTILINE)
+    text = re.sub(
+        r"^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$",
+        r"• \1: \2",
+        text,
+        flags=re.MULTILINE,
+    )
+    text = re.sub(r"^---+\s*$", "", text, flags=re.MULTILINE)
+    text = re.sub(r"[ \t]+\n", "\n", text)
+    text = re.sub(r"\n{3,}", "\n\n", text)
+    return text.strip()
diff --git a/src/vocalinux/version.py b/src/vocalinux/version.py
index 3f6e1aa..96f60a1 100644
--- a/src/vocalinux/version.py
+++ b/src/vocalinux/version.py
@@ -9,4 +9,4 @@ __email__ = "jatinkrmalik@gmail.com"
 __license__ = "GPL-3.0"
 __copyright__ = "Copyright 2023-2026 Jatin K Malik"
 __url__ = "https://github.com/jatinkrmalik/vocalinux"
-__description__ = "Free, offline voice dictation for Linux — speak and type anywhere"
+__description__ = "Free, offline voice dictation for Linux - speak and type anywhere"
diff --git a/tests/test_about_dialog.py b/tests/test_about_dialog.py
deleted file mode 100644
index 50f7f46..0000000
--- a/tests/test_about_dialog.py
+++ /dev/null
@@ -1,178 +0,0 @@
-"""
-Tests for the About Dialog module.
-
-Tests the show_about_dialog function and AboutDialog class using source code
-inspection to verify expected behavior (since GTK mocking is complex across
-Python versions).
-"""
-
-import os
-import unittest
-
-
-def _get_source_code():
-    """Read the about_dialog.py source code."""
-    source_path = os.path.join(
-        os.path.dirname(__file__),
-        "..",
-        "src",
-        "vocalinux",
-        "ui",
-        "about_dialog.py",
-    )
-    with open(source_path, "r") as f:
-        return f.read()
-
-
-class TestShowAboutDialogFunction(unittest.TestCase):
-    """Test cases for show_about_dialog function structure."""
-
-    def setUp(self):
-        """Set up test fixtures."""
-        self.source_code = _get_source_code()
-
-    def test_show_about_dialog_function_exists(self):
-        """Test that show_about_dialog function is defined."""
-        self.assertIn("def show_about_dialog(", self.source_code)
-
-    def test_show_about_dialog_accepts_parent_parameter(self):
-        """Test that show_about_dialog accepts a parent parameter."""
-        self.assertIn("def show_about_dialog(parent", self.source_code)
-
-    def test_show_about_dialog_creates_about_dialog(self):
-        """Test that show_about_dialog creates a Gtk.AboutDialog."""
-        self.assertIn("Gtk.AboutDialog()", self.source_code)
-
-    def test_show_about_dialog_sets_program_name(self):
-        """Test that show_about_dialog sets the program name."""
-        self.assertIn('set_program_name("Vocalinux")', self.source_code)
-
-    def test_show_about_dialog_sets_version(self):
-        """Test that show_about_dialog sets the version."""
-        self.assertIn("set_version(__version__)", self.source_code)
-
-    def test_show_about_dialog_sets_copyright(self):
-        """Test that show_about_dialog sets the copyright."""
-        self.assertIn("set_copyright(__copyright__)", self.source_code)
-
-    def test_show_about_dialog_sets_website(self):
-        """Test that show_about_dialog sets the website URL."""
-        self.assertIn("set_website(__url__)", self.source_code)
-        self.assertIn('set_website_label("Star on GitHub")', self.source_code)
-
-    def test_show_about_dialog_sets_license_type(self):
-        """Test that show_about_dialog sets the license type."""
-        self.assertIn("set_license_type(Gtk.License.GPL_3_0)", self.source_code)
-
-    def test_show_about_dialog_sets_authors(self):
-        """Test that show_about_dialog sets authors."""
-        self.assertIn("set_authors(", self.source_code)
-        self.assertIn("Jatin K Malik", self.source_code)
-
-    def test_show_about_dialog_adds_credit_sections(self):
-        """Test that show_about_dialog adds credit sections."""
-        self.assertIn("add_credit_section(", self.source_code)
-        self.assertIn("Contributors", self.source_code)
-        self.assertIn("Built With", self.source_code)
-
-    def test_show_about_dialog_handles_logo(self):
-        """Test that show_about_dialog handles logo loading."""
-        self.assertIn("get_icon_path", self.source_code)
-        self.assertIn("set_logo(", self.source_code)
-        self.assertIn("scale_simple(128, 128", self.source_code)
-
-    def test_show_about_dialog_runs_and_destroys(self):
-        """Test that show_about_dialog runs and destroys the dialog."""
-        self.assertIn("about_dialog.run()", self.source_code)
-        self.assertIn("about_dialog.destroy()", self.source_code)
-
-    def test_show_about_dialog_sets_transient_for(self):
-        """Test that show_about_dialog sets transient parent."""
-        self.assertIn("set_transient_for(parent)", self.source_code)
-
-    def test_show_about_dialog_sets_modal(self):
-        """Test that show_about_dialog sets modal."""
-        self.assertIn("set_modal(True)", self.source_code)
-
-
-class TestAboutDialogClass(unittest.TestCase):
-    """Test cases for AboutDialog custom class."""
-
-    def setUp(self):
-        """Set up test fixtures."""
-        self.source_code = _get_source_code()
-
-    def test_about_dialog_class_exists(self):
-        """Test that AboutDialog class is defined."""
-        self.assertIn("class AboutDialog(Gtk.Dialog):", self.source_code)
-
-    def test_about_dialog_has_css(self):
-        """Test that AboutDialog has CSS styling defined."""
-        self.assertIn("ABOUT_CSS", self.source_code)
-        self.assertIn(".about-dialog", self.source_code)
-        self.assertIn(".about-header", self.source_code)
-
-    def test_about_dialog_has_setup_css_method(self):
-        """Test that AboutDialog has _setup_css method."""
-        self.assertIn("def _setup_css(self)", self.source_code)
-
-    def test_about_dialog_has_build_ui_method(self):
-        """Test that AboutDialog has _build_ui method."""
-        self.assertIn("def _build_ui(self)", self.source_code)
-
-    def test_about_dialog_has_build_header_method(self):
-        """Test that AboutDialog has _build_header method."""
-        self.assertIn("def _build_header(self)", self.source_code)
-
-    def test_about_dialog_has_build_links_method(self):
-        """Test that AboutDialog has _build_links method."""
-        self.assertIn("def _build_links(self)", self.source_code)
-
-    def test_about_dialog_has_build_credits_method(self):
-        """Test that AboutDialog has _build_credits method."""
-        self.assertIn("def _build_credits(self)", self.source_code)
-
-    def test_about_dialog_has_build_footer_method(self):
-        """Test that AboutDialog has _build_footer method."""
-        self.assertIn("def _build_footer(self)", self.source_code)
-
-    def test_about_dialog_uses_close_button(self):
-        """Test that AboutDialog uses Close-only button pattern."""
-        self.assertIn("_Close", self.source_code)
-
-
-class TestAboutDialogCSS(unittest.TestCase):
-    """Test cases for AboutDialog CSS styling."""
-
-    def setUp(self):
-        """Set up test fixtures."""
-        self.source_code = _get_source_code()
-
-    def test_css_has_about_icon_style(self):
-        """Test that CSS includes about-icon class."""
-        self.assertIn(".about-icon", self.source_code)
-
-    def test_css_has_about_title_style(self):
-        """Test that CSS includes about-title class."""
-        self.assertIn(".about-title", self.source_code)
-
-    def test_css_has_about_version_style(self):
-        """Test that CSS includes about-version class."""
-        self.assertIn(".about-version", self.source_code)
-
-    def test_css_has_about_description_style(self):
-        """Test that CSS includes about-description class."""
-        self.assertIn(".about-description", self.source_code)
-
-    def test_css_has_about_section_style(self):
-        """Test that CSS includes about-section class."""
-        self.assertIn(".about-section", self.source_code)
-
-    def test_css_uses_theme_variables(self):
-        """Test that CSS uses GTK theme variables."""
-        self.assertIn("@theme_bg_color", self.source_code)
-        self.assertIn("@theme_base_color", self.source_code)
-
-
-if __name__ == "__main__":
-    unittest.main()
diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py
index 2724dbf..e7881f7 100644
--- a/tests/test_tray_indicator.py
+++ b/tests/test_tray_indicator.py
@@ -264,22 +264,17 @@ class TestTrayIndicator(unittest.TestCase):
             mock_dialog_instance.show.assert_called_once()
 
     def test_about_dialog(self):
-        """Test about dialog creation."""
-        with patch("vocalinux.ui.about_dialog.Gtk") as patched_gtk:
-            mock_about_dialog = MagicMock()
-            patched_gtk.AboutDialog.return_value = mock_about_dialog
-            mock_about_dialog.run.side_effect = lambda: None
-            patched_gtk.License.GPL_3_0 = 1
+        """Test About opens Settings focused on the About page."""
+        with patch("vocalinux.ui.tray_indicator.SettingsDialog") as mock_dialog_class:
+            mock_dialog_instance = MagicMock()
+            mock_dialog_class.return_value = mock_dialog_instance
 
-            with patch("vocalinux.ui.about_dialog.GdkPixbuf") as patched_pixbuf:
-                mock_pixbuf = MagicMock()
-                patched_pixbuf.Pixbuf.new_from_file.return_value = mock_pixbuf
+            self.tray_indicator._on_about_clicked(None)
 
-                self.tray_indicator._on_about_clicked(None)
-
-                mock_about_dialog.set_program_name.assert_called_with("Vocalinux")
-                mock_about_dialog.run.assert_called_once()
-                mock_about_dialog.destroy.assert_called_once()
+            mock_dialog_class.assert_called_once()
+            kwargs = mock_dialog_class.call_args.kwargs
+            self.assertEqual(kwargs.get("initial_page"), "about")
+            mock_dialog_instance.show.assert_called_once()
 
     def test_validate_resources_missing_resources_dir(self):
         """Test validation when resources directory doesn't exist."""
@@ -448,25 +443,16 @@ class TestTrayIndicator(unittest.TestCase):
                     self.tray_indicator.run()
                     mock_quit.assert_called_once()
 
-    def test_about_dialog_logo_scaling_error(self):
-        """Test about dialog handles logo scaling errors gracefully."""
-        with patch("vocalinux.ui.about_dialog.Gtk") as patched_gtk:
-            mock_about_dialog = MagicMock()
-            patched_gtk.AboutDialog.return_value = mock_about_dialog
-            mock_about_dialog.run.return_value = None
-            patched_gtk.License.GPL_3_0 = 1
+    def test_about_opens_settings_without_raising(self):
+        """Test About menu item opens settings even if dialog construction is mocked."""
+        with patch("vocalinux.ui.tray_indicator.SettingsDialog") as mock_dialog_class:
+            mock_dialog_instance = MagicMock()
+            mock_dialog_class.return_value = mock_dialog_instance
 
-            with patch("vocalinux.ui.about_dialog.GdkPixbuf") as patched_pixbuf:
-                # Simulate error loading pixbuf
-                patched_pixbuf.Pixbuf.new_from_file.side_effect = Exception("Load error")
+            self.tray_indicator._on_about_clicked(None)
 
-                # Should not raise exception
-                self.tray_indicator._on_about_clicked(None)
-
-                mock_about_dialog.run.assert_called_once()
-                mock_about_dialog.destroy.assert_called_once()
-                # set_logo should NOT be called due to the error
-                mock_about_dialog.set_logo.assert_not_called()
+            mock_dialog_instance.connect.assert_called()
+            mock_dialog_instance.show.assert_called_once()
 
     def test_check_status_notifier_watcher_true_when_present(self):
         mock_proxy = MagicMock()
diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py
new file mode 100644
index 0000000..bbd8660
--- /dev/null
+++ b/tests/test_update_checker.py
@@ -0,0 +1,247 @@
+"""Tests for the GitHub Releases update checker."""
+
+from unittest.mock import MagicMock, patch
+
+from vocalinux.utils.update_checker import (
+    ReleaseInfo,
+    fetch_latest_release,
+    format_release_notes,
+    is_newer_version,
+    is_trusted_release_url,
+    is_update_available,
+    normalize_channel,
+    normalize_version,
+)
+
+
+class TestNormalizeAndCompare:
+    def test_normalize_version_strips_v_prefix(self):
+        assert normalize_version("v0.15.0") == "0.15.0"
+        assert normalize_version("V0.15.0") == "0.15.0"
+
+    def test_equal_versions_are_not_newer(self):
+        assert not is_newer_version("0.15.0", "0.15.0")
+        assert not is_newer_version("v0.15.0", "0.15.0")
+
+    def test_numeric_ordering(self):
+        assert is_newer_version("0.9.0", "0.10.0")
+        assert is_newer_version("0.15.0", "0.15.1")
+        assert not is_newer_version("1.0.0", "0.99.0")
+
+    def test_stable_outranks_prerelease(self):
+        assert is_newer_version("0.15.0-beta", "0.15.0")
+        assert not is_newer_version("0.15.0", "0.15.0-beta")
+
+    def test_dot_prerelease_suffix(self):
+        assert is_newer_version("0.15.0.rc1", "0.15.0")
+        assert not is_newer_version("0.15.0", "0.15.0.rc1")
+
+    def test_build_metadata_ignored(self):
+        assert not is_newer_version("0.15.0+flatpak", "0.15.0")
+
+
+class TestChannels:
+    def test_normalize_channel(self):
+        assert normalize_channel("stable") == "stable"
+        assert normalize_channel("NIGHTLY") == "nightly"
+        assert normalize_channel("nope") == "stable"
+
+    def test_stable_update_uses_semver(self):
+        release = ReleaseInfo(
+            tag_name="v0.15.0",
+            version="0.15.0",
+            name="v0.15.0",
+            html_url="https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.15.0",
+            body="",
+            published_at="2026-08-02T00:00:00Z",
+            prerelease=False,
+            channel="stable",
+        )
+        assert is_update_available("0.14.0", release, "stable")
+        assert not is_update_available("0.15.0", release, "stable")
+
+    def test_nightly_update_compares_dates(self):
+        release = ReleaseInfo(
+            tag_name="nightly-2026-08-02",
+            version="nightly-2026-08-02",
+            name="Nightly Build - 0.14.2.dev20260802+27ecf88",
+            html_url="https://github.com/jatinkrmalik/vocalinux/releases/tag/nightly-2026-08-02",
+            body="",
+            published_at="2026-08-02T00:00:00Z",
+            prerelease=True,
+            channel="nightly",
+        )
+        assert is_update_available("nightly-2026-08-01", release, "nightly")
+        assert not is_update_available("nightly-2026-08-02", release, "nightly")
+        # Installed nightlies use .devYYYYMMDD[+sha], not the GitHub tag name.
+        assert not is_update_available("0.14.2.dev20260802+27ecf88", release, "nightly")
+        assert is_update_available("0.14.2.dev20260801+aaaaaaa", release, "nightly")
+        # Stable installs following nightly should be offered the latest nightly.
+        assert is_update_available("0.15.0", release, "nightly")
+
+
+class TestTrustedUrls:
+    def test_accepts_project_urls(self):
+        assert is_trusted_release_url("https://github.com/jatinkrmalik/vocalinux")
+        assert is_trusted_release_url(
+            "https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.15.0"
+        )
+
+    def test_rejects_untrusted_urls(self):
+        assert not is_trusted_release_url("https://evil.example/payload")
+        assert not is_trusted_release_url("http://github.com/jatinkrmalik/vocalinux")
+        assert not is_trusted_release_url("https://github.com/evil/vocalinux")
+        assert not is_trusted_release_url("")
+
+
+class TestFormatReleaseNotes:
+    def test_empty_body(self):
+        assert "No release notes" in format_release_notes("")
+
+    def test_keeps_code_block_contents(self):
+        body = "Install with:\n```bash\ncurl -fsSL https://example.com | bash\n```\nDone"
+        notes = format_release_notes(body)
+        assert "curl -fsSL https://example.com | bash" in notes
+        assert "```" not in notes
+
+    def test_strips_images_and_keeps_links(self):
+        body = 'See [docs](https://example.com) x'
+        notes = format_release_notes(body)
+        assert "docs (https://example.com)" in notes
+        assert "