5 Commits

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

View File

@@ -63,7 +63,27 @@ jobs:
SERVER_URL: ${{ github.server_url }} SERVER_URL: ${{ github.server_url }}
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$uploadUrl = "$env:SERVER_URL/api/v1/repos/$env:REPO/releases/$env:RELEASE_ID/assets?name=ProgressionLoader.exe" # Gitea API endpoint for uploading release assets
$uploadUrl = "$env:SERVER_URL/api/v1/repos/$env:REPO/releases/$env:RELEASE_ID/assets"
Write-Host "Uploading ProgressionLoader.exe to $uploadUrl" Write-Host "Uploading ProgressionLoader.exe to $uploadUrl"
Invoke-RestMethod -Method Post -Uri $uploadUrl -Headers @{ Authorization = "token $env:TOKEN" } -ContentType "application/octet-stream" -InFile "ProgressionLoader.exe"
# Use multipart form data for Gitea
$boundary = [System.Guid]::NewGuid().ToString()
$LF = "`r`n"
$fileBytes = [System.IO.File]::ReadAllBytes("ProgressionLoader.exe")
$fileEnc = [System.Text.Encoding]::GetEncoding('iso-8859-1').GetString($fileBytes)
$bodyLines = (
"--$boundary",
"Content-Disposition: form-data; name=`"attachment`"; filename=`"ProgressionLoader.exe`"",
"Content-Type: application/octet-stream$LF",
$fileEnc,
"--$boundary--$LF"
) -join $LF
Invoke-RestMethod -Method Post -Uri $uploadUrl -Headers @{
Authorization = "token $env:TOKEN"
"Content-Type" = "multipart/form-data; boundary=$boundary"
} -Body $bodyLines

View File

@@ -1,9 +1,18 @@
# Progression: Loader # Progression: Loader
This project pulls from the three steam workshop collections of Progression, then it searches through your installed mods, in the workshop folder and matches the steam id to a package id, and Formal name, then it takes those names, package ids and steam ids and creates the modloader modslist, thats a mouth full, and save it to your modslist fodler so all you have to do is launch the game. The merge button will take your currently ENABLED mods and merge them with the latest progression pack to give you a new modslist called progresisonhomebrew which you can sort and load. This project pulls from the three steam workshop collections of Progression, then it searches through your installed mods, in the workshop folder and matches the steam id to a package id, and Formal name, then it takes those names, package ids and steam ids and creates the modloader modslist, thats a mouth full, and save it to your modslist folder so all you have to do is launch the game. The merge button will take your currently ENABLED mods and merge them with the latest progression pack to give you a new modslist called progresisonhomebrew which you can sort and load.
DLC detection is based on NotOwned_x.png if the user doesnt the same package NAME as the not owned png it doesnt proceed. DLC detection is based on NotOwned_x.png if the user doesnt the same package NAME as the not owned png it doesnt proceed.
.envs populate pre loaded data, only change if their is an update to collection url .envs populate pre loaded data, only change if their is an update to collection url
Doesnt sort so the current guidence to autosort from base game mod manager will still apply. Doesnt sort so the current guidence to autosort from base game mod manager will still apply.
Art by ferny Art by ferny
to bump the version
python version_manager.py 1.0.1
build
pyinstaller --clean --onefile --windowed --icon=art/Progression.ico --add-data "art;art" --name ProgressionLoader steam_workshop_gui.py

View File

@@ -2,3 +2,4 @@ requests>=2.25.1
python-dotenv>=0.19.0 python-dotenv>=0.19.0
pyglet>=1.5.0 pyglet>=1.5.0
Pillow>=8.0.0 Pillow>=8.0.0
packaging>=21.0

View File

