Compare commits
6 Commits
f6addb9710
...
0.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
f1cd7eadb9
|
|||
|
49ba0a4602
|
|||
|
958fe26cad
|
|||
|
44fe6245b9
|
|||
|
ea6eb1fd5f
|
|||
|
24271abcd8
|
@@ -16,7 +16,22 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Create .env file from secrets
|
||||
shell: powershell
|
||||
run: |
|
||||
$envContent = @"
|
||||
# Default Collection URLs
|
||||
CORE_COLLECTION_URL=${{ secrets.CORE_COLLECTION_URL || 'https://steamcommunity.com/workshop/filedetails/?id=3521297585' }}
|
||||
CONTENT_COLLECTION_URL=${{ secrets.CONTENT_COLLECTION_URL || 'steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712' }}
|
||||
COSMETICS_COLLECTION_URL=${{ secrets.COSMETICS_COLLECTION_URL || 'steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646' }}
|
||||
|
||||
# ModsConfig.xml Path Template
|
||||
MODSCONFIG_PATH_TEMPLATE=${{ secrets.MODSCONFIG_PATH_TEMPLATE || '%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml' }}
|
||||
"@
|
||||
$envContent | Out-File -FilePath .env -Encoding UTF8 -Force
|
||||
Write-Host "Created .env file with configuration"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: powershell
|
||||
@@ -29,28 +44,14 @@ jobs:
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# Stamp version into sealoader_version.py from release tag
|
||||
if ($env:GITHUB_EVENT_NAME -eq 'release') {
|
||||
$tag = '${{ github.event.release.tag_name }}'
|
||||
} else {
|
||||
$tag = (git describe --tags --always) 2>$null
|
||||
if (-not $tag) { $tag = "0.0.0-dev" }
|
||||
}
|
||||
("__version__ = '" + $tag + "'") | Out-File -FilePath sealoader_version.py -Encoding UTF8 -Force
|
||||
# Bundle PNG resources referenced at runtime
|
||||
pyinstaller --noconfirm --onefile --windowed sealoader_gui.py --name SeaLoader `
|
||||
--add-data "SeaLoader.png;." `
|
||||
--add-data "hrsys.png;." `
|
||||
--icon SeaLoader.ico
|
||||
# Build standalone executable with all assets bundled
|
||||
pyinstaller --clean --onefile --windowed --icon=art/Progression.ico --add-data "art;art" --add-data ".env;." --name ProgressionLoader steam_workshop_gui.py
|
||||
|
||||
- name: Prepare artifact
|
||||
shell: powershell
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist_upload | Out-Null
|
||||
Copy-Item dist\SeaLoader.exe dist_upload\SeaLoader.exe
|
||||
if (Test-Path README.md) { Copy-Item README.md dist_upload\ }
|
||||
if (Test-Path LICENSE) { Copy-Item LICENSE dist_upload\ }
|
||||
Compress-Archive -Path dist_upload\* -DestinationPath SeaLoader_Windows_x64.zip -Force
|
||||
# Just upload the standalone .exe file
|
||||
Copy-Item dist\ProgressionLoader.exe ProgressionLoader.exe
|
||||
|
||||
- name: Upload asset to Release
|
||||
if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
|
||||
@@ -62,9 +63,27 @@ jobs:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$uploadUrl = "$env:SERVER_URL/api/v1/repos/$env:REPO/releases/$env:RELEASE_ID/assets?name=SeaLoader_Windows_x64.zip"
|
||||
Write-Host "Uploading asset to $uploadUrl"
|
||||
Invoke-RestMethod -Method Post -Uri $uploadUrl -Headers @{ Authorization = "token $env:TOKEN" } -ContentType "application/zip" -InFile "SeaLoader_Windows_x64.zip"
|
||||
|
||||
# CI artifact upload removed for GHES compatibility
|
||||
# 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"
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
13
README.md
13
README.md
@@ -1,9 +1,18 @@
|
||||
# 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.
|
||||
.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.
|
||||
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
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
requests>=2.25.1
|
||||
python-dotenv>=0.19.0
|
||||
pyglet>=1.5.0
|
||||
Pillow>=8.0.0
|
||||
Pillow>=8.0.0
|
||||
packaging>=21.0
|
||||
@@ -5,16 +5,29 @@ 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
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
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
|
||||
@@ -32,7 +45,7 @@ class LoadingScreen:
|
||||
|
||||
# Set window icon
|
||||
try:
|
||||
icon_path = os.path.join("art", "Progression.ico")
|
||||
icon_path = get_resource_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:
|
||||
@@ -56,7 +69,7 @@ class LoadingScreen:
|
||||
def load_custom_font(self):
|
||||
"""Load the RimWorld font using Windows AddFontResourceEx with private flag"""
|
||||
try:
|
||||
font_path = os.path.join("art", "RimWordFont4.ttf")
|
||||
font_path = get_resource_path(os.path.join("art", "RimWordFont4.ttf"))
|
||||
if os.path.exists(font_path):
|
||||
abs_font_path = os.path.abspath(font_path)
|
||||
|
||||
@@ -190,11 +203,11 @@ class LoadingScreen:
|
||||
"""Load all required images"""
|
||||
try:
|
||||
# Load game title
|
||||
self.game_title_img = Image.open("art/GameTitle.png")
|
||||
self.game_title_img = Image.open(get_resource_path("art/GameTitle.png"))
|
||||
self.game_title_tk = ImageTk.PhotoImage(self.game_title_img)
|
||||
|
||||
# Load HR Systems logo
|
||||
self.hr_logo_img = Image.open("art/hudsonriggssystems.png")
|
||||
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
|
||||
@@ -207,7 +220,7 @@ class LoadingScreen:
|
||||
|
||||
# Load expansion images - dynamically find all NotOwned_ files
|
||||
self.expansion_images = {}
|
||||
art_dir = "art"
|
||||
art_dir = get_resource_path("art")
|
||||
|
||||
if os.path.exists(art_dir):
|
||||
for file in os.listdir(art_dir):
|
||||
@@ -460,7 +473,7 @@ class LoadingScreen:
|
||||
|
||||
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 = "art"
|
||||
art_dir = get_resource_path("art")
|
||||
not_owned_files = []
|
||||
if os.path.exists(art_dir):
|
||||
for file in os.listdir(art_dir):
|
||||
@@ -479,7 +492,7 @@ class LoadingScreen:
|
||||
except Exception as e:
|
||||
print(f"Error checking expansions: {e}")
|
||||
# Assume all missing on error - get from NotOwned_ files
|
||||
art_dir = "art"
|
||||
art_dir = get_resource_path("art")
|
||||
not_owned_files = []
|
||||
if os.path.exists(art_dir):
|
||||
for file in os.listdir(art_dir):
|
||||
@@ -506,7 +519,7 @@ class LoadingScreen:
|
||||
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 = "art"
|
||||
art_dir = get_resource_path("art")
|
||||
not_owned_files = []
|
||||
if os.path.exists(art_dir):
|
||||
for file in os.listdir(art_dir):
|
||||
@@ -738,7 +751,7 @@ class LoadingScreen:
|
||||
|
||||
# Set window icon
|
||||
try:
|
||||
icon_path = os.path.join("art", "Progression.ico")
|
||||
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
|
||||
if os.path.exists(icon_path):
|
||||
warning_window.iconbitmap(icon_path)
|
||||
except Exception:
|
||||
@@ -903,7 +916,7 @@ All collections are required for the complete Progression experience."""
|
||||
|
||||
# Set window icon
|
||||
try:
|
||||
icon_path = os.path.join("art", "Progression.ico")
|
||||
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
|
||||
if os.path.exists(icon_path):
|
||||
instruction_window.iconbitmap(icon_path)
|
||||
except Exception:
|
||||
@@ -1147,6 +1160,13 @@ class SteamWorkshopGUI:
|
||||
self.log_to_output("🔍 Please provide a valid RimWorld installation path to continue.\n")
|
||||
self.log_to_output(" The 'RimWorld Game Folder' label will stop blinking once a valid path is detected.\n\n")
|
||||
|
||||
# Initialize update checker (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()
|
||||
|
||||
@@ -1166,7 +1186,7 @@ class SteamWorkshopGUI:
|
||||
# Load and display progression logo (using game title as progression logo)
|
||||
try:
|
||||
# Load the game title image as progression logo
|
||||
progression_img = Image.open("art/GameTitle.png")
|
||||
progression_img = Image.open(get_resource_path("art/GameTitle.png"))
|
||||
# Resize to fit header
|
||||
img_width, img_height = progression_img.size
|
||||
new_height = 80
|
||||
@@ -1189,12 +1209,12 @@ class SteamWorkshopGUI:
|
||||
|
||||
def create_footer_frame(self, parent):
|
||||
"""Create footer frame with HR Systems logo in bottom right"""
|
||||
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))
|
||||
footer_frame.pack_propagate(False)
|
||||
self.footer_frame = tk.Frame(parent, bg='#2b2b2b', height=100) # Increased height for better visibility
|
||||
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(footer_frame)
|
||||
self.create_hr_logo(self.footer_frame)
|
||||
|
||||
def create_hr_logo(self, parent):
|
||||
"""Create clickable HR Systems logo with radial glow hover effects"""
|
||||
@@ -1204,7 +1224,7 @@ class SteamWorkshopGUI:
|
||||
|
||||
try:
|
||||
# Load HR Systems logo
|
||||
hr_img = Image.open("art/hudsonriggssystems.png")
|
||||
hr_img = Image.open(get_resource_path("art/hudsonriggssystems.png"))
|
||||
# Resize to appropriate size for footer - slightly larger for better visibility
|
||||
img_width, img_height = hr_img.size
|
||||
new_height = 60 # Increased from 50 to 60
|
||||
@@ -1337,7 +1357,7 @@ class SteamWorkshopGUI:
|
||||
def load_custom_font(self):
|
||||
"""Load the RimWorld font using Windows AddFontResourceEx with private flag"""
|
||||
try:
|
||||
font_path = os.path.join("art", "RimWordFont4.ttf")
|
||||
font_path = get_resource_path(os.path.join("art", "RimWordFont4.ttf"))
|
||||
if os.path.exists(font_path):
|
||||
abs_font_path = os.path.abspath(font_path)
|
||||
|
||||
@@ -1468,6 +1488,16 @@ class SteamWorkshopGUI:
|
||||
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))
|
||||
@@ -1483,16 +1513,6 @@ class SteamWorkshopGUI:
|
||||
self.modsconfig_display.pack(fill='x', pady=(5, 0))
|
||||
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
|
||||
self.find_modsconfig_path()
|
||||
|
||||
@@ -1613,7 +1633,7 @@ class SteamWorkshopGUI:
|
||||
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 and Data folder\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
|
||||
@@ -1625,9 +1645,10 @@ class SteamWorkshopGUI:
|
||||
if not path or not os.path.exists(path):
|
||||
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")
|
||||
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
|
||||
|
||||
# Check for Data folder (contains core game data)
|
||||
@@ -2102,7 +2123,13 @@ class SteamWorkshopGUI:
|
||||
def generate_rml_xml(self, mod_data):
|
||||
"""Generate the XML content for the .rml file"""
|
||||
# Get core mods from ModsConfig.xml and RimWorld Data folder
|
||||
msg = "=== GENERATING RML XML ===\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
core_mods = self.get_core_mods_from_config()
|
||||
msg = f"Core mods returned: {core_mods}\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
|
||||
# Extract mod names from About.xml files for workshop mods
|
||||
mod_names = []
|
||||
@@ -2119,8 +2146,14 @@ class SteamWorkshopGUI:
|
||||
]
|
||||
|
||||
# Add core mods first
|
||||
msg = "Adding core mods to XML:\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
for package_id, mod_name in core_mods:
|
||||
xml_lines.append(f'\t\t\t<li>{package_id}</li>')
|
||||
msg = f" Added core mod: {package_id} ({mod_name})\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
|
||||
# Add workshop mod package IDs to meta section
|
||||
for workshop_id, package_id in mod_data:
|
||||
@@ -2189,14 +2222,20 @@ class SteamWorkshopGUI:
|
||||
return '\n'.join(xml_lines)
|
||||
|
||||
def get_core_mods_from_config(self):
|
||||
"""Get core mods from ModsConfig.xml knownExpansions section and their info from RimWorld Data folder"""
|
||||
"""Get core mods from ModsConfig.xml knownExpansions section - includes ALL DLC the user owns"""
|
||||
core_mods = []
|
||||
|
||||
try:
|
||||
if not self.modsconfig_path or not os.path.exists(self.modsconfig_path):
|
||||
self._safe_update_output("ModsConfig.xml not found, using default core mods\n")
|
||||
msg = "ModsConfig.xml not found, using default core mods\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
return [('ludeon.rimworld', 'RimWorld')]
|
||||
|
||||
msg = f"Reading ModsConfig.xml from: {self.modsconfig_path}\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
|
||||
# Parse ModsConfig.xml
|
||||
tree = ET.parse(self.modsconfig_path)
|
||||
root = tree.getroot()
|
||||
@@ -2206,31 +2245,122 @@ class SteamWorkshopGUI:
|
||||
if self.rimworld_var.get().strip():
|
||||
rimworld_game_path = self.rimworld_var.get().strip()
|
||||
rimworld_data_path = os.path.join(rimworld_game_path, "Data")
|
||||
msg = f"RimWorld Data path: {rimworld_data_path}\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
else:
|
||||
msg = "No RimWorld path set, using fallback names\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
|
||||
# Find knownExpansions section
|
||||
# Find knownExpansions section and include ALL expansions found
|
||||
known_expansions_element = root.find('knownExpansions')
|
||||
if known_expansions_element is not None:
|
||||
msg = "Found knownExpansions section, processing all DLC...\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
for li in known_expansions_element.findall('li'):
|
||||
expansion_id = li.text
|
||||
if expansion_id:
|
||||
expansion_id = expansion_id.strip()
|
||||
msg = f"Processing DLC: {expansion_id}\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
# Use the same method as DLC detection to get real names
|
||||
mod_name = self.get_expansion_real_name(expansion_id.strip(), rimworld_data_path)
|
||||
mod_name = self.get_expansion_real_name(expansion_id, rimworld_data_path)
|
||||
msg = f"get_expansion_real_name returned: {mod_name}\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
if not mod_name:
|
||||
mod_name = self.get_default_mod_name(expansion_id.strip())
|
||||
core_mods.append((expansion_id.strip(), mod_name))
|
||||
mod_name = self.get_default_mod_name(expansion_id)
|
||||
msg = f"Using fallback name: {mod_name}\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
core_mods.append((expansion_id, mod_name))
|
||||
msg = f"Added to core_mods: {expansion_id} -> {mod_name}\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
else:
|
||||
msg = "No knownExpansions section found in ModsConfig.xml\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
|
||||
if not core_mods:
|
||||
# Fallback if no expansions found
|
||||
# Fallback if no expansions found - just base game
|
||||
core_mods = [('ludeon.rimworld', 'RimWorld')]
|
||||
msg = "No expansions found, using fallback base game only\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
|
||||
self._safe_update_output(f"Found {len(core_mods)} core expansions from ModsConfig.xml\n")
|
||||
msg = f"Final core_mods list: {core_mods}\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
msg = f"Found {len(core_mods)} core expansions from knownExpansions in ModsConfig.xml\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
|
||||
except Exception as e:
|
||||
self._safe_update_output(f"Error reading core mods: {str(e)}\n")
|
||||
msg = f"Error reading core mods: {str(e)}\n"
|
||||
self._safe_update_output(msg)
|
||||
print(msg.strip())
|
||||
core_mods = [('ludeon.rimworld', 'RimWorld')]
|
||||
|
||||
return core_mods
|
||||
|
||||
def get_expansion_real_name(self, expansion_id, rimworld_data_path):
|
||||
"""Get the real expansion name from RimWorld Data folder About.xml"""
|
||||
try:
|
||||
if not rimworld_data_path or not os.path.exists(rimworld_data_path):
|
||||
# Fallback to extracting from ID if no data path
|
||||
return self.extract_name_from_id(expansion_id)
|
||||
|
||||
# Look for expansion folder in Data directory
|
||||
# Core expansions are usually in subfolders like Royalty, Ideology, etc.
|
||||
possible_folders = [
|
||||
expansion_id.replace('ludeon.rimworld.', '').capitalize(),
|
||||
expansion_id.split('.')[-1].capitalize() if '.' in expansion_id else expansion_id,
|
||||
expansion_id.replace('ludeon.rimworld.', ''),
|
||||
expansion_id.replace('ludeon.rimworld', 'Core')
|
||||
]
|
||||
|
||||
for folder_name in possible_folders:
|
||||
about_xml_path = os.path.join(rimworld_data_path, folder_name, "About", "About.xml")
|
||||
if os.path.exists(about_xml_path):
|
||||
try:
|
||||
tree = ET.parse(about_xml_path)
|
||||
root = tree.getroot()
|
||||
|
||||
name_element = root.find('name')
|
||||
if name_element is not None and name_element.text:
|
||||
return name_element.text.strip()
|
||||
except ET.ParseError:
|
||||
continue
|
||||
|
||||
# If not found in Data folder, use fallback
|
||||
return self.extract_name_from_id(expansion_id)
|
||||
|
||||
except Exception as e:
|
||||
return self.extract_name_from_id(expansion_id)
|
||||
|
||||
def extract_name_from_id(self, expansion_id):
|
||||
"""Extract expansion name from ID as fallback"""
|
||||
if 'royalty' in expansion_id.lower():
|
||||
return 'Royalty'
|
||||
elif 'ideology' in expansion_id.lower():
|
||||
return 'Ideology'
|
||||
elif 'biotech' in expansion_id.lower():
|
||||
return 'Biotech'
|
||||
elif 'anomaly' in expansion_id.lower():
|
||||
return 'Anomaly'
|
||||
elif 'odyssey' in expansion_id.lower():
|
||||
return 'Odyssey'
|
||||
else:
|
||||
# Extract the last part after the last dot and capitalize
|
||||
parts = expansion_id.split('.')
|
||||
if len(parts) > 1:
|
||||
return parts[-1].capitalize()
|
||||
return expansion_id
|
||||
|
||||
def get_default_mod_name(self, mod_id):
|
||||
"""Get default display name for core mods"""
|
||||
default_names = {
|
||||
@@ -2389,7 +2519,7 @@ class SteamWorkshopGUI:
|
||||
|
||||
# Set window icon
|
||||
try:
|
||||
icon_path = os.path.join("art", "Progression.ico")
|
||||
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:
|
||||
@@ -2406,7 +2536,7 @@ class SteamWorkshopGUI:
|
||||
# Load and prepare logo for animation
|
||||
try:
|
||||
# Load the progression logo (GameTitle.png)
|
||||
success_logo_img = Image.open("art/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])
|
||||
@@ -2562,14 +2692,36 @@ you must auto sort after this!"""
|
||||
|
||||
# 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 = os.path.join("art", "Progression.ico")
|
||||
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:
|
||||
@@ -2580,10 +2732,356 @@ def main():
|
||||
root.deiconify() # Show main window
|
||||
app = SteamWorkshopGUI(root)
|
||||
|
||||
# Show loading screen first
|
||||
loading_screen = LoadingScreen(root, show_main_app)
|
||||
# Check for updates SYNCHRONOUSLY before starting anything
|
||||
config = get_update_config()
|
||||
update_info = check_for_updates_before_startup(root)
|
||||
|
||||
if update_info and config.get("updates_required", True):
|
||||
# Show blocking update dialog - app won't continue until user updates
|
||||
show_blocking_update_dialog(root, update_info['checker'], update_info['release'])
|
||||
else:
|
||||
# No update needed or updates not required, start loading screen
|
||||
loading_screen = LoadingScreen(root, show_main_app)
|
||||
|
||||
# If update available but not required, show non-blocking dialog
|
||||
if update_info:
|
||||
root.after(1000, lambda: show_optional_update_dialog(root, update_info['checker'], update_info['release']))
|
||||
|
||||
root.mainloop()
|
||||
|
||||
def check_for_updates_before_startup(root):
|
||||
"""Check for updates synchronously before starting the application"""
|
||||
try:
|
||||
from update_checker import UpdateChecker
|
||||
from update_config import get_update_config
|
||||
|
||||
print("Checking for updates...")
|
||||
|
||||
config = get_update_config()
|
||||
update_checker = UpdateChecker(config["current_version"])
|
||||
|
||||
# Use synchronous check to block until complete
|
||||
release_info, error = update_checker.check_for_updates_sync()
|
||||
|
||||
if error:
|
||||
print(f"Update check failed: {error}")
|
||||
return None
|
||||
elif release_info:
|
||||
latest_version = release_info['version']
|
||||
if update_checker.is_newer_version(latest_version):
|
||||
print(f"Update required: {latest_version}")
|
||||
return {
|
||||
'checker': update_checker,
|
||||
'release': release_info
|
||||
}
|
||||
else:
|
||||
print(f"Already up to date: {latest_version}")
|
||||
else:
|
||||
print("No release info available")
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Update check failed: {e}")
|
||||
return None
|
||||
|
||||
def show_blocking_update_dialog(root, update_checker, release_info):
|
||||
"""Show a blocking update dialog that prevents app from continuing"""
|
||||
# Create a new window for the update dialog
|
||||
update_window = tk.Toplevel(root)
|
||||
update_window.title("Update Required - Progression Loader")
|
||||
update_window.configure(bg='#2b2b2b')
|
||||
update_window.resizable(False, False)
|
||||
update_window.attributes('-topmost', True)
|
||||
|
||||
# Make it modal - user can't interact with other windows
|
||||
update_window.transient(root)
|
||||
update_window.grab_set()
|
||||
|
||||
# Set window icon
|
||||
try:
|
||||
icon_path = get_resource_path(os.path.join("art", "Progression.ico"))
|
||||
if os.path.exists(icon_path):
|
||||
update_window.iconbitmap(icon_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Calculate window size and center it
|
||||
window_width = 600
|
||||
window_height = 600 # Increased height to accommodate buttons
|
||||
|
||||
# Get screen dimensions
|
||||
screen_width = update_window.winfo_screenwidth()
|
||||
screen_height = update_window.winfo_screenheight()
|
||||
|
||||
# Calculate center position
|
||||
x = (screen_width - window_width) // 2
|
||||
y = (screen_height - window_height) // 2
|
||||
|
||||
update_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
||||
|
||||
# Title
|
||||
title_label = tk.Label(update_window,
|
||||
text="🔄 Update Required",
|
||||
fg='#ff6b6b', bg='#2b2b2b',
|
||||
font=('Arial', 20, 'bold'))
|
||||
title_label.pack(pady=20)
|
||||
|
||||
# Message
|
||||
message_label = tk.Label(update_window,
|
||||
text="A new version is available and required to continue.",
|
||||
fg='white', bg='#2b2b2b',
|
||||
font=('Arial', 14))
|
||||
message_label.pack(pady=10)
|
||||
|
||||
# Version info
|
||||
version_frame = tk.Frame(update_window, bg='#2b2b2b')
|
||||
version_frame.pack(pady=15)
|
||||
|
||||
current_label = tk.Label(version_frame,
|
||||
text=f"Current Version: {update_checker.current_version}",
|
||||
fg='#cccccc', bg='#2b2b2b',
|
||||
font=('Arial', 12))
|
||||
current_label.pack()
|
||||
|
||||
latest_label = tk.Label(version_frame,
|
||||
text=f"Required Version: {release_info['version']}",
|
||||
fg='#4ecdc4', bg='#2b2b2b',
|
||||
font=('Arial', 12, 'bold'))
|
||||
latest_label.pack(pady=5)
|
||||
|
||||
# Release notes
|
||||
if release_info.get('body'):
|
||||
notes_label = tk.Label(update_window,
|
||||
text="What's New:",
|
||||
fg='white', bg='#2b2b2b',
|
||||
font=('Arial', 14, 'bold'))
|
||||
notes_label.pack(pady=(20, 10))
|
||||
|
||||
# Create scrollable text widget for release notes
|
||||
notes_frame = tk.Frame(update_window, bg='#2b2b2b')
|
||||
notes_frame.pack(fill='both', expand=True, padx=40, pady=(0, 10))
|
||||
|
||||
notes_text = tk.Text(notes_frame,
|
||||
height=6, # Reduced height to make room for buttons
|
||||
bg='#404040', fg='#ffffff',
|
||||
font=('Arial', 11),
|
||||
wrap=tk.WORD,
|
||||
state='disabled',
|
||||
relief='flat',
|
||||
bd=0)
|
||||
|
||||
scrollbar = tk.Scrollbar(notes_frame, bg='#404040')
|
||||
scrollbar.pack(side='right', fill='y')
|
||||
|
||||
notes_text.pack(side='left', fill='both', expand=True)
|
||||
notes_text.config(yscrollcommand=scrollbar.set)
|
||||
scrollbar.config(command=notes_text.yview)
|
||||
|
||||
# Insert release notes
|
||||
notes_text.config(state='normal')
|
||||
notes_text.insert('1.0', release_info['body'])
|
||||
notes_text.config(state='disabled')
|
||||
|
||||
# Buttons - fixed at bottom
|
||||
button_frame = tk.Frame(update_window, bg='#2b2b2b')
|
||||
button_frame.pack(side='bottom', pady=20) # Pack at bottom with padding
|
||||
|
||||
def auto_update():
|
||||
"""Download and install the update automatically"""
|
||||
try:
|
||||
# Disable buttons during download
|
||||
auto_btn.config(state='disabled', text='Downloading...')
|
||||
download_btn.config(state='disabled')
|
||||
exit_btn.config(state='disabled')
|
||||
|
||||
# Update the window to show progress
|
||||
update_window.update()
|
||||
|
||||
# Start download in a separate thread
|
||||
import threading
|
||||
threading.Thread(target=perform_auto_update, daemon=True).start()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Auto update failed: {e}")
|
||||
# Re-enable buttons on error
|
||||
auto_btn.config(state='normal', text='Auto Update')
|
||||
download_btn.config(state='normal')
|
||||
exit_btn.config(state='normal')
|
||||
|
||||
def perform_auto_update():
|
||||
"""Perform the actual auto update process"""
|
||||
try:
|
||||
import requests
|
||||
import tempfile
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Find the EXE asset in the release
|
||||
exe_asset = None
|
||||
for asset in release_info.get('assets', []):
|
||||
if asset.get('name', '').endswith('.exe'):
|
||||
exe_asset = asset
|
||||
break
|
||||
|
||||
if not exe_asset:
|
||||
raise Exception("No EXE file found in release assets")
|
||||
|
||||
download_url = exe_asset.get('browser_download_url')
|
||||
if not download_url:
|
||||
raise Exception("No download URL found for EXE asset")
|
||||
|
||||
# Update status
|
||||
root.after(0, lambda: auto_btn.config(text='Downloading...'))
|
||||
|
||||
# Download the new EXE
|
||||
response = requests.get(download_url, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
# Save to temporary file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.exe') as temp_file:
|
||||
temp_path = temp_file.name
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
temp_file.write(chunk)
|
||||
|
||||
# Get current executable path
|
||||
if getattr(sys, 'frozen', False):
|
||||
# Running as PyInstaller bundle
|
||||
current_exe = sys.executable
|
||||
else:
|
||||
# Running as script - for testing
|
||||
current_exe = sys.argv[0]
|
||||
|
||||
# Update status
|
||||
root.after(0, lambda: auto_btn.config(text='Installing...'))
|
||||
|
||||
# Get configuration
|
||||
from update_config import get_update_config
|
||||
config = get_update_config()
|
||||
auto_restart = config.get("auto_restart_after_update", True)
|
||||
|
||||
# Create a batch script to replace the executable and optionally restart
|
||||
if auto_restart:
|
||||
restart_command = f'start "" /B "{current_exe}"'
|
||||
restart_message = "Starting new version..."
|
||||
else:
|
||||
restart_command = 'echo Please manually start the application.'
|
||||
restart_message = "Please manually start the updated application."
|
||||
|
||||
batch_script = f'''@echo off
|
||||
echo Updating Progression Loader...
|
||||
timeout /t 3 /nobreak >nul
|
||||
|
||||
REM Replace the executable
|
||||
move "{temp_path}" "{current_exe}"
|
||||
if errorlevel 1 (
|
||||
echo Failed to replace executable
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Update complete! {restart_message}
|
||||
timeout /t 1 /nobreak >nul
|
||||
|
||||
REM Start the new executable (if configured)
|
||||
{restart_command}
|
||||
|
||||
REM Clean up this batch file
|
||||
timeout /t 2 /nobreak >nul
|
||||
del "%~f0"
|
||||
'''
|
||||
|
||||
# Write batch script to temp file
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.bat') as batch_file:
|
||||
batch_file.write(batch_script)
|
||||
batch_path = batch_file.name
|
||||
|
||||
# Update status
|
||||
root.after(0, lambda: auto_btn.config(text='Restarting...'))
|
||||
|
||||
# Show success message
|
||||
root.after(0, lambda: tk.messagebox.showinfo(
|
||||
"Update Complete",
|
||||
"Update downloaded successfully!\nThe application will restart automatically.",
|
||||
parent=update_window
|
||||
))
|
||||
|
||||
# Execute the batch script and exit
|
||||
subprocess.Popen([batch_path], shell=True)
|
||||
|
||||
# Exit the current application
|
||||
root.after(2000, root.quit)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Auto update failed: {e}")
|
||||
# Re-enable buttons on error
|
||||
root.after(0, lambda: auto_btn.config(state='normal', text='Auto Update'))
|
||||
root.after(0, lambda: download_btn.config(state='normal'))
|
||||
root.after(0, lambda: exit_btn.config(state='normal'))
|
||||
|
||||
# Show error message
|
||||
root.after(0, lambda: tk.messagebox.showerror(
|
||||
"Auto Update Failed",
|
||||
f"Failed to automatically update:\n{str(e)}\n\nPlease use 'Download Page' to update manually.",
|
||||
parent=update_window
|
||||
))
|
||||
|
||||
def download_page():
|
||||
"""Open the release download page"""
|
||||
webbrowser.open(release_info['html_url'])
|
||||
print("Please download and install the update manually, then restart the application.")
|
||||
root.quit() # Exit the entire application
|
||||
|
||||
def exit_app():
|
||||
"""Exit the application without updating"""
|
||||
print("Application cannot continue without updating.")
|
||||
root.quit()
|
||||
|
||||
# Auto Update button (primary action)
|
||||
auto_btn = tk.Button(button_frame,
|
||||
text="Auto Update",
|
||||
command=auto_update,
|
||||
bg='#4ecdc4', fg='white',
|
||||
font=('Arial', 14, 'bold'),
|
||||
padx=30, pady=10,
|
||||
cursor='hand2')
|
||||
auto_btn.pack(side='left', padx=10)
|
||||
|
||||
# Download Page button (secondary action)
|
||||
download_btn = tk.Button(button_frame,
|
||||
text="Download Page",
|
||||
command=download_page,
|
||||
bg='#45b7d1', fg='white',
|
||||
font=('Arial', 12),
|
||||
padx=20, pady=10,
|
||||
cursor='hand2')
|
||||
download_btn.pack(side='left', padx=10)
|
||||
|
||||
# Exit button (tertiary action)
|
||||
exit_btn = tk.Button(button_frame,
|
||||
text="Exit Application",
|
||||
command=exit_app,
|
||||
bg='#666666', fg='white',
|
||||
font=('Arial', 12),
|
||||
padx=20, pady=10,
|
||||
cursor='hand2')
|
||||
exit_btn.pack(side='left', padx=10)
|
||||
|
||||
# Handle window close (same as exit)
|
||||
update_window.protocol("WM_DELETE_WINDOW", exit_app)
|
||||
|
||||
# Focus on the auto update button
|
||||
auto_btn.focus_set()
|
||||
|
||||
print("Blocking update dialog shown - app will not continue until user updates")
|
||||
|
||||
def show_optional_update_dialog(root, update_checker, release_info):
|
||||
"""Show a non-blocking update dialog"""
|
||||
try:
|
||||
update_checker.show_update_dialog(root, release_info)
|
||||
except Exception as e:
|
||||
print(f"Could not show update dialog: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
487
update_checker.py
Normal file
487
update_checker.py
Normal 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
50
update_config.py
Normal 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.3",
|
||||
|
||||
# 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
104
version_manager.py
Normal 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()
|
||||
Reference in New Issue
Block a user