4 Commits
0.0.1 ... main

Author SHA1 Message Date
269950cf4a Fix escaping
All checks were successful
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Successful in 41s
2026-01-24 02:32:22 -05:00
f1cd7eadb9 Autoupdates
All checks were successful
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Successful in 42s
2026-01-24 01:55:54 -05:00
49ba0a4602 Update blocking 2026-01-24 01:47:38 -05:00
958fe26cad Testing Auto Updates
All checks were successful
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Successful in 34s
2026-01-24 01:15:53 -05:00
3 changed files with 505 additions and 41 deletions

View File

@@ -12,3 +12,7 @@ to bump the version
python version_manager.py 1.0.1
build
pyinstaller --clean --onefile --windowed --icon=art/Progression.ico --add-data "art;art" --name ProgressionLoader steam_workshop_gui.py

View File

@@ -14,6 +14,7 @@ import time
import webbrowser
from update_checker import UpdateChecker
from update_config import get_update_config
import html
# Load environment variables
load_dotenv()
@@ -1160,7 +1161,7 @@ class SteamWorkshopGUI:
self.log_to_output("🔍 Please provide a valid RimWorld installation path to continue.\n")
self.log_to_output(" The 'RimWorld Game Folder' label will stop blinking once a valid path is detected.\n\n")
# Initialize update checker
# Initialize update checker (for manual checks only)
config = get_update_config()
self.update_checker = UpdateChecker(config["current_version"])
@@ -1173,10 +1174,6 @@ class SteamWorkshopGUI:
# Start blinking animation for RimWorld label (with slight delay to ensure GUI is ready)
self.root.after(1000, self.start_rimworld_label_blink)
# Check for updates on startup if enabled
if config["check_on_startup"]:
self.root.after(config["startup_check_delay"], self.check_for_updates_on_startup)
def create_header_frame(self, parent):
"""Create header frame with progression logo"""
header_frame = tk.Frame(parent, bg='#2b2b2b', height=100)
@@ -2013,6 +2010,14 @@ class SteamWorkshopGUI:
except Exception as e:
self._safe_update_output(f"Error creating Homebrew RML file: {str(e)}\n")
def escape_xml_text(self, text):
"""Escape special XML characters in text content"""
if not text:
return text
# Use html.escape to handle XML/HTML entities properly
# xml_char_ref=False ensures we get named entities like & instead of numeric ones
return html.escape(text, quote=False)
def generate_homebrew_rml_xml(self, current_mods, workshop_results):
"""Generate the XML content for the homebrew .rml file"""
xml_lines = [
@@ -2054,12 +2059,14 @@ class SteamWorkshopGUI:
# Add current mod names first
for package_id, mod_name in current_mods:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
# Add workshop mod names to meta section
for workshop_id, package_id in workshop_results:
mod_name = self.get_mod_name_from_about_xml(workshop_id)
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
xml_lines.extend([
'\t\t</modNames>',
@@ -2083,12 +2090,14 @@ class SteamWorkshopGUI:
# Add current mod names first to modList section
for package_id, mod_name in current_mods:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
# Add workshop mod names to modList section
for workshop_id, package_id in workshop_results:
mod_name = self.get_mod_name_from_about_xml(workshop_id)
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
xml_lines.extend([
'\t\t</names>',
@@ -2127,7 +2136,13 @@ class SteamWorkshopGUI:
def generate_rml_xml(self, mod_data):
"""Generate the XML content for the .rml file"""
# Get core mods from ModsConfig.xml and RimWorld Data folder
msg = "=== GENERATING RML XML ===\n"
self._safe_update_output(msg)
print(msg.strip())
core_mods = self.get_core_mods_from_config()
msg = f"Core mods returned: {core_mods}\n"
self._safe_update_output(msg)
print(msg.strip())
# Extract mod names from About.xml files for workshop mods
mod_names = []
@@ -2144,8 +2159,14 @@ class SteamWorkshopGUI:
]
# Add core mods first
msg = "Adding core mods to XML:\n"
self._safe_update_output(msg)
print(msg.strip())
for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
msg = f" Added core mod: {package_id} ({mod_name})\n"
self._safe_update_output(msg)
print(msg.strip())
# Add workshop mod package IDs to meta section
for workshop_id, package_id in mod_data:
@@ -2171,11 +2192,13 @@ class SteamWorkshopGUI:
# Add core mod names first
for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
# Add workshop mod names to meta section
for mod_name in mod_names:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
xml_lines.extend([
'\t\t</modNames>',
@@ -2199,11 +2222,13 @@ class SteamWorkshopGUI:
# Add core mod names first to modList section
for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
# Add workshop mod names to modList section
for mod_name in mod_names:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>')
escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
xml_lines.extend([
'\t\t</names>',
@@ -2214,14 +2239,20 @@ class SteamWorkshopGUI:
return '\n'.join(xml_lines)
def get_core_mods_from_config(self):
"""Get core mods from ModsConfig.xml knownExpansions section and their info from RimWorld Data folder"""
"""Get core mods from ModsConfig.xml knownExpansions section - includes ALL DLC the user owns"""
core_mods = []
try:
if not self.modsconfig_path or not os.path.exists(self.modsconfig_path):
self._safe_update_output("ModsConfig.xml not found, using default core mods\n")
msg = "ModsConfig.xml not found, using default core mods\n"
self._safe_update_output(msg)
print(msg.strip())
return [('ludeon.rimworld', 'RimWorld')]
msg = f"Reading ModsConfig.xml from: {self.modsconfig_path}\n"
self._safe_update_output(msg)
print(msg.strip())
# Parse ModsConfig.xml
tree = ET.parse(self.modsconfig_path)
root = tree.getroot()
@@ -2231,31 +2262,122 @@ class SteamWorkshopGUI:
if self.rimworld_var.get().strip():
rimworld_game_path = self.rimworld_var.get().strip()
rimworld_data_path = os.path.join(rimworld_game_path, "Data")
msg = f"RimWorld Data path: {rimworld_data_path}\n"
self._safe_update_output(msg)
print(msg.strip())
else:
msg = "No RimWorld path set, using fallback names\n"
self._safe_update_output(msg)
print(msg.strip())
# Find knownExpansions section
# Find knownExpansions section and include ALL expansions found
known_expansions_element = root.find('knownExpansions')
if known_expansions_element is not None:
msg = "Found knownExpansions section, processing all DLC...\n"
self._safe_update_output(msg)
print(msg.strip())
for li in known_expansions_element.findall('li'):
expansion_id = li.text
if expansion_id:
expansion_id = expansion_id.strip()
msg = f"Processing DLC: {expansion_id}\n"
self._safe_update_output(msg)
print(msg.strip())
# Use the same method as DLC detection to get real names
mod_name = self.get_expansion_real_name(expansion_id.strip(), rimworld_data_path)
mod_name = self.get_expansion_real_name(expansion_id, rimworld_data_path)
msg = f"get_expansion_real_name returned: {mod_name}\n"
self._safe_update_output(msg)
print(msg.strip())
if not mod_name:
mod_name = self.get_default_mod_name(expansion_id.strip())
core_mods.append((expansion_id.strip(), mod_name))
mod_name = self.get_default_mod_name(expansion_id)
msg = f"Using fallback name: {mod_name}\n"
self._safe_update_output(msg)
print(msg.strip())
core_mods.append((expansion_id, mod_name))
msg = f"Added to core_mods: {expansion_id} -> {mod_name}\n"
self._safe_update_output(msg)
print(msg.strip())
else:
msg = "No knownExpansions section found in ModsConfig.xml\n"
self._safe_update_output(msg)
print(msg.strip())
if not core_mods:
# Fallback if no expansions found
# Fallback if no expansions found - just base game
core_mods = [('ludeon.rimworld', 'RimWorld')]
msg = "No expansions found, using fallback base game only\n"
self._safe_update_output(msg)
print(msg.strip())
self._safe_update_output(f"Found {len(core_mods)} core expansions from ModsConfig.xml\n")
msg = f"Final core_mods list: {core_mods}\n"
self._safe_update_output(msg)
print(msg.strip())
msg = f"Found {len(core_mods)} core expansions from knownExpansions in ModsConfig.xml\n"
self._safe_update_output(msg)
print(msg.strip())
except Exception as e:
self._safe_update_output(f"Error reading core mods: {str(e)}\n")
msg = f"Error reading core mods: {str(e)}\n"
self._safe_update_output(msg)
print(msg.strip())
core_mods = [('ludeon.rimworld', 'RimWorld')]
return core_mods
def get_expansion_real_name(self, expansion_id, rimworld_data_path):
"""Get the real expansion name from RimWorld Data folder About.xml"""
try:
if not rimworld_data_path or not os.path.exists(rimworld_data_path):
# Fallback to extracting from ID if no data path
return self.extract_name_from_id(expansion_id)
# Look for expansion folder in Data directory
# Core expansions are usually in subfolders like Royalty, Ideology, etc.
possible_folders = [
expansion_id.replace('ludeon.rimworld.', '').capitalize(),
expansion_id.split('.')[-1].capitalize() if '.' in expansion_id else expansion_id,
expansion_id.replace('ludeon.rimworld.', ''),
expansion_id.replace('ludeon.rimworld', 'Core')
]
for folder_name in possible_folders:
about_xml_path = os.path.join(rimworld_data_path, folder_name, "About", "About.xml")
if os.path.exists(about_xml_path):
try:
tree = ET.parse(about_xml_path)
root = tree.getroot()
name_element = root.find('name')
if name_element is not None and name_element.text:
return name_element.text.strip()
except ET.ParseError:
continue
# If not found in Data folder, use fallback
return self.extract_name_from_id(expansion_id)
except Exception as e:
return self.extract_name_from_id(expansion_id)
def extract_name_from_id(self, expansion_id):
"""Extract expansion name from ID as fallback"""
if 'royalty' in expansion_id.lower():
return 'Royalty'
elif 'ideology' in expansion_id.lower():
return 'Ideology'
elif 'biotech' in expansion_id.lower():
return 'Biotech'
elif 'anomaly' in expansion_id.lower():
return 'Anomaly'
elif 'odyssey' in expansion_id.lower():
return 'Odyssey'
else:
# Extract the last part after the last dot and capitalize
parts = expansion_id.split('.')
if len(parts) > 1:
return parts[-1].capitalize()
return expansion_id
def get_default_mod_name(self, mod_id):
"""Get default display name for core mods"""
default_names = {
@@ -2603,20 +2725,6 @@ you must auto sort after this!"""
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)
@@ -2625,6 +2733,9 @@ def main():
root = tk.Tk()
root.withdraw() # Hide main window initially
# Import config
from update_config import get_update_config
# Set window icon
try:
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
@@ -2638,10 +2749,356 @@ def main():
root.deiconify() # Show main window
app = SteamWorkshopGUI(root)
# Show loading screen first
loading_screen = LoadingScreen(root, show_main_app)
# Check for updates SYNCHRONOUSLY before starting anything
config = get_update_config()
update_info = check_for_updates_before_startup(root)
if update_info and config.get("updates_required", True):
# Show blocking update dialog - app won't continue until user updates
show_blocking_update_dialog(root, update_info['checker'], update_info['release'])
else:
# No update needed or updates not required, start loading screen
loading_screen = LoadingScreen(root, show_main_app)
# If update available but not required, show non-blocking dialog
if update_info:
root.after(1000, lambda: show_optional_update_dialog(root, update_info['checker'], update_info['release']))
root.mainloop()
def check_for_updates_before_startup(root):
"""Check for updates synchronously before starting the application"""
try:
from update_checker import UpdateChecker
from update_config import get_update_config
print("Checking for updates...")
config = get_update_config()
update_checker = UpdateChecker(config["current_version"])
# Use synchronous check to block until complete
release_info, error = update_checker.check_for_updates_sync()
if error:
print(f"Update check failed: {error}")
return None
elif release_info:
latest_version = release_info['version']
if update_checker.is_newer_version(latest_version):
print(f"Update required: {latest_version}")
return {
'checker': update_checker,
'release': release_info
}
else:
print(f"Already up to date: {latest_version}")
else:
print("No release info available")
return None
except Exception as e:
print(f"Update check failed: {e}")
return None
def show_blocking_update_dialog(root, update_checker, release_info):
"""Show a blocking update dialog that prevents app from continuing"""
# Create a new window for the update dialog
update_window = tk.Toplevel(root)
update_window.title("Update Required - Progression Loader")
update_window.configure(bg='#2b2b2b')
update_window.resizable(False, False)
update_window.attributes('-topmost', True)
# Make it modal - user can't interact with other windows
update_window.transient(root)
update_window.grab_set()
# Set window icon
try:
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
if os.path.exists(icon_path):
update_window.iconbitmap(icon_path)
except:
pass
# Calculate window size and center it
window_width = 600
window_height = 600 # Increased height to accommodate buttons
# Get screen dimensions
screen_width = update_window.winfo_screenwidth()
screen_height = update_window.winfo_screenheight()
# Calculate center position
x = (screen_width - window_width) // 2
y = (screen_height - window_height) // 2
update_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
# Title
title_label = tk.Label(update_window,
text="🔄 Update Required",
fg='#ff6b6b', bg='#2b2b2b',
font=('Arial', 20, 'bold'))
title_label.pack(pady=20)
# Message
message_label = tk.Label(update_window,
text="A new version is available and required to continue.",
fg='white', bg='#2b2b2b',
font=('Arial', 14))
message_label.pack(pady=10)
# Version info
version_frame = tk.Frame(update_window, bg='#2b2b2b')
version_frame.pack(pady=15)
current_label = tk.Label(version_frame,
text=f"Current Version: {update_checker.current_version}",
fg='#cccccc', bg='#2b2b2b',
font=('Arial', 12))
current_label.pack()
latest_label = tk.Label(version_frame,
text=f"Required Version: {release_info['version']}",
fg='#4ecdc4', bg='#2b2b2b',
font=('Arial', 12, 'bold'))
latest_label.pack(pady=5)
# Release notes
if release_info.get('body'):
notes_label = tk.Label(update_window,
text="What's New:",
fg='white', bg='#2b2b2b',
font=('Arial', 14, 'bold'))
notes_label.pack(pady=(20, 10))
# Create scrollable text widget for release notes
notes_frame = tk.Frame(update_window, bg='#2b2b2b')
notes_frame.pack(fill='both', expand=True, padx=40, pady=(0, 10))
notes_text = tk.Text(notes_frame,
height=6, # Reduced height to make room for buttons
bg='#404040', fg='#ffffff',
font=('Arial', 11),
wrap=tk.WORD,
state='disabled',
relief='flat',
bd=0)
scrollbar = tk.Scrollbar(notes_frame, bg='#404040')
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 - fixed at bottom
button_frame = tk.Frame(update_window, bg='#2b2b2b')
button_frame.pack(side='bottom', pady=20) # Pack at bottom with padding
def auto_update():
"""Download and install the update automatically"""
try:
# Disable buttons during download
auto_btn.config(state='disabled', text='Downloading...')
download_btn.config(state='disabled')
exit_btn.config(state='disabled')
# Update the window to show progress
update_window.update()
# Start download in a separate thread
import threading
threading.Thread(target=perform_auto_update, daemon=True).start()
except Exception as e:
print(f"Auto update failed: {e}")
# Re-enable buttons on error
auto_btn.config(state='normal', text='Auto Update')
download_btn.config(state='normal')
exit_btn.config(state='normal')
def perform_auto_update():
"""Perform the actual auto update process"""
try:
import requests
import tempfile
import shutil
import subprocess
import sys
# Find the EXE asset in the release
exe_asset = None
for asset in release_info.get('assets', []):
if asset.get('name', '').endswith('.exe'):
exe_asset = asset
break
if not exe_asset:
raise Exception("No EXE file found in release assets")
download_url = exe_asset.get('browser_download_url')
if not download_url:
raise Exception("No download URL found for EXE asset")
# Update status
root.after(0, lambda: auto_btn.config(text='Downloading...'))
# Download the new EXE
response = requests.get(download_url, stream=True)
response.raise_for_status()
# Save to temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix='.exe') as temp_file:
temp_path = temp_file.name
for chunk in response.iter_content(chunk_size=8192):
temp_file.write(chunk)
# Get current executable path
if getattr(sys, 'frozen', False):
# Running as PyInstaller bundle
current_exe = sys.executable
else:
# Running as script - for testing
current_exe = sys.argv[0]
# Update status
root.after(0, lambda: auto_btn.config(text='Installing...'))
# Get configuration
from update_config import get_update_config
config = get_update_config()
auto_restart = config.get("auto_restart_after_update", True)
# Create a batch script to replace the executable and optionally restart
if auto_restart:
restart_command = f'start "" /B "{current_exe}"'
restart_message = "Starting new version..."
else:
restart_command = 'echo Please manually start the application.'
restart_message = "Please manually start the updated application."
batch_script = f'''@echo off
echo Updating Progression Loader...
timeout /t 3 /nobreak >nul
REM Replace the executable
move "{temp_path}" "{current_exe}"
if errorlevel 1 (
echo Failed to replace executable
pause
exit /b 1
)
echo Update complete! {restart_message}
timeout /t 1 /nobreak >nul
REM Start the new executable (if configured)
{restart_command}
REM Clean up this batch file
timeout /t 2 /nobreak >nul
del "%~f0"
'''
# Write batch script to temp file
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.bat') as batch_file:
batch_file.write(batch_script)
batch_path = batch_file.name
# Update status
root.after(0, lambda: auto_btn.config(text='Restarting...'))
# Show success message
root.after(0, lambda: tk.messagebox.showinfo(
"Update Complete",
"Update downloaded successfully!\nThe application will restart automatically.",
parent=update_window
))
# Execute the batch script and exit
subprocess.Popen([batch_path], shell=True)
# Exit the current application
root.after(2000, root.quit)
except Exception as e:
print(f"Auto update failed: {e}")
# Re-enable buttons on error
root.after(0, lambda: auto_btn.config(state='normal', text='Auto Update'))
root.after(0, lambda: download_btn.config(state='normal'))
root.after(0, lambda: exit_btn.config(state='normal'))
# Show error message
root.after(0, lambda: tk.messagebox.showerror(
"Auto Update Failed",
f"Failed to automatically update:\n{str(e)}\n\nPlease use 'Download Page' to update manually.",
parent=update_window
))
def download_page():
"""Open the release download page"""
webbrowser.open(release_info['html_url'])
print("Please download and install the update manually, then restart the application.")
root.quit() # Exit the entire application
def exit_app():
"""Exit the application without updating"""
print("Application cannot continue without updating.")
root.quit()
# Auto Update button (primary action)
auto_btn = tk.Button(button_frame,
text="Auto Update",
command=auto_update,
bg='#4ecdc4', fg='white',
font=('Arial', 14, 'bold'),
padx=30, pady=10,
cursor='hand2')
auto_btn.pack(side='left', padx=10)
# Download Page button (secondary action)
download_btn = tk.Button(button_frame,
text="Download Page",
command=download_page,
bg='#45b7d1', fg='white',
font=('Arial', 12),
padx=20, pady=10,
cursor='hand2')
download_btn.pack(side='left', padx=10)
# Exit button (tertiary action)
exit_btn = tk.Button(button_frame,
text="Exit Application",
command=exit_app,
bg='#666666', fg='white',
font=('Arial', 12),
padx=20, pady=10,
cursor='hand2')
exit_btn.pack(side='left', padx=10)
# Handle window close (same as exit)
update_window.protocol("WM_DELETE_WINDOW", exit_app)
# Focus on the auto update button
auto_btn.focus_set()
print("Blocking update dialog shown - app will not continue until user updates")
def show_optional_update_dialog(root, update_checker, release_info):
"""Show a non-blocking update dialog"""
try:
update_checker.show_update_dialog(root, release_info)
except Exception as e:
print(f"Could not show update dialog: {e}")
if __name__ == "__main__":
main()

View File

@@ -5,7 +5,7 @@ Configuration settings for the update checker
# Update checker configuration
UPDATE_CONFIG = {
# Current version of the application
"current_version": "0.0.1",
"current_version": "0.0.4",
# Repository information
"repo_owner": "HRiggs",
@@ -18,8 +18,11 @@ UPDATE_CONFIG = {
# Whether to check for updates on startup
"check_on_startup": True,
# Delay before checking for updates on startup (in milliseconds)
"startup_check_delay": 5000,
# Whether updates are required (blocks app if true)
"updates_required": True,
# Whether to auto-restart after successful update
"auto_restart_after_update": True,
# Whether to include pre-releases in update checks
"include_prereleases": True,