# update_manager.py - Auto-update functionality for Glossarion import os import sys import json import requests import threading import concurrent.futures import time import re from typing import Optional, Dict, Tuple, List from packaging import version import tkinter as tk from tkinter import ttk, messagebox, font import ttkbootstrap as tb from datetime import datetime class UpdateManager: """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" def __init__(self, main_gui, base_dir): self.main_gui = main_gui self.base_dir = base_dir self.update_available = False # 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.master.title() if 'v' in title: self.CURRENT_VERSION = title.split('v')[-1].strip() else: self.CURRENT_VERSION = "0.0.0" 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 retry logic max_retries = 2 timeout = 10 # Reduced 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 the background using the shared executor. Returns a Future if an executor is available, else runs in a thread. """ try: # Ensure shared executor if hasattr(self.main_gui, '_ensure_executor'): self.main_gui._ensure_executor() execu = getattr(self, 'executor', None) or getattr(self.main_gui, 'executor', None) if execu: future = execu.submit(self.check_for_updates, silent, force_show) return future except Exception: pass # Fallback to thread if executor not available def _worker(): try: self.check_for_updates(silent=silent, force_show=force_show) except Exception: pass t = threading.Thread(target=_worker, daemon=True) t.start() return None def check_for_updates(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) """ 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 shorter timeout and retry logic max_retries = 2 timeout = 10 # Reduced from 30 seconds 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 self.all_releases = self.fetch_multiple_releases(count=10) 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 # Show update dialog when update is available print(f"[DEBUG] Showing update dialog for version {latest_version}") self.main_gui.master.after(100, self.show_update_dialog) return True, release_data else: # We're up to date self.update_available = False # Show dialog if explicitly requested (from menu) if force_show or not silent: self.main_gui.master.after(100, self.show_update_dialog) return False, None except requests.Timeout: if not silent: messagebox.showerror("Update Check Failed", "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.") return False, None except requests.ConnectionError as e: if not silent: if 'api.github.com' in str(e): messagebox.showerror("Update Check Failed", "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: messagebox.showerror("Update Check Failed", f"Network error: {str(e)}\n\n" "The next update check will be in 1 hour.") return False, None except requests.HTTPError as e: if not silent: if e.response.status_code == 403: messagebox.showerror("Update Check Failed", "GitHub API rate limit exceeded. Please try again later.") else: messagebox.showerror("Update Check Failed", f"GitHub returned error: {e.response.status_code}") return False, None except ValueError as e: if not silent: messagebox.showerror("Update Check Failed", "Invalid response from GitHub. The update service may be temporarily unavailable.") return False, None except Exception as e: if not silent: messagebox.showerror("Update Check Failed", f"An unexpected error occurred:\n{str(e)}") 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 _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_tkinter(self, text_widget, markdown_text): """Convert GitHub markdown to formatted tkinter text - simplified version Args: text_widget: The Text widget to insert formatted text into markdown_text: The markdown source text """ # Configure minimal tags text_widget.tag_config("heading", font=('TkDefaultFont', 12, 'bold')) text_widget.tag_config("bold", font=('TkDefaultFont', 10, 'bold')) # Process text line by line with minimal formatting lines = markdown_text.split('\n') 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: text_widget.insert('end', heading_text + '\n', 'heading') # 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) text_widget.insert('end', ' • ' + 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()) text_widget.insert('end', f' {num}. {clean_text}\n') # Handle separator lines elif line.strip() in ['---', '***', '___']: text_widget.insert('end', '─' * 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) # Check if this looks like it should be bold (common pattern) if clean_text.endswith(':') and len(clean_text) < 50: text_widget.insert('end', clean_text + '\n', 'bold') else: text_widget.insert('end', clean_text + '\n') # Empty lines else: text_widget.insert('end', '\n') 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_update_dialog(self): """Show update dialog (for updates or version history)""" if not self.latest_release and not self.all_releases: # Try to fetch releases if we don't have them self.all_releases = self.fetch_multiple_releases(count=10) if self.all_releases: self.latest_release = self.all_releases[0] else: messagebox.showerror("Error", "Unable to fetch version information from GitHub.") return # Set appropriate title if self.update_available: title = "Update Available" else: title = "Version History" # Create dialog first without content dialog, scrollable_frame, canvas = self.main_gui.wm.setup_scrollable( self.main_gui.master, title, width=None, height=None, max_width_ratio=0.5, max_height_ratio=0.8 ) # Show dialog immediately dialog.update_idletasks() # Then populate content self.main_gui.master.after(10, lambda: self._populate_update_dialog(dialog, scrollable_frame, canvas)) def _populate_update_dialog(self, dialog, scrollable_frame, canvas): """Populate the update dialog content""" # Main container main_frame = ttk.Frame(scrollable_frame) main_frame.pack(fill='both', expand=True, padx=20, pady=20) # Initialize selected_asset to None self.selected_asset = None # Version info version_frame = ttk.LabelFrame(main_frame, text="Version Information", padding=10) version_frame.pack(fill='x', pady=(0, 10)) ttk.Label(version_frame, text=f"Current Version: {self.CURRENT_VERSION}").pack(anchor='w') if self.latest_release: latest_version = self.latest_release['tag_name'] if self.update_available: ttk.Label(version_frame, text=f"Latest Version: {latest_version}", font=('TkDefaultFont', 10, 'bold')).pack(anchor='w') else: ttk.Label(version_frame, text=f"Latest Version: {latest_version} ✓ You are up to date!", foreground='green', font=('TkDefaultFont', 10, 'bold')).pack(anchor='w') # 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 exe files from the first/latest release exe_assets = [a for a in release_to_check.get('assets', []) if a['name'].lower().endswith('.exe')] print(f"[DEBUG] Found {len(exe_assets)} exe files in release {release_to_check.get('tag_name')}") # Show selection UI if there are exe 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_frame = ttk.LabelFrame(main_frame, text=frame_title, padding=10) asset_frame.pack(fill='x', pady=(0, 10)) if len(exe_assets) > 1: # Multiple exe files - show radio buttons to choose self.asset_var = tk.StringVar() for i, asset in enumerate(exe_assets): filename = asset['name'] size_mb = asset['size'] / (1024 * 1024) # Try to identify variant type from filename if 'full' in filename.lower(): variant_label = f"Full Version - {filename} ({size_mb:.1f} MB)" else: variant_label = f"Standard Version - {filename} ({size_mb:.1f} MB)" rb = ttk.Radiobutton(asset_frame, text=variant_label, variable=self.asset_var, value=str(i)) rb.pack(anchor='w', pady=2) # Select first option by default if i == 0: self.asset_var.set(str(i)) self.selected_asset = asset # Add listener for selection changes def on_asset_change(*args): idx = int(self.asset_var.get()) self.selected_asset = exe_assets[idx] self.asset_var.trace_add('write', 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) ttk.Label(asset_frame, text=f"{filename} ({size_mb:.1f} MB)").pack(anchor='w') # Create notebook for version history notebook = ttk.Notebook(main_frame) notebook.pack(fill='both', expand=True, pady=(0, 10)) # 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 frame for this version tab_frame = ttk.Frame(notebook) notebook.add(tab_frame, text=tab_label) # Add release date if 'published_at' in release: date_str = release['published_at'][:10] # Get YYYY-MM-DD date_label = ttk.Label(tab_frame, text=f"Released: {date_str}", font=('TkDefaultFont', 9, 'italic')) date_label.pack(anchor='w', padx=10, pady=(10, 5)) # Create text widget for release notes text_frame = ttk.Frame(tab_frame) text_frame.pack(fill='both', expand=True, padx=10, pady=(0, 10)) notes_text = tk.Text(text_frame, height=12, wrap='word', width=60) notes_scroll = ttk.Scrollbar(text_frame, command=notes_text.yview) notes_text.config(yscrollcommand=notes_scroll.set) notes_text.pack(side='left', fill='both', expand=True) notes_scroll.pack(side='right', fill='y') # Format and insert release notes with markdown support release_notes = release.get('body', 'No release notes available') self.format_markdown_to_tkinter(notes_text, release_notes) notes_text.config(state='disabled') # Make read-only # Don't set background color as it causes rendering artifacts else: # Fallback to simple display if no releases fetched notes_frame = ttk.LabelFrame(main_frame, text="Release Notes", padding=10) notes_frame.pack(fill='both', expand=True, pady=(0, 10)) notes_text = tk.Text(notes_frame, height=10, wrap='word') notes_scroll = ttk.Scrollbar(notes_frame, command=notes_text.yview) notes_text.config(yscrollcommand=notes_scroll.set) notes_text.pack(side='left', fill='both', expand=True) notes_scroll.pack(side='right', fill='y') if self.latest_release: release_notes = self.latest_release.get('body', 'No release notes available') self.format_markdown_to_tkinter(notes_text, release_notes) else: notes_text.insert('1.0', 'Unable to fetch release notes.') notes_text.config(state='disabled') # Download progress (initially hidden) self.progress_frame = ttk.Frame(main_frame) self.progress_label = ttk.Label(self.progress_frame, text="Downloading update...") self.progress_label.pack(anchor='w') self.progress_bar = ttk.Progressbar(self.progress_frame, mode='determinate', length=400) self.progress_bar.pack(fill='x', pady=5) # Add status label for download details self.status_label = ttk.Label(self.progress_frame, text="", font=('TkDefaultFont', 8)) self.status_label.pack(anchor='w') # Buttons button_frame = ttk.Frame(main_frame) button_frame.pack(fill='x', pady=(10, 0)) def start_download(): if not self.selected_asset: messagebox.showerror("No File Selected", "Please select a version to download.") return self.progress_frame.pack(fill='x', pady=(0, 10), before=button_frame) download_btn.config(state='disabled') if 'remind_btn' in locals(): remind_btn.config(state='disabled') if 'skip_btn' in locals(): skip_btn.config(state='disabled') if 'close_btn' in locals(): close_btn.config(state='disabled') # Reset progress self.progress_bar['value'] = 0 self.download_progress = 0 # Start download using shared executor if available try: if hasattr(self.main_gui, '_ensure_executor'): self.main_gui._ensure_executor() execu = getattr(self, 'executor', None) or getattr(self.main_gui, 'executor', None) if execu: execu.submit(self.download_update, dialog) else: thread = threading.Thread(target=self.download_update, args=(dialog,), daemon=True) thread.start() except Exception: thread = threading.Thread(target=self.download_update, args=(dialog,), daemon=True) thread.start() # 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 = tb.Button(button_frame, text="Download Update", command=start_download, bootstyle="success") download_btn.pack(side='left', padx=(0, 5)) remind_btn = tb.Button(button_frame, text="Remind Me Later", command=dialog.destroy, bootstyle="secondary") remind_btn.pack(side='left', padx=5) skip_btn = tb.Button(button_frame, text="Skip This Version", command=lambda: self.skip_version(dialog), bootstyle="link") skip_btn.pack(side='left', padx=5) elif has_exe_files: # We're up to date but have downloadable files # Check if there are multiple exe 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')]) if exe_count > 1: # Multiple versions available download_btn = tb.Button(button_frame, text="Download Different Path", command=start_download, bootstyle="info") else: # Single version available download_btn = tb.Button(button_frame, text="Re-download", command=start_download, bootstyle="secondary") download_btn.pack(side='left', padx=(0, 5)) close_btn = tb.Button(button_frame, text="Close", command=dialog.destroy, bootstyle="secondary") close_btn.pack(side='left', padx=(0, 5)) else: # No downloadable files close_btn = tb.Button(button_frame, text="Close", command=dialog.destroy, bootstyle="primary") close_btn.pack(side='left', padx=(0, 5)) # Add "View All Releases" link button def open_releases_page(): import webbrowser webbrowser.open("https://github.com/Shirochi-stack/Glossarion/releases") tb.Button(button_frame, text="View All Releases", command=open_releases_page, bootstyle="link").pack(side='right', padx=5) # Auto-resize at the end dialog.after(100, lambda: self.main_gui.wm.auto_resize_dialog(dialog, canvas, max_width_ratio=0.5, max_height_ratio=0.8)) # Handle window close dialog.protocol("WM_DELETE_WINDOW", lambda: [dialog._cleanup_scrolling(), dialog.destroy()]) def skip_version(self, dialog): """Mark this version as skipped and close dialog""" if not self.latest_release: dialog.destroy() 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.destroy() # Show confirmation messagebox.showinfo("Version Skipped", f"Version {version_tag} will be skipped in future update checks.\n" "You can manually check for updates from the Help menu.") def download_update(self, dialog): """Download the update file""" try: # Use the selected asset asset = self.selected_asset if not asset: dialog.after(0, lambda: messagebox.showerror("Download Error", "No file selected for download.")) return # Get the current executable path if getattr(sys, 'frozen', False): # Running as compiled executable current_exe = sys.executable download_dir = os.path.dirname(current_exe) else: # Running as script 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): temp_path = new_exe_path + ".new" download_path = temp_path else: download_path = new_exe_path # Download with progress tracking and shorter timeout response = requests.get(asset['browser_download_url'], stream=True, timeout=15) total_size = int(response.headers.get('content-length', 0)) downloaded = 0 chunk_size = 8192 with open(download_path, 'wb') as f: for chunk in response.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) downloaded += len(chunk) # Update progress bar if total_size > 0: progress = int((downloaded / total_size) * 100) size_mb = downloaded / (1024 * 1024) total_mb = total_size / (1024 * 1024) # Use after_idle for smoother updates def update_progress(p=progress, d=size_mb, t=total_mb): try: self.progress_bar['value'] = p self.progress_label.config(text=f"Downloading update... {p}%") self.status_label.config(text=f"{d:.1f} MB / {t:.1f} MB") except: pass # Dialog might have been closed dialog.after_idle(update_progress) # Download complete dialog.after(0, lambda: self.download_complete(dialog, download_path)) except Exception as e: # Capture the error message immediately error_msg = str(e) dialog.after(0, lambda: messagebox.showerror("Download Failed", error_msg)) def download_complete(self, dialog, file_path): """Handle completed download""" dialog.destroy() result = messagebox.askyesno( "Download Complete", "Update downloaded successfully.\n\n" "Would you like to install it now?\n" "(The application will need to restart)" ) if result: 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...") self.main_gui.master.quit() sys.exit(0) except Exception as e: messagebox.showerror("Installation Error", f"Could not start update process:\n{str(e)}")