Glossarion / update_manager.py
Shirochi's picture
Upload 93 files
ec038f4 verified
# 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
@Slot()
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))
@Slot()
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
@Slot()
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))
@Slot(int, int)
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
@Slot()
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
@Slot(QNetworkReply.NetworkError)
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()
@Slot(int, float, float)
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()