Spaces:
Running
Running
| # update_manager.py - Auto-update functionality for Glossarion | |
| import os | |
| import sys | |
| # Suppress Qt SSL warnings in frozen builds (no SSL backend available, using verify=none) | |
| if getattr(sys, 'frozen', False): | |
| os.environ['QT_LOGGING_RULES'] = 'qt.network.ssl=false' | |
| import json | |
| import requests | |
| import threading | |
| import concurrent.futures | |
| import time | |
| import re | |
| from typing import Optional, Dict, Tuple, List | |
| from packaging import version | |
| from PySide6.QtWidgets import ( | |
| QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, | |
| QRadioButton, QButtonGroup, QGroupBox, QTabWidget, QWidget, | |
| QTextEdit, QProgressBar, QMessageBox, QApplication | |
| ) | |
| from PySide6.QtCore import Qt, QTimer, QObject, QThread, Signal, Slot, QUrl | |
| from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply | |
| from PySide6.QtGui import QFont, QIcon, QPixmap, QTextCursor | |
| from datetime import datetime | |
| class UpdateCheckWorker(QThread): | |
| """Worker thread for checking updates in background""" | |
| update_checked = Signal(bool, object) # (update_available, release_data) | |
| error_occurred = Signal(str) # error message | |
| def __init__(self, update_manager, silent=True, force_show=False): | |
| super().__init__() | |
| self.update_manager = update_manager | |
| self.silent = silent | |
| self.force_show = force_show | |
| def run(self): | |
| """Run update check in background thread""" | |
| try: | |
| print("[DEBUG] Worker thread starting update check...") | |
| result = self.update_manager._check_for_updates_internal(self.silent, self.force_show) | |
| self.update_checked.emit(*result) | |
| except Exception as e: | |
| print(f"[DEBUG] Worker thread error: {e}") | |
| self.error_occurred.emit(str(e)) | |
| class DownloadWorker(QObject): | |
| """Worker that downloads a file and emits progress via Qt signals""" | |
| progress = Signal(int, float, float) # percent (-1 if unknown), downloaded_MB, total_MB | |
| finished = Signal(str) # file path | |
| error = Signal(str) | |
| cancelled = Signal() | |
| def __init__(self, url: str, path: str): | |
| super().__init__() | |
| self.url = url | |
| self.path = path | |
| self._cancel = False | |
| def run(self): | |
| print(f"[DEBUG] DownloadWorker.run() started for {self.url}") | |
| try: | |
| # Get certifi CA bundle path for SSL verification in frozen builds | |
| try: | |
| import certifi | |
| ca_bundle = certifi.where() | |
| print(f"[DEBUG] Using CA bundle: {ca_bundle}") | |
| except Exception as e: | |
| print(f"[DEBUG] Certifi error: {e}, using default SSL") | |
| ca_bundle = True # Use default | |
| with requests.Session() as s: | |
| self._session = s | |
| headers = { | |
| 'User-Agent': 'Glossarion-Updater', | |
| 'Accept': 'application/octet-stream' | |
| } | |
| print(f"[DEBUG] Making GET request...") | |
| with s.get(self.url, headers=headers, stream=True, timeout=(60, 300), allow_redirects=True, verify=ca_bundle) as r: | |
| print(f"[DEBUG] Got response: {r.status_code}") | |
| r.raise_for_status() | |
| total_size = int(r.headers.get('content-length', 0)) | |
| print(f"[DEBUG] Content length: {total_size} bytes") | |
| downloaded = 0 | |
| last_emit_percent = -1 | |
| last_emit_time = time.time() | |
| print(f"[DEBUG] Opening file for writing: {self.path}") | |
| with open(self.path, 'wb') as f: | |
| print("[DEBUG] Starting chunk iteration...") | |
| chunk_count = 0 | |
| for chunk in r.iter_content(chunk_size=65536): | |
| chunk_count += 1 | |
| if chunk_count == 1: | |
| print(f"[DEBUG] First chunk received, size: {len(chunk) if chunk else 0}") | |
| if self._cancel: | |
| try: | |
| f.flush() | |
| except Exception: | |
| pass | |
| try: | |
| f.close() | |
| except Exception: | |
| pass | |
| try: | |
| if os.path.exists(self.path): | |
| os.remove(self.path) | |
| except Exception: | |
| pass | |
| self.cancelled.emit() | |
| return | |
| if not chunk: | |
| continue | |
| f.write(chunk) | |
| downloaded += len(chunk) | |
| now = time.time() | |
| if total_size > 0: | |
| p = int(downloaded * 100 / total_size) | |
| # Throttle: emit only when percent changes or 0.2s passed | |
| if p != last_emit_percent or (now - last_emit_time) >= 0.2: | |
| last_emit_percent = p | |
| last_emit_time = now | |
| self.progress.emit(p, downloaded / (1024 * 1024), total_size / (1024 * 1024)) | |
| else: | |
| # Unknown total size (no content-length) | |
| if (now - last_emit_time) >= 0.5: | |
| last_emit_time = now | |
| self.progress.emit(-1, downloaded / (1024 * 1024), 0.0) | |
| # Emit final progress and finished signal | |
| try: | |
| if total_size > 0: | |
| self.progress.emit(100, downloaded / (1024 * 1024), total_size / (1024 * 1024)) | |
| except Exception: | |
| pass | |
| self.finished.emit(self.path) | |
| except Exception as e: | |
| print(f"[DEBUG] DownloadWorker error: {type(e).__name__}: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| # On error, ensure partial file is removed | |
| try: | |
| if os.path.exists(self.path): | |
| os.remove(self.path) | |
| except Exception: | |
| pass | |
| self.error.emit(str(e)) | |
| def cancel(self): | |
| self._cancel = True | |
| try: | |
| # Attempt to close the session to unblock reads | |
| getattr(self, "_session", None) and self._session.close() | |
| except Exception: | |
| pass | |
| class QtDownloadJob(QObject): | |
| progress = Signal(int, float, float) | |
| finished = Signal(str) | |
| error = Signal(int, str) # (QNetworkReply.NetworkError, message) | |
| cancelled = Signal() | |
| def __init__(self): | |
| super().__init__() | |
| self.nam = QNetworkAccessManager(self) | |
| self.reply: QNetworkReply | None = None | |
| self._file = None | |
| self._path = None | |
| self._was_cancelled = False | |
| def start(self, url: str, path: str): | |
| self._path = path | |
| try: | |
| self._file = open(path, 'wb') | |
| except Exception as e: | |
| self.error.emit(int(QNetworkReply.UnknownNetworkError), f"Cannot open file for writing: {e}") | |
| return | |
| req = QNetworkRequest(QUrl(url)) | |
| req.setRawHeader(b'User-Agent', b'Glossarion-Updater') | |
| req.setRawHeader(b'Accept', b'application/octet-stream') | |
| # Follow safe redirects | |
| try: | |
| req.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True) | |
| except Exception: | |
| pass | |
| # Disable SSL verification in frozen builds (no SSL backend available) | |
| try: | |
| import sys | |
| if getattr(sys, 'frozen', False): | |
| from PySide6.QtNetwork import QSslConfiguration | |
| ssl_config = QSslConfiguration.defaultConfiguration() | |
| ssl_config.setPeerVerifyMode(QSslConfiguration.VerifyNone) | |
| req.setSslConfiguration(ssl_config) | |
| except Exception: | |
| pass | |
| self.reply = self.nam.get(req) | |
| # Ignore SSL errors in frozen builds (no SSL backend) | |
| try: | |
| if getattr(sys, 'frozen', False): | |
| self.reply.ignoreSslErrors() | |
| except Exception: | |
| pass | |
| self.reply.downloadProgress.connect(self._on_progress) | |
| self.reply.readyRead.connect(self._on_ready_read) | |
| self.reply.finished.connect(self._on_finished) | |
| self.reply.errorOccurred.connect(self._on_error) | |
| def cancel(self): | |
| self._was_cancelled = True | |
| # Close and delete partial file ASAP | |
| try: | |
| if self._file: | |
| try: | |
| self._file.flush() | |
| except Exception: | |
| pass | |
| try: | |
| self._file.close() | |
| except Exception: | |
| pass | |
| if self._path and os.path.exists(self._path): | |
| os.remove(self._path) | |
| except Exception: | |
| pass | |
| try: | |
| if self.reply: | |
| self.reply.abort() | |
| except Exception: | |
| pass | |
| # Emit cancelled immediately so UI can update without waiting for Qt signals | |
| try: | |
| self.cancelled.emit() | |
| except Exception: | |
| pass | |
| def _on_ready_read(self): | |
| try: | |
| # Bail early if cancelled or no reply/file | |
| if self._was_cancelled or not self.reply or not self._file: | |
| return | |
| # Avoid reading from closed device | |
| try: | |
| if hasattr(self.reply, 'isOpen') and not self.reply.isOpen(): | |
| return | |
| except Exception: | |
| pass | |
| data = self.reply.readAll() | |
| if data: | |
| self._file.write(bytes(data)) | |
| except Exception as e: | |
| self.error.emit(str(e)) | |
| def _on_progress(self, bytes_received: int, bytes_total: int): | |
| try: | |
| if self._was_cancelled: | |
| return | |
| if bytes_total > 0: | |
| p = int(bytes_received * 100 / bytes_total) | |
| self.progress.emit(p, bytes_received / (1024 * 1024), bytes_total / (1024 * 1024)) | |
| else: | |
| self.progress.emit(-1, bytes_received / (1024 * 1024), 0.0) | |
| except Exception: | |
| pass | |
| def _on_finished(self): | |
| try: | |
| # Safely close file; only drain buffer when not cancelled and on success | |
| if self._file: | |
| try: | |
| if not self._was_cancelled and self.reply and self.reply.error() == QNetworkReply.NoError: | |
| # Drain any remaining data | |
| self._on_ready_read() | |
| except Exception: | |
| pass | |
| # Flush/close defensively (file may already be closed) | |
| try: | |
| if hasattr(self._file, 'closed') and not self._file.closed: | |
| self._file.flush() | |
| except Exception: | |
| pass | |
| try: | |
| if hasattr(self._file, 'closed') and not self._file.closed: | |
| self._file.close() | |
| except Exception: | |
| pass | |
| if self.reply: | |
| if self._was_cancelled or self.reply.error() == QNetworkReply.OperationCanceledError: | |
| # Ensure partial file is deleted on cancel | |
| try: | |
| if self._path and os.path.exists(self._path): | |
| os.remove(self._path) | |
| except Exception: | |
| pass | |
| try: | |
| self.cancelled.emit() | |
| except Exception: | |
| pass | |
| return | |
| if self.reply.error() == QNetworkReply.NoError: | |
| self.finished.emit(self._path) | |
| return | |
| finally: | |
| try: | |
| if self.reply: | |
| self.reply.deleteLater() | |
| except Exception: | |
| pass | |
| self.reply = None | |
| self._file = None | |
| def _on_error(self, code): | |
| try: | |
| err = self.reply.errorString() if self.reply else str(code) | |
| except Exception: | |
| err = str(code) | |
| # Treat user-initiated abort as cancellation, not an error dialog | |
| try: | |
| if code == QNetworkReply.OperationCanceledError: | |
| self.cancelled.emit() | |
| return | |
| except Exception: | |
| pass | |
| # Convert code to int (it's a QNetworkReply.NetworkError enum) | |
| try: | |
| error_code = int(code) | |
| except (TypeError, ValueError): | |
| error_code = -1 | |
| self.error.emit(error_code, err) | |
| class UpdateManager(QObject): | |
| """Handles automatic update checking and installation for Glossarion""" | |
| GITHUB_API_URL = "https://api.github.com/repos/Shirochi-stack/Glossarion/releases" | |
| GITHUB_LATEST_URL = "https://api.github.com/repos/Shirochi-stack/Glossarion/releases/latest" | |
| # Signals for thread-safe communication | |
| _download_progress_signal = Signal(int, float, float) | |
| _download_complete_signal = Signal(str) | |
| _download_error_signal = Signal(str) | |
| _download_cancelled_signal = Signal() | |
| def __init__(self, main_gui, base_dir): | |
| super().__init__() | |
| self.main_gui = main_gui | |
| self.dialog = main_gui # Set dialog as the main GUI window for message box parent | |
| self.base_dir = base_dir | |
| self.update_available = False | |
| self._check_in_progress = False # Prevent concurrent checks | |
| self._current_dialog = None # Store current dialog for signal handlers | |
| # Use shared executor from main GUI if available | |
| try: | |
| if hasattr(self.main_gui, '_ensure_executor'): | |
| self.main_gui._ensure_executor() | |
| self.executor = getattr(self.main_gui, 'executor', None) | |
| except Exception: | |
| self.executor = None | |
| self.latest_release = None | |
| self.all_releases = [] # Store all fetched releases | |
| self.download_progress = 0 | |
| self.is_downloading = False | |
| # Load persistent check time from config | |
| self._last_check_time = self.main_gui.config.get('last_update_check_time', 0) | |
| self._check_cache_duration = 1800 # Cache for 30 minutes | |
| self.selected_asset = None # Store selected asset for download | |
| # Get version from the main GUI's __version__ variable | |
| if hasattr(main_gui, '__version__'): | |
| self.CURRENT_VERSION = main_gui.__version__ | |
| else: | |
| # Extract from window title as fallback | |
| title = self.main_gui.windowTitle() | |
| if 'v' in title: | |
| self.CURRENT_VERSION = title.split('v')[-1].strip() | |
| else: | |
| self.CURRENT_VERSION = "0.0.0" | |
| def _load_halgakos_pixmap(self, logical_size: int = 72): | |
| """Load Halgakos icon with HiDPI scaling.""" | |
| try: | |
| ico_path = os.path.join(self.base_dir, 'Halgakos.ico') | |
| if not os.path.isfile(ico_path): | |
| return None | |
| pm = QPixmap(ico_path) | |
| if pm.isNull(): | |
| return None | |
| screen = QApplication.primaryScreen() | |
| dpr = screen.devicePixelRatio() if screen else 1.0 | |
| target = int(logical_size * dpr) | |
| scaled = pm.scaled(target, target, Qt.KeepAspectRatio, Qt.SmoothTransformation) | |
| scaled.setDevicePixelRatio(dpr) | |
| return scaled | |
| except Exception: | |
| return None | |
| def fetch_multiple_releases(self, count=10) -> List[Dict]: | |
| """Fetch multiple releases from GitHub | |
| Args: | |
| count: Number of releases to fetch | |
| Returns: | |
| List of release data dictionaries | |
| """ | |
| try: | |
| headers = { | |
| 'Accept': 'application/vnd.github.v3+json', | |
| 'User-Agent': 'Glossarion-Updater' | |
| } | |
| # Fetch multiple releases with minimal retry logic | |
| max_retries = 1 # Reduced to prevent hanging | |
| timeout = 20 # Very short timeout | |
| for attempt in range(max_retries + 1): | |
| try: | |
| response = requests.get( | |
| f"{self.GITHUB_API_URL}?per_page={count}", | |
| headers=headers, | |
| timeout=timeout | |
| ) | |
| response.raise_for_status() | |
| break # Success | |
| except (requests.Timeout, requests.ConnectionError) as e: | |
| if attempt == max_retries: | |
| raise # Re-raise after final attempt | |
| time.sleep(1) | |
| releases = response.json() | |
| # Process each release's notes | |
| for release in releases: | |
| if 'body' in release and release['body']: | |
| # Clean up but don't truncate for history viewing | |
| body = release['body'] | |
| # Just clean up excessive newlines | |
| body = re.sub(r'\n{3,}', '\n\n', body) | |
| release['body'] = body | |
| return releases | |
| except Exception as e: | |
| print(f"Error fetching releases: {e}") | |
| return [] | |
| def check_for_updates_async(self, silent=True, force_show=False): | |
| """Run check_for_updates in background using QThread (PySide6 compatible). | |
| """ | |
| print("[DEBUG] Starting background update check with QThread") | |
| # Prevent concurrent update checks | |
| if self._check_in_progress: | |
| print("[DEBUG] Update check already in progress, skipping...") | |
| return None | |
| self._check_in_progress = True | |
| # Show loading dialog for manual checks (when not silent) | |
| if not silent: | |
| self._show_loading_dialog() | |
| # Create and start worker thread | |
| self.worker = UpdateCheckWorker(self, silent, force_show) | |
| self.worker.update_checked.connect(self._on_update_checked) | |
| self.worker.error_occurred.connect(self._on_update_error) | |
| self.worker.finished.connect(self._on_update_finished) # Clean up flag | |
| self.worker.start() | |
| return None # Async, results will come via signals | |
| def _on_update_checked(self, update_available, release_data): | |
| """Handle update check results from worker thread""" | |
| print(f"[DEBUG] Update check completed: available={update_available}") | |
| if update_available or self.worker.force_show: | |
| self.show_update_dialog() | |
| def _on_update_error(self, error_msg): | |
| """Handle update check error from worker thread""" | |
| print(f"[DEBUG] Update check error: {error_msg}") | |
| # Close loading dialog first | |
| self._close_loading_dialog() | |
| if not self.worker.silent: | |
| msg = QMessageBox(self.dialog if hasattr(self.dialog, 'show') else None) | |
| msg.setIcon(QMessageBox.Critical) | |
| msg.setWindowTitle("Update Check Failed") | |
| msg.setText(f"Failed to check for updates: {error_msg}") | |
| msg.exec() | |
| def _on_update_finished(self): | |
| """Clean up after update check is finished""" | |
| print("[DEBUG] Update check finished, resetting progress flag") | |
| self._check_in_progress = False | |
| # Close loading dialog if it exists | |
| self._close_loading_dialog() | |
| def _check_for_updates_internal(self, silent=True, force_show=False) -> Tuple[bool, Optional[Dict]]: | |
| """Check GitHub for newer releases | |
| Args: | |
| silent: If True, don't show error messages | |
| force_show: If True, show the dialog even when up to date | |
| Returns: | |
| Tuple of (update_available, release_info) | |
| """ | |
| print("[DEBUG] _check_for_updates_internal called") | |
| try: | |
| # Check if we need to skip the check due to cache | |
| current_time = time.time() | |
| if not force_show and (current_time - self._last_check_time) < self._check_cache_duration: | |
| print(f"[DEBUG] Skipping update check - cache still valid for {int(self._check_cache_duration - (current_time - self._last_check_time))} seconds") | |
| return False, None | |
| # Check if this version was previously skipped | |
| skipped_versions = self.main_gui.config.get('skipped_versions', []) | |
| headers = { | |
| 'Accept': 'application/vnd.github.v3+json', | |
| 'User-Agent': 'Glossarion-Updater' | |
| } | |
| # Try with reasonable timeout and minimal retries to prevent hanging | |
| max_retries = 0 # No retries to prevent hanging | |
| timeout = 30 # Reasonable timeout | |
| for attempt in range(max_retries + 1): | |
| try: | |
| print(f"[DEBUG] Update check attempt {attempt + 1}/{max_retries + 1}") | |
| response = requests.get(self.GITHUB_LATEST_URL, headers=headers, timeout=timeout) | |
| response.raise_for_status() | |
| break # Success, exit retry loop | |
| except (requests.Timeout, requests.ConnectionError) as e: | |
| if attempt == max_retries: | |
| # Last attempt failed, save check time and re-raise | |
| self._save_last_check_time() | |
| raise | |
| print(f"[DEBUG] Network error on attempt {attempt + 1}: {e}") | |
| time.sleep(1) # Short delay before retry | |
| release_data = response.json() | |
| latest_version = release_data['tag_name'].lstrip('v') | |
| # Save successful check time | |
| self._save_last_check_time() | |
| # Fetch all releases for history regardless (with timeout protection) | |
| try: | |
| self.all_releases = self.fetch_multiple_releases(count=10) | |
| except Exception as e: | |
| print(f"[DEBUG] Could not fetch release history: {e}") | |
| self.all_releases = [release_data] # Use just the latest release | |
| self.latest_release = release_data | |
| # Check if this version was skipped by user | |
| if release_data['tag_name'] in skipped_versions and not force_show: | |
| return False, None | |
| # Compare versions | |
| if version.parse(latest_version) > version.parse(self.CURRENT_VERSION): | |
| self.update_available = True | |
| # Update available - will be handled by signal | |
| print(f"[DEBUG] Update available for version {latest_version}") | |
| return True, release_data | |
| else: | |
| # We're up to date | |
| self.update_available = False | |
| # Dialog will be shown via signal if force_show is True | |
| return False, None | |
| except requests.Timeout: | |
| if not silent: | |
| msg = QMessageBox(self.dialog if hasattr(self.dialog, 'show') else None) | |
| msg.setIcon(QMessageBox.Critical) | |
| msg.setWindowTitle("Update Check Failed") | |
| msg.setText("Connection timed out while checking for updates.\n\n" | |
| "This is usually due to network connectivity issues.\n" | |
| "The next update check will be in 1 hour.") | |
| msg.exec() | |
| return False, None | |
| except requests.ConnectionError as e: | |
| if not silent: | |
| msg = QMessageBox(self.dialog if hasattr(self.dialog, 'show') else None) | |
| msg.setIcon(QMessageBox.Critical) | |
| msg.setWindowTitle("Update Check Failed") | |
| if 'api.github.com' in str(e): | |
| msg.setText("Cannot reach GitHub servers for update check.\n\n" | |
| "This may be due to:\n" | |
| "• Internet connectivity issues\n" | |
| "• Firewall blocking GitHub API\n" | |
| "• GitHub API temporarily unavailable\n\n" | |
| "The next update check will be in 1 hour.") | |
| else: | |
| msg.setText(f"Network error: {str(e)}\n\n" | |
| "The next update check will be in 1 hour.") | |
| msg.exec() | |
| return False, None | |
| except requests.HTTPError as e: | |
| if not silent: | |
| msg = QMessageBox(self.dialog if hasattr(self.dialog, 'show') else None) | |
| msg.setIcon(QMessageBox.Critical) | |
| msg.setWindowTitle("Update Check Failed") | |
| if e.response.status_code == 403: | |
| msg.setText("GitHub API rate limit exceeded. Please try again later.") | |
| else: | |
| msg.setText(f"GitHub returned error: {e.response.status_code}") | |
| msg.exec() | |
| return False, None | |
| except ValueError as e: | |
| if not silent: | |
| msg = QMessageBox(self.dialog if hasattr(self.dialog, 'show') else None) | |
| msg.setIcon(QMessageBox.Critical) | |
| msg.setWindowTitle("Update Check Failed") | |
| msg.setText("Invalid response from GitHub. The update service may be temporarily unavailable.") | |
| msg.exec() | |
| return False, None | |
| except Exception as e: | |
| if not silent: | |
| msg = QMessageBox(self.dialog if hasattr(self.dialog, 'show') else None) | |
| msg.setIcon(QMessageBox.Critical) | |
| msg.setWindowTitle("Update Check Failed") | |
| msg.setText(f"An unexpected error occurred:\n{str(e)}") | |
| msg.exec() | |
| return False, None | |
| def check_for_updates_manual(self): | |
| """Manual update check from menu - always shows dialog (async)""" | |
| return self.check_for_updates_async(silent=False, force_show=True) | |
| def check_for_updates(self, silent=True, force_show=False): | |
| """Public method for checking updates - delegates to async method""" | |
| return self.check_for_updates_async(silent=silent, force_show=force_show) | |
| def _save_last_check_time(self): | |
| """Save the last update check time to config""" | |
| try: | |
| current_time = time.time() | |
| self._last_check_time = current_time | |
| self.main_gui.config['last_update_check_time'] = current_time | |
| # Save config without showing message | |
| self.main_gui.save_config(show_message=False) | |
| except Exception as e: | |
| print(f"[DEBUG] Failed to save last check time: {e}") | |
| def format_markdown_to_qt(self, text_widget, markdown_text): | |
| """Convert GitHub markdown to formatted Qt text - simplified version | |
| Args: | |
| text_widget: The QTextEdit widget to insert formatted text into | |
| markdown_text: The markdown source text | |
| """ | |
| # Set default font | |
| default_font = QFont() | |
| default_font.setPointSize(10) | |
| text_widget.setFont(default_font) | |
| # Process text line by line with minimal formatting | |
| lines = markdown_text.split('\n') | |
| cursor = text_widget.textCursor() | |
| cursor.movePosition(QTextCursor.End) | |
| for line in lines: | |
| # Strip any weird unicode characters that might cause display issues | |
| line = ''.join(char for char in line if ord(char) < 65536) | |
| # Handle headings | |
| if line.startswith('#'): | |
| # Remove all # symbols and get the heading text | |
| heading_text = line.lstrip('#').strip() | |
| if heading_text: | |
| cursor.insertText(heading_text + '\n') | |
| # Make it bold by moving back and applying format | |
| cursor.movePosition(QTextCursor.PreviousBlock) | |
| cursor.select(QTextCursor.BlockUnderCursor) | |
| fmt = cursor.charFormat() | |
| font = QFont() | |
| font.setBold(True) | |
| font.setPointSize(12) | |
| fmt.setFont(font) | |
| cursor.mergeCharFormat(fmt) | |
| cursor.movePosition(QTextCursor.End) | |
| # Handle bullet points | |
| elif line.strip().startswith(('- ', '* ')): | |
| # Get the text after the bullet | |
| bullet_text = line.strip()[2:].strip() | |
| # Clean the text of markdown formatting | |
| bullet_text = self._clean_markdown_text(bullet_text) | |
| cursor.insertText(' • ' + bullet_text + '\n') | |
| # Handle numbered lists | |
| elif re.match(r'^\s*\d+\.\s', line): | |
| # Extract number and text | |
| match = re.match(r'^(\s*)(\d+)\.\s(.+)', line) | |
| if match: | |
| indent, num, text = match.groups() | |
| clean_text = self._clean_markdown_text(text.strip()) | |
| cursor.insertText(f' {num}. {clean_text}\n') | |
| # Handle separator lines | |
| elif line.strip() in ['---', '***', '___']: | |
| cursor.insertText('─' * 40 + '\n') | |
| # Handle code blocks - just skip the markers | |
| elif line.strip().startswith('```'): | |
| continue # Skip code fence markers | |
| # Regular text | |
| elif line.strip(): | |
| # Clean and insert the line | |
| clean_text = self._clean_markdown_text(line) | |
| cursor.insertText(clean_text + '\n') | |
| # Empty lines | |
| else: | |
| cursor.insertText('\n') | |
| # Move cursor to start and scroll to top | |
| cursor.movePosition(QTextCursor.Start) | |
| text_widget.setTextCursor(cursor) | |
| def _clean_markdown_text(self, text): | |
| """Remove markdown formatting from text | |
| Args: | |
| text: Text with markdown formatting | |
| Returns: | |
| Clean text without markdown symbols | |
| """ | |
| # Remove inline code backticks | |
| text = re.sub(r'`([^`]+)`', r'\1', text) | |
| # Remove bold markers | |
| text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text) | |
| text = re.sub(r'__([^_]+)__', r'\1', text) | |
| # Remove italic markers | |
| text = re.sub(r'\*([^*]+)\*', r'\1', text) | |
| text = re.sub(r'_([^_]+)_', r'\1', text) | |
| # Remove links but keep link text | |
| text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) | |
| # Remove any remaining special characters that might cause issues | |
| text = text.replace('\u200b', '') # Remove zero-width spaces | |
| text = text.replace('\ufeff', '') # Remove BOM | |
| return text.strip() | |
| def _show_loading_dialog(self): | |
| """Show loading dialog during update check""" | |
| from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar | |
| from PySide6.QtCore import Qt, QTimer | |
| from PySide6.QtGui import QIcon, QPixmap | |
| import os | |
| # Get the main GUI window for parenting - ensure it's a proper QWidget | |
| from PySide6.QtWidgets import QWidget | |
| parent = None | |
| if hasattr(self.dialog, 'show') and isinstance(self.dialog, QWidget): | |
| parent = self.dialog | |
| # Create loading dialog | |
| self.loading_dialog = QDialog(parent) | |
| self.loading_dialog.setWindowTitle("Checking for Updates") | |
| # Size by screen ratio for HiDPI friendliness | |
| screen_rect = QApplication.primaryScreen().geometry() | |
| dlg_w = max(300, int(screen_rect.width() * 0.17)) | |
| dlg_h = max(170, int(screen_rect.height() * 0.17)) | |
| self.loading_dialog.setMinimumSize(dlg_w, dlg_h) | |
| self.loading_dialog.resize(dlg_w, dlg_h) | |
| self.loading_dialog.setModal(True) | |
| # Set the proper application icon for the dialog | |
| try: | |
| ico_path = os.path.join(self.base_dir, 'Halgakos.ico') | |
| if os.path.isfile(ico_path): | |
| self.loading_dialog.setWindowIcon(QIcon(ico_path)) | |
| except Exception as e: | |
| print(f"Could not load icon for loading dialog: {e}") | |
| # Position dialog at center of parent | |
| if parent: | |
| self.loading_dialog.move(parent.geometry().center() - self.loading_dialog.rect().center()) | |
| # Create main layout | |
| main_layout = QVBoxLayout(self.loading_dialog) | |
| main_layout.setContentsMargins(20, 24, 20, 24) | |
| main_layout.setSpacing(16) | |
| # Try to load and resize the icon (HiDPI-aware 72x72 logical) | |
| try: | |
| icon_pixmap = self._load_halgakos_pixmap(96) | |
| if icon_pixmap: | |
| icon_label = QLabel() | |
| icon_label.setPixmap(icon_pixmap) | |
| icon_label.setAlignment(Qt.AlignCenter) | |
| icon_label.setFixedSize(96, 96) | |
| main_layout.addWidget(icon_label, 0, Qt.AlignHCenter) | |
| main_layout.addSpacing(24) | |
| except Exception as e: | |
| print(f"Could not load loading icon: {e}") | |
| # Add loading text | |
| self.loading_text = QLabel("Checking for updates...") | |
| self.loading_text.setAlignment(Qt.AlignCenter) | |
| main_layout.addWidget(self.loading_text) | |
| # Add progress bar (centered) | |
| progress_bar = QProgressBar() | |
| progress_bar.setRange(0, 0) # Indeterminate mode | |
| progress_bar.setTextVisible(False) | |
| progress_bar.setFixedWidth(240) | |
| main_layout.addWidget(progress_bar, 0, Qt.AlignHCenter) | |
| # Animation state | |
| self.loading_animation_active = True | |
| self.loading_rotation = 0 | |
| def animate_text(): | |
| """Animate the loading text""" | |
| if not self.loading_animation_active or not hasattr(self, 'loading_text'): | |
| return | |
| try: | |
| # Simple text-based animation | |
| dots = "." * ((self.loading_rotation // 10) % 4) | |
| self.loading_text.setText(f"Checking for updates{dots}") | |
| self.loading_rotation += 1 | |
| # Schedule next animation frame | |
| QTimer.singleShot(100, animate_text) | |
| except: | |
| pass # Dialog might have been destroyed | |
| # Start text animation | |
| animate_text() | |
| # Show the dialog | |
| self.loading_dialog.show() | |
| def _close_loading_dialog(self): | |
| """Close the loading dialog if it exists""" | |
| try: | |
| if hasattr(self, 'loading_animation_active'): | |
| self.loading_animation_active = False | |
| if hasattr(self, 'loading_dialog') and self.loading_dialog: | |
| self.loading_dialog.close() | |
| delattr(self, 'loading_dialog') | |
| except: | |
| pass # Dialog might already be destroyed | |
| def show_update_dialog(self): | |
| """Show update dialog (for updates or version history)""" | |
| print("[DEBUG] show_update_dialog called") | |
| if not self.latest_release and not self.all_releases: | |
| print("[DEBUG] No release data, trying to fetch...") | |
| # Try to fetch releases if we don't have them | |
| try: | |
| self.all_releases = self.fetch_multiple_releases(count=10) | |
| if self.all_releases: | |
| self.latest_release = self.all_releases[0] | |
| print(f"[DEBUG] Fetched {len(self.all_releases)} releases") | |
| else: | |
| print("[DEBUG] No releases fetched") | |
| msg = QMessageBox(self.dialog if hasattr(self.dialog, 'show') else None) | |
| msg.setIcon(QMessageBox.Critical) | |
| msg.setWindowTitle("Error") | |
| msg.setText("Unable to fetch version information from GitHub.") | |
| msg.exec() | |
| return | |
| except Exception as e: | |
| print(f"[DEBUG] Error fetching releases in show_update_dialog: {e}") | |
| return | |
| # Set appropriate title | |
| if self.update_available: | |
| title = "Update Available" | |
| else: | |
| title = "Version History" | |
| print(f"[DEBUG] Creating update dialog with title: {title}") | |
| # Use existing QApplication instance - never create a new one | |
| app = QApplication.instance() | |
| if not app: | |
| print("[ERROR] No QApplication instance found - update dialog cannot be shown") | |
| return | |
| # Determine parent window for positioning reference only | |
| from PySide6.QtWidgets import QWidget | |
| reference_widget = None | |
| try: | |
| # Check if there's an active modal widget (e.g., Other Settings dialog) | |
| reference_widget = app.activeModalWidget() | |
| if not reference_widget: | |
| # Fall back to active window | |
| reference_widget = app.activeWindow() | |
| if not reference_widget: | |
| # Fall back to main GUI if it's a proper QWidget | |
| if hasattr(self.dialog, 'show') and isinstance(self.dialog, QWidget): | |
| reference_widget = self.dialog | |
| except Exception: | |
| if hasattr(self.dialog, 'show') and isinstance(self.dialog, QWidget): | |
| reference_widget = self.dialog | |
| # Create dialog with parent to prevent it from minimizing the parent | |
| dialog = QDialog(reference_widget) | |
| dialog.setWindowTitle(title) | |
| dialog.setModal(False) # Non-modal so it doesn't block other windows | |
| # Apply dark theme styling to fix white background | |
| dialog.setStyleSheet(""" | |
| QDialog { | |
| background-color: #1e1e1e; | |
| color: white; | |
| } | |
| QWidget { | |
| background-color: #1e1e1e; | |
| color: white; | |
| } | |
| QLabel { | |
| color: white; | |
| background-color: transparent; | |
| } | |
| """) | |
| print(f"[DEBUG] Dialog created successfully with dark theme") | |
| # Get screen dimensions and calculate size | |
| screen = app.primaryScreen().geometry() | |
| dialog_width = int(screen.width() * 0.25) | |
| dialog_height = int(screen.height() * 0.8) | |
| dialog.resize(dialog_width, dialog_height) | |
| # Set icon if available | |
| icon_path = os.path.join(self.base_dir, 'halgakos.ico') | |
| if os.path.exists(icon_path): | |
| dialog.setWindowIcon(QIcon(icon_path)) | |
| # Apply additional widget styling (radio buttons, group boxes, tabs) | |
| dialog.setStyleSheet(dialog.styleSheet() + """ | |
| QRadioButton { | |
| color: white; | |
| spacing: 6px; | |
| } | |
| QRadioButton::indicator { | |
| width: 14px; | |
| height: 14px; | |
| border: 1px solid #5a9fd4; | |
| border-radius: 7px; | |
| background-color: #2d2d2d; | |
| } | |
| QRadioButton::indicator:hover { | |
| border: 1px solid #7ab8e8; | |
| background-color: #3d3d3d; | |
| } | |
| QRadioButton::indicator:checked { | |
| background-color: #5a9fd4; | |
| border: 1px solid #5a9fd4; | |
| } | |
| QRadioButton::indicator:checked:hover { | |
| background-color: #7ab8e8; | |
| border: 1px solid #7ab8e8; | |
| } | |
| QRadioButton::indicator:disabled { | |
| border: 1px solid #555555; | |
| background-color: #1e1e1e; | |
| } | |
| QRadioButton::indicator:checked:disabled { | |
| background-color: #5a9fd4; | |
| border: 1px solid #5a9fd4; | |
| } | |
| QGroupBox { | |
| color: white; | |
| border: 1px solid #555555; | |
| border-radius: 4px; | |
| margin-top: 10px; | |
| padding-top: 10px; | |
| } | |
| QGroupBox::title { | |
| subcontrol-origin: margin; | |
| subcontrol-position: top left; | |
| left: 10px; | |
| padding: 0 5px; | |
| color: #5a9fd4; | |
| font-weight: bold; | |
| } | |
| QTabWidget { | |
| background-color: #1b1b1b; | |
| } | |
| QTabWidget::pane { | |
| border: 1px solid #5a9fd4; | |
| border-radius: 6px; | |
| top: 12px; | |
| background-color: #111111; | |
| } | |
| QTabWidget::tab-bar { | |
| alignment: center; | |
| } | |
| QTabBar::tab { | |
| background: #2b2b2b; | |
| color: #cfcfcf; | |
| padding: 6px 14px; | |
| border: 1px solid #3d3d3d; | |
| border-bottom: none; | |
| border-top-left-radius: 6px; | |
| border-top-right-radius: 6px; | |
| margin-right: 4px; | |
| min-width: 70px; | |
| font-weight: 600; | |
| } | |
| QTabBar::tab:!selected { | |
| margin-top: 4px; | |
| color: #9fa6b2; | |
| background: #1f1f1f; | |
| } | |
| QTabBar::tab:hover { | |
| background: #3a3a3a; | |
| color: white; | |
| border-color: #5a9fd4; | |
| } | |
| QTabBar::tab:selected { | |
| background: qlineargradient(x1:0, y1:0, x2:0, y2:1, | |
| stop:0 #65b0ff, stop:1 #3c7dd9); | |
| color: white; | |
| border-color: #5a9fd4; | |
| } | |
| QTabBar::tab:disabled { | |
| color: #555555; | |
| } | |
| """) | |
| # Populate content | |
| self._populate_update_dialog(dialog, reference_widget) | |
| def _populate_update_dialog(self, dialog, reference_widget=None): | |
| """Populate the update dialog content""" | |
| # Main layout | |
| main_layout = QVBoxLayout() | |
| main_layout.setContentsMargins(20, 20, 20, 20) | |
| main_layout.setSpacing(10) | |
| # Title row with Halgakos icons (72x72 logical) | |
| title_container = QWidget() | |
| title_layout = QHBoxLayout(title_container) | |
| title_layout.setContentsMargins(0, 0, 0, 0) | |
| title_layout.setSpacing(14) | |
| title_layout.setAlignment(Qt.AlignCenter) | |
| left_pm = self._load_halgakos_pixmap(72) | |
| left_icon = QLabel() | |
| if left_pm: | |
| left_icon.setPixmap(left_pm) | |
| left_icon.setFixedSize(72, 72) | |
| left_icon.setAlignment(Qt.AlignCenter) | |
| title_layout.addWidget(left_icon, 0, Qt.AlignVCenter) | |
| title_label = QLabel(dialog.windowTitle()) | |
| title_font = QFont() | |
| title_font.setPointSize(14) | |
| title_font.setBold(True) | |
| title_label.setFont(title_font) | |
| title_label.setAlignment(Qt.AlignCenter) | |
| title_layout.addWidget(title_label, 0, Qt.AlignVCenter) | |
| right_pm = self._load_halgakos_pixmap(72) | |
| right_icon = QLabel() | |
| if right_pm: | |
| right_icon.setPixmap(right_pm) | |
| right_icon.setFixedSize(72, 72) | |
| right_icon.setAlignment(Qt.AlignCenter) | |
| title_layout.addWidget(right_icon, 0, Qt.AlignVCenter) | |
| main_layout.addWidget(title_container) | |
| # Initialize selected_asset to None | |
| self.selected_asset = None | |
| # Version info | |
| version_group = QGroupBox("Version Information") | |
| version_layout = QVBoxLayout() | |
| version_layout.setContentsMargins(10, 10, 10, 10) | |
| current_label = QLabel(f"Current Version: {self.CURRENT_VERSION}") | |
| version_layout.addWidget(current_label) | |
| if self.latest_release: | |
| latest_version = self.latest_release['tag_name'] | |
| if self.update_available: | |
| latest_label = QLabel(f"Latest Version: {latest_version}") | |
| latest_font = QFont() | |
| latest_font.setBold(True) | |
| latest_font.setPointSize(10) | |
| latest_label.setFont(latest_font) | |
| version_layout.addWidget(latest_label) | |
| else: | |
| latest_label = QLabel(f"Latest Version: {latest_version} ✓ You are up to date!") | |
| latest_font = QFont() | |
| latest_font.setBold(True) | |
| latest_font.setPointSize(10) | |
| latest_label.setFont(latest_font) | |
| latest_label.setStyleSheet("color: green;") | |
| version_layout.addWidget(latest_label) | |
| version_group.setLayout(version_layout) | |
| main_layout.addWidget(version_group) | |
| # ALWAYS show asset selection when we have the first release data (current or latest) | |
| release_to_check = self.all_releases[0] if self.all_releases else self.latest_release | |
| if release_to_check: | |
| # Get downloadable files from the first/latest release (.exe for Windows, .dmg for macOS) | |
| exe_assets = [a for a in release_to_check.get('assets', []) | |
| if a['name'].lower().endswith(('.exe', '.dmg'))] | |
| print(f"[DEBUG] Found {len(exe_assets)} downloadable files in release {release_to_check.get('tag_name')}") | |
| # Show selection UI if there are downloadable files | |
| if exe_assets: | |
| # Determine the title based on whether there are multiple variants | |
| if len(exe_assets) > 1: | |
| frame_title = "Select Version to Download" | |
| else: | |
| frame_title = "Available Download" | |
| asset_group = QGroupBox(frame_title) | |
| asset_group.setObjectName("asset_group") | |
| asset_layout = QVBoxLayout() | |
| asset_layout.setContentsMargins(10, 10, 10, 10) | |
| if len(exe_assets) > 1: | |
| # Multiple exe files - show radio buttons to choose | |
| self.asset_button_group = QButtonGroup() | |
| for i, asset in enumerate(exe_assets): | |
| filename = asset['name'] | |
| size_mb = asset['size'] / (1024 * 1024) | |
| # Identify variant type based on first letter of filename | |
| first_letter = filename[0].upper() if filename else '' | |
| if first_letter == 'G': | |
| variant_type = "Standard" | |
| elif first_letter == 'L': | |
| variant_type = "Lite" | |
| elif first_letter == 'N': | |
| variant_type = "No CUDA" | |
| else: | |
| # Fallback: check for keywords in filename | |
| if 'lite' in filename.lower(): | |
| variant_type = "Lite" | |
| elif 'cuda' in filename.lower(): | |
| variant_type = "No CUDA" | |
| elif 'full' in filename.lower(): | |
| variant_type = "Full" | |
| else: | |
| variant_type = "Standard" | |
| # Add platform indicator for non-Windows files | |
| if filename.lower().endswith('.dmg'): | |
| platform_tag = " [macOS]" | |
| else: | |
| platform_tag = "" | |
| variant_label = f"{variant_type}{platform_tag} - {filename} ({size_mb:.1f} MB)" | |
| rb = QRadioButton(variant_label) | |
| rb.setProperty("asset_index", i) | |
| self.asset_button_group.addButton(rb, i) | |
| asset_layout.addWidget(rb) | |
| # Select first option by default | |
| if i == 0: | |
| rb.setChecked(True) | |
| self.selected_asset = asset | |
| # Add listener for selection changes | |
| def on_asset_change(button_id): | |
| self.selected_asset = exe_assets[button_id] | |
| self.asset_button_group.idClicked.connect(on_asset_change) | |
| else: | |
| # Only one exe file - just show it and set it as selected | |
| self.selected_asset = exe_assets[0] | |
| filename = exe_assets[0]['name'] | |
| size_mb = exe_assets[0]['size'] / (1024 * 1024) | |
| asset_label = QLabel(f"{filename} ({size_mb:.1f} MB)") | |
| asset_layout.addWidget(asset_label) | |
| asset_group.setLayout(asset_layout) | |
| main_layout.addWidget(asset_group) | |
| # Create tab widget for version history | |
| tab_widget = QTabWidget() | |
| tab_widget.setMinimumHeight(300) | |
| # Add tabs for different versions | |
| if self.all_releases: | |
| for i, release in enumerate(self.all_releases[:5]): # Show up to 5 versions | |
| version_tag = release['tag_name'] | |
| version_num = version_tag.lstrip('v') | |
| is_current = version_num == self.CURRENT_VERSION | |
| is_latest = i == 0 | |
| # Create tab label | |
| tab_label = version_tag | |
| if is_current and is_latest: | |
| tab_label += " (Current)" | |
| elif is_current: | |
| tab_label += " (Current)" | |
| elif is_latest: | |
| tab_label += " (Latest)" | |
| # Create widget for this version | |
| tab_widget_container = QWidget() | |
| tab_layout = QVBoxLayout(tab_widget_container) | |
| tab_layout.setContentsMargins(10, 10, 10, 10) | |
| # Add release date | |
| if 'published_at' in release: | |
| date_str = release['published_at'][:10] # Get YYYY-MM-DD | |
| date_label = QLabel(f"Released: {date_str}") | |
| date_font = QFont() | |
| date_font.setItalic(True) | |
| date_font.setPointSize(9) | |
| date_label.setFont(date_font) | |
| tab_layout.addWidget(date_label) | |
| # Create text widget for release notes | |
| notes_text = QTextEdit() | |
| notes_text.setReadOnly(True) | |
| notes_text.setMinimumHeight(200) | |
| # Format and insert release notes with markdown support | |
| release_notes = release.get('body', 'No release notes available') | |
| self.format_markdown_to_qt(notes_text, release_notes) | |
| tab_layout.addWidget(notes_text) | |
| tab_widget.addTab(tab_widget_container, tab_label) | |
| else: | |
| # Fallback to simple display if no releases fetched | |
| tab_widget_container = QWidget() | |
| tab_layout = QVBoxLayout(tab_widget_container) | |
| tab_layout.setContentsMargins(10, 10, 10, 10) | |
| notes_text = QTextEdit() | |
| notes_text.setReadOnly(True) | |
| notes_text.setMinimumHeight(200) | |
| if self.latest_release: | |
| release_notes = self.latest_release.get('body', 'No release notes available') | |
| self.format_markdown_to_qt(notes_text, release_notes) | |
| else: | |
| notes_text.setPlainText('Unable to fetch release notes.') | |
| tab_layout.addWidget(notes_text) | |
| tab_widget.addTab(tab_widget_container, "Release Notes") | |
| main_layout.addWidget(tab_widget) | |
| # Download progress (initially hidden) | |
| self.progress_widget = QWidget() | |
| progress_layout = QVBoxLayout(self.progress_widget) | |
| progress_layout.setContentsMargins(0, 10, 0, 10) | |
| self.progress_label = QLabel("") | |
| progress_layout.addWidget(self.progress_label) | |
| self.progress_bar = QProgressBar() | |
| self.progress_bar.setMinimum(0) | |
| self.progress_bar.setMaximum(100) | |
| self.progress_bar.setTextVisible(True) | |
| progress_layout.addWidget(self.progress_bar) | |
| # Add status label for download details | |
| self.status_label = QLabel("") | |
| status_font = QFont() | |
| status_font.setPointSize(8) | |
| self.status_label.setFont(status_font) | |
| progress_layout.addWidget(self.status_label) | |
| # Add cancel button (hidden until download starts) | |
| self.cancel_btn = QPushButton("Cancel") | |
| self.cancel_btn.setVisible(False) | |
| progress_layout.addWidget(self.cancel_btn) | |
| # Hide progress initially | |
| self.progress_widget.setVisible(False) | |
| # Add progress widget to layout (hidden initially) | |
| main_layout.addWidget(self.progress_widget) | |
| # Buttons | |
| button_layout = QHBoxLayout() | |
| button_layout.setSpacing(10) | |
| def start_download(): | |
| if not self.selected_asset: | |
| msg = QMessageBox(dialog) | |
| msg.setIcon(QMessageBox.Warning) | |
| msg.setWindowTitle("No File Selected") | |
| msg.setText("Please select a version to download.") | |
| msg.exec() | |
| return | |
| # Show progress and enable Cancel | |
| self.progress_widget.setVisible(True) | |
| self.cancel_btn.setVisible(True) | |
| self.cancel_btn.setEnabled(True) | |
| # Disable only the download button and asset selector | |
| try: | |
| btn = dialog.findChild(QPushButton, "update_download_btn") | |
| if btn: | |
| btn.setEnabled(False) | |
| asset_box = dialog.findChild(QGroupBox, "asset_group") | |
| if asset_box: | |
| asset_box.setEnabled(False) | |
| except Exception: | |
| pass | |
| # Reset progress | |
| self.progress_bar.setRange(0, 0) # indeterminate until we know size | |
| self.progress_bar.setValue(0) | |
| self.progress_label.setText("Connecting to GitHub...") | |
| self.status_label.setText("") | |
| self.download_progress = 0 | |
| # Wire up cancel behavior (connected after worker is created inside download_update) | |
| try: | |
| self.cancel_btn.clicked.disconnect() | |
| except Exception: | |
| pass | |
| # Start download using Qt thread worker (signals/slots) | |
| try: | |
| self.download_update(dialog) | |
| # After starting thread, connect cancel to worker | |
| def do_cancel(): | |
| self.cancel_btn.setEnabled(False) | |
| try: | |
| if hasattr(self, 'download_worker') and self.download_worker: | |
| self.download_worker.cancel() | |
| # Immediately update UI to cancelled | |
| self._on_download_cancelled(dialog) | |
| except Exception: | |
| # Still try to update UI to cancelled | |
| try: | |
| self._on_download_cancelled(dialog) | |
| except Exception: | |
| pass | |
| self.cancel_btn.clicked.connect(do_cancel) | |
| except Exception as e: | |
| msg = QMessageBox(dialog) | |
| msg.setIcon(QMessageBox.Critical) | |
| msg.setWindowTitle("Download Error") | |
| msg.setText(str(e)) | |
| msg.exec() | |
| self.cancel_btn.setVisible(False) | |
| # Always show download button if we have exe files | |
| has_exe_files = self.selected_asset is not None | |
| if self.update_available: | |
| # Show update-specific buttons | |
| download_btn = QPushButton("Download Update") | |
| download_btn.setObjectName("update_download_btn") | |
| download_btn.setMinimumHeight(35) | |
| download_btn.clicked.connect(start_download) | |
| download_btn.setStyleSheet(""" | |
| QPushButton { | |
| background-color: #28a745; | |
| color: white; | |
| padding: 8px 20px; | |
| font-size: 11pt; | |
| font-weight: bold; | |
| border-radius: 4px; | |
| } | |
| QPushButton:hover { background-color: #218838; } | |
| """) | |
| button_layout.addWidget(download_btn) | |
| remind_btn = QPushButton("Remind Me Later") | |
| remind_btn.setMinimumHeight(35) | |
| remind_btn.clicked.connect(dialog.close) | |
| remind_btn.setStyleSheet(""" | |
| QPushButton { | |
| background-color: #6c757d; | |
| color: white; | |
| padding: 8px 20px; | |
| font-size: 11pt; | |
| font-weight: bold; | |
| border-radius: 4px; | |
| } | |
| QPushButton:hover { background-color: #5a6268; } | |
| """) | |
| button_layout.addWidget(remind_btn) | |
| skip_btn = QPushButton("Skip This Version") | |
| skip_btn.setMinimumHeight(35) | |
| skip_btn.clicked.connect(lambda: self.skip_version(dialog)) | |
| skip_btn.setStyleSheet(""" | |
| QPushButton { | |
| background-color: transparent; | |
| color: #007bff; | |
| padding: 8px 20px; | |
| font-size: 11pt; | |
| border: none; | |
| text-decoration: underline; | |
| } | |
| QPushButton:hover { color: #0056b3; } | |
| """) | |
| button_layout.addWidget(skip_btn) | |
| elif has_exe_files: | |
| # We're up to date but have downloadable files | |
| # Check if there are multiple downloadable files | |
| release_to_check = self.all_releases[0] if self.all_releases else self.latest_release | |
| exe_count = 0 | |
| if release_to_check: | |
| exe_count = len([a for a in release_to_check.get('assets', []) | |
| if a['name'].lower().endswith(('.exe', '.dmg'))]) | |
| if exe_count > 1: | |
| # Multiple versions available | |
| download_btn = QPushButton("Download Different Path") | |
| download_btn.setObjectName("update_download_btn") | |
| download_btn.setStyleSheet(""" | |
| QPushButton { | |
| background-color: #17a2b8; | |
| color: white; | |
| padding: 8px 20px; | |
| font-size: 11pt; | |
| font-weight: bold; | |
| border-radius: 4px; | |
| } | |
| QPushButton:hover { background-color: #117a8b; } | |
| """) | |
| else: | |
| # Single version available | |
| download_btn = QPushButton("Re-download") | |
| download_btn.setObjectName("update_download_btn") | |
| download_btn.setStyleSheet(""" | |
| QPushButton { | |
| background-color: #6c757d; | |
| color: white; | |
| padding: 8px 20px; | |
| font-size: 11pt; | |
| font-weight: bold; | |
| border-radius: 4px; | |
| } | |
| QPushButton:hover { background-color: #5a6268; } | |
| """) | |
| download_btn.setMinimumHeight(35) | |
| download_btn.clicked.connect(start_download) | |
| button_layout.addWidget(download_btn) | |
| close_btn = QPushButton("Close") | |
| close_btn.setMinimumHeight(35) | |
| close_btn.clicked.connect(dialog.close) | |
| close_btn.setStyleSheet(""" | |
| QPushButton { | |
| background-color: #6c757d; | |
| color: white; | |
| padding: 8px 20px; | |
| font-size: 11pt; | |
| font-weight: bold; | |
| border-radius: 4px; | |
| } | |
| QPushButton:hover { background-color: #5a6268; } | |
| """) | |
| button_layout.addWidget(close_btn) | |
| else: | |
| # No downloadable files | |
| close_btn = QPushButton("Close") | |
| close_btn.setMinimumHeight(35) | |
| close_btn.clicked.connect(dialog.close) | |
| close_btn.setStyleSheet(""" | |
| QPushButton { | |
| background-color: #007bff; | |
| color: white; | |
| padding: 8px 20px; | |
| font-size: 11pt; | |
| font-weight: bold; | |
| border-radius: 4px; | |
| } | |
| QPushButton:hover { background-color: #0056b3; } | |
| """) | |
| button_layout.addWidget(close_btn) | |
| # Add "View All Releases" link button | |
| def open_releases_page(): | |
| import webbrowser | |
| webbrowser.open("https://github.com/Shirochi-stack/Glossarion/releases") | |
| view_releases_btn = QPushButton("View All Releases") | |
| view_releases_btn.setMinimumHeight(35) | |
| view_releases_btn.clicked.connect(open_releases_page) | |
| view_releases_btn.setStyleSheet(""" | |
| QPushButton { | |
| background-color: transparent; | |
| color: #007bff; | |
| padding: 8px 20px; | |
| font-size: 11pt; | |
| border: none; | |
| text-decoration: underline; | |
| } | |
| QPushButton:hover { color: #0056b3; } | |
| """) | |
| button_layout.addStretch() | |
| button_layout.addWidget(view_releases_btn) | |
| # Add button layout to main layout | |
| main_layout.addLayout(button_layout) | |
| # Set dialog layout | |
| dialog.setLayout(main_layout) | |
| # Show dialog first so frame geometry is accurate | |
| dialog.show() | |
| # Position dialog after showing for accurate centering (includes window decorations) | |
| def center_dialog(): | |
| if reference_widget: | |
| # Center the dialog on the reference widget | |
| reference_geometry = reference_widget.geometry() | |
| dialog_frame = dialog.frameGeometry() | |
| center_point = reference_geometry.center() | |
| dialog_frame.moveCenter(center_point) | |
| dialog.move(dialog_frame.topLeft()) | |
| else: | |
| # Center on screen | |
| screen_geometry = app.primaryScreen().geometry() | |
| dialog_frame = dialog.frameGeometry() | |
| screen_center = screen_geometry.center() | |
| dialog_frame.moveCenter(screen_center) | |
| dialog.move(dialog_frame.topLeft()) | |
| # Use QTimer to ensure window is fully shown before centering | |
| QTimer.singleShot(0, center_dialog) | |
| # Keep reference to prevent garbage collection | |
| self._update_dialog = dialog | |
| def skip_version(self, dialog): | |
| """Mark this version as skipped and close dialog""" | |
| if not self.latest_release: | |
| dialog.close() | |
| return | |
| # Get current skipped versions list | |
| if 'skipped_versions' not in self.main_gui.config: | |
| self.main_gui.config['skipped_versions'] = [] | |
| # Add this version to skipped list | |
| version_tag = self.latest_release['tag_name'] | |
| if version_tag not in self.main_gui.config['skipped_versions']: | |
| self.main_gui.config['skipped_versions'].append(version_tag) | |
| # Save config | |
| self.main_gui.save_config(show_message=False) | |
| # Close dialog | |
| dialog.close() | |
| # Show confirmation | |
| msg = QMessageBox(self.dialog if hasattr(self.dialog, 'show') else None) | |
| msg.setIcon(QMessageBox.Information) | |
| msg.setWindowTitle("Version Skipped") | |
| msg.setText(f"Version {version_tag} will be skipped in future update checks.\n" | |
| "You can manually check for updates from the Help menu.") | |
| msg.exec() | |
| def _on_download_progress(self, p: int, downloaded_mb: float, total_mb: float): | |
| # Reset watchdog on progress | |
| try: | |
| if hasattr(self, '_download_watchdog') and self._download_watchdog: | |
| self._download_watchdog.start() | |
| except Exception: | |
| pass | |
| """Update progress UI from worker signals""" | |
| try: | |
| if p >= 0: | |
| self.progress_bar.setRange(0, 100) | |
| self.progress_bar.setValue(p) | |
| self.progress_label.setText(f"Downloading update... {p}%") | |
| self.status_label.setText(f"{downloaded_mb:.1f} MB / {total_mb:.1f} MB") | |
| else: | |
| # Unknown total size; show indeterminate bar with byte counter | |
| self.progress_bar.setRange(0, 0) | |
| self.progress_label.setText("Downloading update...") | |
| self.status_label.setText(f"{downloaded_mb:.1f} MB downloaded") | |
| except Exception: | |
| pass | |
| def _show_download_error(self, dialog, error_msg: str): | |
| # Stop watchdog | |
| try: | |
| if hasattr(self, '_download_watchdog') and self._download_watchdog: | |
| self._download_watchdog.stop() | |
| except Exception: | |
| pass | |
| # Re-enable buttons on error and reset UI | |
| try: | |
| for btn in dialog.findChildren(QPushButton): | |
| btn.setEnabled(True) | |
| if hasattr(self, 'cancel_btn'): | |
| self.cancel_btn.setVisible(False) | |
| self.cancel_btn.setEnabled(True) | |
| # Reset progress UI | |
| self.progress_bar.setRange(0, 100) | |
| self.progress_bar.setValue(0) | |
| self.progress_label.setText("") | |
| self.status_label.setText("") | |
| except Exception: | |
| pass | |
| msg = QMessageBox(dialog) | |
| msg.setIcon(QMessageBox.Critical) | |
| msg.setWindowTitle("Download Failed") | |
| msg.setText(error_msg) | |
| msg.exec() | |
| def download_update(self, dialog): | |
| """Start the update download in a QThread and update UI via signals""" | |
| # Use the selected asset | |
| asset = self.selected_asset | |
| if not asset: | |
| self._show_download_error(dialog, "No file selected for download.") | |
| return | |
| # Get the current executable path and target directory | |
| if getattr(sys, 'frozen', False): | |
| current_exe = sys.executable | |
| download_dir = os.path.dirname(current_exe) | |
| else: | |
| current_exe = None | |
| download_dir = self.base_dir | |
| # Use the exact filename from GitHub | |
| original_filename = asset['name'] # e.g., "Glossarion v3.1.3.exe" | |
| new_exe_path = os.path.join(download_dir, original_filename) | |
| # If new file would overwrite current executable, download to temp name first | |
| if current_exe and os.path.normpath(new_exe_path) == os.path.normpath(current_exe): | |
| download_path = new_exe_path + ".new" | |
| else: | |
| download_path = new_exe_path | |
| url = asset['browser_download_url'] | |
| # Track path for cleanup on cancel/error | |
| self._current_download_path = download_path | |
| # Reset and show initial progress state | |
| self.progress_bar.setRange(0, 0) # indeterminate until we know size | |
| self.progress_bar.setValue(0) | |
| self.progress_label.setText("Connecting to GitHub...") | |
| self.status_label.setText("") | |
| # Use Qt network only when NOT frozen (frozen builds lack SSL support) | |
| if not getattr(sys, 'frozen', False): | |
| try: | |
| # Clean up any previous job | |
| if hasattr(self, 'qt_downloader') and self.qt_downloader: | |
| try: | |
| self.qt_downloader.deleteLater() | |
| except Exception: | |
| pass | |
| self.qt_downloader = QtDownloadJob() | |
| # Reset cancel handled flag for this run | |
| self._cancel_handled = False | |
| self.qt_downloader.progress.connect(self._on_download_progress) | |
| self.qt_downloader.finished.connect(lambda fp: self.download_complete(dialog, fp)) | |
| self.qt_downloader.cancelled.connect(lambda: self._on_download_cancelled(dialog)) | |
| self.qt_downloader.error.connect(lambda code, msg: self._on_qtnetwork_error(dialog, code, msg)) | |
| # Watchdog for no progress | |
| try: | |
| if hasattr(self, '_download_watchdog') and self._download_watchdog: | |
| self._download_watchdog.stop() | |
| self._download_watchdog.deleteLater() | |
| except Exception: | |
| pass | |
| self._download_watchdog = QTimer(self) | |
| self._download_watchdog.setInterval(90000) | |
| self._download_watchdog.timeout.connect(lambda: self._on_download_timeout(dialog)) | |
| self._download_watchdog.start() | |
| self.qt_downloader.start(url, download_path) | |
| # Hook cancel | |
| if hasattr(self, 'cancel_btn') and self.cancel_btn: | |
| try: | |
| self.cancel_btn.clicked.disconnect() | |
| except Exception: | |
| pass | |
| # Cancel and immediately reflect UI as cancelled | |
| self.cancel_btn.clicked.connect(lambda: (self.cancel_btn.setEnabled(False), self.qt_downloader.cancel(), self._on_download_cancelled(dialog))) | |
| return | |
| except Exception: | |
| pass | |
| # Fallback: Use plain threading.Thread (QThread + requests causes deadlock) | |
| print(f"[DEBUG] Using requests downloader for {url}") | |
| self._download_cancelled = False | |
| self._current_dialog = dialog | |
| # Connect signals to handlers | |
| self._download_progress_signal.connect(self._on_download_progress) | |
| self._download_complete_signal.connect(lambda fp: self.download_complete(self._current_dialog, fp)) | |
| self._download_error_signal.connect(lambda msg: self._show_download_error(self._current_dialog, msg)) | |
| self._download_cancelled_signal.connect(lambda: self._on_download_cancelled(self._current_dialog)) | |
| def download_thread_func(): | |
| # In frozen builds, SSL verification can be problematic - disable it | |
| if getattr(sys, 'frozen', False): | |
| print(f"[DEBUG] Frozen build detected, disabling SSL verification") | |
| ca_bundle = False | |
| # Suppress SSL warnings | |
| import urllib3 | |
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | |
| else: | |
| try: | |
| import certifi | |
| ca_bundle = certifi.where() | |
| print(f"[DEBUG] Using CA bundle: {ca_bundle}") | |
| except Exception as e: | |
| print(f"[DEBUG] Certifi error: {e}, using default SSL") | |
| ca_bundle = True | |
| try: | |
| print("[DEBUG] Starting download...") | |
| # Use urllib for frozen builds (requests has SSL issues) | |
| if getattr(sys, 'frozen', False): | |
| print("[DEBUG] Using urllib for download") | |
| import urllib.request | |
| import ssl | |
| # Create unverified SSL context | |
| ssl_context = ssl._create_unverified_context() | |
| req = urllib.request.Request(url, headers={ | |
| 'User-Agent': 'Glossarion-Updater', | |
| 'Accept': 'application/octet-stream' | |
| }) | |
| with urllib.request.urlopen(req, context=ssl_context, timeout=30) as response: | |
| print(f"[DEBUG] Got response: {response.status}") | |
| total_size = int(response.headers.get('content-length', 0)) | |
| print(f"[DEBUG] Content length: {total_size} bytes") | |
| downloaded = 0 | |
| last_emit_time = time.time() | |
| with open(download_path, 'wb') as f: | |
| while True: | |
| if self._download_cancelled: | |
| try: | |
| f.close() | |
| if os.path.exists(download_path): | |
| os.remove(download_path) | |
| except Exception: | |
| pass | |
| self._download_cancelled_signal.emit() | |
| return | |
| chunk = response.read(65536) | |
| if not chunk: | |
| break | |
| f.write(chunk) | |
| downloaded += len(chunk) | |
| now = time.time() | |
| if total_size > 0: | |
| p = int(downloaded * 100 / total_size) | |
| if (now - last_emit_time) >= 0.1: | |
| last_emit_time = now | |
| self._download_progress_signal.emit(p, downloaded / (1024 * 1024), total_size / (1024 * 1024)) | |
| print(f"[DEBUG] Download complete: {downloaded} bytes") | |
| self._download_complete_signal.emit(download_path) | |
| else: | |
| # Use requests for source builds | |
| print("[DEBUG] Using requests for download") | |
| with requests.Session() as s: | |
| headers = { | |
| 'User-Agent': 'Glossarion-Updater', | |
| 'Accept': 'application/octet-stream' | |
| } | |
| with s.get(url, headers=headers, stream=True, timeout=(30, 300), allow_redirects=True, verify=ca_bundle) as r: | |
| print(f"[DEBUG] Got response: {r.status_code}") | |
| r.raise_for_status() | |
| total_size = int(r.headers.get('content-length', 0)) | |
| print(f"[DEBUG] Content length: {total_size} bytes") | |
| downloaded = 0 | |
| last_emit_time = time.time() | |
| with open(download_path, 'wb') as f: | |
| for chunk in r.iter_content(chunk_size=65536): | |
| if self._download_cancelled: | |
| try: | |
| f.close() | |
| if os.path.exists(download_path): | |
| os.remove(download_path) | |
| except Exception: | |
| pass | |
| QTimer.singleShot(0, lambda: self._on_download_cancelled(dialog)) | |
| return | |
| if not chunk: | |
| continue | |
| f.write(chunk) | |
| downloaded += len(chunk) | |
| now = time.time() | |
| if total_size > 0: | |
| p = int(downloaded * 100 / total_size) | |
| if (now - last_emit_time) >= 0.1: | |
| last_emit_time = now | |
| self._download_progress_signal.emit(p, downloaded / (1024 * 1024), total_size / (1024 * 1024)) | |
| print(f"[DEBUG] Download complete: {downloaded} bytes") | |
| self._download_complete_signal.emit(download_path) | |
| except Exception as e: | |
| print(f"[DEBUG] Download error: {type(e).__name__}: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| try: | |
| if os.path.exists(download_path): | |
| os.remove(download_path) | |
| except Exception: | |
| pass | |
| self._download_error_signal.emit(str(e)) | |
| self.download_thread = threading.Thread(target=download_thread_func, daemon=True) | |
| # Watchdog: cancel if no progress | |
| try: | |
| if hasattr(self, '_download_watchdog') and self._download_watchdog: | |
| self._download_watchdog.stop() | |
| self._download_watchdog.deleteLater() | |
| except Exception: | |
| pass | |
| self._download_watchdog = QTimer(self) | |
| self._download_watchdog.setInterval(90000) | |
| self._download_watchdog.timeout.connect(lambda: self._on_download_timeout(dialog)) | |
| self._download_watchdog.start() | |
| # Hook cancel button | |
| if hasattr(self, 'cancel_btn') and self.cancel_btn: | |
| try: | |
| self.cancel_btn.clicked.disconnect() | |
| except Exception: | |
| pass | |
| self.cancel_btn.clicked.connect(lambda: (self.cancel_btn.setEnabled(False), setattr(self, '_download_cancelled', True))) | |
| print("[DEBUG] Starting download thread...") | |
| self.download_thread.start() | |
| print("[DEBUG] Download thread started") | |
| def _on_download_timeout(self, dialog): | |
| try: | |
| # Try cancel on either backend | |
| if hasattr(self, 'qt_downloader') and self.qt_downloader: | |
| self.qt_downloader.cancel() | |
| if hasattr(self, 'download_worker') and self.download_worker: | |
| self.download_worker.cancel() | |
| except Exception: | |
| pass | |
| self._show_download_error(dialog, "Connection timed out. No data received.") | |
| def _on_qtnetwork_error(self, dialog, code, message: str): | |
| # Handle Qt network errors; treat cancellation gracefully | |
| try: | |
| if int(code) == int(QNetworkReply.OperationCanceledError): | |
| if getattr(self, '_cancel_handled', False): | |
| return | |
| self._on_download_cancelled(dialog) | |
| return | |
| except Exception: | |
| pass | |
| self._show_download_error(dialog, message) | |
| def _on_download_cancelled(self, dialog): | |
| # Gracefully handle user-cancelled download | |
| self._cancel_handled = True | |
| try: | |
| if hasattr(self, '_download_watchdog') and self._download_watchdog: | |
| self._download_watchdog.stop() | |
| except Exception: | |
| pass | |
| # Attempt to delete any partial file leftover | |
| try: | |
| if hasattr(self, '_current_download_path') and self._current_download_path and os.path.exists(self._current_download_path): | |
| os.remove(self._current_download_path) | |
| except Exception: | |
| pass | |
| try: | |
| # Reset progress UI and controls | |
| self.progress_bar.setRange(0, 100) | |
| self.progress_bar.setValue(0) | |
| self.progress_label.setText("Cancelled") | |
| self.status_label.setText("") | |
| if hasattr(self, 'cancel_btn'): | |
| self.cancel_btn.setVisible(False) | |
| self.cancel_btn.setEnabled(True) | |
| # Re-enable controls | |
| btn = dialog.findChild(QPushButton, "update_download_btn") | |
| if btn: | |
| btn.setEnabled(True) | |
| asset_box = dialog.findChild(QGroupBox, "asset_group") | |
| if asset_box: | |
| asset_box.setEnabled(True) | |
| except Exception: | |
| pass | |
| def download_complete(self, dialog, file_path): | |
| """Handle completed download""" | |
| try: | |
| if hasattr(self, '_download_watchdog') and self._download_watchdog: | |
| self._download_watchdog.stop() | |
| except Exception: | |
| pass | |
| try: | |
| if hasattr(self, 'cancel_btn'): | |
| self.cancel_btn.setVisible(False) | |
| self.cancel_btn.setEnabled(True) | |
| except Exception: | |
| pass | |
| dialog.close() | |
| msg = QMessageBox(self.dialog if hasattr(self.dialog, 'show') else None) | |
| msg.setIcon(QMessageBox.Question) | |
| msg.setWindowTitle("Download Complete") | |
| msg.setText("Update downloaded successfully.\n\n" | |
| "Would you like to install it now?\n" | |
| "(The application will need to restart)") | |
| msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) | |
| msg.setDefaultButton(QMessageBox.Yes) | |
| if msg.exec() == QMessageBox.Yes: | |
| self.install_update(file_path) | |
| def install_update(self, update_file): | |
| """Launch the update installer and exit current app""" | |
| try: | |
| # Save current state/config if needed | |
| self.main_gui.save_config(show_message=False) | |
| # Get current executable path | |
| if getattr(sys, 'frozen', False): | |
| current_exe = sys.executable | |
| current_dir = os.path.dirname(current_exe) | |
| # Create a batch file to handle the update | |
| batch_content = f"""@echo off | |
| echo Updating Glossarion... | |
| echo Waiting for current version to close... | |
| timeout /t 3 /nobreak > nul | |
| :: Delete the old executable | |
| echo Deleting old version... | |
| if exist "{current_exe}" ( | |
| del /f /q "{current_exe}" | |
| if exist "{current_exe}" ( | |
| echo Failed to delete old version, retrying... | |
| timeout /t 2 /nobreak > nul | |
| del /f /q "{current_exe}" | |
| ) | |
| ) | |
| :: Start the new version | |
| echo Starting new version... | |
| start "" "{update_file}" | |
| :: Clean up this batch file | |
| del "%~f0" | |
| """ | |
| batch_path = os.path.join(current_dir, "update_glossarion.bat") | |
| with open(batch_path, 'w') as f: | |
| f.write(batch_content) | |
| # Run the batch file | |
| import subprocess | |
| subprocess.Popen([batch_path], shell=True, creationflags=subprocess.CREATE_NO_WINDOW) | |
| print(f"[DEBUG] Update batch file created: {batch_path}") | |
| print(f"[DEBUG] Will delete: {current_exe}") | |
| print(f"[DEBUG] Will start: {update_file}") | |
| else: | |
| # Running as script, just start the new exe | |
| import subprocess | |
| subprocess.Popen([update_file], shell=True) | |
| # Exit current application | |
| print("[DEBUG] Closing application for update...") | |
| QApplication.quit() | |
| sys.exit(0) | |
| except Exception as e: | |
| msg = QMessageBox(self.dialog if hasattr(self.dialog, 'show') else None) | |
| msg.setIcon(QMessageBox.Critical) | |
| msg.setWindowTitle("Installation Error") | |
| msg.setText(f"Could not start update process:\n{str(e)}") | |
| msg.exec() | |