All checks were successful
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Successful in 37s
487 lines
19 KiB
Python
487 lines
19 KiB
Python
"""
|
|
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 |