@@ -12,6 +12,9 @@ from dotenv import load_dotenv
from PIL import Image, ImageTk, ImageDraw, ImageFilter from PIL import Image, ImageTk, ImageDraw, ImageFilter
import time import time
import webbrowser import webbrowser
from update_checker import UpdateChecker
from update_config import get_update_config
import html
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
@@ -1158,6 +1161,13 @@ class SteamWorkshopGUI:
self.log_to_output("🔍 Please provide a valid RimWorld installation path to continue.\n") 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") 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 # Check initial state now that all GUI elements are created
self.on_rimworld_path_change() self.on_rimworld_path_change()
@@ -1200,12 +1210,12 @@ class SteamWorkshopGUI:
def create_footer_frame(self, parent): def create_footer_frame(self, parent):
"""Create footer frame with HR Systems logo in bottom right""" """Create footer frame with HR Systems logo in bottom right"""
footer_frame = tk.Frame(parent, bg='#2b2b2b', height=100) # Increased height for better visibility self.footer_frame = tk.Frame(parent, bg='#2b2b2b', height=100) # Increased height for better visibility
footer_frame.pack(fill='x', side=tk.BOTTOM, pady=(10, 0)) self.footer_frame.pack(fill='x', side=tk.BOTTOM, pady=(10, 0))
footer_frame.pack_propagate(False) self.footer_frame.pack_propagate(False)
# Create clickable HR Systems logo in bottom right # Create clickable HR Systems logo in bottom right
self.create_hr_logo(footer_frame) self.create_hr_logo(self.footer_frame)
def create_hr_logo(self, parent): def create_hr_logo(self, parent):
"""Create clickable HR Systems logo with radial glow hover effects""" """Create clickable HR Systems logo with radial glow hover effects"""
@@ -1479,6 +1489,16 @@ class SteamWorkshopGUI:
self.workshop_display.pack(fill='x', pady=(5, 0)) self.workshop_display.pack(fill='x', pady=(5, 0))
self.workshop_display.bind('<KeyRelease>', self.on_workshop_path_change) 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.xml folder display
modsconfig_frame = tk.Frame(parent, bg='#2b2b2b') modsconfig_frame = tk.Frame(parent, bg='#2b2b2b')
modsconfig_frame.pack(fill='x', pady=(0, 15)) modsconfig_frame.pack(fill='x', pady=(0, 15))
@@ -1494,16 +1514,6 @@ class SteamWorkshopGUI:
self.modsconfig_display.pack(fill='x', pady=(5, 0)) self.modsconfig_display.pack(fill='x', pady=(5, 0))
self.modsconfig_display.bind('<KeyRelease>', self.on_modsconfig_path_change) self.modsconfig_display.bind('<KeyRelease>', self.on_modsconfig_path_change)
# White line separator
separator_line = tk.Frame(modsconfig_frame, bg='#ffffff', height=1)
separator_line.pack(fill='x', pady=(10, 5))
# Warning text
warning_label = tk.Label(modsconfig_frame, text="Don't edit 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, 5))
# Initialize ModsConfig path # Initialize ModsConfig path
self.find_modsconfig_path() self.find_modsconfig_path()
@@ -1624,7 +1634,7 @@ class SteamWorkshopGUI:
self.disable_buttons() self.disable_buttons()
self.workshop_var.set("Invalid RimWorld path") self.workshop_var.set("Invalid RimWorld path")
self.log_to_output(f"✗ Invalid RimWorld installation at: {rimworld_path}\n") self.log_to_output(f"✗ Invalid RimWorld installation at: {rimworld_path}\n")
self.log_to_output(" Please ensure the path contains RimWorld.exe and Data folder\n") self.log_to_output(" Please ensure the path contains RimWorld.exe (or RimWorldWin64.exe) and Data folder\n")
else: else:
# Empty path - keep blinking and buttons disabled # Empty path - keep blinking and buttons disabled
self.is_rimworld_valid = False self.is_rimworld_valid = False
@@ -1636,9 +1646,10 @@ class SteamWorkshopGUI:
if not path or not os.path.exists(path): if not path or not os.path.exists(path):
return False return False
# Check for RimWorld.exe # Check for RimWorld executable (can be RimWorld.exe or RimWorldWin64.exe)
rimworld_exe = os.path.join(path, "RimWorld.exe") rimworld_exe = os.path.join(path, "RimWorld.exe")
if not os.path.exists(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 return False
# Check for Data folder (contains core game data) # Check for Data folder (contains core game data)
@@ -1999,6 +2010,14 @@ class SteamWorkshopGUI:
except Exception as e: except Exception as e:
self._safe_update_output(f"Error creating Homebrew RML file: {str(e)}\n") 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): def generate_homebrew_rml_xml(self, current_mods, workshop_results):
"""Generate the XML content for the homebrew .rml file""" """Generate the XML content for the homebrew .rml file"""
xml_lines = [ xml_lines = [
@@ -2040,12 +2059,14 @@ class SteamWorkshopGUI:
# Add current mod names first # Add current mod names first
for package_id, mod_name in current_mods: for package_id, mod_name in current_mods:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>') escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
# Add workshop mod names to meta section # Add workshop mod names to meta section
for workshop_id, package_id in workshop_results: for workshop_id, package_id in workshop_results:
mod_name = self.get_mod_name_from_about_xml(workshop_id) mod_name = self.get_mod_name_from_about_xml(workshop_id)
xml_lines.append(f'\t\t\t<li>{mod_name}</li>') escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
xml_lines.extend([ xml_lines.extend([
'\t\t</modNames>', '\t\t</modNames>',
@@ -2069,12 +2090,14 @@ class SteamWorkshopGUI:
# Add current mod names first to modList section # Add current mod names first to modList section
for package_id, mod_name in current_mods: for package_id, mod_name in current_mods:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>') escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
# Add workshop mod names to modList section # Add workshop mod names to modList section
for workshop_id, package_id in workshop_results: for workshop_id, package_id in workshop_results:
mod_name = self.get_mod_name_from_about_xml(workshop_id) mod_name = self.get_mod_name_from_about_xml(workshop_id)
xml_lines.append(f'\t\t\t<li>{mod_name}</li>') escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
xml_lines.extend([ xml_lines.extend([
'\t\t</names>', '\t\t</names>',
@@ -2113,7 +2136,13 @@ class SteamWorkshopGUI:
def generate_rml_xml(self, mod_data): def generate_rml_xml(self, mod_data):
"""Generate the XML content for the .rml file""" """Generate the XML content for the .rml file"""
# Get core mods from ModsConfig.xml and RimWorld Data folder # 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() 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 # Extract mod names from About.xml files for workshop mods
mod_names = [] mod_names = []
@@ -2130,8 +2159,14 @@ class SteamWorkshopGUI:
] ]
# Add core mods first # 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: for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>{package_id}</li>') 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 # Add workshop mod package IDs to meta section
for workshop_id, package_id in mod_data: for workshop_id, package_id in mod_data:
@@ -2157,11 +2192,13 @@ class SteamWorkshopGUI:
# Add core mod names first # Add core mod names first
for package_id, mod_name in core_mods: for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>') escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
# Add workshop mod names to meta section # Add workshop mod names to meta section
for mod_name in mod_names: for mod_name in mod_names:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>') escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
xml_lines.extend([ xml_lines.extend([
'\t\t</modNames>', '\t\t</modNames>',
@@ -2185,11 +2222,13 @@ class SteamWorkshopGUI:
# Add core mod names first to modList section # Add core mod names first to modList section
for package_id, mod_name in core_mods: for package_id, mod_name in core_mods:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>') escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
# Add workshop mod names to modList section # Add workshop mod names to modList section
for mod_name in mod_names: for mod_name in mod_names:
xml_lines.append(f'\t\t\t<li>{mod_name}</li>') escaped_name = self.escape_xml_text(mod_name)
xml_lines.append(f'\t\t\t<li>{escaped_name}</li>')
xml_lines.extend([ xml_lines.extend([
'\t\t</names>', '\t\t</names>',
@@ -2200,14 +2239,20 @@ class SteamWorkshopGUI:
return '\n'.join(xml_lines) return '\n'.join(xml_lines)
def get_core_mods_from_config(self): def get_core_mods_from_config(self):
"""Get core mods from ModsConfig.xml knownExpansions section and their info from RimWorld Data folder""" """Get core mods from ModsConfig.xml knownExpansions section - includes ALL DLC the user owns"""
core_mods = [] core_mods = []
try: try:
if not self.modsconfig_path or not os.path.exists(self.modsconfig_path): if not self.modsconfig_path or not os.path.exists(self.modsconfig_path):
self._safe_update_output("ModsConfig.xml not found, using default core mods\n") msg = "ModsConfig.xml not found, using default core mods\n"
self._safe_update_output(msg)
print(msg.strip())
return [('ludeon.rimworld', 'RimWorld')] 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 # Parse ModsConfig.xml
tree = ET.parse(self.modsconfig_path) tree = ET.parse(self.modsconfig_path)
root = tree.getroot() root = tree.getroot()
@@ -2217,31 +2262,122 @@ class SteamWorkshopGUI:
if self.rimworld_var.get().strip(): if self.rimworld_var.get().strip():
rimworld_game_path = self.rimworld_var.get().strip() rimworld_game_path = self.rimworld_var.get().strip()
rimworld_data_path = os.path.join(rimworld_game_path, "Data") rimworld_data_path = os.path.join(rimworld_game_path, "Data")
msg = f"RimWorld Data path: {rimworld_data_path}\n"
self._safe_update_output(msg)
print(msg.strip())
else:
msg = "No RimWorld path set, using fallback names\n"
self._safe_update_output(msg)
print(msg.strip())
# Find knownExpansions section # Find knownExpansions section and include ALL expansions found
known_expansions_element = root.find('knownExpansions') known_expansions_element = root.find('knownExpansions')
if known_expansions_element is not None: 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'): for li in known_expansions_element.findall('li'):
expansion_id = li.text expansion_id = li.text
if expansion_id: 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 # Use the same method as DLC detection to get real names
mod_name = self.get_expansion_real_name(expansion_id.strip(), rimworld_data_path) mod_name = self.get_expansion_real_name(expansion_id, rimworld_data_path)
msg = f"get_expansion_real_name returned: {mod_name}\n"
self._safe_update_output(msg)
print(msg.strip())
if not mod_name: if not mod_name:
mod_name = self.get_default_mod_name(expansion_id.strip()) mod_name = self.get_default_mod_name(expansion_id)
core_mods.append((expansion_id.strip(), mod_name)) 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: if not core_mods:
# Fallback if no expansions found # Fallback if no expansions found - just base game
core_mods = [('ludeon.rimworld', 'RimWorld')] core_mods = [('ludeon.rimworld', 'RimWorld')]
msg = "No expansions found, using fallback base game only\n"
self._safe_update_output(msg)
print(msg.strip())
self._safe_update_output(f"Found {len(core_mods)} core expansions from ModsConfig.xml\n") msg = f"Final core_mods list: {core_mods}\n"
self._safe_update_output(msg)
print(msg.strip())
msg = f"Found {len(core_mods)} core expansions from knownExpansions in ModsConfig.xml\n"
self._safe_update_output(msg)
print(msg.strip())
except Exception as e: except Exception as e:
self._safe_update_output(f"Error reading core mods: {str(e)}\n") msg = f"Error reading core mods: {str(e)}\n"
self._safe_update_output(msg)
print(msg.strip())
core_mods = [('ludeon.rimworld', 'RimWorld')] core_mods = [('ludeon.rimworld', 'RimWorld')]
return core_mods 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): def get_default_mod_name(self, mod_id):
"""Get default display name for core mods""" """Get default display name for core mods"""
default_names = { default_names = {
@@ -2574,10 +2710,32 @@ you must auto sort after this!"""
# Exit the entire application since main window is hidden # Exit the entire application since main window is hidden
self.root.quit() 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(): def main():
root = tk.Tk() root = tk.Tk()
root.withdraw() # Hide main window initially root.withdraw() # Hide main window initially
# Import config
from update_config import get_update_config
# Set window icon # Set window icon
try: try:
icon_path = get_resource_path(os.path.join("art", "Progression.ico")) icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
@@ -2591,10 +2749,356 @@ def main():
root.deiconify() # Show main window root.deiconify() # Show main window
app = SteamWorkshopGUI(root) app = SteamWorkshopGUI(root)
# Show loading screen first # 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) 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() 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__": if __name__ == "__main__":
main() main()

487
update_checker.py Normal file
View File

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

50
update_config.py Normal file
View File

@@ -0,0 +1,50 @@
"""
Configuration settings for the update checker
"""
# Update checker configuration
UPDATE_CONFIG = {
# Current version of the application
"current_version": "0.0.4",
# Repository information
"repo_owner": "HRiggs",
"repo_name": "ProgressionMods",
"api_base_url": "https://git.hudsonriggs.systems/api/v1",
# Update check frequency (in hours) - set to 0 to check every startup
"check_interval_hours": 0,
# Whether to check for updates on startup
"check_on_startup": True,
# Whether updates are required (blocks app if true)
"updates_required": True,
# Whether to auto-restart after successful update
"auto_restart_after_update": True,
# Whether to include pre-releases in update checks
"include_prereleases": True,
# User agent string for API requests
"user_agent": "ProgressionLoader-UpdateChecker/1.0",
# Request timeout in seconds
"request_timeout": 10,
# File to store last check time and skipped versions (set to None to disable persistence)
"last_check_file": None
}
def get_update_config():
"""Get the update configuration"""
return UPDATE_CONFIG.copy()
def set_current_version(version):
"""Set the current version"""
UPDATE_CONFIG["current_version"] = version
def get_current_version():
"""Get the current version"""
return UPDATE_CONFIG["current_version"]

104
version_manager.py Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
Version Manager for Progression Loader
"""
import re
import sys
def get_current_version():
"""Get the current version from update_config.py"""
try:
with open('update_config.py', 'r') as f:
content = f.read()
match = re.search(r'"current_version":\s*"([^"]+)"', content)
if match:
return match.group(1)
except FileNotFoundError:
pass
return None
def set_version(new_version):
"""Set the version in update_config.py"""
try:
with open('update_config.py', 'r') as f:
content = f.read()
updated_content = re.sub(
r'"current_version":\s*"[^"]+"',
f'"current_version": "{new_version}"',
content
)
with open('update_config.py', 'w') as f:
f.write(updated_content)
print(f"✅ Updated version to {new_version}")
return True
except Exception as e:
print(f"❌ Error updating version: {e}")
return False
def validate_version(version_string):
"""Validate semantic versioning format"""
pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9\-\.]+))?$'
return re.match(pattern, version_string) is not None
def suggest_next_version(current_version):
"""Suggest next version numbers"""
if not current_version:
return ["1.0.0"]
try:
parts = current_version.split('.')
if len(parts) >= 3:
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
return [
f"{major}.{minor}.{patch + 1}", # Patch
f"{major}.{minor + 1}.0", # Minor
f"{major + 1}.0.0" # Major
]
except ValueError:
pass
return ["1.0.0"]
def main():
"""Main interface"""
if len(sys.argv) < 2:
current = get_current_version()
print(f"Current Version: {current or 'Not found'}")
if current:
suggestions = suggest_next_version(current)
print("Suggested versions:", ", ".join(suggestions))
print("\nUsage:")
print(" python version_manager.py <version> # Set version")
print(" python version_manager.py current # Show current")
print("\nExample: python version_manager.py 1.0.1")
return
command = sys.argv[1]
if command == "current":
current = get_current_version()
print(f"Current version: {current or 'Not found'}")
return
# Set version
new_version = command
if not validate_version(new_version):
print(f"❌ Invalid version format: {new_version}")
print("Use format: MAJOR.MINOR.PATCH (e.g., 1.0.1)")
return
if set_version(new_version):
print("Next steps:")
print(f" git add update_config.py")
print(f" git commit -m 'Bump version to {new_version}'")
print(f" git tag v{new_version} && git push --tags")
if __name__ == "__main__":
main()