""" Update Checker Module for Progression Loader Checks for new releases from https://git.hudsonriggs.systems/HRiggs/ProgressionMods/releases """ import requests import json import re from packaging import version import tkinter as tk from tkinter import messagebox import webbrowser import threading import time from datetime import datetime, timedelta import os from pathlib import Path from update_config import get_update_config class UpdateChecker: def __init__(self, current_version=None): # Load configuration self.config = get_update_config() # Set version self.current_version = current_version or self.config["current_version"] # API configuration self.api_base_url = self.config["api_base_url"] self.repo_owner = self.config["repo_owner"] self.repo_name = self.config["repo_name"] self.releases_url = f"{self.api_base_url}/repos/{self.repo_owner}/{self.repo_name}/releases" # Check configuration self.last_check_file = Path(self.config["last_check_file"]) if self.config["last_check_file"] else None self.check_interval_hours = self.config["check_interval_hours"] self.include_prereleases = self.config["include_prereleases"] # Request configuration self.user_agent = self.config["user_agent"] self.request_timeout = self.config["request_timeout"] def get_current_version(self): """Get the current version of the application""" return self.current_version def set_current_version(self, version_string): """Set the current version of the application""" self.current_version = version_string def should_check_for_updates(self): """Check if enough time has passed since last update check""" # If no persistent file or check interval is 0, always check if not self.last_check_file or self.check_interval_hours == 0: return True if not self.last_check_file.exists(): return True try: with open(self.last_check_file, 'r') as f: data = json.load(f) last_check = datetime.fromisoformat(data.get('last_check', '2000-01-01')) return datetime.now() - last_check > timedelta(hours=self.check_interval_hours) except (json.JSONDecodeError, ValueError, KeyError): return True def save_last_check_time(self): """Save the current time as the last check time""" # Skip saving if no persistent file is configured if not self.last_check_file: return try: data = {'last_check': datetime.now().isoformat()} with open(self.last_check_file, 'w') as f: json.dump(data, f) except Exception as e: print(f"Could not save last check time: {e}") def fetch_latest_release(self): """Fetch the latest release information from the API""" try: headers = { 'Accept': 'application/json', 'User-Agent': self.user_agent } response = requests.get(self.releases_url, headers=headers, timeout=self.request_timeout) response.raise_for_status() releases = response.json() if not releases: return None # Filter releases based on prerelease setting if not self.include_prereleases: releases = [r for r in releases if not r.get('prerelease', False)] # Filter out draft releases releases = [r for r in releases if not r.get('draft', False)] if not releases: return None # Get the latest release (first in the filtered list) latest_release = releases[0] return { 'version': latest_release.get('tag_name', '').lstrip('v'), 'name': latest_release.get('name', ''), 'body': latest_release.get('body', ''), 'html_url': latest_release.get('html_url', ''), 'published_at': latest_release.get('published_at', ''), 'assets': latest_release.get('assets', []), 'prerelease': latest_release.get('prerelease', False), 'draft': latest_release.get('draft', False) } except requests.exceptions.RequestException as e: print(f"Network error checking for updates: {e}") return None except json.JSONDecodeError as e: print(f"Error parsing release data: {e}") return None except Exception as e: print(f"Unexpected error checking for updates: {e}") return None def is_newer_version(self, latest_version): """Compare versions to see if the latest is newer than current""" try: current = version.parse(self.current_version) latest = version.parse(latest_version) return latest > current except Exception as e: print(f"Error comparing versions: {e}") # Fallback to string comparison return latest_version != self.current_version def check_for_updates_async(self, callback=None): """Check for updates in a background thread""" def check_thread(): try: if not self.should_check_for_updates(): if callback: callback(None, "No check needed") return latest_release = self.fetch_latest_release() self.save_last_check_time() if callback: callback(latest_release, None) except Exception as e: if callback: callback(None, str(e)) thread = threading.Thread(target=check_thread, daemon=True) thread.start() def check_for_updates_sync(self): """Check for updates synchronously""" if not self.should_check_for_updates(): return None, "No check needed" latest_release = self.fetch_latest_release() self.save_last_check_time() return latest_release, None def show_update_dialog(self, parent_window, release_info): """Show an update notification dialog""" if not release_info: return latest_version = release_info['version'] if not self.is_newer_version(latest_version): return # No update needed # Create update dialog dialog = tk.Toplevel(parent_window) dialog.title("Update Available - Progression Loader") dialog.configure(bg='#2b2b2b') dialog.resizable(False, False) dialog.attributes('-topmost', True) # Set window icon if available try: if hasattr(parent_window, 'iconbitmap'): dialog.iconbitmap(parent_window.iconbitmap()) except: pass # Calculate dialog size dialog_width = 500 dialog_height = 400 # Center the dialog x = parent_window.winfo_x() + (parent_window.winfo_width() // 2) - (dialog_width // 2) y = parent_window.winfo_y() + (parent_window.winfo_height() // 2) - (dialog_height // 2) dialog.geometry(f"{dialog_width}x{dialog_height}+{x}+{y}") # Title title_label = tk.Label(dialog, text="🔄 Update Available!", fg='#00ff00', bg='#2b2b2b', font=('Arial', 16, 'bold')) title_label.pack(pady=20) # Version info version_frame = tk.Frame(dialog, bg='#2b2b2b') version_frame.pack(pady=10) current_label = tk.Label(version_frame, text=f"Current Version: {self.current_version}", fg='#cccccc', bg='#2b2b2b', font=('Arial', 12)) current_label.pack() latest_label = tk.Label(version_frame, text=f"Latest Version: {latest_version}", fg='#00ff00', bg='#2b2b2b', font=('Arial', 12, 'bold')) latest_label.pack() # Release notes if release_info.get('body'): notes_label = tk.Label(dialog, text="Release Notes:", fg='#cccccc', bg='#2b2b2b', font=('Arial', 12, 'bold')) notes_label.pack(pady=(20, 5)) # Create scrollable text widget for release notes notes_frame = tk.Frame(dialog, bg='#2b2b2b') notes_frame.pack(fill='both', expand=True, padx=20, pady=(0, 20)) notes_text = tk.Text(notes_frame, height=8, bg='#404040', fg='#ffffff', font=('Arial', 10), wrap=tk.WORD, state='disabled') scrollbar = tk.Scrollbar(notes_frame) scrollbar.pack(side='right', fill='y') notes_text.pack(side='left', fill='both', expand=True) notes_text.config(yscrollcommand=scrollbar.set) scrollbar.config(command=notes_text.yview) # Insert release notes notes_text.config(state='normal') notes_text.insert('1.0', release_info['body']) notes_text.config(state='disabled') # Buttons button_frame = tk.Frame(dialog, bg='#2b2b2b') button_frame.pack(pady=20) def download_update(): webbrowser.open(release_info['html_url']) dialog.destroy() def remind_later(): # If no persistent file, just close the dialog if not self.last_check_file: dialog.destroy() return # Reset last check time to check again sooner try: data = {'last_check': (datetime.now() - timedelta(hours=self.check_interval_hours - 4)).isoformat()} with open(self.last_check_file, 'w') as f: json.dump(data, f) except: pass dialog.destroy() def skip_version(): # If no persistent file, just close the dialog if not self.last_check_file: dialog.destroy() return # Save this version as skipped try: data = { 'last_check': datetime.now().isoformat(), 'skipped_version': latest_version } with open(self.last_check_file, 'w') as f: json.dump(data, f) except: pass dialog.destroy() download_btn = tk.Button(button_frame, text="Download Update", command=download_update, bg='#00aa00', fg='white', font=('Arial', 12, 'bold'), padx=20, pady=5) download_btn.pack(side='left', padx=5) # Only show remind/skip buttons if persistence is enabled if self.last_check_file: later_btn = tk.Button(button_frame, text="Remind Later", command=remind_later, bg='#0078d4', fg='white', font=('Arial', 12), padx=20, pady=5) later_btn.pack(side='left', padx=5) skip_btn = tk.Button(button_frame, text="Skip Version", command=skip_version, bg='#666666', fg='white', font=('Arial', 12), padx=20, pady=5) skip_btn.pack(side='left', padx=5) else: close_btn = tk.Button(button_frame, text="Close", command=dialog.destroy, bg='#666666', fg='white', font=('Arial', 12), padx=20, pady=5) close_btn.pack(side='left', padx=5) # Handle window close dialog.protocol("WM_DELETE_WINDOW", dialog.destroy) return dialog def should_skip_version(self, version_string): """Check if this version should be skipped""" # Skip version checking if no persistent file is configured if not self.last_check_file: return False try: with open(self.last_check_file, 'r') as f: data = json.load(f) return data.get('skipped_version') == version_string except: return False def manual_check_for_updates(self, parent_window): """Manually check for updates and show result""" def check_complete(release_info, error): if error: messagebox.showerror("Update Check Failed", f"Could not check for updates:\n{error}", parent=parent_window) return if not release_info: messagebox.showinfo("No Updates", "Could not retrieve release information.", parent=parent_window) return latest_version = release_info['version'] if self.is_newer_version(latest_version) and not self.should_skip_version(latest_version): self.show_update_dialog(parent_window, release_info) else: messagebox.showinfo("Up to Date", f"You are running the latest version ({self.current_version}).", parent=parent_window) # Show checking message checking_dialog = tk.Toplevel(parent_window) checking_dialog.title("Checking for Updates") checking_dialog.configure(bg='#2b2b2b') checking_dialog.resizable(False, False) checking_dialog.attributes('-topmost', True) # Center the dialog checking_dialog.geometry("300x100") x = parent_window.winfo_x() + (parent_window.winfo_width() // 2) - 150 y = parent_window.winfo_y() + (parent_window.winfo_height() // 2) - 50 checking_dialog.geometry(f"300x100+{x}+{y}") label = tk.Label(checking_dialog, text="Checking for updates...", fg='white', bg='#2b2b2b', font=('Arial', 12)) label.pack(expand=True) def check_and_close(): release_info, error = self.check_for_updates_sync() checking_dialog.destroy() check_complete(release_info, error) # Start check in background threading.Thread(target=check_and_close, daemon=True).start() def integrate_update_checker_with_gui(gui_class): """ Decorator to integrate update checker with the main GUI class """ original_init = gui_class.__init__ def new_init(self, *args, **kwargs): # Call original init original_init(self, *args, **kwargs) # Initialize update checker self.update_checker = UpdateChecker() # Add update check to menu if it exists self.add_update_menu() # Check for updates on startup (after a short delay) self.root.after(5000, self.check_for_updates_on_startup) def add_update_menu(self): """Add update check option to the application""" try: # Try to add to existing menu bar if hasattr(self, 'menubar'): help_menu = tk.Menu(self.menubar, tearoff=0) help_menu.add_command(label="Check for Updates", command=self.manual_update_check) self.menubar.add_cascade(label="Help", menu=help_menu) else: # Create a simple button in the GUI self.add_update_button() except Exception as e: print(f"Could not add update menu: {e}") def add_update_button(self): """Add an update check button to the GUI""" try: # Find a suitable parent frame (try footer first, then main frame) parent_frame = None if hasattr(self, 'footer_frame'): parent_frame = self.footer_frame elif hasattr(self, 'main_frame'): parent_frame = self.main_frame elif hasattr(self, 'root'): parent_frame = self.root if parent_frame: update_btn = tk.Button(parent_frame, text="Check Updates", command=self.manual_update_check, bg='#404040', fg='white', font=('Arial', 10), padx=10, pady=2) update_btn.pack(side='right', padx=5, pady=5) except Exception as e: print(f"Could not add update button: {e}") def check_for_updates_on_startup(self): """Check for updates when the application starts""" def update_callback(release_info, error): if error or not release_info: return # Silently fail on startup latest_version = release_info['version'] if (self.update_checker.is_newer_version(latest_version) and not self.update_checker.should_skip_version(latest_version)): # Show update dialog self.update_checker.show_update_dialog(self.root, release_info) self.update_checker.check_for_updates_async(update_callback) def manual_update_check(self): """Manually triggered update check""" self.update_checker.manual_check_for_updates(self.root) # Add methods to the class gui_class.add_update_menu = add_update_menu gui_class.add_update_button = add_update_button gui_class.check_for_updates_on_startup = check_for_updates_on_startup gui_class.manual_update_check = manual_update_check gui_class.__init__ = new_init return gui_class