feat(settings): in-app update checker with stable/nightly channels (#631)

* feat(settings): add in-app update checker with stable/nightly channels

Move About into the Settings sidebar and check GitHub Releases for updates.
Stable uses /releases/latest; Nightly follows nightly-YYYY-MM-DD tags.
Remove the standalone About dialog and wire tray About to Settings.

Fixes #183

* docs(readme): update About screenshot caption for Settings page

* fix(settings): address update-checker review and About polish

Discard stale in-flight results when the update channel changes, page
through GitHub releases for nightly tags, restore the About logo next
to the title, and replace the em dash in the app description.

* docs(screenshots): add About update-checker state captures

Host PR review screenshots on the branch so GitHub can render them
from raw.githubusercontent.com (Cursor artifact URLs 403 in PR bodies).

* fix(updates): recognize .devYYYYMMDD nightlies and clear Open style

Parse CI nightly versions like 0.14.2.dev20260802+sha when checking for
updates, and drop the suggested-action style from Open after a failed
GitHub lookup.
This commit is contained in:
Jatin K Malik
2026-08-02 20:25:40 -07:00
committed by GitHub
parent 20375f8657
commit 27eb1d3807
15 changed files with 875 additions and 594 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ Vocalinux in action. Settings gallery shots may lag the searchable sidebar UI fr
<tr>
<td align="center">
<img src="resources/screenshots/05-about-view.png" alt="About View" width="350"><br>
<em>About dialog with version info</em>
<em>About &amp; Updates in Settings</em>
</td>
<td align="center">
<img src="resources/screenshots/03-log-viewer.png" alt="Log Viewer" width="350"><br>
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

-369
View File
@@ -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 <jatinkrmalik@gmail.com>"])
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("<small>Built with VOSK, OpenAI Whisper, GTK 3, and Python</small>")
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(
'<a href="https://www.gnu.org/licenses/gpl-3.0.html">'
"GNU General Public License v3.0</a>"
)
footer.pack_start(license_label, False, False, 0)
return footer
+4
View File
@@ -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",
},
}
+332 -11
View File
@@ -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.
+13 -3
View File
@@ -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."""
+260
View File
@@ -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<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$")
_PRE_RELEASE_SUFFIX_RE = re.compile(
r"^(?P<main>\d+(?:\.\d+)*)(?:[-.](?P<pre>(?: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"<img\b[^>]*>", "", 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()
+1 -1
View File
@@ -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"
-178
View File
@@ -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()
+17 -31
View File
@@ -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()
+247
View File
@@ -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) <img src="x.png" alt="x" />'
notes = format_release_notes(body)
assert "docs (https://example.com)" in notes
assert "<img" not in notes
class TestFetchLatestRelease:
def test_returns_none_on_network_error(self):
mock_requests = MagicMock()
mock_requests.get.side_effect = Exception("offline")
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
assert fetch_latest_release() is None
def test_parses_release_payload(self):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"tag_name": "v0.15.0",
"name": "v0.15.0",
"html_url": "https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.15.0",
"body": "Notes",
"published_at": "2026-08-02T22:29:58Z",
"prerelease": False,
}
mock_requests = MagicMock()
mock_requests.get.return_value = mock_response
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release()
assert isinstance(release, ReleaseInfo)
assert release.version == "0.15.0"
assert release.html_url.endswith("/v0.15.0")
def test_drops_untrusted_html_url(self):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"tag_name": "v0.15.0",
"name": "v0.15.0",
"html_url": "https://evil.example/download",
"body": "Notes",
"published_at": "2026-08-02T22:29:58Z",
"prerelease": False,
}
mock_requests = MagicMock()
mock_requests.get.return_value = mock_response
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release()
assert release is not None
assert release.html_url == ""
def test_nightly_channel_picks_nightly_tag(self):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{
"tag_name": "v0.15.0",
"name": "v0.15.0",
"html_url": "https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.15.0",
"body": "Stable",
"published_at": "2026-08-02T22:29:58Z",
"prerelease": False,
"draft": False,
},
{
"tag_name": "nightly-2026-08-02",
"name": "nightly-2026-08-02",
"html_url": "https://github.com/jatinkrmalik/vocalinux/releases/tag/nightly-2026-08-02",
"body": "Nightly",
"published_at": "2026-08-02T05:00:00Z",
"prerelease": True,
"draft": False,
},
]
mock_requests = MagicMock()
mock_requests.get.return_value = mock_response
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release(channel="nightly")
assert release is not None
assert release.tag_name == "nightly-2026-08-02"
assert release.channel == "nightly"
mock_requests.get.assert_called_once()
assert mock_requests.get.call_args[0][0].endswith("/releases")
def test_nightly_channel_pages_past_non_nightly_releases(self):
"""Newest nightly can sit beyond the first /releases page."""
page1 = [
{
"tag_name": f"v0.15.{i}",
"name": f"v0.15.{i}",
"html_url": f"https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.15.{i}",
"body": "",
"published_at": "2026-08-03T00:00:00Z",
"prerelease": False,
"draft": False,
}
for i in range(30, 0, -1)
]
page2 = [
{
"tag_name": "nightly-2026-08-01",
"name": "nightly-2026-08-01",
"html_url": (
"https://github.com/jatinkrmalik/vocalinux/releases/tag/nightly-2026-08-01"
),
"body": "Nightly",
"published_at": "2026-08-01T05:00:00Z",
"prerelease": True,
"draft": False,
}
]
responses = []
for payload in (page1, page2):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = payload
responses.append(mock_response)
mock_requests = MagicMock()
mock_requests.get.side_effect = responses
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release(channel="nightly")
assert release is not None
assert release.tag_name == "nightly-2026-08-01"
assert mock_requests.get.call_count == 2
assert mock_requests.get.call_args_list[0].kwargs["params"]["page"] == 1
assert mock_requests.get.call_args_list[1].kwargs["params"]["page"] == 2