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:
@@ -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 & 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 |
@@ -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
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user