Files
ProgressionMods/steam_workshop_gui.py
HRiggs df6c970112
All checks were successful
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Successful in -14h59m18s
Update
2026-01-24 15:39:53 -05:00

3840 lines
170 KiB
Python

import tkinter as tk
from tkinter import ttk, scrolledtext, filedialog, messagebox, font
import customtkinter as ctk
import requests
import re
from urllib.parse import urlparse, parse_qs
import threading
import os
import sys
from pathlib import Path
import xml.etree.ElementTree as ET
from dotenv import load_dotenv
from PIL import Image, ImageTk, ImageDraw, ImageFilter
import time
import webbrowser
from update_checker import UpdateChecker
from update_config import get_update_config
import html
# Load environment variables
load_dotenv()
# Set CustomTkinter appearance
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("dark-blue")
def get_resource_path(relative_path):
"""Get absolute path to resource, works for dev and for PyInstaller"""
try:
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
class LoadingScreen:
def __init__(self, root, on_complete_callback):
self.root = root
self.on_complete_callback = on_complete_callback
self.current_stage = 0
self.expansion_check_results = {}
# Load custom font first
self.load_custom_font()
# Create fullscreen loading window using CTkToplevel
self.loading_window = ctk.CTkToplevel(root)
self.loading_window.title("Progression: Loader")
# Set window icon
try:
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
if os.path.exists(icon_path):
self.loading_window.iconbitmap(icon_path)
except Exception as e:
print(f"Could not load icon for loading window: {e}")
self.loading_window.attributes('-topmost', True)
# Make it fullscreen initially, but allow free resizing
self.loading_window.state('zoomed')
# Set minimum size for loading window too
self.loading_window.minsize(800, 600)
# Bind to enforce minimum size for loading window
self.loading_window.bind('<Configure>', self._enforce_loading_minimum_size)
self.loading_window.protocol("WM_DELETE_WINDOW", self.on_close)
# Load images
self.load_images()
# Create UI elements
self.create_loading_ui()
# Start the loading sequence
self.start_loading_sequence()
def load_custom_font(self):
"""Load the RimWorld font using Windows AddFontResourceEx with private flag"""
try:
font_path = get_resource_path(os.path.join("art", "georgia.ttf"))
if os.path.exists(font_path):
abs_font_path = os.path.abspath(font_path)
# Use the Stack Overflow method with AddFontResourceEx
success = self._load_font_private(abs_font_path)
if success:
# Use the actual font family name from the TTF file
self.custom_font_family = "Georgia"
self.custom_font_available = True
return
else:
self.custom_font_available = False
else:
self.custom_font_available = False
except Exception as e:
self.custom_font_available = False
# Initialize fallback values if font loading failed
if not hasattr(self, 'custom_font_available'):
self.custom_font_available = False
if not hasattr(self, 'custom_font_family'):
self.custom_font_family = None
def _load_font_private(self, fontpath):
"""
Load font privately using AddFontResourceEx
Based on Stack Overflow solution by Felipe
"""
try:
from ctypes import windll, byref, create_unicode_buffer
# Constants for AddFontResourceEx
FR_PRIVATE = 0x10 # Font is private to this process
FR_NOT_ENUM = 0x20 # Font won't appear in font enumeration
# Create unicode buffer for the font path
pathbuf = create_unicode_buffer(fontpath)
# Use AddFontResourceExW for Unicode strings
AddFontResourceEx = windll.gdi32.AddFontResourceExW
# Set flags: private (unloaded when process dies) and not enumerable
flags = FR_PRIVATE | FR_NOT_ENUM
# Add the font resource
numFontsAdded = AddFontResourceEx(byref(pathbuf), flags, 0)
return bool(numFontsAdded)
except Exception:
return False
def get_font(self, size=10, weight='normal'):
"""Get font tuple for UI elements"""
if self.custom_font_available and self.custom_font_family:
try:
return font.Font(family=self.custom_font_family, size=size, weight=weight)
except Exception:
pass
# Fall back to system font
return font.Font(family='Arial', size=size, weight=weight)
def create_radial_glow_image(self, base_image):
"""Create a radial glow effect around the image with #ff8686 color"""
# Get base image dimensions
base_width, base_height = base_image.size
# Create larger canvas for glow effect
glow_padding = 15
glow_width = base_width + (glow_padding * 2)
glow_height = base_height + (glow_padding * 2)
# Create glow background matching the label background
glow_bg = Image.new('RGBA', (glow_width, glow_height), (43, 43, 43, 255)) # #2b2b2b background
# Create radial glow mask
glow_mask = Image.new('L', (glow_width, glow_height), 0)
draw = ImageDraw.Draw(glow_mask)
# Calculate center and create multiple circles for smooth gradient
center_x, center_y = glow_width // 2, glow_height // 2
# Create glow that extends beyond the original image
max_radius = glow_padding + max(base_width, base_height) // 2
# Create radial gradient by drawing concentric circles
for i in range(max_radius, 0, -1):
# Calculate alpha based on distance from center (stronger in center)
# Make the glow more intense closer to the logo
if i <= max(base_width, base_height) // 2:
# Inner area (around the logo) - stronger glow
alpha = int(255 * 0.8)
else:
# Outer area - fade out
fade_factor = (max_radius - i) / (max_radius - max(base_width, base_height) // 2)
alpha = int(255 * fade_factor * 0.6)
if alpha > 0:
draw.ellipse([center_x - i, center_y - i, center_x + i, center_y + i], fill=alpha)
# Apply blur for smooth glow effect
glow_mask = glow_mask.filter(ImageFilter.GaussianBlur(radius=6))
# Create glow color layer (#ff8686)
glow_color = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 0))
glow_layer = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 255))
# Apply the mask to create the glow effect
glow_color.paste(glow_layer, mask=glow_mask)
# Composite glow onto background
glow_bg = Image.alpha_composite(glow_bg, glow_color)
# Convert base image to RGBA if needed
if base_image.mode != 'RGBA':
base_image = base_image.convert('RGBA')
# Paste the original image in the exact center to maintain position
paste_x = (glow_width - base_width) // 2
paste_y = (glow_height - base_height) // 2
glow_bg.paste(base_image, (paste_x, paste_y), base_image)
return ImageTk.PhotoImage(glow_bg)
def load_background_image(self, width, height):
"""Load and scale the Desolation background image to fit the given dimensions"""
try:
bg_img = Image.open(get_resource_path("art/Desolation.png"))
# Scale to cover the entire area while maintaining aspect ratio
bg_ratio = bg_img.width / bg_img.height
target_ratio = width / height
if bg_ratio > target_ratio:
# Image is wider, scale by height
new_height = height
new_width = int(height * bg_ratio)
else:
# Image is taller, scale by width
new_width = width
new_height = int(width / bg_ratio)
bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Crop to exact size from center
left = (new_width - width) // 2
top = (new_height - height) // 2
bg_img = bg_img.crop((left, top, left + width, top + height))
return ImageTk.PhotoImage(bg_img)
except Exception as e:
print(f"Error loading background image: {e}")
return None
def load_images(self):
"""Load all required images"""
try:
# Load game title
self.game_title_img = Image.open(get_resource_path("art/GameTitle.png"))
self.game_title_tk = ImageTk.PhotoImage(self.game_title_img)
# Create CTkImage version for use with CTkLabel
self.game_title_ctk = ctk.CTkImage(light_image=self.game_title_img,
dark_image=self.game_title_img,
size=self.game_title_img.size)
# Load HR Systems logo
self.hr_logo_img = Image.open(get_resource_path("art/hudsonriggssystems.png"))
# Resize HR logo to be smaller
hr_width, hr_height = self.hr_logo_img.size
new_hr_height = 60
new_hr_width = int((new_hr_height / hr_height) * hr_width)
self.hr_logo_img = self.hr_logo_img.resize((new_hr_width, new_hr_height), Image.Resampling.LANCZOS)
self.hr_logo_tk = ImageTk.PhotoImage(self.hr_logo_img)
# Create CTkImage version
self.hr_logo_ctk = ctk.CTkImage(light_image=self.hr_logo_img,
dark_image=self.hr_logo_img,
size=(new_hr_width, new_hr_height))
# Create radial glow effect version for loading screen
self.hr_logo_glow_tk = self.create_radial_glow_image(self.hr_logo_img)
# Load expansion images - dynamically find all NotOwned_ files
self.expansion_images = {}
art_dir = get_resource_path("art")
if os.path.exists(art_dir):
for file in os.listdir(art_dir):
if file.startswith("NotOwned_") and file.endswith(".png"):
try:
img = Image.open(os.path.join(art_dir, file))
# Resize expansion images
img_width, img_height = img.size
new_height = 150
new_width = int((new_height / img_height) * img_width)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
expansion_name = file.replace("NotOwned_", "").replace(".png", "")
self.expansion_images[expansion_name] = ImageTk.PhotoImage(img)
except Exception as e:
print(f"Error loading {file}: {e}")
except Exception as e:
print(f"Error loading images: {e}")
# Create placeholder images if loading fails
self.game_title_tk = None
self.game_title_ctk = None
self.hr_logo_tk = None
self.hr_logo_ctk = None
self.expansion_images = {}
# Create placeholder images if loading fails
self.game_title_tk = None
self.hr_logo_tk = None
self.expansion_images = {}
def create_loading_ui(self):
"""Create the loading screen UI with background image using Canvas for true transparency"""
# Force window to update and get proper dimensions
self.loading_window.update()
width = self.loading_window.winfo_width()
height = self.loading_window.winfo_height()
# Fallback to screen size if window size not available
if width < 100 or height < 100:
width = self.loading_window.winfo_screenwidth()
height = self.loading_window.winfo_screenheight()
print(f"Loading UI with dimensions: {width}x{height}")
# Store canvas dimensions for later use
self.canvas_width = width
self.canvas_height = height
# Create canvas for background and all content
self.bg_canvas = tk.Canvas(self.loading_window, highlightthickness=0)
self.bg_canvas.pack(fill=tk.BOTH, expand=True)
# Load and display background image
self.bg_image_pil = self._load_and_scale_background(width, height)
if self.bg_image_pil:
print(f"Background image loaded: {self.bg_image_pil.size}")
self.bg_image_tk = ImageTk.PhotoImage(self.bg_image_pil)
self.bg_canvas.create_image(0, 0, image=self.bg_image_tk, anchor='nw', tags='bg')
else:
print("Failed to load background image!")
self.bg_canvas.configure(bg='#2b2b2b')
# Bind window state change events
self.loading_window.bind('<Map>', self._on_window_map)
self.loading_window.bind('<Unmap>', self._on_window_unmap)
# Bind resize event to update background (minimum size enforcement is handled separately)
# Note: _enforce_loading_minimum_size calls _on_loading_resize after size correction
# Place widgets directly on the canvas
# Game title (initially large and centered)
if hasattr(self, 'game_title_tk') and self.game_title_tk:
self.title_image_id = self.bg_canvas.create_image(width//2, height//2,
image=self.game_title_tk,
anchor='center', tags='title')
else:
self.title_text_id = self.bg_canvas.create_text(width//2, height//2,
text="Progression: Loader",
fill='white',
font=self.get_font(24, 'bold'),
anchor='center', tags='title')
# HR Systems logo (initially at bottom)
if hasattr(self, 'hr_logo_tk') and self.hr_logo_tk:
self.hr_logo_id = self.bg_canvas.create_image(width//2, int(height*0.95),
image=self.hr_logo_tk,
anchor='center', tags='hr_logo')
else:
self.hr_logo_id = self.bg_canvas.create_text(width//2, int(height*0.95),
text="Hudson Riggs Systems",
fill='#888888',
font=self.get_font(12),
anchor='center', tags='hr_logo')
# Bind click events for HR logo
self.bg_canvas.tag_bind('hr_logo', '<Button-1>', self.open_hr_website)
self.bg_canvas.tag_bind('hr_logo', '<Enter>', self.on_loading_hr_enter)
self.bg_canvas.tag_bind('hr_logo', '<Leave>', self.on_loading_hr_leave)
# Create a reference for compatibility
self.main_frame = self.loading_window
# Store canvas dimensions for later use
self.canvas_width = width
self.canvas_height = height
def _load_and_scale_background(self, width, height):
"""Load and scale the Desolation background image"""
try:
bg_img = Image.open(get_resource_path("art/Desolation.png"))
# Scale to cover the entire area while maintaining aspect ratio
bg_ratio = bg_img.width / bg_img.height
target_ratio = width / height
if bg_ratio > target_ratio:
new_height = height
new_width = int(height * bg_ratio)
else:
new_width = width
new_height = int(width / bg_ratio)
bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Crop to exact size from center
left = (new_width - width) // 2
top = (new_height - height) // 2
bg_img = bg_img.crop((left, top, left + width, top + height))
return bg_img
except Exception as e:
print(f"Error loading background image: {e}")
return None
def _enforce_loading_minimum_size(self, event):
"""Enforce minimum window size for loading screen"""
if event.widget == self.loading_window:
width = self.loading_window.winfo_width()
height = self.loading_window.winfo_height()
# Check if window is smaller than minimum
if width < 800 or height < 600:
# Resize to minimum if too small
new_width = max(width, 800)
new_height = max(height, 600)
self.loading_window.geometry(f"{new_width}x{new_height}")
return # Don't process resize if we're correcting size
# Continue with normal resize handling
self._on_loading_resize(event)
def _on_loading_resize(self, event):
"""Handle window resize to update background image and reposition elements responsively"""
if event.widget == self.loading_window and hasattr(self, 'bg_canvas'):
width = event.width
height = event.height
# Skip if dimensions are too small (handled by minimum size enforcement)
if width < 800 or height < 600:
return
# Update canvas dimensions for responsive scaling
self.canvas_width = width
self.canvas_height = height
# Update background image with new dimensions
self.bg_image_pil = self._load_and_scale_background(width, height)
if self.bg_image_pil:
self.bg_image_tk = ImageTk.PhotoImage(self.bg_image_pil)
self.bg_canvas.delete('bg')
self.bg_canvas.create_image(0, 0, image=self.bg_image_tk, anchor='nw', tags='bg')
self.bg_canvas.tag_lower('bg')
# Reposition elements responsively based on new dimensions
self._reposition_loading_elements_responsive(width, height)
# Ensure logo stays on top after repositioning
self._bring_logo_to_front()
# If we're in a stage, refresh the stage content to reposition elements
if hasattr(self, 'current_stage_method'):
# Add a small delay to avoid too frequent updates during resize
if hasattr(self, '_refresh_timer'):
self.loading_window.after_cancel(self._refresh_timer)
self._refresh_timer = self.loading_window.after(150, self._refresh_current_stage)
def _reposition_loading_elements_responsive(self, width, height):
"""Reposition loading elements responsively based on window size"""
# Calculate responsive positions as percentages of window size
title_y = max(60, int(height * 0.08)) # 8% from top, minimum 60px
hr_logo_y = max(height - 80, int(height * 0.95)) # 5% from bottom, minimum 80px from bottom
# Reposition title to maintain relative position
title_items = self.bg_canvas.find_withtag('title')
for item in title_items:
self.bg_canvas.coords(item, width//2, title_y)
# Reposition HR logo to maintain relative position
hr_items = self.bg_canvas.find_withtag('hr_logo')
for item in hr_items:
self.bg_canvas.coords(item, width//2, hr_logo_y)
def _refresh_current_stage(self):
"""Refresh the current stage content after window resize"""
# This will be called to reposition stage elements after resize
if hasattr(self, 'current_stage_method') and self.current_stage_method:
# Re-call the current stage method to reposition elements
try:
self.current_stage_method()
except Exception as e:
print(f"Error refreshing stage: {e}")
# If refresh fails, just continue without crashing
def _on_window_map(self, event):
"""Handle window map event (window becomes visible)"""
pass
def _on_window_unmap(self, event):
"""Handle window unmap event (window becomes hidden)"""
pass
def get_ctk_font(self, size=10, weight='normal'):
"""Get CTkFont for UI elements"""
if self.custom_font_available and self.custom_font_family:
return ctk.CTkFont(family=self.custom_font_family, size=size,
weight=weight if weight != 'normal' else 'normal')
return ctk.CTkFont(family='Arial', size=size, weight=weight if weight != 'normal' else 'normal')
def create_text_background(self, x, y, width, height, opacity=0.75, corner_radius=20):
"""Create a semi-transparent rounded rectangle background for text
Width and height are automatically increased by 10%"""
# Increase size by 10%
width = int(width * 1.1)
height = int(height * 1.1)
# Create a semi-transparent image with rounded corners
bg_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(bg_img)
# Calculate alpha value (0-255) - default 75% opacity
alpha = int(255 * opacity)
fill_color = (43, 43, 43, alpha) # #2b2b2b with transparency
# Draw rounded rectangle
draw.rounded_rectangle(
[(0, 0), (width - 1, height - 1)],
radius=corner_radius,
fill=fill_color
)
# Apply gaussian blur for soft edges
bg_img = bg_img.filter(ImageFilter.GaussianBlur(radius=3))
# Convert to PhotoImage
bg_tk = ImageTk.PhotoImage(bg_img)
# Store reference to prevent garbage collection
if not hasattr(self, '_text_bg_images'):
self._text_bg_images = []
self._text_bg_images.append(bg_tk)
# Create canvas image
img_id = self.bg_canvas.create_image(x, y, image=bg_tk, anchor='center', tags='stage_content')
# Make sure it's behind text but above background
self.bg_canvas.tag_lower(img_id, 'stage_content')
# Ensure logo stays on top
self._bring_logo_to_front()
return img_id
def _bring_logo_to_front(self):
"""Ensure the game logo stays on top of all other elements"""
# Bring title/logo to the very front
title_items = self.bg_canvas.find_withtag('title')
for item in title_items:
self.bg_canvas.tag_raise(item)
# Also bring HR logo to front (but below title)
hr_items = self.bg_canvas.find_withtag('hr_logo')
for item in hr_items:
self.bg_canvas.tag_raise(item)
# Make sure title is above HR logo
for item in title_items:
self.bg_canvas.tag_raise(item)
def start_loading_sequence(self):
"""Start the loading animation sequence"""
# Stage 1: Show initial screen for 2 seconds
self.loading_window.after(2000, self.stage_2_move_title)
def stage_2_move_title(self):
"""Stage 2: Move title to top and show expansion check"""
# Start the smooth animation to move title to top
self.animate_title_to_top_smooth()
# After animation completes, check subscriptions
self.loading_window.after(1500, self.stage_3_check_subscriptions)
def animate_title_to_top_smooth(self):
"""Smoothly animate the title moving to the top of the screen using canvas"""
# Get current window dimensions
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
# If window dimensions aren't available yet, wait and try again
if window_width <= 1 or window_height <= 1:
self.loading_window.after(100, self.animate_title_to_top_smooth)
return
# Get current title image dimensions
if hasattr(self, 'game_title_img') and self.game_title_img:
current_img = self.game_title_img
current_width, current_height = current_img.size
# Target size (smaller for header)
target_height = 80
target_width = int((target_height / current_height) * current_width)
# Animation parameters (using pixel positions)
start_y = window_height // 2
target_y = int(window_height * 0.08)
# Start the smooth animation
self.animate_title_step(0, 30, start_y, target_y,
current_width, current_height, target_width, target_height)
else:
# Fallback for text version
self.animate_text_title_smooth()
def animate_title_step(self, step, total_steps, start_y, target_y,
start_width, start_height, target_width, target_height):
"""Perform one step of the title animation using canvas"""
if step <= total_steps:
# Calculate progress (0.0 to 1.0)
progress = step / total_steps
# Use easing function for smooth animation (ease-out)
eased_progress = 1 - (1 - progress) ** 3
# Calculate current position
current_y = start_y + (target_y - start_y) * eased_progress
# Calculate current size
current_width = start_width + (target_width - start_width) * eased_progress
current_height = start_height + (target_height - start_height) * eased_progress
# Resize image for current step
if hasattr(self, 'game_title_img'):
resized_img = self.game_title_img.resize(
(int(current_width), int(current_height)),
Image.Resampling.LANCZOS
)
self.current_title_tk = ImageTk.PhotoImage(resized_img)
# Update canvas image
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
self.bg_canvas.delete('title')
self.bg_canvas.create_image(window_width//2, int(current_y),
image=self.current_title_tk,
anchor='center', tags='title')
# Schedule next step
self.loading_window.after(50, lambda: self.animate_title_step(
step + 1, total_steps, start_y, target_y,
start_width, start_height, target_width, target_height
))
else:
# Animation complete - finalize position
self.finalize_title_position()
def animate_text_title_smooth(self):
"""Animate text title if image is not available"""
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
start_y = window_height // 2
target_y = int(window_height * 0.08)
# Animate text title position and size
self.animate_text_step(0, 30, start_y, target_y, 24, 16)
def animate_text_step(self, step, total_steps, start_y, target_y,
start_size, target_size):
"""Animate text title step by step using canvas"""
if step <= total_steps:
progress = step / total_steps
eased_progress = 1 - (1 - progress) ** 3
current_y = start_y + (target_y - start_y) * eased_progress
current_size = int(start_size + (target_size - start_size) * eased_progress)
# Update canvas text
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
self.bg_canvas.delete('title')
self.bg_canvas.create_text(window_width//2, int(current_y),
text="Progression: Loader",
fill='white',
font=self.get_font(current_size, 'bold'),
anchor='center', tags='title')
self.loading_window.after(50, lambda: self.animate_text_step(
step + 1, total_steps, start_y, target_y,
start_size, target_size
))
else:
self.finalize_title_position()
def finalize_title_position(self):
"""Finalize the title position after animation and show content"""
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
# Set final image size
if hasattr(self, 'game_title_img') and self.game_title_img:
final_height = 80
final_width = int((final_height / self.game_title_img.size[1]) * self.game_title_img.size[0])
final_img = self.game_title_img.resize((final_width, final_height), Image.Resampling.LANCZOS)
self.small_title_tk = ImageTk.PhotoImage(final_img)
# Position title at top center
self.bg_canvas.delete('title')
self.bg_canvas.create_image(window_width//2, int(window_height * 0.08),
image=self.small_title_tk,
anchor='center', tags='title')
else:
self.bg_canvas.delete('title')
self.bg_canvas.create_text(window_width//2, int(window_height * 0.08),
text="Progression: Loader",
fill='white',
font=self.get_font(16, 'bold'),
anchor='center', tags='title')
def stage_3_check_subscriptions(self):
"""Stage 3: Show Steam Workshop subscriptions directly"""
# Skip expansion check and go directly to subscription stage
self.show_subscription_stage()
def check_expansions_thread(self):
"""Check which expansions are owned"""
try:
# Find ModsConfig.xml
modsconfig_path = self.find_modsconfig_path()
if not modsconfig_path or not os.path.exists(modsconfig_path):
# If no ModsConfig found, get all NotOwned_ files and assume all are missing
art_dir = get_resource_path("art")
not_owned_files = []
if os.path.exists(art_dir):
for file in os.listdir(art_dir):
if file.startswith("NotOwned_") and file.endswith(".png"):
expansion_name = file.replace("NotOwned_", "").replace(".png", "")
not_owned_files.append(expansion_name)
self.expansion_check_results = {expansion: False for expansion in not_owned_files}
else:
# Parse ModsConfig.xml to check known expansions
self.expansion_check_results = self.parse_known_expansions(modsconfig_path)
# Update UI on main thread
self.loading_window.after(0, self.show_expansion_results)
except Exception as e:
print(f"Error checking expansions: {e}")
# Assume all missing on error - get from NotOwned_ files
art_dir = get_resource_path("art")
not_owned_files = []
if os.path.exists(art_dir):
for file in os.listdir(art_dir):
if file.startswith("NotOwned_") and file.endswith(".png"):
expansion_name = file.replace("NotOwned_", "").replace(".png", "")
not_owned_files.append(expansion_name)
self.expansion_check_results = {expansion: False for expansion in not_owned_files}
self.loading_window.after(0, self.show_expansion_results)
def find_modsconfig_path(self):
"""Find ModsConfig.xml file"""
try:
path_template = os.getenv('MODSCONFIG_PATH_TEMPLATE',
r'%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml')
modsconfig_path = os.path.expandvars(path_template)
if os.path.exists(modsconfig_path):
return modsconfig_path
return None
except Exception:
return None
def parse_known_expansions(self, modsconfig_path):
"""Parse ModsConfig.xml to check which expansions are owned"""
# Get all NotOwned_ PNG files from art directory
art_dir = get_resource_path("art")
not_owned_files = []
if os.path.exists(art_dir):
for file in os.listdir(art_dir):
if file.startswith("NotOwned_") and file.endswith(".png"):
# Extract expansion name (remove NotOwned_ prefix and .png suffix)
expansion_name = file.replace("NotOwned_", "").replace(".png", "")
not_owned_files.append(expansion_name)
# Initialize results - all expansions are missing by default
results = {expansion: False for expansion in not_owned_files}
try:
tree = ET.parse(modsconfig_path)
root = tree.getroot()
# Get RimWorld Data folder path from the RimWorld game path
rimworld_data_path = None
if hasattr(self, 'rimworld_var') and self.rimworld_var.get().strip():
rimworld_game_path = self.rimworld_var.get().strip()
rimworld_data_path = os.path.join(rimworld_game_path, "Data")
known_expansions = root.find('knownExpansions')
if known_expansions is not None:
for li in known_expansions.findall('li'):
expansion_id = li.text
if expansion_id:
# Get the real expansion name from RimWorld Data folder
real_name = self.get_expansion_real_name(expansion_id.strip(), rimworld_data_path)
# Check if this real name matches any of our NotOwned_ files
for expansion in not_owned_files:
if real_name and expansion.lower() == real_name.lower():
results[expansion] = True
break
except Exception as e:
print(f"Error parsing ModsConfig.xml: {e}")
return results
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 show_expansion_results(self):
"""Show the results of expansion checking using canvas with proper backgrounds"""
# Clear previous stage content
self.bg_canvas.delete('stage_content')
# Track current stage for responsive updates
self.current_stage_method = self.show_expansion_results
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
missing_expansions = [name for name, owned in self.expansion_check_results.items() if not owned]
if not missing_expansions:
# All expansions owned, proceed to RimWorld check
# Create background for success message - responsive sizing
success_bg_width = max(300, min(int(window_width * 0.5), 600))
success_bg_height = max(80, min(int(window_height * 0.15), 150))
self.create_text_background(window_width//2, int(window_height * 0.4), success_bg_width, success_bg_height)
self.bg_canvas.create_text(window_width//2, int(window_height * 0.4),
text="✓ All RimWorld expansions detected!",
fill='#00ff00',
font=self.get_font(16, 'bold'),
anchor='center', tags='stage_content')
self.loading_window.after(2000, self.stage_6_final_confirmation)
else:
# Create background for error content - responsive sizing
error_bg_width = max(500, min(int(window_width * 0.7), 900))
error_bg_height = max(300, min(int(window_height * 0.6), 600))
self.create_text_background(window_width//2, int(window_height * 0.45), error_bg_width, error_bg_height)
self.bg_canvas.create_text(window_width//2, int(window_height * 0.25),
text=f"You don't own {', '.join(missing_expansions)}!",
fill='#ff4444',
font=self.get_font(16, 'bold'),
anchor='center', tags='stage_content')
# Show expansion images side by side with individual backgrounds
img_y = int(window_height * 0.4)
img_spacing = max(120, min(160, int(window_width * 0.08))) # Responsive spacing
total_width = len(missing_expansions) * img_spacing
start_x = window_width//2 - (total_width - img_spacing) // 2
for i, expansion in enumerate(missing_expansions):
img_x = start_x + i * img_spacing
# Create background for each expansion image - responsive size
img_bg_size = max(100, min(140, int(window_height * 0.15)))
self.create_text_background(img_x, img_y, img_bg_size, img_bg_size, opacity=0.6)
if expansion in self.expansion_images:
self.bg_canvas.create_image(img_x, img_y,
image=self.expansion_images[expansion],
anchor='center', tags='stage_content')
# Cannot continue message
self.bg_canvas.create_text(window_width//2, int(window_height * 0.55),
text="Cannot continue!",
fill='#ff4444',
font=self.get_font(14, 'bold'),
anchor='center', tags='stage_content')
# Exit button with transparent corners
self.create_canvas_button(window_width//2, int(window_height * 0.65), "Exit Application",
self.exit_application, '#ff4444', width=180)
def show_subscription_stage(self):
"""Show the subscription check stage using canvas"""
# Clear previous stage content and background images
self.bg_canvas.delete('stage_content')
self._text_bg_images = [] # Clear old background images
# Track current stage for responsive updates
self.current_stage_method = self.show_subscription_stage
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
# Create semi-transparent background panel for content - responsive sizing
panel_width = max(400, min(int(window_width * 0.6), 800)) # 60% of width, min 400px, max 800px
panel_height = max(200, min(int(window_height * 0.35), 400)) # 35% of height, min 200px, max 400px
self.create_text_background(window_width//2, int(window_height * 0.35), panel_width, panel_height)
self.bg_canvas.create_text(window_width//2, int(window_height * 0.18),
text="Steam Workshop Collections",
fill='white',
font=self.get_font(18, 'bold'),
anchor='center', tags='stage_content')
# Collection checkboxes
collections = [
("Core Collection", "https://steamcommunity.com/workshop/filedetails/?id=3521297585"),
("Content Collection", "https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712"),
("Cosmetics Collection", "https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646")
]
self.subscription_vars = {}
self.checkbox_ids = {}
y_pos = int(window_height * 0.28)
for name, url in collections:
var = tk.BooleanVar()
self.subscription_vars[name] = var
# Create custom checkbox using canvas (no white background)
checkbox_x = window_width//2 - 300
# Draw checkbox box
box_id = self.bg_canvas.create_rectangle(
checkbox_x - 10, y_pos - 10, checkbox_x + 10, y_pos + 10,
outline='white', width=2, fill='', tags=('stage_content', f'checkbox_{name}')
)
# Create invisible clickable area around checkbox (larger for easier clicking)
click_area = self.bg_canvas.create_rectangle(
checkbox_x - 20, y_pos - 20, checkbox_x + 20, y_pos + 20,
outline='', fill='', tags=('stage_content', f'checkbox_{name}')
)
# Store checkbox info for click handling
self.checkbox_ids[name] = {'box': box_id, 'check': None, 'var': var}
# Bind click event to toggle checkbox
self.bg_canvas.tag_bind(f'checkbox_{name}', '<Button-1>',
lambda e, n=name: self.toggle_checkbox(n))
# Create text label next to checkbox
text_x = checkbox_x + 25
self.bg_canvas.create_text(text_x, y_pos,
text="I have subscribed to ",
fill='white',
font=self.get_font(12),
anchor='w', tags='stage_content')
# Calculate the width of the "I have subscribed to " text to position the link correctly
temp_font = self.get_font(12)
text_width = temp_font.measure("I have subscribed to ")
# Create clickable link for collection name - positioned right after the text
link_x = text_x + text_width
link_id = self.bg_canvas.create_text(link_x, y_pos,
text=name,
fill='#4da6ff',
font=self.get_font(12, 'bold'),
anchor='w', tags=('stage_content', f'link_{name}'))
# Bind click and hover events to the text
self.bg_canvas.tag_bind(f'link_{name}', '<Button-1>', lambda e, u=url: self.open_collection_url(u))
self.bg_canvas.tag_bind(f'link_{name}', '<Enter>', lambda e, lid=link_id: self.bg_canvas.itemconfig(lid, fill='#7ec8ff'))
self.bg_canvas.tag_bind(f'link_{name}', '<Leave>', lambda e, lid=link_id: self.bg_canvas.itemconfig(lid, fill='#4da6ff'))
y_pos += 40
# Continue button - create custom button with transparent corners
self.create_canvas_button(window_width//2, y_pos + 40, "Continue",
self.check_all_subscribed, '#0078d4')
# Ensure logo stays on top
self._bring_logo_to_front()
def toggle_checkbox(self, name):
"""Toggle a checkbox state"""
info = self.checkbox_ids[name]
var = info['var']
var.set(not var.get())
checkbox_x = self.canvas_width//2 - 300
y_pos = int(self.canvas_height * 0.28) + list(self.checkbox_ids.keys()).index(name) * 40
if var.get():
# Draw checkmark
if info['check'] is None:
check_id = self.bg_canvas.create_text(checkbox_x, y_pos,
text="",
fill='#00ff00',
font=self.get_font(14, 'bold'),
anchor='center', tags='stage_content')
self.checkbox_ids[name]['check'] = check_id
else:
# Remove checkmark
if info['check'] is not None:
self.bg_canvas.delete(info['check'])
self.checkbox_ids[name]['check'] = None
def create_canvas_button(self, x, y, text, command, color, width=150, height=40):
"""Create a button on canvas with transparent corners"""
# Create rounded rectangle button image
btn_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(btn_img)
# Parse color
r = int(color[1:3], 16)
g = int(color[3:5], 16)
b = int(color[5:7], 16)
# Draw rounded rectangle
draw.rounded_rectangle(
[(0, 0), (width - 1, height - 1)],
radius=10,
fill=(r, g, b, 255)
)
btn_tk = ImageTk.PhotoImage(btn_img)
# Store reference
if not hasattr(self, '_button_images'):
self._button_images = []
self._button_images.append(btn_tk)
# Create button background
btn_id = self.bg_canvas.create_image(x, y, image=btn_tk, anchor='center', tags='stage_content')
# Create safe tag name (remove special characters)
safe_tag = f'btn_{text.replace(" ", "_").replace("!", "").replace("?", "").replace(",", "")}'
# Create button text
text_id = self.bg_canvas.create_text(x, y, text=text, fill='white',
font=self.get_font(12, 'bold'),
anchor='center', tags=('stage_content', safe_tag))
# Bind click event
self.bg_canvas.tag_bind(safe_tag, '<Button-1>', lambda e: command())
self.bg_canvas.tag_bind(btn_id, '<Button-1>', lambda e: command())
return btn_id
def check_all_subscribed(self):
"""Check if all collections are subscribed"""
all_checked = all(var.get() for var in self.subscription_vars.values())
if not all_checked:
self.show_subscription_warning()
return
# All subscribed, proceed to RimWorld download check
self.stage_4_rimworld_check()
def stage_5_check_expansions(self):
"""Stage 5: Check RimWorld Expansions after RimWorld setup verification"""
# Clear previous stage content
self.bg_canvas.delete('stage_content')
# Track current stage for responsive updates
self.current_stage_method = self.stage_5_check_expansions
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
# Create semi-transparent background panel for content - responsive sizing
panel_width = max(400, min(int(window_width * 0.6), 800)) # 60% of width, min 400px, max 800px
panel_height = max(200, min(int(window_height * 0.4), 400)) # 40% of height, min 200px, max 400px
self.create_text_background(window_width//2, int(window_height * 0.4), panel_width, panel_height)
# Create title as canvas text
self.bg_canvas.create_text(window_width//2, int(window_height * 0.18),
text="Checking RimWorld Expansions...",
fill='white',
font=self.get_font(18, 'bold'),
anchor='center', tags='stage_content')
# Check expansions in a separate thread
threading.Thread(target=self.check_expansions_thread, daemon=True).start()
def show_subscription_warning(self):
"""Show subscription warning in custom styled window"""
# Create warning window as CTkToplevel for transparent widgets
warning_window = ctk.CTkToplevel(self.loading_window)
warning_window.title("Progression: Loader - Subscription Required")
warning_window.configure(fg_color='#2b2b2b')
# Set window icon
try:
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
if os.path.exists(icon_path):
warning_window.after(200, lambda: warning_window.iconbitmap(icon_path))
except Exception:
pass # Ignore icon loading errors for popup windows
warning_window.attributes('-topmost', True)
# Warning text
warning_text = """Steam Workshop Collections Required
You must subscribe to all three collections before continuing:
• Core Collection - Essential progression mods
• Content Collection - Expanded content and features
• Cosmetics Collection - Visual enhancements and aesthetics
To subscribe to a collection:
1. Click on the collection name link (blue text)
2. Click "Subscribe" on the Steam Workshop page
3. Wait for Steam to download the collection
4. Return here and check the subscription box
5. Repeat for all three collections
All collections are required for the complete Progression experience."""
# Calculate window size
window_width = 650
window_height = 500
# Set the calculated window size
warning_window.geometry(f"{window_width}x{window_height}")
# Center the window on screen
warning_window.update_idletasks()
x = (warning_window.winfo_screenwidth() // 2) - (window_width // 2)
y = (warning_window.winfo_screenheight() // 2) - (window_height // 2)
warning_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
# Title
title_label = ctk.CTkLabel(warning_window,
text="⚠️ Subscription Required",
text_color='#ff8686', fg_color='transparent',
font=self.get_ctk_font(16, 'bold'))
title_label.pack(pady=20)
# Warning label with proper sizing
warning_label = ctk.CTkLabel(warning_window,
text=warning_text,
text_color='#cccccc', fg_color='transparent',
font=self.get_ctk_font(11),
justify='left',
wraplength=550)
warning_label.pack(pady=20, padx=30, expand=True)
# Close button
close_btn = ctk.CTkButton(warning_window,
text="I Understand",
command=warning_window.destroy,
fg_color='#ff8686', text_color='white',
font=self.get_ctk_font(12, 'bold'))
close_btn.pack(pady=20)
def stage_4_rimworld_check(self):
"""Stage 4: Check RimWorld download and launch status using canvas"""
# Clear previous stage content
self.bg_canvas.delete('stage_content')
# Track current stage for responsive updates
self.current_stage_method = self.stage_4_rimworld_check
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
# Create semi-transparent background panel for content - responsive sizing
panel_width = max(400, min(int(window_width * 0.6), 800)) # 60% of width, min 400px, max 800px
panel_height = max(250, min(int(window_height * 0.45), 500)) # 45% of height, min 250px, max 500px
self.create_text_background(window_width//2, int(window_height * 0.4), panel_width, panel_height)
self.bg_canvas.create_text(window_width//2, int(window_height * 0.18),
text="RimWorld Setup Verification",
fill='white',
font=self.get_font(18, 'bold'),
anchor='center', tags='stage_content')
# Add instructions
self.bg_canvas.create_text(window_width//2, int(window_height * 0.25),
text="Please ensure the following before continuing:",
fill='#cccccc',
font=self.get_font(14),
anchor='center', tags='stage_content')
# Checklist items
checklist_items = [
"✓ RimWorld has finished downloading completely",
"✓ You have launched RimWorld at least once",
"✓ The game runs without errors",
"✓ All required DLCs are installed and activated"
]
y_pos = int(window_height * 0.32)
for item in checklist_items:
self.bg_canvas.create_text(window_width//2, y_pos,
text=item,
fill='#00ff00',
font=self.get_font(12),
anchor='center', tags='stage_content')
y_pos += 30
# Add confirmation message
self.bg_canvas.create_text(window_width//2, y_pos + 30,
text="Have you completed all the above requirements?",
fill='white',
font=self.get_font(14, 'bold'),
anchor='center', tags='stage_content')
# Yes Mother button - canvas button with transparent corners
self.create_canvas_button(window_width//2 - 80, y_pos + 80, "Yes Mother",
self.proceed_to_dlc_check, '#00aa00')
# Not Yet button - canvas button with transparent corners
self.create_canvas_button(window_width//2 + 80, y_pos + 80, "Not Yet",
self.show_setup_instructions, '#aa6600')
# Ensure logo stays on top
self._bring_logo_to_front()
def proceed_to_dlc_check(self):
"""Proceed to DLC check after RimWorld verification"""
# Clear stage content and proceed to expansion check
self.bg_canvas.delete('stage_content')
self._text_bg_images = []
self._button_images = []
self.stage_5_check_expansions()
def show_setup_instructions(self):
"""Show setup instructions if user is not ready"""
# Create instruction window as CTkToplevel for transparent widgets
instruction_window = ctk.CTkToplevel(self.loading_window)
instruction_window.title("Progression: Loader - Setup Instructions")
instruction_window.configure(fg_color='#2b2b2b')
# Set window icon
try:
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
if os.path.exists(icon_path):
instruction_window.after(200, lambda: instruction_window.iconbitmap(icon_path))
except Exception:
pass # Ignore icon loading errors for popup windows
instruction_window.attributes('-topmost', True)
# Instructions text
instructions_text = """To properly set up RimWorld:
1. Download RimWorld from Steam
• Right-click RimWorld in your Steam library
• Select "Install" if not already installed
• Wait for download to complete (may take several minutes)
2. Launch RimWorld at least once
• Click "Play" in Steam or run RimWorld.exe
• Let the game load to the main menu
• This creates necessary configuration files
3. Install Required DLCs
• Purchase and install: Royalty, Ideology, Biotech, Anomaly
• Each DLC must be downloaded and activated
4. Verify Installation
• Launch RimWorld again
• Check that all DLCs appear in the main menu
• Ensure no error messages appear
Once completed, return to this screen and click "Yes Mother"."""
# Calculate window size
window_width = 650
window_height = 550
# Set the calculated window size
instruction_window.geometry(f"{window_width}x{window_height}")
# Center the window on screen
instruction_window.update_idletasks()
x = (instruction_window.winfo_screenwidth() // 2) - (window_width // 2)
y = (instruction_window.winfo_screenheight() // 2) - (window_height // 2)
instruction_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
# Title
title_label = ctk.CTkLabel(instruction_window,
text="Progression: Loader - Setup Instructions",
text_color='white', fg_color='transparent',
font=self.get_ctk_font(16, 'bold'))
title_label.pack(pady=20)
# Instructions label with proper sizing
instructions_label = ctk.CTkLabel(instruction_window,
text=instructions_text,
text_color='#cccccc', fg_color='transparent',
font=self.get_ctk_font(11),
justify='left',
wraplength=550)
instructions_label.pack(pady=20, padx=30, expand=True)
# Close button
close_btn = ctk.CTkButton(instruction_window,
text="I Understand",
command=instruction_window.destroy,
fg_color='#0078d4', text_color='white',
font=self.get_ctk_font(12, 'bold'))
close_btn.pack(pady=20)
def stage_6_final_confirmation(self):
"""Stage 6: Final confirmation screen using canvas"""
# Clear previous stage content
self.bg_canvas.delete('stage_content')
self._text_bg_images = [] # Clear old background images
self._button_images = []
# Track current stage for responsive updates
self.current_stage_method = self.stage_6_final_confirmation
window_width = self.canvas_width if hasattr(self, 'canvas_width') else self.loading_window.winfo_width()
window_height = self.canvas_height if hasattr(self, 'canvas_height') else self.loading_window.winfo_height()
# Create semi-transparent background panel for content - responsive sizing
panel_width = max(400, min(int(window_width * 0.6), 800)) # 60% of width, min 400px, max 800px
panel_height = max(200, min(int(window_height * 0.35), 400)) # 35% of height, min 200px, max 400px
self.create_text_background(window_width//2, int(window_height * 0.4), panel_width, panel_height)
# Final message
self.bg_canvas.create_text(window_width//2, int(window_height * 0.25),
text="Ready to Create Mod List",
fill='white',
font=self.get_font(18, 'bold'),
anchor='center', tags='stage_content')
self.bg_canvas.create_text(window_width//2, int(window_height * 0.38),
text="This tool creates a Mods list in your RimWorld folder\nwith the latest from the Steam Workshop collections for Progression.\n\nWould you like to continue?",
fill='#cccccc',
font=self.get_font(14),
justify='center',
anchor='center', tags='stage_content')
# Continue button with transparent corners
self.create_canvas_button(window_width//2 - 80, int(window_height * 0.55), "Yes, Continue",
self.complete_loading, '#00aa00', width=150)
# Exit button with transparent corners
self.create_canvas_button(window_width//2 + 80, int(window_height * 0.55), "Exit",
self.exit_application, '#aa0000', width=100)
# Ensure logo stays on top
self._bring_logo_to_front()
def open_hr_website(self, event=None):
"""Open Hudson Riggs Systems website"""
import webbrowser
webbrowser.open('https://hudsonriggs.systems')
def on_loading_hr_enter(self, event=None):
"""Handle mouse enter on HR logo in loading screen"""
# Change cursor to hand and highlight
self.bg_canvas.config(cursor='hand2')
def on_loading_hr_leave(self, event=None):
"""Handle mouse leave on HR logo in loading screen"""
self.bg_canvas.config(cursor='')
def open_collection_url(self, url):
"""Open Steam Workshop collection URL in browser"""
import webbrowser
# Clean up steam:// protocol URLs
if url.startswith('steam://openurl/'):
url = url.replace('steam://openurl/', '')
webbrowser.open(url)
def on_link_enter(self, label):
"""Handle mouse enter on collection link (hover effect)"""
label.configure(fg='#66b3ff') # Lighter blue on hover
def on_link_leave(self, label):
"""Handle mouse leave on collection link (remove hover effect)"""
label.configure(fg='#4da6ff') # Return to normal blue
def complete_loading(self):
"""Complete the loading sequence and show main app"""
self.loading_window.destroy()
self.on_complete_callback()
def exit_application(self):
"""Exit the application"""
self.root.quit()
def on_close(self):
"""Handle window close event"""
self.exit_application()
class SteamWorkshopGUI:
def __init__(self, root):
self.root = root
self.root.title("Progression: Loader")
self.root.state('zoomed') # Make fullscreen on Windows
self.root.configure(bg='#2b2b2b')
# Set minimum window size for responsive design and enforce it always
self.root.minsize(800, 600)
# Bind to enforce minimum size continuously
self.root.bind('<Configure>', self._enforce_minimum_size)
# Load custom font
self.load_custom_font()
# Configure dark theme
self.setup_dark_theme()
# Initialize paths
self.workshop_folder = None
self.modsconfig_path = None
# Initialize blinking state and button references
self.blink_state = False
self.rimworld_label = None
self.load_btn = None
self.merge_btn = None
self.is_rimworld_valid = False
# Store references for responsive layout
self.left_frame = None
self.right_frame = None
self.content_frame = None
self.padded_frame = None
# Create canvas for background image
self.bg_canvas = tk.Canvas(root, highlightthickness=0, bg='#2b2b2b')
self.bg_canvas.pack(fill=tk.BOTH, expand=True)
# Load background image
self.root.update_idletasks()
self._update_main_background()
# Bind resize event to update background and layout
self.root.bind('<Configure>', self._on_main_resize)
# Create main frame as canvas window
self.main_content_frame = tk.Frame(self.bg_canvas, bg='#2b2b2b')
# Add padding frame with responsive padding
self.padded_frame = tk.Frame(self.main_content_frame, bg='#2b2b2b')
self.padded_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Create header frame for progression logo
self.create_header_frame(self.padded_frame)
# Create content frame (below header) with responsive layout
self.content_frame = tk.Frame(self.padded_frame, bg='#2b2b2b')
self.content_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
# Create responsive layout
self._create_responsive_layout()
# Create footer frame for HR Systems logo
self.create_footer_frame(self.padded_frame)
self.create_input_section(self.left_frame)
self.create_output_section(self.right_frame)
# Place main content on canvas
self._position_main_content()
# Log initial message
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 (for manual checks only)
config = get_update_config()
self.update_checker = UpdateChecker(config["current_version"])
# Add update check functionality
self.add_update_check_button()
# Check initial state now that all GUI elements are created
self.on_rimworld_path_change()
# Start blinking animation for RimWorld label (with slight delay to ensure GUI is ready)
self.root.after(1000, self.start_rimworld_label_blink)
def _enforce_minimum_size(self, event):
"""Enforce minimum window size at all times"""
if event.widget == self.root:
width = self.root.winfo_width()
height = self.root.winfo_height()
# Check if window is smaller than minimum
if width < 800 or height < 600:
# Resize to minimum if too small
new_width = max(width, 800)
new_height = max(height, 600)
self.root.geometry(f"{new_width}x{new_height}")
def _create_responsive_layout(self):
"""Create responsive layout that adapts to window size"""
# Get current window dimensions
window_width = self.root.winfo_width()
window_height = self.root.winfo_height()
# Determine layout based on window size and aspect ratio
aspect_ratio = window_width / window_height if window_height > 0 else 1.0
# For very wide screens (aspect ratio > 2.0) or large windows, use side-by-side layout
# For narrow screens or small windows, use stacked layout
if aspect_ratio > 1.5 and window_width > 1200:
self._create_side_by_side_layout()
else:
self._create_stacked_layout()
def _create_side_by_side_layout(self):
"""Create side-by-side layout for wide screens"""
# Clear existing layout
for widget in self.content_frame.winfo_children():
widget.destroy()
# Left panel for inputs - responsive width
window_width = self.root.winfo_width()
left_width = max(350, min(500, int(window_width * 0.35))) # 35% of width, min 350px, max 500px
self.left_frame = tk.Frame(self.content_frame, bg='#2b2b2b', width=left_width)
self.left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
self.left_frame.pack_propagate(False)
# Right panel for output - responsive padding for HR logo
hr_padding = max(80, min(150, int(window_width * 0.08))) # Responsive HR logo space
self.right_frame = tk.Frame(self.content_frame, bg='#2b2b2b')
self.right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(0, hr_padding))
def _create_stacked_layout(self):
"""Create stacked layout for narrow screens"""
# Clear existing layout
for widget in self.content_frame.winfo_children():
widget.destroy()
# Top panel for inputs - responsive height
window_height = self.root.winfo_height()
input_height = max(300, min(500, int(window_height * 0.45))) # 45% of height, min 300px, max 500px
self.left_frame = tk.Frame(self.content_frame, bg='#2b2b2b', height=input_height)
self.left_frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 10))
self.left_frame.pack_propagate(False)
# Bottom panel for output
self.right_frame = tk.Frame(self.content_frame, bg='#2b2b2b')
self.right_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True)
def _update_responsive_layout(self):
"""Update layout based on current window size"""
if hasattr(self, 'content_frame') and self.content_frame.winfo_exists():
# Store current content before recreating layout
input_content = None
output_content = None
# Recreate responsive layout
self._create_responsive_layout()
# Recreate content in new layout
if hasattr(self, 'left_frame') and self.left_frame:
self.create_input_section(self.left_frame)
if hasattr(self, 'right_frame') and self.right_frame:
self.create_output_section(self.right_frame)
def _update_main_background(self):
"""Update the main window background image"""
width = self.root.winfo_width() or 1920
height = self.root.winfo_height() or 1080
if width > 1 and height > 1:
self.bg_image_tk = self.load_background_image(width, height)
if self.bg_image_tk:
self.bg_canvas.delete('bg')
self.bg_canvas.create_image(0, 0, image=self.bg_image_tk, anchor='nw', tags='bg')
self.bg_canvas.tag_lower('bg')
def _position_main_content(self):
"""Position the main content frame on the canvas with responsive sizing"""
width = self.root.winfo_width() or 1920
height = self.root.winfo_height() or 1080
# Use responsive padding
padding_x = max(10, int(width * 0.01)) # 1% of width, minimum 10px
padding_y = max(10, int(height * 0.01)) # 1% of height, minimum 10px
self.bg_canvas.delete('main_content')
self.bg_canvas.create_window(padding_x, padding_y, window=self.main_content_frame, anchor='nw',
width=width - (2 * padding_x), height=height - (2 * padding_y), tags='main_content')
def load_background_image(self, width, height):
"""Load and scale the Desolation background image to fit the given dimensions"""
try:
bg_img = Image.open(get_resource_path("art/Desolation.png"))
# Scale to cover the entire area while maintaining aspect ratio
bg_ratio = bg_img.width / bg_img.height
target_ratio = width / height
if bg_ratio > target_ratio:
# Image is wider, scale by height
new_height = height
new_width = int(height * bg_ratio)
else:
# Image is taller, scale by width
new_width = width
new_height = int(width / bg_ratio)
bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Crop to exact size from center
left = (new_width - width) // 2
top = (new_height - height) // 2
bg_img = bg_img.crop((left, top, left + width, top + height))
return ImageTk.PhotoImage(bg_img)
except Exception as e:
print(f"Error loading background image: {e}")
return None
def _on_main_resize(self, event):
"""Handle window resize to update background image and responsive layout"""
if event.widget == self.root and hasattr(self, 'bg_canvas'):
# First enforce minimum size
self._enforce_minimum_size(event)
# Update background image
self._update_main_background()
# Update responsive layout if window size changed significantly
if hasattr(self, '_last_window_size'):
last_width, last_height = self._last_window_size
current_width = self.root.winfo_width()
current_height = self.root.winfo_height()
# Check if aspect ratio changed significantly (threshold of 0.3)
if current_width > 0 and current_height > 0 and last_width > 0 and last_height > 0:
last_ratio = last_width / last_height
current_ratio = current_width / current_height
if abs(current_ratio - last_ratio) > 0.3:
# Significant aspect ratio change - update layout
self.root.after(100, self._update_responsive_layout) # Delay to avoid rapid updates
# Store current window size
self._last_window_size = (self.root.winfo_width(), self.root.winfo_height())
# Update content positioning
self._position_main_content()
def _on_window_map(self, event):
"""Handle window map event (window becomes visible)"""
if event.widget == self.root:
# Window is now visible - update layout if needed
self.root.after(100, self._check_window_state)
def _on_window_unmap(self, event):
"""Handle window unmap event (window becomes hidden)"""
pass
def _check_window_state(self):
"""Check and update window state tracking"""
try:
# Check if window is maximized or fullscreen
state = self.root.state()
if state == 'zoomed':
if not getattr(self, '_is_maximized', False):
self._is_maximized = True
self._on_window_state_change('maximized')
elif state == 'normal':
if getattr(self, '_is_maximized', False):
self._is_maximized = False
self._on_window_state_change('normal')
except Exception:
pass # Ignore state check errors
def _on_window_state_change(self, new_state):
"""Handle window state changes (maximized, normal, etc.)"""
# Update responsive layout when window state changes
self.root.after(200, self._update_responsive_layout)
def create_header_frame(self, parent):
"""Create header frame with progression logo - responsive sizing"""
# Responsive header height based on window size
window_height = self.root.winfo_height() or 1080
header_height = max(80, min(120, int(window_height * 0.1))) # 10% of height, min 80px, max 120px
header_frame = tk.Frame(parent, bg='#2b2b2b', height=header_height)
header_frame.pack(fill='x', pady=(0, 10))
header_frame.pack_propagate(False)
# Create custom frame with border for progression logo
logo_container = tk.Frame(header_frame, bg='#404040', relief='raised', bd=2)
logo_container.pack(expand=True)
# Load and display progression logo (using game title as progression logo)
try:
# Load the game title image as progression logo
progression_img = Image.open(get_resource_path("art/GameTitle.png"))
# Responsive logo sizing
img_width, img_height = progression_img.size
logo_height = max(60, min(100, int(header_height * 0.7))) # 70% of header height
logo_width = int((logo_height / img_height) * img_width)
progression_img = progression_img.resize((logo_width, logo_height), Image.Resampling.LANCZOS)
self.progression_logo_tk = ImageTk.PhotoImage(progression_img)
progression_label = tk.Label(logo_container,
image=self.progression_logo_tk,
bg='#404040')
progression_label.pack(padx=10, pady=10)
except Exception as e:
# Fallback text if image loading fails - responsive font size
font_size = max(16, min(24, int(header_height * 0.25)))
progression_label = tk.Label(logo_container,
text="PROGRESSION PACK",
fg='white', bg='#404040',
font=self.get_font(font_size, 'bold'))
progression_label.pack(padx=20, pady=30)
def create_footer_frame(self, parent):
"""Create footer frame with HR Systems logo in bottom right - responsive sizing"""
# Responsive footer height
window_height = self.root.winfo_height() or 1080
footer_height = max(80, min(120, int(window_height * 0.08))) # 8% of height, min 80px, max 120px
self.footer_frame = tk.Frame(parent, bg='#2b2b2b', height=footer_height)
self.footer_frame.pack(fill='x', side=tk.BOTTOM, pady=(10, 0))
self.footer_frame.pack_propagate(False)
# Create clickable HR Systems logo in bottom right
self.create_hr_logo(self.footer_frame)
def create_hr_logo(self, parent):
"""Create clickable HR Systems logo with radial glow hover effects - responsive sizing"""
# Responsive positioning and sizing
window_width = self.root.winfo_width() or 1920
window_height = self.root.winfo_height() or 1080
# Responsive padding based on window size
padding_x = max(20, min(60, int(window_width * 0.03))) # 3% of width, min 20px, max 60px
padding_y = max(15, min(30, int(window_height * 0.02))) # 2% of height, min 15px, max 30px
# Container for logo positioned in bottom right with extra space for glow
logo_frame = tk.Frame(parent, bg='#2b2b2b')
logo_frame.pack(side=tk.RIGHT, anchor='se', padx=padding_x, pady=padding_y)
try:
# Load HR Systems logo
hr_img = Image.open(get_resource_path("art/hudsonriggssystems.png"))
# Responsive logo sizing
img_width, img_height = hr_img.size
logo_height = max(40, min(80, int(window_height * 0.06))) # 6% of height, min 40px, max 80px
logo_width = int((logo_height / img_height) * img_width)
hr_img = hr_img.resize((logo_width, logo_height), Image.Resampling.LANCZOS)
self.hr_logo_normal = ImageTk.PhotoImage(hr_img)
# Create radial glow effect version
self.hr_logo_glow = self.create_radial_glow_image(hr_img)
# Create clickable label with fixed size to prevent position changes
# Calculate the glow image size to set fixed dimensions
glow_padding = max(15, min(25, int(logo_height * 0.4))) # Responsive glow padding
label_width = logo_width + (glow_padding * 2)
label_height = logo_height + (glow_padding * 2)
self.hr_logo_label = tk.Label(logo_frame,
image=self.hr_logo_normal,
bg='#2b2b2b',
cursor='hand2',
width=label_width,
height=label_height,
compound='center') # Center the image within the fixed size
self.hr_logo_label.pack()
# Bind click and hover events
self.hr_logo_label.bind('<Button-1>', self.open_hr_website)
self.hr_logo_label.bind('<Enter>', self.on_hr_logo_enter)
self.hr_logo_label.bind('<Leave>', self.on_hr_logo_leave)
except Exception as e:
# Fallback text link if image loading fails - responsive font size
font_size = max(8, min(12, int(window_height * 0.012))) # Responsive font size
self.hr_logo_label = tk.Label(logo_frame,
text="Hudson Riggs Systems",
fg='#888888', bg='#2b2b2b',
font=self.get_font(font_size),
cursor='hand2')
self.hr_logo_label.pack()
# Bind events for text version
self.hr_logo_label.bind('<Button-1>', self.open_hr_website)
self.hr_logo_label.bind('<Enter>', self.on_hr_text_enter)
self.hr_logo_label.bind('<Leave>', self.on_hr_text_leave)
def create_radial_glow_image(self, base_image):
"""Create a radial glow effect around the image with #ff8686 color"""
# Get base image dimensions
base_width, base_height = base_image.size
# Create larger canvas for glow effect
glow_padding = 20
glow_width = base_width + (glow_padding * 2)
glow_height = base_height + (glow_padding * 2)
# Create glow background matching the label background
glow_bg = Image.new('RGBA', (glow_width, glow_height), (43, 43, 43, 255)) # #2b2b2b background
# Create radial glow mask
glow_mask = Image.new('L', (glow_width, glow_height), 0)
draw = ImageDraw.Draw(glow_mask)
# Calculate center and create multiple circles for smooth gradient
center_x, center_y = glow_width // 2, glow_height // 2
# Create glow that extends beyond the original image
max_radius = glow_padding + max(base_width, base_height) // 2
# Create radial gradient by drawing concentric circles
for i in range(max_radius, 0, -1):
# Calculate alpha based on distance from center (stronger in center)
# Make the glow more intense closer to the logo
if i <= max(base_width, base_height) // 2:
# Inner area (around the logo) - stronger glow
alpha = int(255 * 0.8)
else:
# Outer area - fade out
fade_factor = (max_radius - i) / (max_radius - max(base_width, base_height) // 2)
alpha = int(255 * fade_factor * 0.6)
if alpha > 0:
draw.ellipse([center_x - i, center_y - i, center_x + i, center_y + i], fill=alpha)
# Apply blur for smooth glow effect
glow_mask = glow_mask.filter(ImageFilter.GaussianBlur(radius=8))
# Create glow color layer (#ff8686)
glow_color = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 0))
glow_layer = Image.new('RGBA', (glow_width, glow_height), (255, 134, 134, 255))
# Apply the mask to create the glow effect
glow_color.paste(glow_layer, mask=glow_mask)
# Composite glow onto background
glow_bg = Image.alpha_composite(glow_bg, glow_color)
# Convert base image to RGBA if needed
if base_image.mode != 'RGBA':
base_image = base_image.convert('RGBA')
# Paste the original image in the exact center to maintain position
paste_x = (glow_width - base_width) // 2
paste_y = (glow_height - base_height) // 2
glow_bg.paste(base_image, (paste_x, paste_y), base_image)
return ImageTk.PhotoImage(glow_bg)
def open_hr_website(self, event=None):
"""Open Hudson Riggs Systems website"""
import webbrowser
webbrowser.open('https://hudsonriggs.systems')
def on_hr_logo_enter(self, event=None):
"""Handle mouse enter on HR logo (radial glow effect)"""
if hasattr(self, 'hr_logo_glow'):
self.hr_logo_label.configure(image=self.hr_logo_glow)
def on_hr_logo_leave(self, event=None):
"""Handle mouse leave on HR logo (remove glow effect)"""
if hasattr(self, 'hr_logo_normal'):
self.hr_logo_label.configure(image=self.hr_logo_normal)
def on_hr_text_enter(self, event=None):
"""Handle mouse enter on HR text (glow effect)"""
self.hr_logo_label.configure(fg='#ff8686', bg='#404040')
def on_hr_text_leave(self, event=None):
"""Handle mouse leave on HR text (remove glow effect)"""
self.hr_logo_label.configure(fg='#888888', bg='#2b2b2b')
def load_custom_font(self):
"""Load the RimWorld font using Windows AddFontResourceEx with private flag"""
try:
font_path = get_resource_path(os.path.join("art", "georgia.ttf"))
if os.path.exists(font_path):
abs_font_path = os.path.abspath(font_path)
# Use the Stack Overflow method with AddFontResourceEx
success = self._load_font_private(abs_font_path)
if success:
# Use the actual font family name from the TTF file
self.custom_font_family = "Georgia"
self.custom_font_available = True
return
else:
self.custom_font_available = False
else:
self.custom_font_available = False
except Exception as e:
self.custom_font_available = False
# Initialize fallback values if font loading failed
if not hasattr(self, 'custom_font_available'):
self.custom_font_available = False
if not hasattr(self, 'custom_font_family'):
self.custom_font_family = None
def _load_font_private(self, fontpath):
"""
Load font privately using AddFontResourceEx
Based on Stack Overflow solution by Felipe
"""
try:
from ctypes import windll, byref, create_unicode_buffer
# Constants for AddFontResourceEx
FR_PRIVATE = 0x10 # Font is private to this process
FR_NOT_ENUM = 0x20 # Font won't appear in font enumeration
# Create unicode buffer for the font path
pathbuf = create_unicode_buffer(fontpath)
# Use AddFontResourceExW for Unicode strings
AddFontResourceEx = windll.gdi32.AddFontResourceExW
# Set flags: private (unloaded when process dies) and not enumerable
flags = FR_PRIVATE | FR_NOT_ENUM
# Add the font resource
numFontsAdded = AddFontResourceEx(byref(pathbuf), flags, 0)
return bool(numFontsAdded)
except Exception:
return False
def get_font(self, size=10, weight='normal'):
"""Get font tuple for UI elements"""
if self.custom_font_available and self.custom_font_family:
try:
return font.Font(family=self.custom_font_family, size=size, weight=weight)
except Exception:
pass
# Fall back to system font
return font.Font(family='Arial', size=size, weight=weight)
def get_ctk_font(self, size=10, weight='normal'):
"""Get CTkFont for UI elements"""
if self.custom_font_available and self.custom_font_family:
try:
return ctk.CTkFont(family=self.custom_font_family, size=size, weight=weight)
except Exception:
pass
# Fall back to system font
return ctk.CTkFont(family='Arial', size=size, weight=weight)
def setup_dark_theme(self):
"""Configure dark theme colors"""
style = ttk.Style()
style.theme_use('clam')
# Configure colors for dark theme
style.configure('TLabel', background='#2b2b2b', foreground='#ffffff')
style.configure('TButton', background='#404040', foreground='#ffffff')
style.map('TButton', background=[('active', '#505050')])
style.configure('TEntry', background='#404040', foreground='#ffffff', fieldbackground='#404040')
def create_input_section(self, parent):
"""Create the left input section"""
title_label = tk.Label(parent, text="Progression: Loader",
font=self.get_font(14, 'bold'), bg='#2b2b2b', fg='#ffffff')
title_label.pack(pady=(0, 10))
# RimWorld game folder input
rimworld_frame = tk.Frame(parent, bg='#2b2b2b')
rimworld_frame.pack(fill='x', pady=(0, 10))
self.rimworld_label = tk.Label(rimworld_frame, text="RimWorld Game Folder:",
font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#00bfff') # Start with bright light blue
self.rimworld_label.pack(anchor='w')
rimworld_help = tk.Label(rimworld_frame,
text="Right-click RimWorld in Steam > Manage > Browse local files, copy that path",
font=self.get_font(8), bg='#2b2b2b', fg='#888888', wraplength=380)
rimworld_help.pack(anchor='w', pady=(2, 5))
rimworld_input_frame = tk.Frame(rimworld_frame, bg='#2b2b2b')
rimworld_input_frame.pack(fill='x')
self.rimworld_var = tk.StringVar()
self.rimworld_entry = tk.Entry(rimworld_input_frame, textvariable=self.rimworld_var,
font=self.get_font(9), bg='#404040', fg='#ffffff',
insertbackground='#ffffff', relief='flat', bd=5)
self.rimworld_entry.pack(side='left', fill='x', expand=True)
self.rimworld_entry.bind('<KeyRelease>', self.on_rimworld_path_change)
# Don't check initial state here - will be done after all GUI elements are created
browse_game_btn = tk.Button(rimworld_input_frame, text="Browse",
command=self.browse_rimworld_folder,
bg='#505050', fg='white', font=self.get_font(8),
relief='flat', padx=10)
browse_game_btn.pack(side='right', padx=(5, 0))
# Workshop folder display (derived from RimWorld path)
workshop_frame = tk.Frame(parent, bg='#2b2b2b')
workshop_frame.pack(fill='x', pady=(0, 10))
workshop_label = tk.Label(workshop_frame, text="Workshop Folder (Auto-derived):",
font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#ffffff')
workshop_label.pack(anchor='w')
self.workshop_var = tk.StringVar(value="Enter RimWorld path above")
self.workshop_display = tk.Entry(workshop_frame, textvariable=self.workshop_var,
font=self.get_font(8), bg='#404040', fg='#ffffff',
insertbackground='#ffffff', relief='flat', bd=5)
self.workshop_display.pack(fill='x', pady=(5, 0))
self.workshop_display.bind('<KeyRelease>', self.on_workshop_path_change)
# White line separator above ModsConfig section
separator_line = tk.Frame(parent, bg='#ffffff', height=1)
separator_line.pack(fill='x', pady=(15, 5))
# Warning text above ModsConfig section
warning_label = tk.Label(parent, text="Don't edit anything below unless you know what you are doing",
font=self.get_font(8), bg='#2b2b2b', fg='#ffaa00', # Orange warning color
anchor='w')
warning_label.pack(anchor='w', pady=(0, 10))
# ModsConfig.xml folder display
modsconfig_frame = tk.Frame(parent, bg='#2b2b2b')
modsconfig_frame.pack(fill='x', pady=(0, 15))
modsconfig_label = tk.Label(modsconfig_frame, text="ModsConfig.xml Path:",
font=self.get_font(9, 'bold'), bg='#2b2b2b', fg='#ffffff')
modsconfig_label.pack(anchor='w')
self.modsconfig_var = tk.StringVar()
self.modsconfig_display = tk.Entry(modsconfig_frame, textvariable=self.modsconfig_var,
font=self.get_font(8), bg='#404040', fg='#ffffff',
insertbackground='#ffffff', relief='flat', bd=5)
self.modsconfig_display.pack(fill='x', pady=(5, 0))
self.modsconfig_display.bind('<KeyRelease>', self.on_modsconfig_path_change)
# Initialize ModsConfig path
self.find_modsconfig_path()
# Core collection
core_url = os.getenv('CORE_COLLECTION_URL', 'https://steamcommunity.com/workshop/filedetails/?id=3521297585')
self.create_url_input(parent, "Core Collection:", core_url)
# Content collection
content_url = os.getenv('CONTENT_COLLECTION_URL', 'steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712')
self.create_url_input(parent, "Content Collection:", content_url)
# Cosmetics collection
cosmetics_url = os.getenv('COSMETICS_COLLECTION_URL', 'steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646')
self.create_url_input(parent, "Cosmetics Collection:", cosmetics_url)
# Load Progression Pack button
self.load_btn = tk.Button(parent, text="Load Progression Pack Complete",
command=self.load_progression_pack,
bg='#404040', fg='#888888', font=self.get_font(12, 'bold'), # Greyed out initially
relief='flat', padx=30, pady=12, state='disabled')
self.load_btn.pack(pady=30)
# Merge with Current Mods button (yellow)
self.merge_btn = tk.Button(parent, text="Merge with Current Mods Config",
command=self.merge_with_current_mods,
bg='#404040', fg='#888888', font=self.get_font(12, 'bold'), # Greyed out initially
relief='flat', padx=30, pady=12, state='disabled')
self.merge_btn.pack(pady=(10, 30))
def create_url_input(self, parent, label_text, default_url, label_font_size=10, entry_font_size=9):
"""Create a labeled URL input field with responsive sizing"""
label = tk.Label(parent, text=label_text, font=self.get_font(label_font_size, 'bold'),
bg='#2b2b2b', fg='#ffffff')
label.pack(anchor='w', pady=(10, 5))
entry = tk.Entry(parent, font=self.get_font(entry_font_size), bg='#404040', fg='#ffffff',
insertbackground='#ffffff', relief='flat', bd=5)
entry.pack(fill='x', pady=(0, 10))
entry.insert(0, default_url)
# Store reference to entry widgets
if not hasattr(self, 'url_entries'):
self.url_entries = {}
collection_name = label_text.replace(" Collection:", "").lower()
self.url_entries[collection_name] = entry
def create_output_section(self, parent):
"""Create the right output section with responsive sizing"""
# Responsive font sizes
window_height = self.root.winfo_height() or 1080
title_font_size = max(12, min(16, int(window_height * 0.015)))
text_font_size = max(8, min(12, int(window_height * 0.011)))
title_label = tk.Label(parent, text="Logs",
font=self.get_font(title_font_size, 'bold'), bg='#2b2b2b', fg='#ffffff')
title_label.pack(pady=(0, 10))
# Output text area with scrollbar - responsive sizing
self.output_text = scrolledtext.ScrolledText(parent,
font=self.get_font(text_font_size),
bg='#1e1e1e', fg='#ffffff',
insertbackground='#ffffff',
selectbackground='#404040',
relief='flat', bd=5)
self.output_text.pack(fill=tk.BOTH, expand=True)
def log_to_output(self, message):
"""Log a message to the output text area"""
if hasattr(self, 'output_text'):
self.output_text.insert(tk.END, message)
self.output_text.see(tk.END) # Scroll to bottom
def on_workshop_path_change(self, event=None):
"""Called when workshop path is manually changed"""
self.workshop_folder = self.workshop_var.get().strip()
def on_modsconfig_path_change(self, event=None):
"""Called when ModsConfig path is manually changed"""
self.modsconfig_path = self.modsconfig_var.get().strip()
def on_rimworld_path_change(self, event=None):
"""Called when RimWorld path is changed"""
rimworld_path = self.rimworld_var.get().strip()
if rimworld_path:
# Check if this is a valid RimWorld installation
is_valid = self.validate_rimworld_path(rimworld_path)
if is_valid:
# Stop blinking and set to normal color
self.is_rimworld_valid = True
self.rimworld_label.configure(fg='#ffffff') # White for valid path
self.enable_buttons()
# Derive workshop path from RimWorld path
# From: D:\SteamLibrary\steamapps\common\RimWorld
# To: D:\SteamLibrary\steamapps\workshop\content\294100
if "steamapps" in rimworld_path.lower():
# Find the steamapps part and replace common\RimWorld with workshop\content\294100
parts = rimworld_path.split(os.sep)
try:
steamapps_index = next(i for i, part in enumerate(parts) if part.lower() == 'steamapps')
# Build new path: everything up to steamapps + steamapps + workshop + content + 294100
workshop_parts = parts[:steamapps_index + 1] + ['workshop', 'content', '294100']
workshop_path = os.sep.join(workshop_parts)
self.workshop_folder = workshop_path
self.workshop_var.set(workshop_path)
# Log success
self.log_to_output(f"✓ Valid RimWorld installation detected at: {rimworld_path}\n")
self.log_to_output(f"✓ Workshop folder derived: {workshop_path}\n")
except (StopIteration, IndexError):
self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'")
self.log_to_output("⚠ Warning: RimWorld path should contain 'steamapps'\n")
else:
self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'")
self.log_to_output("⚠ Warning: RimWorld path should contain 'steamapps'\n")
else:
# Invalid RimWorld path - keep blinking and buttons disabled
self.is_rimworld_valid = False
self.disable_buttons()
self.workshop_var.set("Invalid RimWorld path")
self.log_to_output(f"✗ Invalid RimWorld installation at: {rimworld_path}\n")
self.log_to_output(" Please ensure the path contains RimWorld.exe (or RimWorldWin64.exe) and Data folder\n")
else:
# Empty path - keep blinking and buttons disabled
self.is_rimworld_valid = False
self.disable_buttons()
self.workshop_var.set("Enter RimWorld path above")
def validate_rimworld_path(self, path):
"""Validate if the given path is a valid RimWorld installation"""
if not path or not os.path.exists(path):
return False
# Check for RimWorld executable (can be RimWorld.exe or RimWorldWin64.exe)
rimworld_exe = os.path.join(path, "RimWorld.exe")
rimworld_win64_exe = os.path.join(path, "RimWorldWin64.exe")
if not (os.path.exists(rimworld_exe) or os.path.exists(rimworld_win64_exe)):
return False
# Check for Data folder (contains core game data)
data_folder = os.path.join(path, "Data")
if not os.path.exists(data_folder):
return False
# Check for Core mod in Data folder
core_folder = os.path.join(data_folder, "Core")
if not os.path.exists(core_folder):
return False
return True
def start_rimworld_label_blink(self):
"""Start the blinking animation for RimWorld label"""
if self.rimworld_label:
if not self.is_rimworld_valid:
# Toggle between bright light blue and dark grey for maximum contrast
if self.blink_state:
self.rimworld_label.configure(fg='#00bfff') # Bright light blue (DeepSkyBlue)
else:
self.rimworld_label.configure(fg='#555555') # Even darker grey for more contrast
self.blink_state = not self.blink_state
# Continue blinking until valid path is provided (even faster blink rate)
self.root.after(500, self.start_rimworld_label_blink) # Even faster blink (500ms)
def enable_buttons(self):
"""Enable the buttons when RimWorld path is valid"""
if self.load_btn:
self.load_btn.configure(state='normal', bg='#0078d4', fg='white')
if self.merge_btn:
self.merge_btn.configure(state='normal', bg='#ffcc00', fg='black')
def disable_buttons(self):
"""Disable the buttons when RimWorld path is invalid"""
if self.load_btn:
self.load_btn.configure(state='disabled', bg='#404040', fg='#888888')
if self.merge_btn:
self.merge_btn.configure(state='disabled', bg='#404040', fg='#888888')
def browse_rimworld_folder(self):
"""Allow user to browse for RimWorld game folder"""
folder = filedialog.askdirectory(
title="Select RimWorld Game Folder (Right-click RimWorld in Steam > Manage > Browse local files)",
initialdir="C:\\"
)
if folder:
self.rimworld_var.set(folder)
self.on_rimworld_path_change() # Update workshop path
def find_modsconfig_path(self):
"""Find the ModsConfig.xml file in AppData"""
try:
# Get path template from .env
path_template = os.getenv('MODSCONFIG_PATH_TEMPLATE',
r'%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml')
# Expand environment variables
modsconfig_path = os.path.expandvars(path_template)
if os.path.exists(modsconfig_path):
self.modsconfig_path = modsconfig_path
self.modsconfig_var.set(modsconfig_path)
return modsconfig_path
else:
self.modsconfig_var.set("ModsConfig.xml not found")
return None
except Exception as e:
self.modsconfig_var.set(f"Error finding ModsConfig.xml: {str(e)}")
return None
def extract_workshop_id(self, url):
"""Extract workshop ID from Steam URL"""
# Clean up steam:// protocol URLs
if url.startswith('steam://openurl/'):
url = url.replace('steam://openurl/', '')
# Extract ID from URL parameters
try:
parsed_url = urlparse(url)
if 'id=' in parsed_url.query:
query_params = parse_qs(parsed_url.query)
return query_params.get('id', [None])[0]
elif '/filedetails/?id=' in url:
# Direct extraction for simple cases
match = re.search(r'id=(\d+)', url)
if match:
return match.group(1)
except Exception as e:
print(f"Error extracting ID from {url}: {e}")
return None
def load_progression_pack(self):
"""Load all progression pack collections and extract package names"""
if not self.is_rimworld_valid:
self.log_to_output("✗ Please provide a valid RimWorld installation path first!\n")
return
if not self.workshop_folder or not os.path.exists(self.workshop_folder):
self.output_text.delete(1.0, tk.END)
self.output_text.insert(tk.END, "Workshop folder not found!\nPlease enter the correct RimWorld path above.")
return
self.output_text.delete(1.0, tk.END)
self.output_text.insert(tk.END, "Loading Progression Pack Complete...\n\n")
threading.Thread(target=self._load_progression_pack_thread, daemon=True).start()
def merge_with_current_mods(self):
"""Merge current active mods with Steam collection mods"""
if not self.is_rimworld_valid:
self.log_to_output("✗ Please provide a valid RimWorld installation path first!\n")
return
# Show warning dialog
warning_message = ("⚠️ WARNING ⚠️\n\n"
"This will merge your current active mods with the Steam Workshop collections.\n"
"This may create a potentially unwanted experience with mod conflicts,\n"
"incompatibilities, or performance issues.\n\n"
"The merged mod list will be saved as 'ProgressionHomebrew'.\n\n"
"Do you want to continue?")
result = messagebox.askyesno("Merge Warning", warning_message, icon='warning')
if result:
if not self.workshop_folder or not os.path.exists(self.workshop_folder):
self.output_text.delete(1.0, tk.END)
self.output_text.insert(tk.END, "Workshop folder not found!\nPlease enter the correct RimWorld path above.")
return
if not self.modsconfig_path or not os.path.exists(self.modsconfig_path):
self.output_text.delete(1.0, tk.END)
self.output_text.insert(tk.END, "ModsConfig.xml not found!\nPlease ensure RimWorld has been launched at least once.")
return
self.output_text.delete(1.0, tk.END)
self.output_text.insert(tk.END, "Merging current mods with Progression Pack...\n\n")
threading.Thread(target=self._merge_with_current_mods_thread, daemon=True).start()
def _load_progression_pack_thread(self):
"""Thread function to load progression pack"""
try:
all_workshop_ids = []
# Get collection URLs from entries
collections = {
"Core": self.url_entries['core'].get().strip(),
"Content": self.url_entries['content'].get().strip(),
"Cosmetics": self.url_entries['cosmetics'].get().strip()
}
# Fetch all workshop IDs from collections
for collection_name, url in collections.items():
if url:
self._safe_update_output(f"Fetching {collection_name} collection...\n")
workshop_id = self.extract_workshop_id(url)
if workshop_id:
workshop_ids = self.fetch_collection_items(workshop_id)
all_workshop_ids.extend(workshop_ids)
self._safe_update_output(f"Found {len(workshop_ids)} items in {collection_name}\n")
# Remove duplicates
unique_ids = list(set(all_workshop_ids))
self._safe_update_output(f"\nTotal unique workshop items: {len(unique_ids)}\n")
self._safe_update_output("Extracting package names from About.xml files...\n\n")
# Extract package names from About.xml files
results = []
for workshop_id in sorted(unique_ids):
package_name = self.get_package_name_from_about_xml(workshop_id)
results.append((workshop_id, package_name))
# Display results
self._safe_update_output("=== PROGRESSION PACK COMPLETE ===\n\n")
self._safe_update_output(f"{'Workshop ID':<15} | Package Name\n")
self._safe_update_output("-" * 80 + "\n")
for workshop_id, package_name in results:
self._safe_update_output(f"{workshop_id:<15} | {package_name}\n")
self._safe_update_output(f"\nTotal mods processed: {len(results)}\n")
# Create ProgressionVanilla.rml file
self.create_rml_file(results)
except Exception as e:
self._safe_update_output(f"Error loading progression pack: {str(e)}\n")
def _merge_with_current_mods_thread(self):
"""Thread function to merge current mods with progression pack"""
try:
# Step 1: Get current active mods from ModsConfig.xml
self._safe_update_output("Reading current active mods from ModsConfig.xml...\n")
current_mods = self.get_current_active_mods()
self._safe_update_output(f"Found {len(current_mods)} currently active mods\n\n")
# Step 2: Get Steam Workshop collection mods
all_workshop_ids = []
# Get collection URLs from entries
collections = {
"Core": self.url_entries['core'].get().strip(),
"Content": self.url_entries['content'].get().strip(),
"Cosmetics": self.url_entries['cosmetics'].get().strip()
}
# Fetch all workshop IDs from collections
for collection_name, url in collections.items():
if url:
self._safe_update_output(f"Fetching {collection_name} collection...\n")
workshop_id = self.extract_workshop_id(url)
if workshop_id:
workshop_ids = self.fetch_collection_items(workshop_id)
all_workshop_ids.extend(workshop_ids)
self._safe_update_output(f"Found {len(workshop_ids)} items in {collection_name}\n")
# Remove duplicates
unique_workshop_ids = list(set(all_workshop_ids))
self._safe_update_output(f"\nTotal unique workshop items: {len(unique_workshop_ids)}\n")
self._safe_update_output("Extracting package names from About.xml files...\n\n")
# Extract package names from About.xml files for workshop mods
workshop_results = []
for workshop_id in sorted(unique_workshop_ids):
package_name = self.get_package_name_from_about_xml(workshop_id)
workshop_results.append((workshop_id, package_name))
# Step 3: Remove duplicates and merge the mod lists
self._safe_update_output("=== MERGING CURRENT MODS WITH PROGRESSION PACK ===\n\n")
# Display current mods
self._safe_update_output("Current Active Mods:\n")
self._safe_update_output("-" * 50 + "\n")
for package_id, mod_name in current_mods:
self._safe_update_output(f"{package_id} | {mod_name}\n")
self._safe_update_output(f"\nWorkshop Mods from Collections:\n")
self._safe_update_output("-" * 50 + "\n")
for workshop_id, package_name in workshop_results:
self._safe_update_output(f"{workshop_id:<15} | {package_name}\n")
# Step 4: Remove duplicates based on package ID and workshop ID
current_package_ids = {package_id for package_id, _ in current_mods}
current_workshop_ids = {package_id for package_id, _ in current_mods if package_id.isdigit()}
filtered_workshop_results = []
duplicates_found = []
for workshop_id, package_id in workshop_results:
# Check for duplicate package ID
if package_id in current_package_ids:
duplicates_found.append((workshop_id, package_id, "Package ID already exists"))
# Check for duplicate workshop ID (user might have same mod in current list)
elif workshop_id in current_workshop_ids:
duplicates_found.append((workshop_id, package_id, "Workshop ID already exists"))
else:
filtered_workshop_results.append((workshop_id, package_id))
# Report duplicates
if duplicates_found:
self._safe_update_output(f"\nDuplicates Removed:\n")
self._safe_update_output("-" * 70 + "\n")
for workshop_id, package_id, reason in duplicates_found:
self._safe_update_output(f"{workshop_id:<15} | {package_id:<30} | {reason}\n")
else:
self._safe_update_output(f"\nNo duplicates found - all workshop mods are unique!\n")
# Step 5: Create final merged mod list
merged_results = current_mods + filtered_workshop_results
self._safe_update_output(f"\nFinal Results:\n")
self._safe_update_output(f"Current mods: {len(current_mods)}\n")
self._safe_update_output(f"New workshop mods: {len(filtered_workshop_results)}\n")
self._safe_update_output(f"Duplicates removed: {len(duplicates_found)}\n")
self._safe_update_output(f"Total merged mods: {len(merged_results)}\n")
# Create ProgressionHomebrew.rml file
self.create_homebrew_rml_file(current_mods, filtered_workshop_results)
except Exception as e:
self._safe_update_output(f"Error merging mods: {str(e)}\n")
def get_current_active_mods(self):
"""Get currently active mods from ModsConfig.xml"""
current_mods = []
try:
if not self.modsconfig_path or not os.path.exists(self.modsconfig_path):
self._safe_update_output("ModsConfig.xml not found\n")
return current_mods
# Parse ModsConfig.xml
tree = ET.parse(self.modsconfig_path)
root = tree.getroot()
# Get active mods from activeMods section
active_mods_element = root.find('activeMods')
if active_mods_element is not None:
for li in active_mods_element.findall('li'):
mod_id = li.text
if mod_id:
mod_id = mod_id.strip()
# Get mod name - try to find it from various sources
mod_name = self.get_mod_display_name(mod_id)
current_mods.append((mod_id, mod_name))
return current_mods
except Exception as e:
self._safe_update_output(f"Error reading current mods: {str(e)}\n")
return current_mods
def get_mod_display_name(self, mod_id):
"""Get display name for a mod ID from various sources"""
# First check if it's a core mod
if mod_id.startswith('ludeon.rimworld'):
return self.get_default_mod_name(mod_id)
# For workshop mods, try to find the About.xml in workshop folder
if self.workshop_folder and os.path.exists(self.workshop_folder):
# Check if mod_id is a workshop ID (numeric)
if mod_id.isdigit():
mod_name = self.get_mod_name_from_about_xml(mod_id)
if mod_name != f"Workshop ID {mod_id}":
return mod_name
# Fallback: return the mod ID itself
return mod_id
def create_homebrew_rml_file(self, current_mods, workshop_results):
"""Create ProgressionHomebrew.rml file with merged mods"""
try:
# Get ModLists directory path
modlists_path = os.path.expandvars(r"%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\ModLists")
# Create directory if it doesn't exist
os.makedirs(modlists_path, exist_ok=True)
rml_file_path = os.path.join(modlists_path, "ProgressionHomebrew.rml")
# Create XML content for merged mods
xml_content = self.generate_homebrew_rml_xml(current_mods, workshop_results)
# Write to file
with open(rml_file_path, 'w', encoding='utf-8') as f:
f.write(xml_content)
self._safe_update_output(f"\nProgressionHomebrew.rml created successfully at:\n{rml_file_path}\n")
# Show success animation
self.show_success_animation("ProgressionHomebrew")
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 &amp; 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 = [
'<?xml version="1.0" encoding="utf-8"?>',
'<savedModList>',
'\t<meta>',
'\t\t<gameVersion>1.6.4633 rev1261</gameVersion>',
'\t\t<modIds>'
]
# Add current mods first (maintain load order)
for package_id, mod_name in current_mods:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
# Add workshop mod package IDs to meta section
for workshop_id, package_id in workshop_results:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
xml_lines.extend([
'\t\t</modIds>',
'\t\t<modSteamIds>'
])
# Add Steam IDs (0 for non-workshop mods, actual ID for workshop mods)
for package_id, mod_name in current_mods:
if package_id.isdigit():
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
else:
xml_lines.append(f'\t\t\t<li>0</li>')
# Add workshop IDs to meta section
for workshop_id, package_id in workshop_results:
xml_lines.append(f'\t\t\t<li>{workshop_id}</li>')
xml_lines.extend([
'\t\t</modSteamIds>',
'\t\t<modNames>'
])
# Add current mod names first
for package_id, mod_name in current_mods:
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)
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>',
'\t</meta>',
'\t<modList>',
'\t\t<ids>'
])
# Add current mods first to modList section
for package_id, mod_name in current_mods:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
# Add workshop mod package IDs to modList section
for workshop_id, package_id in workshop_results:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
xml_lines.extend([
'\t\t</ids>',
'\t\t<names>'
])
# Add current mod names first to modList section
for package_id, mod_name in current_mods:
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)
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>',
'\t</modList>',
'</savedModList>'
])
return '\n'.join(xml_lines)
def create_rml_file(self, mod_data):
"""Create ProgressionVanilla.rml file with workshop IDs and names"""
try:
# Get ModLists directory path
modlists_path = os.path.expandvars(r"%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\ModLists")
# Create directory if it doesn't exist
os.makedirs(modlists_path, exist_ok=True)
rml_file_path = os.path.join(modlists_path, "ProgressionVanilla.rml")
# Create XML content
xml_content = self.generate_rml_xml(mod_data)
# Write to file
with open(rml_file_path, 'w', encoding='utf-8') as f:
f.write(xml_content)
self._safe_update_output(f"\nProgressionVanilla.rml created successfully at:\n{rml_file_path}\n")
# Show success animation
self.show_success_animation("ProgressionVanilla")
except Exception as e:
self._safe_update_output(f"Error creating RML file: {str(e)}\n")
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 = []
for workshop_id, package_id in mod_data:
mod_name = self.get_mod_name_from_about_xml(workshop_id)
mod_names.append(mod_name)
xml_lines = [
'<?xml version="1.0" encoding="utf-8"?>',
'<savedModList>',
'\t<meta>',
'\t\t<gameVersion>1.6.4633 rev1261</gameVersion>',
'\t\t<modIds>'
]
# 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:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
xml_lines.extend([
'\t\t</modIds>',
'\t\t<modSteamIds>'
])
# Add core mods (they don't have Steam IDs, so use 0)
for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>0</li>')
# Add workshop IDs to meta section
for workshop_id, package_id in mod_data:
xml_lines.append(f'\t\t\t<li>{workshop_id}</li>')
xml_lines.extend([
'\t\t</modSteamIds>',
'\t\t<modNames>'
])
# Add core mod names first
for package_id, mod_name in core_mods:
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:
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>',
'\t</meta>',
'\t<modList>',
'\t\t<ids>'
])
# Add core mods first to modList section
for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
# Add workshop mod package IDs to modList section
for workshop_id, package_id in mod_data:
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
xml_lines.extend([
'\t\t</ids>',
'\t\t<names>'
])
# Add core mod names first to modList section
for package_id, mod_name in core_mods:
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:
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>',
'\t</modList>',
'</savedModList>'
])
return '\n'.join(xml_lines)
def get_core_mods_from_config(self):
"""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):
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()
# Get RimWorld Data folder path from the RimWorld game path
rimworld_data_path = None
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 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, 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)
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 - 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())
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:
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 = {
'ludeon.rimworld': 'RimWorld',
'ludeon.rimworld.royalty': 'Royalty',
'ludeon.rimworld.ideology': 'Ideology',
'ludeon.rimworld.biotech': 'Biotech',
'ludeon.rimworld.anomaly': 'Anomaly',
'ludeon.rimworld.odyssey': 'Odyssey'
}
return default_names.get(mod_id, mod_id)
def get_mod_name_from_about_xml(self, workshop_id):
"""Extract mod name from About/About.xml file"""
try:
about_xml_path = os.path.join(self.workshop_folder, workshop_id, "About", "About.xml")
if not os.path.exists(about_xml_path):
return f"Workshop ID {workshop_id}"
# Parse the XML file
tree = ET.parse(about_xml_path)
root = tree.getroot()
# Look for name element
name_element = root.find('name')
if name_element is not None and name_element.text:
return name_element.text.strip()
else:
return f"Workshop ID {workshop_id}"
except ET.ParseError:
return f"Workshop ID {workshop_id}"
except Exception as e:
return f"Workshop ID {workshop_id}"
def get_package_name_from_about_xml(self, workshop_id):
"""Extract package name from About/About.xml file"""
try:
about_xml_path = os.path.join(self.workshop_folder, workshop_id, "About", "About.xml")
if not os.path.exists(about_xml_path):
return "About.xml not found"
# Parse the XML file
tree = ET.parse(about_xml_path)
root = tree.getroot()
# Look for packageId element
package_id_element = root.find('packageId')
if package_id_element is not None and package_id_element.text:
return package_id_element.text.strip()
else:
return "packageId not found"
except ET.ParseError:
return "XML parse error"
except Exception as e:
return f"Error: {str(e)}"
def _safe_update_output(self, text):
"""Safely update output text without recursion"""
try:
self.output_text.insert(tk.END, text)
self.output_text.see(tk.END)
except Exception:
pass # Ignore update errors to prevent recursion
def fetch_collection_items(self, collection_id):
"""Fetch workshop IDs from a Steam Workshop collection"""
try:
# Steam Workshop collection URL
url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={collection_id}"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
self._safe_update_output(f"Fetching collection page...\n")
response = requests.get(url, headers=headers, timeout=15)
response.raise_for_status()
# Extract workshop IDs from collection items only
html_content = response.text
workshop_ids = []
# Find the collectionChildren section
collection_start = html_content.find('<div class="collectionChildren">')
if collection_start != -1:
# Find the proper end of the collectionChildren section
# Look for the closing </div> that matches the opening collectionChildren div
# We need to count div tags to find the matching closing tag
search_pos = collection_start + len('<div class="collectionChildren">')
div_count = 1 # We've opened one div
collection_end = -1
while search_pos < len(html_content) and div_count > 0:
next_open = html_content.find('<div', search_pos)
next_close = html_content.find('</div>', search_pos)
if next_close == -1:
break
if next_open != -1 and next_open < next_close:
div_count += 1
search_pos = next_open + 4
else:
div_count -= 1
if div_count == 0:
collection_end = next_close + 6
break
search_pos = next_close + 6
if collection_end == -1:
collection_end = len(html_content)
collection_section = html_content[collection_start:collection_end]
# Extract IDs from sharedfile_ elements (these are the actual collection items)
sharedfile_ids = re.findall(r'id="sharedfile_(\d+)"', collection_section)
workshop_ids.extend(sharedfile_ids)
self._safe_update_output(f"Found {len(sharedfile_ids)} collection items\n")
else:
# Fallback: search the entire page but be more selective
self._safe_update_output("Collection section not found, using fallback method\n")
sharedfile_ids = re.findall(r'id="sharedfile_(\d+)"', html_content)
workshop_ids.extend(sharedfile_ids)
# Remove duplicates and the collection ID itself
unique_ids = list(set(workshop_ids))
if collection_id in unique_ids:
unique_ids.remove(collection_id)
return sorted(unique_ids) # Sort for consistent output
except requests.RequestException as e:
self._safe_update_output(f"Network error: {str(e)}\n")
return []
except Exception as e:
self._safe_update_output(f"Error fetching collection: {str(e)}\n")
return []
def _create_success_text_background(self, x, y, width, height, opacity=0.75, corner_radius=20):
"""Create a semi-transparent rounded rectangle background for success text
Width and height are automatically increased by 10%"""
print(f"Creating success background at {x}, {y} with size {width}x{height}")
# Increase size by 10%
width = int(width * 1.1)
height = int(height * 1.1)
# Create a semi-transparent image with rounded corners
bg_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(bg_img)
# Calculate alpha value (0-255) - default 75% opacity
alpha = int(255 * opacity)
fill_color = (43, 43, 43, alpha) # #2b2b2b with transparency
# Draw rounded rectangle
draw.rounded_rectangle(
[(0, 0), (width - 1, height - 1)],
radius=corner_radius,
fill=fill_color
)
# Apply gaussian blur for soft edges
bg_img = bg_img.filter(ImageFilter.GaussianBlur(radius=3))
# Convert to PhotoImage
bg_tk = ImageTk.PhotoImage(bg_img)
# Store reference to prevent garbage collection
if not hasattr(self, '_success_bg_images'):
self._success_bg_images = []
self._success_bg_images.append(bg_tk)
# Create canvas image
img_id = self.success_bg_canvas.create_image(x, y, image=bg_tk, anchor='center', tags='success_text')
# Make sure it's behind text but above background
self.success_bg_canvas.tag_lower(img_id, 'success_text')
print(f"Created success background with ID: {img_id}")
return img_id
def show_success_animation(self, mod_list_name):
"""Show success animation with logo returning to center and instructions"""
# Close the main creator window
self.root.withdraw() # Hide the main window
# Create overlay window for success animation
self.success_window = tk.Toplevel()
self.success_window.title("Progression: Loader - Success!")
self.success_window.configure(bg='#2b2b2b')
# Set window icon
try:
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
if os.path.exists(icon_path):
self.success_window.iconbitmap(icon_path)
except Exception:
pass # Ignore icon loading errors for popup windows
self.success_window.attributes('-topmost', True)
self.success_window.state('zoomed') # Fullscreen
self.success_window.protocol("WM_DELETE_WINDOW", self.close_success_animation)
# Create canvas for background image and all content
self.success_bg_canvas = tk.Canvas(self.success_window, highlightthickness=0)
self.success_bg_canvas.pack(fill=tk.BOTH, expand=True)
# Load background image
self.success_window.update_idletasks()
width = self.success_window.winfo_width() or 1920
height = self.success_window.winfo_height() or 1080
self.success_bg_image_tk = self.load_background_image(width, height)
if self.success_bg_image_tk:
self.success_bg_canvas.create_image(0, 0, image=self.success_bg_image_tk, anchor='nw', tags='bg')
else:
self.success_bg_canvas.configure(bg='#2b2b2b')
# Bind resize event
self.success_window.bind('<Configure>', self._on_success_resize)
# Store mod list name for later use
self.success_mod_list_name = mod_list_name
# Load and prepare logo for animation
try:
# Load the progression logo (GameTitle.png)
success_logo_img = Image.open(get_resource_path("art/GameTitle.png"))
# Start with smaller size (current header size)
start_height = 80
start_width = int((start_height / success_logo_img.size[1]) * success_logo_img.size[0])
# Target size (larger for center display)
target_height = 200
target_width = int((target_height / success_logo_img.size[1]) * success_logo_img.size[0])
self.success_logo_img = success_logo_img
# Start animation from top to center using canvas image
self.animate_success_logo(0, 30, start_width, start_height, target_width, target_height, mod_list_name)
except Exception as e:
# Fallback if image loading fails
self.show_success_text_only(mod_list_name)
def _on_success_resize(self, event):
"""Handle success window resize to update background image"""
if event.widget == self.success_window and hasattr(self, 'success_bg_canvas'):
width = event.width
height = event.height
if width > 1 and height > 1:
self.success_bg_image_tk = self.load_background_image(width, height)
if self.success_bg_image_tk:
self.success_bg_canvas.delete('bg')
self.success_bg_canvas.create_image(0, 0, image=self.success_bg_image_tk, anchor='nw', tags='bg')
self.success_bg_canvas.tag_lower('bg')
def animate_success_logo(self, step, total_steps, start_width, start_height,
target_width, target_height, mod_list_name):
"""Animate the logo moving from top to center with size increase"""
if step <= total_steps:
# Calculate progress with easing
progress = step / total_steps
eased_progress = 1 - (1 - progress) ** 3 # Ease-out
# Get window dimensions
window_width = self.success_window.winfo_width()
window_height = self.success_window.winfo_height()
if window_width <= 1 or window_height <= 1:
# Window not ready, try again
self.success_window.after(50, lambda: self.animate_success_logo(
step, total_steps, start_width, start_height,
target_width, target_height, mod_list_name))
return
# Calculate positions - move logo up by 3%
start_x = window_width // 2
start_y = 60 # Top position (header position)
target_x = window_width // 2
target_y = window_height // 2 - 150 - int(window_height * 0.03) # Higher up by 3% + room for text
current_x = start_x + (target_x - start_x) * eased_progress
current_y = start_y + (target_y - start_y) * eased_progress
# Calculate current size
current_width = start_width + (target_width - start_width) * eased_progress
current_height = start_height + (target_height - start_height) * eased_progress
# Resize and display logo using canvas image
resized_img = self.success_logo_img.resize(
(int(current_width), int(current_height)),
Image.Resampling.LANCZOS
)
self.success_logo_tk = ImageTk.PhotoImage(resized_img)
# Delete old logo and create new one
self.success_bg_canvas.delete('logo')
self.success_bg_canvas.create_image(current_x, current_y, image=self.success_logo_tk, anchor='center', tags='logo')
# Schedule next step
self.success_window.after(50, lambda: self.animate_success_logo(
step + 1, total_steps, start_width, start_height,
target_width, target_height, mod_list_name
))
else:
# Animation complete - show success message
self.show_success_message(mod_list_name)
def show_success_text_only(self, mod_list_name):
"""Show success message without logo animation (fallback)"""
# Ensure window is updated and get proper dimensions
self.success_window.update_idletasks()
window_width = self.success_window.winfo_width()
window_height = self.success_window.winfo_height()
# Fallback to default dimensions if window size not available
if window_width <= 1 or window_height <= 1:
window_width = 1920
window_height = 1080
# Create centered text using canvas - moved up by 3%
self.success_bg_canvas.create_text(
window_width // 2, window_height // 2 - 150 - int(window_height * 0.03),
text="Success!",
fill='#00ff00',
font=self.get_font(24, 'bold'),
tags='success_text'
)
self.show_success_message(mod_list_name, create_background=False)
def show_success_message(self, mod_list_name, create_background=True):
"""Show the success message and instructions using canvas text"""
# Ensure window is updated and get proper dimensions
self.success_window.update_idletasks()
window_width = self.success_window.winfo_width()
window_height = self.success_window.winfo_height()
# Fallback to default dimensions if window size not available
if window_width <= 1 or window_height <= 1:
window_width = 1920
window_height = 1080
center_x = window_width // 2
# Create semi-transparent background panel for text content IMMEDIATELY
if create_background:
panel_width = int(window_width * 0.6)
panel_height = int(window_height * 0.45)
# Force window update before creating background
self.success_window.update()
self._create_success_text_background(center_x, int(window_height * 0.55), panel_width, panel_height)
# Force another update to ensure background is drawn
self.success_window.update()
# Ensure logo stays on top of the background
self.success_bg_canvas.tag_raise('logo')
# Starting Y position (below the logo)
y_pos = window_height // 2 - 50
# Success message
self.success_bg_canvas.create_text(
center_x, y_pos,
text="Mods list created successfully!",
fill='#00ff00',
font=self.get_font(20, 'bold'),
tags='success_text'
)
y_pos += 50
# Mod list name
self.success_bg_canvas.create_text(
center_x, y_pos,
text=f"It is named: {mod_list_name.lower()}",
fill='white',
font=self.get_font(16),
tags='success_text'
)
y_pos += 50
# Instructions (different for homebrew vs vanilla)
if mod_list_name == "ProgressionHomebrew":
instructions_text = f"""To load it:
1. Open your mod manager in game
2. Go to load section
3. Press load next to "{mod_list_name.lower()}"
⚠️ Important for homebrew version ⚠️
This contains your current mods + progression pack
You must auto sort after loading to prevent conflicts!
Some mods may be incompatible - check for errors!"""
else:
instructions_text = f"""To load it:
1. Open your mod manager in game
2. Go to load section
3. Press load next to "{mod_list_name.lower()}"
You must auto sort after this!"""
self.success_bg_canvas.create_text(
center_x, y_pos + 80,
text=instructions_text,
fill='#cccccc',
font=self.get_font(14),
justify='center',
tags='success_text'
)
y_pos += 200
# Auto sort warning (more prominent for homebrew)
if mod_list_name == "ProgressionHomebrew":
warning_color = '#ff4444'
warning_msg = "⚠️ Critical: auto sort after loading! Check for mod conflicts! ⚠️"
else:
warning_color = '#ff8686'
warning_msg = "⚠️ Important: auto sort after loading! ⚠️"
self.success_bg_canvas.create_text(
center_x, y_pos,
text=warning_msg,
fill=warning_color,
font=self.get_font(16, 'bold'),
tags='success_text'
)
y_pos += 60
# Close button with transparent corners
self._create_success_canvas_button(center_x, y_pos + 30, "got it!",
self.close_success_animation, '#00aa00', width=150, height=50)
# Ensure logo stays on top of all elements
self._bring_success_logo_to_front()
# No auto-close - user must manually close
def _bring_success_logo_to_front(self):
"""Ensure the success logo stays on top of all other elements"""
# Bring logo to the very front
self.success_bg_canvas.tag_raise('logo')
def _create_success_canvas_button(self, x, y, text, command, color, width=150, height=40):
"""Create a button on success canvas with transparent corners"""
# Create rounded rectangle button image
btn_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(btn_img)
# Parse color
r = int(color[1:3], 16)
g = int(color[3:5], 16)
b = int(color[5:7], 16)
# Draw rounded rectangle
draw.rounded_rectangle(
[(0, 0), (width - 1, height - 1)],
radius=10,
fill=(r, g, b, 255)
)
btn_tk = ImageTk.PhotoImage(btn_img)
# Store reference
if not hasattr(self, '_success_button_images'):
self._success_button_images = []
self._success_button_images.append(btn_tk)
# Create button background
btn_id = self.success_bg_canvas.create_image(x, y, image=btn_tk, anchor='center', tags='success_text')
# Create safe tag name (remove special characters)
safe_tag = f'btn_{text.replace(" ", "_").replace("!", "").replace("?", "").replace(",", "")}'
# Create button text
text_id = self.success_bg_canvas.create_text(x, y, text=text, fill='white',
font=self.get_font(14, 'bold'),
anchor='center', tags=('success_text', safe_tag))
# Bind click event
self.success_bg_canvas.tag_bind(safe_tag, '<Button-1>', lambda e: command())
self.success_bg_canvas.tag_bind(btn_id, '<Button-1>', lambda e: command())
return btn_id
def close_success_animation(self):
"""Close the success animation window and exit the application"""
if hasattr(self, 'success_window'):
self.success_window.destroy()
# Exit the entire application since main window is hidden
self.root.quit()
def add_update_check_button(self):
"""Add an update check button to the footer"""
try:
if hasattr(self, 'footer_frame'):
update_btn = tk.Button(self.footer_frame,
text="Check for Updates",
command=self.manual_update_check,
bg='#404040', fg='white',
font=('Arial', 10),
padx=15, pady=5,
cursor='hand2')
update_btn.pack(side='left', padx=10, pady=10)
except Exception as e:
print(f"Could not add update button: {e}")
def manual_update_check(self):
"""Manually triggered update check"""
self.update_checker.manual_check_for_updates(self.root)
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"))
if os.path.exists(icon_path):
root.iconbitmap(icon_path)
except Exception as e:
print(f"Could not load icon: {e}")
def show_main_app():
"""Show the main application after loading is complete"""
root.deiconify() # Show main window
app = SteamWorkshopGUI(root)
# 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()