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 dialog with version info
+ About & Updates in Settings
|

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"?details[^>]*>", "", text, flags=re.IGNORECASE)
+ text = re.sub(r"?summary[^>]*>", "", 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) '
+ notes = format_release_notes(body)
+ assert "docs (https://example.com)" in notes
+ assert "![]() |