diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 31043e8..f498e5d 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -25,11 +25,13 @@ The application is structured into two main packages: `core` and `ui`. - **`pyexiv2_init.py`**: Handles safe PyExiv2 initialization ensuring it loads before Qt libraries to prevent access violations. - **`rating_loader_worker.py`**: A worker dedicated to loading image ratings and metadata. - **`similarity_engine.py`**: Handles image feature extraction and clustering. + - **`update_checker.py`**: Handles checking for application updates from GitHub releases. Includes version comparison logic, automatic update scheduling, and update information parsing. - **`ui/`**: Contains all UI-related components, following the Model-View-Controller (MVC) pattern. - **`main_window.py`**: The main application window (the "View"). It should contain minimal business logic and delegate user actions to the `AppController`. - **`app_controller.py`**: The controller that mediates between the UI and the `core` logic. It handles user actions, calls the appropriate `core` services, and updates the UI. - **`app_state.py`**: Holds the application's runtime state, including caches and loaded data. This object is shared across the application. - **`worker_manager.py`**: Manages all background threads and workers, decoupling the UI from long-running tasks. + - **`update_dialog.py`**: UI dialogs for displaying update notifications and manual update check results. - **Controller Layer (Encapsulation Refactor)**: Non-trivial UI behaviors previously embedded in `MainWindow` have been extracted into focused controllers under `src/ui/controllers/`: - `navigation_controller.py`: Linear & group-aware navigation (honors skip-deleted logic, smart up/down traversal). Consumes a minimal protocol for selection & model interrogation. - `hotkey_controller.py`: Central mapping of key events to actions (allows headless tests to exercise hotkey dispatch logic). @@ -67,12 +69,22 @@ The application is structured into two main packages: `core` and `ui`. - If the feature is a core logic change (e.g., a new image analysis technique), it should be implemented in the `src/core` directory. - If it's a new UI component, it should be in `src/ui`. - - If it's a new background task, a new worker should be added and managed by `src/ui/worker_manager.py`. -2. **Create new files when necessary:** + - If it's a new background task, a new worker should be added to `src/workers` and managed by `src/ui/worker_manager.py`. + +2. **Workers Directory (`src/workers`):** + - Contains background worker classes for core business logic and application-level operations + - **`update_worker.py`**: Background worker for checking application updates without blocking the UI + - **`rating_loader_worker.py`**: Background worker for loading metadata and ratings for images + - Workers should inherit from QObject and use Qt signals for communication + - Managed by the `WorkerManager` in the UI layer + - UI-specific workers (like preview preloading, blur detection) remain in `src/ui/ui_components.py` due to tight coupling with UI operations + +3. **Create new files when necessary:** - Create a new file for a new class or a distinct set of functionalities. For example, a new image analysis feature like "sharpness detection" would warrant a new file `src/core/image_features/sharpness_detector.py`. - For smaller, related functions, you can add them to an existing relevant file. -3. **Integrating the feature:** + +4. **Integrating the feature:** - The `AppController` is the primary point of integration. User actions from the UI (e.g., a button click in `MainWindow`) should call a method in the `AppController`. - The `AppController` then calls the relevant service in the `core` package. For any file system operations, the `AppController` must call the appropriate method in `ImageFileOperations`. @@ -132,6 +144,7 @@ The application is structured into two main packages: `core` and `ui`. ``` - **State Management**: The application's state (e.g., list of loaded images, cache data) is managed by the `AppState` class. Avoid storing state directly in the `MainWindow` or other UI components. - **Styling**: All UI components should be styled using stylesheets. The application uses a dark theme defined in `src/ui/dark_theme.qss`. When creating new UI components or modifying existing ones, ensure that the styling is consistent with this theme. +- **Update Notifications**: The application includes an automatic update notification system that checks GitHub releases. Update checks run automatically on startup (with configurable intervals) and can be triggered manually from the Help menu. The system respects user preferences and can be disabled. All update-related constants are centralized in `app_settings.py`. ## 4. Rotation Suggestion Acceptance & Auto-Advance diff --git a/README.md b/README.md index 6b0c77e..be02e2b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ PhotoSort is a powerful desktop application focused on speed designed to streaml * **Optimized Image Handling**: Supports a wide range of formats, including various RAW types, with efficient caching. * **Intelligent Image Rotation**: Smart rotation system that automatically tries lossless metadata rotation first, with optional fallback to pixel rotation when needed. +- **Update Notifications**: Automatically checks for new releases and notifies users when updates are available, with direct download links. - **Metadata Display**: Shows EXIF information (camera model, exposure settings, etc.). ## Getting Started diff --git a/src/core/app_settings.py b/src/core/app_settings.py index 8b17cdf..7770d1f 100644 --- a/src/core/app_settings.py +++ b/src/core/app_settings.py @@ -20,6 +20,8 @@ ORIENTATION_MODEL_NAME_KEY = ( "Models/OrientationModelName" # Key for the orientation model file name ) +UPDATE_CHECK_ENABLED_KEY = "Updates/CheckEnabled" # Enable automatic update checks +UPDATE_LAST_CHECK_KEY = "Updates/LastCheckTime" # Last time updates were checked # Default values DEFAULT_PREVIEW_CACHE_SIZE_GB = 2.0 # Default to 2 GB for preview cache @@ -27,6 +29,7 @@ DEFAULT_ROTATION_CONFIRM_LOSSY = True # Default to asking before lossy rotation MAX_RECENT_FOLDERS = 10 # Max number of recent folders to store DEFAULT_ORIENTATION_MODEL_NAME = None # Default to None, so we can auto-detect +DEFAULT_UPDATE_CHECK_ENABLED = True # Default to enable automatic update checks # --- UI Constants --- # Grid view settings @@ -105,6 +108,12 @@ "sentence-transformers/clip-ViT-B-32" # Common default, adjust if different ) +# --- Update Check Constants --- +UPDATE_CHECK_INTERVAL_HOURS = 24 # Check for updates every 24 hours +UPDATE_CHECK_TIMEOUT_SECONDS = 10 # Timeout for update check requests +GITHUB_REPO_OWNER = "duartebarbosadev" # GitHub repository owner +GITHUB_REPO_NAME = "PhotoSort" # GitHub repository name + def _get_settings() -> QSettings: """Get a QSettings instance with the application's organization and name.""" @@ -227,3 +236,30 @@ def set_orientation_model_name(model_name: str): """Sets the orientation model name in settings.""" settings = _get_settings() settings.setValue(ORIENTATION_MODEL_NAME_KEY, model_name) + + +# --- Update Check Settings --- +def get_update_check_enabled() -> bool: + """Gets whether automatic update checks are enabled.""" + settings = _get_settings() + return settings.value( + UPDATE_CHECK_ENABLED_KEY, DEFAULT_UPDATE_CHECK_ENABLED, type=bool + ) + + +def set_update_check_enabled(enabled: bool): + """Sets whether automatic update checks are enabled.""" + settings = _get_settings() + settings.setValue(UPDATE_CHECK_ENABLED_KEY, enabled) + + +def get_last_update_check_time() -> int: + """Gets the timestamp of the last update check (seconds since epoch).""" + settings = _get_settings() + return settings.value(UPDATE_LAST_CHECK_KEY, 0, type=int) + + +def set_last_update_check_time(timestamp: int): + """Sets the timestamp of the last update check.""" + settings = _get_settings() + settings.setValue(UPDATE_LAST_CHECK_KEY, timestamp) diff --git a/src/core/update_checker.py b/src/core/update_checker.py new file mode 100644 index 0000000..89f86c1 --- /dev/null +++ b/src/core/update_checker.py @@ -0,0 +1,243 @@ +""" +Update Checker Module +Handles checking for new application updates from GitHub releases. +""" + +import json +import logging +import time +from dataclasses import dataclass +from typing import Optional, Tuple +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + +from core.app_settings import ( + UPDATE_CHECK_TIMEOUT_SECONDS, + GITHUB_REPO_OWNER, + GITHUB_REPO_NAME, + get_update_check_enabled, + get_last_update_check_time, + set_last_update_check_time, + UPDATE_CHECK_INTERVAL_HOURS, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class UpdateInfo: + """Information about an available update.""" + + version: str + release_url: str + release_notes: str + published_at: str + download_url: Optional[str] = None + + +class UpdateChecker: + """Handles checking for application updates from GitHub releases.""" + + def __init__(self): + self.github_api_url = f"https://api.github.com/repos/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/releases/latest" + + def should_check_for_updates(self) -> bool: + """Check if enough time has passed since the last update check.""" + if not get_update_check_enabled(): + logger.debug("Automatic update checks are disabled") + return False + + # Skip automatic updates for development versions + from core.build_info import VERSION + + current_version = VERSION or "dev" + if current_version == "dev" or current_version.startswith("dev"): + logger.debug("Skipping automatic update check for development version") + return False + + last_check = get_last_update_check_time() + current_time = int(time.time()) + time_since_last_check = current_time - last_check + check_interval = UPDATE_CHECK_INTERVAL_HOURS * 3600 # Convert to seconds + + should_check = time_since_last_check >= check_interval + if should_check: + logger.debug( + f"Time for update check. Last check: {time_since_last_check // 3600} hours ago" + ) + else: + next_check_hours = (check_interval - time_since_last_check) // 3600 + logger.debug(f"Next update check in {next_check_hours} hours") + + return should_check + + def check_for_updates( + self, current_version: str + ) -> Tuple[bool, Optional[UpdateInfo], Optional[str]]: + """ + Check for updates from GitHub releases. + + Args: + current_version: The current application version + + Returns: + Tuple of (update_available, update_info, error_message) + """ + logger.info("Checking for application updates...") + + try: + # Update the last check time regardless of result + set_last_update_check_time(int(time.time())) + + # Make request to GitHub API + request = Request( + self.github_api_url, + headers={ + "User-Agent": f"{GITHUB_REPO_NAME}/{current_version}", + "Accept": "application/vnd.github.v3+json", + }, + ) + + with urlopen(request, timeout=UPDATE_CHECK_TIMEOUT_SECONDS) as response: + if response.status != 200: + error_msg = f"GitHub API returned status {response.status}" + logger.warning(error_msg) + return False, None, error_msg + + data = json.loads(response.read().decode("utf-8")) + + # Parse release information + latest_version = data.get("tag_name", "").lstrip( + "v" + ) # Remove 'v' prefix if present + release_url = data.get("html_url", "") + release_notes = data.get("body", "") + published_at = data.get("published_at", "") + + if not latest_version: + error_msg = "Could not parse version from GitHub release" + logger.warning(error_msg) + return False, None, error_msg + + # Find download URL for current platform + download_url = self._find_download_url(data.get("assets", [])) + + # Compare versions + if self._is_newer_version(latest_version, current_version): + logger.info( + f"New version available: {latest_version} (current: {current_version})" + ) + update_info = UpdateInfo( + version=latest_version, + release_url=release_url, + release_notes=release_notes, + published_at=published_at, + download_url=download_url, + ) + return True, update_info, None + else: + logger.info( + f"Application is up to date (current: {current_version}, latest: {latest_version})" + ) + return False, None, None + + except HTTPError as e: + error_msg = f"HTTP error checking for updates: {e.code} {e.reason}" + logger.warning(error_msg) + return False, None, error_msg + except URLError as e: + error_msg = f"Network error checking for updates: {e.reason}" + logger.warning(error_msg) + return False, None, error_msg + except json.JSONDecodeError as e: + error_msg = f"Error parsing GitHub API response: {e}" + logger.warning(error_msg) + return False, None, error_msg + except Exception as e: + error_msg = f"Unexpected error checking for updates: {e}" + logger.error(error_msg, exc_info=True) + return False, None, error_msg + + def _find_download_url(self, assets: list) -> Optional[str]: + """Find the appropriate download URL for the current platform.""" + import platform + + system = platform.system().lower() + + for asset in assets: + name = asset.get("name", "").lower() + download_url = asset.get("browser_download_url") + + if not download_url: + continue + + # Match platform-specific assets + if system == "windows" and "windows" in name and name.endswith(".exe"): + return download_url + elif system == "darwin" and "macos" in name and name.endswith(".dmg"): + return download_url + + return None + + def _is_newer_version(self, latest: str, current: str) -> bool: + """ + Compare two version strings to determine if latest is newer than current. + Handles semantic versioning with optional pre-release suffixes (e.g., 1.0.2a). + """ + if not current: # Development version + return True + + try: + # Parse version components + latest_parts = self._parse_version(latest) + current_parts = self._parse_version(current) + + # Compare version components + return latest_parts > current_parts + + except Exception as e: + logger.warning(f"Error comparing versions '{latest}' vs '{current}': {e}") + # If parsing fails, assume update is available to be safe + return True + + def _parse_version(self, version: str) -> Tuple[int, ...]: + """ + Parse a version string into comparable components. + Examples: "1.0.2" -> (1, 0, 2), "1.0.2a" -> (1, 0, 2, -1) + Release versions are considered newer than pre-release versions of same number. + """ + # Handle development versions + if version.startswith("dev-") or not version: + return (0,) # Development versions are always older + + # Remove 'v' prefix if present + version = version.lstrip("v") + + # Split into numeric and suffix parts + import re + + match = re.match(r"^(\d+(?:\.\d+)*)([a-zA-Z]*)$", version) + if not match: + # If we can't parse it, treat as (0,) to be safe + return (0,) + + numeric_part, suffix = match.groups() + + # Parse numeric components + parts = [int(x) for x in numeric_part.split(".")] + + # Handle pre-release suffixes (alpha, beta, etc.) + if suffix: + # Pre-release versions are considered older than release versions + # Map common suffixes to negative numbers for proper ordering + suffix_map = {"a": -3, "alpha": -3, "b": -2, "beta": -2, "rc": -1} + suffix_value = suffix_map.get( + suffix.lower(), -10 + ) # Unknown suffixes are very old + parts.append(suffix_value) + else: + # For release versions (no suffix), add 0 to distinguish from pre-release + # This ensures 1.0.2 > 1.0.2a (where 1.0.2a has -3 as last component) + parts.append(0) + + return tuple(parts) diff --git a/src/ui/app_controller.py b/src/ui/app_controller.py index 215383a..c480fb4 100644 --- a/src/ui/app_controller.py +++ b/src/ui/app_controller.py @@ -157,6 +157,11 @@ def connect_signals(self): self.handle_rotation_model_not_found ) + # Update Check Worker + self.worker_manager.update_check_finished.connect( + self.handle_update_check_finished + ) + # --- Public Methods (called from MainWindow) --- def load_folder(self, folder_path: str): @@ -813,6 +818,67 @@ def _apply_approved_rotations(self, approved_rotations: Dict[str, int]): f"Rotation application finished in {apply_end_time - apply_start_time:.2f}s." ) + # --- Update Check Handlers --- + + def manual_check_for_updates(self): + """Manually check for updates (called from menu).""" + from core.build_info import VERSION + from ui.update_dialog import UpdateCheckDialog + + # Show the update check dialog + self.update_check_dialog = UpdateCheckDialog(self.main_window) + self.update_check_dialog.show() + + # Start the update check + current_version = VERSION or "dev" + self.worker_manager.start_update_check(current_version) + + def automatic_check_for_updates(self): + """Automatically check for updates on startup if enabled.""" + from core.update_checker import UpdateChecker + from core.build_info import VERSION + + update_checker = UpdateChecker() + if update_checker.should_check_for_updates(): + logger.info("Starting automatic update check...") + current_version = VERSION or "dev" + self.worker_manager.start_update_check(current_version) + + def handle_update_check_finished( + self, update_available: bool, update_info, error_message: str + ): + """Handle the completion of an update check.""" + from ui.update_dialog import UpdateNotificationDialog + from core.build_info import VERSION + + # Handle manual check dialog if it exists + is_manual_check = ( + hasattr(self, "update_check_dialog") + and self.update_check_dialog is not None + ) + + if is_manual_check: + if update_available: + # Close the manual check dialog since we'll show the update notification + self.update_check_dialog.reject() + self.update_check_dialog = None + elif error_message: + self.update_check_dialog.set_status(f"Error: {error_message}", True) + else: + self.update_check_dialog.set_status("No updates available.", True) + + # Show update notification dialog only if an update is available + if update_available and update_info: + current_version = VERSION or "dev" + dialog = UpdateNotificationDialog( + update_info, current_version, self.main_window + ) + dialog.exec() + elif error_message: + logger.warning(f"Update check failed: {error_message}") + else: + logger.info("No updates available") + # If no more rotation suggestions, hide the rotation view if not self.main_window.rotation_suggestions: self.main_window._hide_rotation_view() diff --git a/src/ui/dark_theme.qss b/src/ui/dark_theme.qss index 50de660..7006d8b 100644 --- a/src/ui/dark_theme.qss +++ b/src/ui/dark_theme.qss @@ -1392,6 +1392,188 @@ QPushButton#lossyRotationProceedButton:pressed { background-color: #E67A35; } +/* --- Update Notification Dialogs --- */ +QDialog#updateNotificationDialog, +QDialog#updateCheckDialog { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #2D2D2D, stop: 1 #252525); + border: 2px solid #0084FF; + border-radius: 12px; +} + +/* Update Dialog Titles */ +QLabel#updateTitle, +QLabel#checkTitle { + color: #FFFFFF; + background-color: transparent; + font-weight: bold; +} + +/* Version Frame */ +QFrame#versionFrame { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #252525, stop: 1 #202020); + border: 1px solid #404040; + border-radius: 8px; +} + +QLabel#currentVersionLabel { + color: #C8C8C8; + font-size: 11pt; + background-color: transparent; +} + +QLabel#newVersionLabel { + color: #0084FF; + font-size: 11pt; + font-weight: bold; + background-color: transparent; +} + +/* Settings Frame */ +QFrame#settingsFrame { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #252525, stop: 1 #202020); + border: 1px solid #404040; + border-radius: 8px; +} + +/* Release Notes Area */ +QTextEdit#releaseNotesArea { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #2A2A2A, stop: 1 #252525); + border: 1px solid #404040; + border-radius: 6px; + color: #E0E0E0; + font-size: 10pt; + padding: 10px; +} + +QTextEdit#releaseNotesArea:focus { + border-color: #0084FF; +} + +/* Notes Label */ +QLabel#notesLabel { + color: #0084FF; + font-weight: bold; + background-color: transparent; +} + +/* Update Checkbox */ +QCheckBox#updateCheckbox { + color: #C8C8C8; + background-color: transparent; + spacing: 10px; + font-size: 10pt; +} + +QCheckBox#updateCheckbox::indicator { + width: 18px; + height: 18px; + border: 2px solid #404040; + border-radius: 4px; + background-color: #2D2D2D; +} + +QCheckBox#updateCheckbox::indicator:hover { + border-color: #505050; + background-color: #353535; +} + +QCheckBox#updateCheckbox::indicator:checked { + background-color: #0084FF; + border-color: #005A9E; + image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHZpZXdCb3g9IjAgMCAxNCAxNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTExIDQuNUw1Ljc1IDkuNzVMMy41IDcuNSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPC9zdmc+); +} + +/* Dialog Buttons - Smaller sizing */ +QPushButton#laterButton { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #383838, stop: 1 #303030); + color: #E0E0E0; + border: 1px solid #484848; + border-radius: 6px; + padding: 6px 16px; + font-size: 9pt; + font-weight: 500; + min-width: 70px; + min-height: 28px; +} + +QPushButton#laterButton:hover { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #454545, stop: 1 #3C3C3C); + border-color: #555555; + color: #FFFFFF; +} + +QPushButton#laterButton:pressed { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #282828, stop: 1 #202020); +} + +QPushButton#downloadButton { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #0084FF, stop: 1 #0066CC); + color: #FFFFFF; + border: 1px solid #005A9E; + border-radius: 6px; + padding: 6px 20px; + font-size: 9pt; + font-weight: bold; + min-width: 120px; + min-height: 28px; +} + +QPushButton#downloadButton:hover { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #0094FF, stop: 1 #0074DD); + border-color: #0084FF; +} + +QPushButton#downloadButton:pressed { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #0066CC, stop: 1 #0052AA); +} + +QPushButton#checkCloseButton { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #0084FF, stop: 1 #0066CC); + color: #FFFFFF; + border: 1px solid #005A9E; + border-radius: 6px; + padding: 6px 16px; + font-size: 9pt; + font-weight: 500; + min-width: 60px; + min-height: 28px; +} + +QPushButton#checkCloseButton:hover { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #0094FF, stop: 1 #0074DD); +} + +QPushButton#checkCloseButton:pressed { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #0066CC, stop: 1 #0052AA); +} + +QPushButton#checkCloseButton:disabled { + background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 #383838, stop: 1 #303030); + color: #666666; + border-color: #484848; +} + +/* Status Label */ +QLabel#statusLabel { + color: #C8C8C8; + font-size: 11pt; + background-color: transparent; +} + /* --- Default Message Boxes --- */ QMessageBox { background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 91b9243..b5f33d5 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -278,6 +278,9 @@ def __init__(self, initial_folder=None): 0, lambda: self.app_controller.load_folder(self.initial_folder) ) + # Start automatic update check after a short delay + QTimer.singleShot(2000, self.app_controller.automatic_check_for_updates) + def _should_apply_raw_processing(self, file_path: str) -> bool: """Determine if RAW processing should be applied to the given file.""" if not file_path: diff --git a/src/ui/menu_manager.py b/src/ui/menu_manager.py index e4dbadc..52f88fd 100644 --- a/src/ui/menu_manager.py +++ b/src/ui/menu_manager.py @@ -72,6 +72,7 @@ def __init__(self, main_window: "MainWindow"): # Other global actions self.find_action: QAction self.about_action: QAction + self.check_updates_action: QAction self.rating_actions: dict[int, QAction] = {} self.image_focus_actions: dict[int, QAction] = {} @@ -213,6 +214,10 @@ def _create_actions(self): self.about_action.setShortcut(QKeySequence("F12")) main_win.addAction(self.about_action) + # Check for updates action + self.check_updates_action = QAction("Check for &Updates...", main_win) + main_win.addAction(self.check_updates_action) + logger.debug("Actions created.") def _create_file_menu(self, menu_bar): @@ -360,6 +365,8 @@ def _create_settings_menu(self, menu_bar): def _create_help_menu(self, menu_bar): help_menu = menu_bar.addMenu("&Help") + help_menu.addAction(self.check_updates_action) + help_menu.addSeparator() help_menu.addAction(self.about_action) def connect_signals(self): @@ -472,6 +479,9 @@ def _guarded_show_rotation_view(): for action in self.image_focus_actions.values(): action.triggered.connect(main_win._handle_image_focus_shortcut) self.about_action.triggered.connect(self.dialog_manager.show_about_dialog) + self.check_updates_action.triggered.connect( + main_win.app_controller.manual_check_for_updates + ) def update_recent_folders_menu(self): """Update the 'Open Recent' menu with the latest list of folders.""" diff --git a/src/ui/update_dialog.py b/src/ui/update_dialog.py new file mode 100644 index 0000000..b2c8df6 --- /dev/null +++ b/src/ui/update_dialog.py @@ -0,0 +1,370 @@ +""" +Update Notification Dialog +Shows available updates to the user. +""" + +import logging +import webbrowser + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QTextEdit, + QCheckBox, + QFrame, +) + +from core.update_checker import UpdateInfo +from core.app_settings import set_update_check_enabled + +logger = logging.getLogger(__name__) + + +class UpdateNotificationDialog(QDialog): + """Dialog to notify users about available updates.""" + + def __init__(self, update_info: UpdateInfo, current_version: str, parent=None): + super().__init__(parent) + self.update_info = update_info + self.current_version = current_version + + self.setWindowTitle("Update Available") + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setModal(True) + self.resize(550, 500) # More space for release notes + self.setObjectName("updateNotificationDialog") + + # Enable dragging for the entire dialog + self._drag_pos = None + + self._setup_ui() + + def _setup_ui(self): + """Set up the dialog UI.""" + layout = QVBoxLayout(self) + layout.setSpacing(18) + layout.setContentsMargins(25, 25, 25, 25) + + # Title with icon + title_layout = QHBoxLayout() + title_layout.setSpacing(12) + + icon_label = QLabel("🔄") + icon_label.setObjectName("updateIcon") + icon_label.setStyleSheet("font-size: 20px; color: #0084FF;") + title_layout.addWidget(icon_label) + + title_label = QLabel(f"PhotoSort {self.update_info.version} Available") + title_label.setObjectName("updateTitle") + title_font = QFont() + title_font.setPointSize(16) + title_font.setBold(True) + title_label.setFont(title_font) + title_layout.addWidget(title_label) + + title_layout.addStretch() + layout.addLayout(title_layout) + + # Version comparison section + version_frame = QFrame() + version_frame.setObjectName("versionFrame") + version_layout = QHBoxLayout(version_frame) + version_layout.setContentsMargins(15, 12, 15, 12) + + current_version_label = QLabel(f"Current: {self.current_version}") + current_version_label.setObjectName("currentVersionLabel") + version_layout.addWidget(current_version_label) + + arrow_label = QLabel("→") + arrow_label.setObjectName("versionArrow") + arrow_label.setStyleSheet("font-size: 16px; color: #0084FF; font-weight: bold;") + version_layout.addWidget(arrow_label) + + new_version_label = QLabel(f"New: {self.update_info.version}") + new_version_label.setObjectName("newVersionLabel") + version_layout.addWidget(new_version_label) + + version_layout.addStretch() + layout.addWidget(version_frame) + + # Release notes section + notes_label = QLabel("What's New:") + notes_label.setObjectName("notesLabel") + notes_font = QFont() + notes_font.setPointSize(12) + notes_font.setBold(True) + notes_label.setFont(notes_font) + layout.addWidget(notes_label) + + # Scrollable text area for release notes + notes_area = QTextEdit() + notes_area.setObjectName("releaseNotesArea") + notes_area.setReadOnly(True) + notes_area.setMinimumHeight(200) + + # Convert markdown to HTML for better rendering + release_notes = self.update_info.release_notes or "No release notes available." + html_notes = self._convert_markdown_to_html(release_notes) + notes_area.setHtml(html_notes) + + layout.addWidget(notes_area) + + # Settings section + settings_frame = QFrame() + settings_frame.setObjectName("settingsFrame") + settings_layout = QVBoxLayout(settings_frame) + settings_layout.setContentsMargins(15, 12, 15, 12) + + # Checkbox for disabling update checks + self.disable_checks_checkbox = QCheckBox( + "Don't check for updates automatically" + ) + self.disable_checks_checkbox.setObjectName("updateCheckbox") + self.disable_checks_checkbox.setToolTip( + "You can re-enable update checks in the Help menu" + ) + + # Auto-check if user has previously disabled automatic updates + from core.app_settings import get_update_check_enabled + + current_setting = get_update_check_enabled() + logger.info( + f"Creating update dialog. Current update check setting: {current_setting}" + ) + if not current_setting: + self.disable_checks_checkbox.setChecked(True) + logger.info("Checkbox auto-checked because updates are currently disabled") + + settings_layout.addWidget(self.disable_checks_checkbox) + layout.addWidget(settings_frame) + + # Button layout with smaller buttons + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + + # Later/Close button + later_button = QPushButton("Later") + later_button.setObjectName("laterButton") + later_button.clicked.connect(self._on_close_clicked) + button_layout.addWidget(later_button) + + button_layout.addStretch() + + # Download Update button + download_button = QPushButton("Download Update") + download_button.setObjectName("downloadButton") + download_button.setDefault(True) + download_button.clicked.connect(self._on_download_update_clicked) + button_layout.addWidget(download_button) + + layout.addLayout(button_layout) + + def mousePressEvent(self, event): + """Handle mouse press for dialog dragging.""" + if event.button() == Qt.MouseButton.LeftButton: + self._drag_pos = ( + event.globalPosition().toPoint() - self.frameGeometry().topLeft() + ) + event.accept() + + def mouseMoveEvent(self, event): + """Handle mouse move for dialog dragging.""" + if event.buttons() == Qt.MouseButton.LeftButton and self._drag_pos is not None: + self.move(event.globalPosition().toPoint() - self._drag_pos) + event.accept() + + def _on_close_clicked(self): + """Handle 'Close' button click.""" + logger.info( + f"Close button clicked. Checkbox checked: {self.disable_checks_checkbox.isChecked()}" + ) + if self.disable_checks_checkbox.isChecked(): + set_update_check_enabled(False) + logger.info("Automatic update checks disabled by user") + else: + set_update_check_enabled(True) + logger.info("Automatic update checks re-enabled by user") + + self.reject() + + def _on_download_update_clicked(self): + """Handle 'Download Update' button click.""" + logger.info( + f"Download Update button clicked. Checkbox checked: {self.disable_checks_checkbox.isChecked()}" + ) + if self.disable_checks_checkbox.isChecked(): + set_update_check_enabled(False) + logger.info("Automatic update checks disabled by user") + else: + set_update_check_enabled(True) + logger.info("Automatic update checks re-enabled by user") + + # Try to open download URL first, fallback to release page + url_to_open = self.update_info.download_url or self.update_info.release_url + + try: + webbrowser.open(url_to_open) + logger.info(f"Opened update URL: {url_to_open}") + except Exception as e: + logger.error(f"Failed to open update URL: {e}") + + self.accept() + + def _convert_markdown_to_html(self, markdown_text: str) -> str: + """Convert basic markdown formatting to HTML.""" + if not markdown_text: + return "No release notes available." + + html = markdown_text + + # Convert headers + html = ( + html.replace("### ", "
{stripped}
") + + if in_list: + result_lines.append("") + + return "\n".join(result_lines) + + +class UpdateCheckDialog(QDialog): + """Simple dialog for manual update checks.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Check for Updates") + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setModal(True) + self.resize(380, 140) + self.setObjectName("updateCheckDialog") + + # Enable dragging for the entire dialog + self._drag_pos = None + + self._setup_ui() + + def _setup_ui(self): + """Set up the dialog UI.""" + layout = QVBoxLayout(self) + layout.setSpacing(18) + layout.setContentsMargins(25, 25, 25, 25) + + # Title with icon + title_layout = QHBoxLayout() + title_layout.setSpacing(12) + + icon_label = QLabel("🔍") + icon_label.setObjectName("checkIcon") + icon_label.setStyleSheet("font-size: 18px; color: #0084FF;") + title_layout.addWidget(icon_label) + + title_label = QLabel("Check for Updates") + title_label.setObjectName("checkTitle") + title_font = QFont() + title_font.setPointSize(14) + title_font.setBold(True) + title_label.setFont(title_font) + title_layout.addWidget(title_label) + + title_layout.addStretch() + layout.addLayout(title_layout) + + # Status section + status_layout = QHBoxLayout() + status_icon = QLabel("⏳") + status_icon.setObjectName("statusIcon") + status_icon.setStyleSheet("font-size: 14px;") + status_layout.addWidget(status_icon) + + self.status_label = QLabel("Checking for updates...") + self.status_label.setObjectName("statusLabel") + status_layout.addWidget(self.status_label) + status_layout.addStretch() + + layout.addLayout(status_layout) + + # Add stretch to center content vertically + layout.addStretch() + + # Button layout + button_layout = QHBoxLayout() + button_layout.addStretch() + + self.bottom_close_button = QPushButton("Close") + self.bottom_close_button.setObjectName("checkCloseButton") + self.bottom_close_button.clicked.connect(self.reject) + self.bottom_close_button.setEnabled(False) + button_layout.addWidget(self.bottom_close_button) + + layout.addLayout(button_layout) + + def mousePressEvent(self, event): + """Handle mouse press for dialog dragging.""" + if event.button() == Qt.MouseButton.LeftButton: + self._drag_pos = ( + event.globalPosition().toPoint() - self.frameGeometry().topLeft() + ) + event.accept() + + def mouseMoveEvent(self, event): + """Handle mouse move for dialog dragging.""" + if event.buttons() == Qt.MouseButton.LeftButton and self._drag_pos is not None: + self.move(event.globalPosition().toPoint() - self._drag_pos) + event.accept() + + def set_status(self, message: str, enable_close: bool = False): + """Update the status message.""" + self.status_label.setText(message) + self.bottom_close_button.setEnabled(enable_close) diff --git a/src/ui/worker_manager.py b/src/ui/worker_manager.py index 91a05f3..527c1ed 100644 --- a/src/ui/worker_manager.py +++ b/src/ui/worker_manager.py @@ -12,8 +12,9 @@ SimilarityWorker, CudaDetectionWorker, ) +from workers.update_worker import UpdateCheckWorker from core.image_pipeline import ImagePipeline -from core.rating_loader_worker import ( +from workers.rating_loader_worker import ( RatingLoaderWorker, ) from core.caching.rating_cache import RatingCache @@ -76,6 +77,11 @@ class WorkerManager(QObject): # CUDA Detection Signals cuda_detection_finished = pyqtSignal(bool) + # Update Check Signals + update_check_finished = pyqtSignal( + bool, object, str + ) # (update_available, update_info, error_message) + def __init__( self, image_pipeline_instance: ImagePipeline, parent: Optional[QObject] = None ): @@ -103,6 +109,9 @@ def __init__( self.cuda_detection_thread: Optional[QThread] = None self.cuda_detection_worker: Optional[CudaDetectionWorker] = None + self.update_check_thread: Optional[QThread] = None + self.update_check_worker: Optional[UpdateCheckWorker] = None + def _terminate_thread( self, thread: Optional[QThread], worker_stop_method: Optional[callable] = None ): @@ -537,6 +546,48 @@ def is_cuda_detection_running(self) -> bool: and self.cuda_detection_thread.isRunning() ) + def start_update_check(self, current_version: str): + """Start checking for updates in a background thread.""" + if self.is_update_check_running(): + logger.warning("Update check is already running") + return + + logger.info("Starting update check...") + + self.update_check_thread = QThread() + self.update_check_worker = UpdateCheckWorker(current_version) + self.update_check_worker.moveToThread(self.update_check_thread) + + # Connect signals + self.update_check_worker.update_check_finished.connect( + self.update_check_finished.emit + ) + self.update_check_worker.update_check_finished.connect( + self._cleanup_update_check_worker + ) + + # Connect start signal + self.update_check_thread.started.connect( + self.update_check_worker.check_for_updates + ) + + # Start the thread + self.update_check_thread.start() + + def _cleanup_update_check_worker(self): + """Clean up the update check worker and thread.""" + if self.update_check_thread is not None: + self.update_check_thread.quit() + self.update_check_thread.wait() + self.update_check_thread = None + self.update_check_worker = None + + def is_update_check_running(self) -> bool: + return ( + self.update_check_thread is not None + and self.update_check_thread.isRunning() + ) + def is_any_worker_running(self) -> bool: return ( self.is_file_scanner_running() @@ -546,4 +597,5 @@ def is_any_worker_running(self) -> bool: or self.is_rating_loader_running() or self.is_rotation_detection_running() or self.is_cuda_detection_running() + or self.is_update_check_running() ) diff --git a/src/workers/preview_preloader_worker.py b/src/workers/preview_preloader_worker.py new file mode 100644 index 0000000..6a26c31 --- /dev/null +++ b/src/workers/preview_preloader_worker.py @@ -0,0 +1,57 @@ +""" +Preview Preloader Worker +Background worker for preloading preview images. +""" + +import logging +from PyQt6.QtCore import QObject, pyqtSignal + +from core.image_pipeline import ImagePipeline + +logger = logging.getLogger(__name__) + + +class PreviewPreloaderWorker(QObject): + progress_update = pyqtSignal(int, str) + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__( + self, + image_paths, + max_size, + image_pipeline_instance: ImagePipeline, + parent=None, + ): + super().__init__(parent) + self._image_paths = image_paths + self._max_size = max_size + self._image_pipeline = image_pipeline_instance + self._is_running = True + + def stop(self): + self._is_running = False + + def run(self): + if not self._image_paths: + self.finished.emit() + return + + for i, image_path in enumerate(self._image_paths): + if not self._is_running: + break + + # Emit progress + basename = image_path.split("/")[-1] + self.progress_update.emit(i + 1, basename) + + try: + # Preload the preview using the pipeline + self._image_pipeline.get_preview_qpixmap( + image_path, max_size=self._max_size, skip_cache=False + ) + except Exception as e: + logger.error(f"Error preloading {image_path}: {e}") + self.error.emit(f"Error preloading {basename}: {str(e)}") + + self.finished.emit() diff --git a/src/core/rating_loader_worker.py b/src/workers/rating_loader_worker.py similarity index 100% rename from src/core/rating_loader_worker.py rename to src/workers/rating_loader_worker.py diff --git a/src/workers/update_worker.py b/src/workers/update_worker.py new file mode 100644 index 0000000..894e5fa --- /dev/null +++ b/src/workers/update_worker.py @@ -0,0 +1,39 @@ +""" +Update Check Worker +Background worker for checking application updates. +""" + +import logging +from PyQt6.QtCore import QObject, pyqtSignal + +from core.update_checker import UpdateChecker + +logger = logging.getLogger(__name__) + + +class UpdateCheckWorker(QObject): + """Worker for checking updates in a background thread.""" + + # Signals + update_check_finished = pyqtSignal( + bool, object, str + ) # (update_available, update_info, error_message) + + def __init__(self, current_version: str): + super().__init__() + self.current_version = current_version + self.update_checker = UpdateChecker() + + def check_for_updates(self): + """Check for updates and emit the result.""" + try: + logger.debug("Starting background update check...") + update_available, update_info, error_message = ( + self.update_checker.check_for_updates(self.current_version) + ) + self.update_check_finished.emit( + update_available, update_info, error_message + ) + except Exception as e: + logger.error(f"Unexpected error in update check worker: {e}", exc_info=True) + self.update_check_finished.emit(False, None, str(e)) diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py new file mode 100644 index 0000000..b86e565 --- /dev/null +++ b/tests/test_update_checker.py @@ -0,0 +1,298 @@ +import os +import sys +import json +from unittest.mock import Mock, patch +from urllib.error import URLError, HTTPError + +# Ensure project root on path (in case tests run differently) +project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if project_root not in sys.path: # pragma: no cover - defensive + sys.path.insert(0, project_root) + +# Import after path setup (required for test environment) +# ruff: noqa: E402 +from src.core.update_checker import UpdateChecker, UpdateInfo + + +class TestUpdateChecker: + """Tests the update checking functionality.""" + + def test_version_comparison_basic(self): + """Test basic version comparison logic.""" + checker = UpdateChecker() + + # Basic version updates + assert checker._is_newer_version("1.0.1", "1.0.0") is True + assert checker._is_newer_version("1.1.0", "1.0.0") is True + assert checker._is_newer_version("2.0.0", "1.0.0") is True + + # No updates needed + assert checker._is_newer_version("1.0.0", "1.0.0") is False + assert checker._is_newer_version("1.0.0", "1.0.1") is False + + # Development versions + assert checker._is_newer_version("1.0.0", "dev") is True + assert checker._is_newer_version("1.0.0", "") is True + assert checker._is_newer_version("dev-abc123", "1.0.0") is False + + def test_version_comparison_prerelease(self): + """Test pre-release version comparison.""" + checker = UpdateChecker() + + # Pre-release to release + assert checker._is_newer_version("1.0.2", "1.0.2a") is True + assert checker._is_newer_version("1.0.2", "1.0.2b") is True + + # Release to pre-release + assert checker._is_newer_version("1.0.2a", "1.0.2") is False + assert checker._is_newer_version("1.0.2b", "1.0.2") is False + + # Pre-release to pre-release + assert checker._is_newer_version("1.0.2a", "1.0.1a") is True + assert checker._is_newer_version("1.0.2b", "1.0.2a") is True + + def test_version_parsing(self): + """Test version string parsing.""" + checker = UpdateChecker() + + assert checker._parse_version("1.0.0") == (1, 0, 0, 0) + assert checker._parse_version("1.0.2a") == (1, 0, 2, -3) + assert checker._parse_version("1.2.3") == (1, 2, 3, 0) + assert checker._parse_version("2.0.0b") == (2, 0, 0, -2) + assert checker._parse_version("dev-abc123") == (0,) + assert checker._parse_version("") == (0,) + + def test_should_check_for_updates_disabled(self): + """Test update check when disabled.""" + checker = UpdateChecker() + + with patch( + "src.core.update_checker.get_update_check_enabled", return_value=False + ): + assert checker.should_check_for_updates() is False + + def test_should_check_for_updates_dev_version(self): + """Test update check skipped for development versions.""" + checker = UpdateChecker() + + with ( + patch( + "src.core.update_checker.get_update_check_enabled", return_value=True + ), + patch("core.build_info.VERSION", "dev"), + ): + assert checker.should_check_for_updates() is False + + with ( + patch( + "src.core.update_checker.get_update_check_enabled", return_value=True + ), + patch("core.build_info.VERSION", "dev-abc123"), + ): + assert checker.should_check_for_updates() is False + + def test_should_check_for_updates_time_based(self): + """Test update check timing logic for release versions.""" + checker = UpdateChecker() + + # Test with a release version (not dev) + with ( + patch( + "src.core.update_checker.get_update_check_enabled", return_value=True + ), + patch("src.core.update_checker.get_last_update_check_time", return_value=0), + patch("src.core.update_checker.time.time", return_value=100000), + patch("core.build_info.VERSION", "1.0.0"), + ): + assert checker.should_check_for_updates() is True + + # Test time not elapsed yet + with ( + patch( + "src.core.update_checker.get_update_check_enabled", return_value=True + ), + patch( + "src.core.update_checker.get_last_update_check_time", return_value=99999 + ), + patch("src.core.update_checker.time.time", return_value=100000), + patch("core.build_info.VERSION", "1.0.0"), + ): + assert checker.should_check_for_updates() is False + + @patch("src.core.update_checker.urlopen") + def test_check_for_updates_success(self, mock_urlopen): + """Test successful update check.""" + checker = UpdateChecker() + + # Mock GitHub API response + mock_response_data = { + "tag_name": "v1.0.3", + "html_url": "https://github.com/duartebarbosadev/PhotoSort/releases/tag/v1.0.3", + "body": "Bug fixes and improvements", + "published_at": "2025-01-01T00:00:00Z", + "assets": [ + { + "name": "PhotoSort-Windows-x64.exe", + "browser_download_url": "https://github.com/duartebarbosadev/PhotoSort/releases/download/v1.0.3/PhotoSort-Windows-x64.exe", + } + ], + } + + mock_response = Mock() + mock_response.status = 200 + mock_response.read.return_value = json.dumps(mock_response_data).encode("utf-8") + mock_urlopen.return_value.__enter__.return_value = mock_response + + with patch("src.core.update_checker.set_last_update_check_time"): + update_available, update_info, error = checker.check_for_updates("1.0.0") + + assert update_available is True + assert update_info is not None + assert update_info.version == "1.0.3" + assert error is None + + @patch("src.core.update_checker.urlopen") + def test_check_for_updates_no_update(self, mock_urlopen): + """Test update check when no update is available.""" + checker = UpdateChecker() + + # Mock GitHub API response with older version + mock_response_data = { + "tag_name": "v1.0.0", + "html_url": "https://github.com/duartebarbosadev/PhotoSort/releases/tag/v1.0.0", + "body": "Initial release", + "published_at": "2024-01-01T00:00:00Z", + "assets": [], + } + + mock_response = Mock() + mock_response.status = 200 + mock_response.read.return_value = json.dumps(mock_response_data).encode("utf-8") + mock_urlopen.return_value.__enter__.return_value = mock_response + + with patch("src.core.update_checker.set_last_update_check_time"): + update_available, update_info, error = checker.check_for_updates("1.0.0") + + assert update_available is False + assert update_info is None + assert error is None + + @patch("src.core.update_checker.urlopen") + def test_check_for_updates_network_error(self, mock_urlopen): + """Test update check with network error.""" + checker = UpdateChecker() + + mock_urlopen.side_effect = URLError("Network error") + + with patch("src.core.update_checker.set_last_update_check_time"): + update_available, update_info, error = checker.check_for_updates("1.0.0") + + assert update_available is False + assert update_info is None + assert "Network error" in error + + @patch("src.core.update_checker.urlopen") + def test_check_for_updates_http_error(self, mock_urlopen): + """Test update check with HTTP error.""" + checker = UpdateChecker() + + mock_urlopen.side_effect = HTTPError( + "http://example.com", 404, "Not Found", {}, None + ) + + with patch("src.core.update_checker.set_last_update_check_time"): + update_available, update_info, error = checker.check_for_updates("1.0.0") + + assert update_available is False + assert update_info is None + assert "HTTP error" in error + + @patch("platform.system") + def test_find_download_url_windows(self, mock_platform): + """Test finding Windows download URL.""" + checker = UpdateChecker() + mock_platform.return_value = "Windows" + + assets = [ + { + "name": "PhotoSort-Windows-x64.exe", + "browser_download_url": "https://example.com/windows.exe", + }, + { + "name": "PhotoSort-macOS-Intel.dmg", + "browser_download_url": "https://example.com/macos.dmg", + }, + ] + + url = checker._find_download_url(assets) + assert url == "https://example.com/windows.exe" + + @patch("platform.system") + def test_find_download_url_macos(self, mock_platform): + """Test finding macOS download URL.""" + checker = UpdateChecker() + mock_platform.return_value = "Darwin" + + assets = [ + { + "name": "PhotoSort-Windows-x64.exe", + "browser_download_url": "https://example.com/windows.exe", + }, + { + "name": "PhotoSort-macOS-Intel.dmg", + "browser_download_url": "https://example.com/macos.dmg", + }, + ] + + url = checker._find_download_url(assets) + assert url == "https://example.com/macos.dmg" + + @patch("platform.system") + def test_find_download_url_no_match(self, mock_platform): + """Test finding download URL when no platform match.""" + checker = UpdateChecker() + mock_platform.return_value = "Windows" + + assets = [ + { + "name": "PhotoSort-Linux-x64.tar.gz", + "browser_download_url": "https://example.com/linux.tar.gz", + } + ] + + url = checker._find_download_url(assets) + assert url is None + + +class TestUpdateInfo: + """Tests the UpdateInfo dataclass.""" + + def test_update_info_creation(self): + """Test creating UpdateInfo object.""" + info = UpdateInfo( + version="1.0.3", + release_url="https://github.com/example/repo/releases/tag/v1.0.3", + release_notes="Bug fixes", + published_at="2025-01-01T00:00:00Z", + download_url="https://github.com/example/repo/releases/download/v1.0.3/app.exe", + ) + + assert info.version == "1.0.3" + assert info.release_url == "https://github.com/example/repo/releases/tag/v1.0.3" + assert info.release_notes == "Bug fixes" + assert info.published_at == "2025-01-01T00:00:00Z" + assert ( + info.download_url + == "https://github.com/example/repo/releases/download/v1.0.3/app.exe" + ) + + def test_update_info_optional_download_url(self): + """Test UpdateInfo with optional download URL.""" + info = UpdateInfo( + version="1.0.3", + release_url="https://github.com/example/repo/releases/tag/v1.0.3", + release_notes="Bug fixes", + published_at="2025-01-01T00:00:00Z", + ) + + assert info.download_url is None