992 lines
42 KiB
Python
992 lines
42 KiB
Python
from CTkMessagebox import CTkMessagebox
|
|
from bs4 import BeautifulSoup
|
|
import customtkinter as ctk
|
|
from pathlib import Path
|
|
from CTkToolTip import *
|
|
from PIL import Image
|
|
import configparser
|
|
import webbrowser
|
|
import subprocess
|
|
import threading
|
|
import requests
|
|
import zipfile
|
|
import shutil
|
|
import psutil
|
|
import json
|
|
import math
|
|
import time
|
|
import sys
|
|
import io
|
|
import os
|
|
import re
|
|
|
|
VERSION = "v0.2.0"
|
|
GITHUB_REPO = "faroukbmiled/BOIIIWD"
|
|
LATEST_RELEASE_URL = "https://github.com/faroukbmiled/BOIIIWD/releases/latest/download/Release.zip"
|
|
UPDATER_FOLDER = "update"
|
|
CONFIG_FILE_PATH = "config.ini"
|
|
global stopped, steampid, console
|
|
steampid = None
|
|
stopped = False
|
|
console = False
|
|
|
|
ctk.set_appearance_mode("Dark") # Modes: "System" (standard), "Dark", "Light"
|
|
ctk.set_default_color_theme("dark-blue") # Themes: "blue" (standard), "green", "dark-blue"
|
|
|
|
# Start Helper Functions
|
|
def get_latest_release_version():
|
|
try:
|
|
release_api_url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
|
response = requests.get(release_api_url)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
return data["tag_name"]
|
|
except requests.exceptions.RequestException as e:
|
|
show_message("Warning", f"Error while checking for updates: \n{e}")
|
|
return None
|
|
|
|
|
|
def create_update_script(current_exe, new_exe, updater_folder, program_name):
|
|
script_content = f"""
|
|
@echo off
|
|
echo Terminating BOIIIWD.exe...
|
|
taskkill /im "{program_name}" /t /f
|
|
|
|
echo Replacing BOIIIWD.exe...
|
|
cd "{updater_folder}"
|
|
taskkill /im "{program_name}" /t /f
|
|
move /y "{new_exe}" "../"{program_name}""
|
|
|
|
echo Starting BOIIIWD.exe...
|
|
cd ..
|
|
start "" "{current_exe}"
|
|
|
|
echo Exiting!
|
|
exit
|
|
"""
|
|
|
|
script_path = os.path.join(updater_folder, "boiiiwd_updater.bat")
|
|
with open(script_path, "w") as script_file:
|
|
script_file.write(script_content)
|
|
|
|
return script_path
|
|
|
|
def cwd():
|
|
if getattr(sys, 'frozen', False):
|
|
return os.path.dirname(sys.executable)
|
|
else:
|
|
return os.path.dirname(os.path.abspath(__file__))
|
|
|
|
def extract_workshop_id(link):
|
|
try:
|
|
pattern = r'(?<=id=)(\d+)'
|
|
match = re.search(pattern, link)
|
|
|
|
if match:
|
|
return match.group(0)
|
|
else:
|
|
return None
|
|
except:
|
|
return None
|
|
|
|
def check_steamcmd():
|
|
steamcmd_path = get_steamcmd_path()
|
|
steamcmd_exe_path = os.path.join(steamcmd_path, "steamcmd.exe")
|
|
|
|
if not os.path.exists(steamcmd_exe_path):
|
|
return False
|
|
|
|
return True
|
|
|
|
def initialize_steam(master):
|
|
try:
|
|
steamcmd_path = get_steamcmd_path()
|
|
steamcmd_exe_path = os.path.join(steamcmd_path, "steamcmd.exe")
|
|
process = subprocess.Popen([steamcmd_exe_path, "+quit"], creationflags=subprocess.CREATE_NEW_CONSOLE)
|
|
master.attributes('-alpha', 0.0)
|
|
process.wait()
|
|
show_message("SteamCMD has terminated!", "BOIIIWD is ready for action.", icon="info")
|
|
except:
|
|
show_message("Error!", "An error occurred please check your paths and try again.", icon="cancel")
|
|
master.attributes('-alpha', 1.0)
|
|
|
|
def valid_id(workshop_id):
|
|
url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={workshop_id}"
|
|
response = requests.get(url)
|
|
response.raise_for_status()
|
|
content = response.text
|
|
soup = BeautifulSoup(content, "html.parser")
|
|
|
|
try:
|
|
soup.find("div", class_="rightDetailsBlock").text.strip()
|
|
soup.find("div", class_="workshopItemTitle").text.strip()
|
|
soup.find("div", class_="detailsStatRight").text.strip()
|
|
stars_div = soup.find("div", class_="fileRatingDetails")
|
|
stars_div.find("img")["src"]
|
|
return True
|
|
except:
|
|
return False
|
|
|
|
def convert_speed(speed_bytes):
|
|
if speed_bytes < 1024:
|
|
return speed_bytes, "B/s"
|
|
elif speed_bytes < 1024 * 1024:
|
|
return speed_bytes / 1024, "KB/s"
|
|
elif speed_bytes < 1024 * 1024 * 1024:
|
|
return speed_bytes / (1024 * 1024), "MB/s"
|
|
else:
|
|
return speed_bytes / (1024 * 1024 * 1024), "GB/s"
|
|
|
|
def create_default_config():
|
|
config = configparser.ConfigParser()
|
|
config["Settings"] = {
|
|
"SteamCMDPath": cwd(),
|
|
"DestinationFolder": "",
|
|
"checkforupdtes": "on",
|
|
"console": "off"
|
|
}
|
|
with open(CONFIG_FILE_PATH, "w") as config_file:
|
|
config.write(config_file)
|
|
|
|
def run_steamcmd_command(command, self):
|
|
steamcmd_path = get_steamcmd_path()
|
|
show_console = subprocess.CREATE_NO_WINDOW
|
|
if console:
|
|
show_console = subprocess.CREATE_NEW_CONSOLE
|
|
|
|
process = subprocess.Popen(
|
|
[steamcmd_path + "\steamcmd.exe"] + command.split(),
|
|
stdout=None if console else subprocess.PIPE,
|
|
stderr=None if console else subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
universal_newlines=True,
|
|
creationflags=show_console
|
|
)
|
|
|
|
global steampid
|
|
steampid = process.pid
|
|
|
|
if process.poll() is not None:
|
|
return process.returncode
|
|
|
|
process.communicate()
|
|
|
|
show_message("SteamCMD has terminated", "SteamCMD has been terminated\nTry again if it randomly stopped!")
|
|
global stopped
|
|
stopped = True
|
|
self.button_download.configure(state="normal")
|
|
self.button_stop.configure(state="disabled")
|
|
|
|
return process.returncode
|
|
|
|
|
|
def get_steamcmd_path():
|
|
config = configparser.ConfigParser()
|
|
config.read(CONFIG_FILE_PATH)
|
|
return config.get("Settings", "SteamCMDPath", fallback=cwd())
|
|
|
|
def check_config(name, fallback=None):
|
|
config = configparser.ConfigParser()
|
|
config.read(CONFIG_FILE_PATH)
|
|
if fallback:
|
|
return config.get("Settings", name, fallback=fallback)
|
|
return config.get("Settings", name, fallback="on")
|
|
|
|
def save_config(name, value):
|
|
config = configparser.ConfigParser()
|
|
config.read(CONFIG_FILE_PATH)
|
|
if name and value:
|
|
config.set("Settings", name, value)
|
|
with open(CONFIG_FILE_PATH, "w") as config_file:
|
|
config.write(config_file)
|
|
|
|
def extract_json_data(json_path):
|
|
with open(json_path, "r") as json_file:
|
|
data = json.load(json_file)
|
|
return data["Type"], data["FolderName"]
|
|
|
|
def convert_bytes_to_readable(size_in_bytes):
|
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
|
if size_in_bytes < 1024.0:
|
|
return f"{size_in_bytes:.2f} {unit}"
|
|
size_in_bytes /= 1024.0
|
|
|
|
def get_workshop_file_size(workshop_id, raw=None):
|
|
url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={workshop_id}&searchtext="
|
|
response = requests.get(url)
|
|
soup = BeautifulSoup(response.text, "html.parser")
|
|
file_size_element = soup.find("div", class_="detailsStatRight")
|
|
|
|
try:
|
|
if raw:
|
|
file_size_text = file_size_element.get_text(strip=True)
|
|
file_size_text = file_size_text.replace(",", "")
|
|
file_size_in_mb = float(file_size_text.replace(" MB", ""))
|
|
file_size_in_bytes = int(file_size_in_mb * 1024 * 1024)
|
|
return convert_bytes_to_readable(file_size_in_bytes)
|
|
|
|
if file_size_element:
|
|
file_size_text = file_size_element.get_text(strip=True)
|
|
file_size_text = file_size_text.replace(",", "")
|
|
file_size_in_mb = float(file_size_text.replace(" MB", ""))
|
|
file_size_in_bytes = int(file_size_in_mb * 1024 * 1024)
|
|
return file_size_in_bytes
|
|
return None
|
|
except:
|
|
return None
|
|
|
|
def show_message(title, message, icon="warning", exit_on_close=False):
|
|
|
|
if exit_on_close:
|
|
msg = CTkMessagebox(title=title, message=message, icon=icon, option_1="No", option_2="Ok")
|
|
response = msg.get()
|
|
if response=="No":
|
|
return False
|
|
if response=="Ok":
|
|
return True
|
|
else:
|
|
return False
|
|
else:
|
|
msg = CTkMessagebox(title=title, message=message, icon=icon)
|
|
# End helper functions
|
|
|
|
class UpdateWindow(ctk.CTkToplevel):
|
|
def __init__(self, master, update_url):
|
|
global master_win
|
|
master_win = master
|
|
super().__init__(master)
|
|
self.title("BOIIIWD Self-Updater")
|
|
self.geometry("400x150")
|
|
self.after(250, lambda: self.iconbitmap('ryuk.ico'))
|
|
self.protocol("WM_DELETE_WINDOW", self.cancel_update)
|
|
self.attributes('-topmost', 'true')
|
|
|
|
self.columnconfigure(0, weight=1)
|
|
self.columnconfigure(1, weight=1)
|
|
self.rowconfigure(0, weight=1)
|
|
self.rowconfigure(1, weight=1)
|
|
|
|
self.label_download = ctk.CTkLabel(self, text="Starting...")
|
|
self.label_download.grid(row=0, column=0, padx=30, pady=(10, 0), sticky="w")
|
|
|
|
self.label_size = ctk.CTkLabel(self, text="Size: 0")
|
|
self.label_size.grid(row=0, column=1, padx=30, pady=(10, 0), sticky="e")
|
|
|
|
self.progress_bar = ctk.CTkProgressBar(self, mode="determinate", height=20, corner_radius=7)
|
|
self.progress_bar.grid(row=1, column=0, columnspan=4, padx=30, pady=10, sticky="ew")
|
|
self.progress_bar.set(0)
|
|
|
|
self.progress_label = ctk.CTkLabel(self.progress_bar, text="0%", font=("Helvetica", 12), fg_color="transparent", height=0, width=0, corner_radius=0)
|
|
self.progress_label.place(relx=0.5, rely=0.5, anchor="center")
|
|
|
|
self.cancel_button = ctk.CTkButton(self, text="Cancel", command=self.cancel_update)
|
|
self.cancel_button.grid(row=2, column=0, padx=30, pady=(0, 10), sticky="w")
|
|
|
|
self.update_url = update_url
|
|
self.total_size = None
|
|
self.up_cancelled = False
|
|
|
|
def update_progress_bar(self):
|
|
try:
|
|
update_dir = os.path.join(os.getcwd(), UPDATER_FOLDER)
|
|
response = requests.get(LATEST_RELEASE_URL, stream=True)
|
|
response.raise_for_status()
|
|
current_exe = sys.argv[0]
|
|
program_name = os.path.basename(current_exe)
|
|
new_exe = os.path.join(update_dir, "BOIIIWD.exe")
|
|
|
|
if not os.path.exists(update_dir):
|
|
os.makedirs(update_dir)
|
|
|
|
self.progress_bar.set(0.0)
|
|
self.total_size = int(response.headers.get('content-length', 0))
|
|
self.label_size.configure(text=f"Size: {convert_bytes_to_readable(self.total_size)}")
|
|
zip_path = os.path.join(update_dir, "latest_version.zip")
|
|
|
|
with open(zip_path, "wb") as file:
|
|
downloaded_size = 0
|
|
for chunk in response.iter_content(chunk_size=8192):
|
|
if self.up_cancelled:
|
|
break
|
|
if chunk:
|
|
file.write(chunk)
|
|
downloaded_size += len(chunk)
|
|
progress = int((downloaded_size / self.total_size) * 100)
|
|
|
|
self.after(1, lambda p=progress: self.label_download.configure(text=f"Downloading update..."))
|
|
self.after(1, lambda v=progress / 100.0: self.progress_bar.set(v))
|
|
self.after(1, lambda p=progress: self.progress_label.configure(text=f"{p}%"))
|
|
|
|
if not self.up_cancelled:
|
|
self.progress_bar.set(1.0)
|
|
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
|
zip_ref.extractall(update_dir)
|
|
self.label_download.configure(text="Update Downloaded successfully!")
|
|
if not show_message("Success!", "Update Downloaded successfully!\nPress ok to install it", icon="info", exit_on_close=True):
|
|
return
|
|
script_path = create_update_script(current_exe, new_exe, update_dir, program_name)
|
|
subprocess.run(('cmd', '/C', 'start', '', fr'{script_path}'))
|
|
sys.exit(0)
|
|
else:
|
|
if os.path.exists(zip_path):
|
|
os.remove(fr"{zip_path}")
|
|
self.label_download.configure(text="Update cancelled.")
|
|
self.progress_bar.set(0.0)
|
|
# there's a better solution ill implement it later
|
|
global master_win
|
|
master_win.attributes('-alpha', 1.0)
|
|
show_message("Cancelled!", "Update cancelled by user", icon="warning")
|
|
except Exception as e:
|
|
self.progress_bar.set(0.0)
|
|
self.label_download.configure(text="Update failed")
|
|
show_message("Error", f"Error installing the update\n{e}", icon="cancel")
|
|
|
|
def start_update(self):
|
|
self.thread = threading.Thread(target=self.update_progress_bar)
|
|
self.thread.start()
|
|
|
|
def cancel_update(self):
|
|
self.up_cancelled = True
|
|
self.withdraw()
|
|
|
|
class SettingsTab(ctk.CTkFrame):
|
|
def __init__(self, master=None):
|
|
super().__init__(master)
|
|
|
|
# Check for updates checkbox
|
|
self.check_updates_var = ctk.BooleanVar()
|
|
self.check_updates_var.trace_add("write", self.enable_save_button)
|
|
self.check_updates_checkbox = ctk.CTkSwitch(self, text="Check for updates on launch", variable=self.check_updates_var)
|
|
self.check_updates_checkbox.grid(row=0, column=1, padx=20 , pady=(20, 0), sticky="nsew")
|
|
self.check_updates_var.set(self.load_settings(updates=True))
|
|
|
|
# Show console checkbox
|
|
self.console_var = ctk.BooleanVar()
|
|
self.console_var.trace_add("write", self.enable_save_button)
|
|
self.checkbox_show_console = ctk.CTkSwitch(self, text="Console (On Download)", variable=self.console_var)
|
|
self.checkbox_show_console.grid(row=1, column=1, padx=20, pady=(20, 0), sticky="nsew")
|
|
self.checkbox_show_console_tooltip = CTkToolTip(self.checkbox_show_console, message="Toggle SteamCMD console\nPlease don't close the Console If you want to stop press the Stop boutton")
|
|
self.console_var.set(self.load_settings(console=True))
|
|
|
|
# Check for updates button
|
|
self.check_for_updates = ctk.CTkButton(self, text="Check for updates", command=self.check_for_updates)
|
|
self.check_for_updates.grid(row=2, column=1, padx=20, pady=(20, 0), sticky="nsew")
|
|
|
|
# Save button
|
|
self.save_button = ctk.CTkButton(self, text="Save", command=self.save_settings, state='disabled')
|
|
self.save_button.grid(row=3, column=1, padx=20, pady=(150, 0), sticky="nsew")
|
|
|
|
def enable_save_button(self, *args):
|
|
self.save_button.configure(state='normal')
|
|
|
|
def save_settings(self):
|
|
self.save_button.configure(state='disabled')
|
|
global console
|
|
if self.check_updates_checkbox.get():
|
|
save_config("checkforupdtes", "on")
|
|
else:
|
|
save_config("checkforupdtes", "off")
|
|
|
|
if self.checkbox_show_console.get():
|
|
save_config("console", "on")
|
|
console = True
|
|
else:
|
|
save_config("console", "off")
|
|
console = False
|
|
|
|
def load_settings(self, console=None, updates=None):
|
|
if updates:
|
|
if check_config("checkforupdtes") == "on":
|
|
return 1
|
|
else:
|
|
return 0
|
|
if console:
|
|
if check_config("console") == "on":
|
|
console = True
|
|
return 1
|
|
else:
|
|
console = False
|
|
return 0
|
|
|
|
def check_for_updates(self, ignore_up_todate=False):
|
|
try:
|
|
latest_version = get_latest_release_version()
|
|
current_version = VERSION
|
|
|
|
if latest_version and latest_version != current_version:
|
|
msg_box = CTkMessagebox(title="Update Available", message=f"An update is available!\n\nCurrent Version: {current_version}\nLatest Version: {latest_version}", option_1="View", option_2="No", option_3="Yes")
|
|
|
|
result = msg_box.get()
|
|
|
|
if result == "View":
|
|
webbrowser.open(f"https://github.com/{GITHUB_REPO}/releases/latest")
|
|
|
|
if result == "Yes":
|
|
update_window = UpdateWindow(self, LATEST_RELEASE_URL)
|
|
update_window.start_update()
|
|
|
|
if result == "No":
|
|
return
|
|
|
|
elif latest_version == current_version:
|
|
if ignore_up_todate:
|
|
return
|
|
msg_box = CTkMessagebox(title="Up to Date!", message="No Updates Available!", option_1="Ok")
|
|
result = msg_box.get()
|
|
except Exception as e:
|
|
show_message("Error", f"Error while checking for updates: \n{e}", icon="cancel")
|
|
|
|
def load_on_switch_screen(self):
|
|
self.check_updates_var.set(self.load_settings(updates=True))
|
|
self.console_var.set(self.load_settings(console=True))
|
|
|
|
# keep last cuz of trace_add()
|
|
self.save_button.configure(state='disabled')
|
|
|
|
class BOIIIWD(ctk.CTk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
# self.app_instance = BOIIIWD()
|
|
|
|
# configure window
|
|
self.title("BOIII Workshop Downloader - Main")
|
|
self.geometry(f"{910}x{560}")
|
|
self.wm_iconbitmap('ryuk.ico')
|
|
|
|
# configure grid layout (4x4)
|
|
self.grid_columnconfigure(1, weight=1)
|
|
self.grid_columnconfigure((2, 3), weight=0)
|
|
self.grid_rowconfigure((0, 1, 2), weight=1)
|
|
self.settings_tab = SettingsTab(self)
|
|
|
|
# create sidebar frame with widgets
|
|
self.sidebar_icon = ctk.CTkImage(light_image=Image.open("ryuk.png"), dark_image=Image.open("ryuk.png"), size=(40, 40))
|
|
self.sidebar_frame = ctk.CTkFrame(self, width=140, corner_radius=10)
|
|
self.sidebar_frame.grid(row=0, column=0, rowspan=4, padx=(10, 10), pady=(10, 10), sticky="nsew")
|
|
self.sidebar_frame.grid_rowconfigure(4, weight=1)
|
|
self.logo_label = ctk.CTkLabel(self.sidebar_frame, text='',image=self.sidebar_icon)
|
|
self.logo_label.grid(row=0, column=0, padx=20, pady=(20, 10))
|
|
self.txt_label = ctk.CTkLabel(self.sidebar_frame, text="- Sidebar -")
|
|
self.txt_label.grid(row=1, column=0, padx=20, pady=(20, 10))
|
|
self.sidebar_button_1 = ctk.CTkButton(self.sidebar_frame)
|
|
self.sidebar_button_1.grid(row=2, column=0, padx=20, pady=10)
|
|
self.sidebar_button_2 = ctk.CTkButton(self.sidebar_frame)
|
|
self.sidebar_button_2.grid(row=3, column=0, padx=20, pady=10)
|
|
self.sidebar_button_3 = ctk.CTkButton(self.sidebar_frame)
|
|
self.sidebar_button_3.grid(row=5, column=0, padx=20, pady=10)
|
|
self.appearance_mode_label = ctk.CTkLabel(self.sidebar_frame, text="Appearance Mode:", anchor="w")
|
|
self.appearance_mode_label.grid(row=6, column=0, padx=20, pady=(10, 0))
|
|
self.appearance_mode_optionemenu = ctk.CTkOptionMenu(self.sidebar_frame, values=["Light", "Dark", "System"],
|
|
command=self.change_appearance_mode_event)
|
|
self.appearance_mode_optionemenu.grid(row=7, column=0, padx=20, pady=(10, 10))
|
|
self.scaling_label = ctk.CTkLabel(self.sidebar_frame, text="UI Scaling:", anchor="w")
|
|
self.scaling_label.grid(row=8, column=0, padx=20, pady=(10, 0))
|
|
self.scaling_optionemenu = ctk.CTkOptionMenu(self.sidebar_frame, values=["80%", "90%", "100%", "110%", "120%"],
|
|
command=self.change_scaling_event)
|
|
self.scaling_optionemenu.grid(row=9, column=0, padx=20, pady=(10, 20))
|
|
|
|
# create optionsframe
|
|
self.optionsframe = ctk.CTkFrame(self)
|
|
self.optionsframe.grid(row=0, column=1, padx=(20, 20), pady=(20, 0), sticky="nsew")
|
|
|
|
# create slider and progressbar frame
|
|
self.slider_progressbar_frame = ctk.CTkFrame(self)
|
|
self.slider_progressbar_frame.grid(row=1, column=1, padx=(20, 20), pady=(20, 0), sticky="nsew")
|
|
|
|
self.slider_progressbar_frame.columnconfigure(0, weight=0)
|
|
self.slider_progressbar_frame.columnconfigure(1, weight=1)
|
|
self.slider_progressbar_frame.columnconfigure(2, weight=0)
|
|
self.slider_progressbar_frame.rowconfigure(0, weight=1)
|
|
self.slider_progressbar_frame.rowconfigure(1, weight=1)
|
|
self.slider_progressbar_frame.rowconfigure(2, weight=1)
|
|
self.slider_progressbar_frame.rowconfigure(3, weight=1)
|
|
|
|
self.spacer = ctk.CTkLabel(master=self.slider_progressbar_frame, text="")
|
|
self.spacer.grid(row=0, column=0, columnspan=1)
|
|
|
|
self.label_speed = ctk.CTkLabel(master=self.slider_progressbar_frame, text="Network Speed: 0 KB/s")
|
|
self.label_speed.grid(row=1, column=0, padx=20, pady=(0, 10), sticky="w")
|
|
|
|
self.label_file_size = ctk.CTkLabel(master=self.slider_progressbar_frame, text="File size: 0KB")
|
|
self.label_file_size.grid(row=1, column=2, padx=(0, 20), pady=(0, 10), sticky="e")
|
|
|
|
self.progress_bar = ctk.CTkProgressBar(master=self.slider_progressbar_frame, mode="determinate", height=20, corner_radius=7)
|
|
self.progress_bar.grid(row=2, column=0, padx=20, pady=(0, 10), columnspan=3, sticky="ew")
|
|
|
|
self.progress_text = ctk.CTkLabel(self.progress_bar, text="0%", font=("Helvetica", 12), fg_color="transparent", height=0, width=0, corner_radius=0)
|
|
self.progress_text.place(relx=0.5, rely=0.5, anchor="center")
|
|
|
|
self.button_download = ctk.CTkButton(master=self.slider_progressbar_frame, text="Download", command=self.download_map)
|
|
self.button_download.grid(row=4, column=0, padx=20, pady=(10, 20), columnspan=2, sticky="ew")
|
|
|
|
self.button_stop = ctk.CTkButton(master=self.slider_progressbar_frame, text="Stop", command=self.stop_download)
|
|
self.button_stop.grid(row=4, column=2, padx=(0, 20), pady=(10, 20), columnspan=1, sticky="ew")
|
|
|
|
# options frame
|
|
self.optionsframe.columnconfigure(1, weight=1)
|
|
self.optionsframe.columnconfigure(2, weight=1)
|
|
self.optionsframe.columnconfigure(3, weight=1)
|
|
|
|
self.label_workshop_id = ctk.CTkLabel(master=self.optionsframe, text="Enter the Workshop ID or Link of the map/mod you want to download:")
|
|
self.label_workshop_id.grid(row=0, column=1, padx=20, pady=(20, 0), columnspan=3, sticky="w")
|
|
|
|
self.check_if_changed = ctk.StringVar()
|
|
self.check_if_changed.trace_add("write", self.id_chnaged_handler)
|
|
self.edit_workshop_id = ctk.CTkEntry(master=self.optionsframe, textvariable=self.check_if_changed)
|
|
self.edit_workshop_id.grid(row=1, column=1, padx=20, pady=(0, 10), columnspan=4, sticky="ew")
|
|
|
|
self.button_browse = ctk.CTkButton(master=self.optionsframe, text="Browse", command=self.open_browser)
|
|
self.button_browse.grid(row=1, column=5, padx=(0, 20), pady=(0, 10), sticky="ew")
|
|
|
|
self.info_button = ctk.CTkButton(master=self.optionsframe, text="Info", command=self.show_map_info)
|
|
self.info_button.grid(row=2, column=5, padx=(0, 20), pady=(0, 10), sticky="ew")
|
|
|
|
self.label_destination_folder = ctk.CTkLabel(master=self.optionsframe, text="Enter Your BOIII folder:")
|
|
self.label_destination_folder.grid(row=3, column=1, padx=20, pady=(0, 10), columnspan=4, sticky="w")
|
|
|
|
self.edit_destination_folder = ctk.CTkEntry(master=self.optionsframe, placeholder_text="Your BOIII Instalation folder")
|
|
self.edit_destination_folder.grid(row=4, column=1, padx=20, pady=(0, 10), columnspan=4, sticky="ew")
|
|
|
|
self.button_BOIII_browse = ctk.CTkButton(master=self.optionsframe, text="Select", command=self.open_BOIII_browser)
|
|
self.button_BOIII_browse.grid(row=4, column=5, padx=(0, 20), pady=(0, 10), sticky="ew")
|
|
|
|
self.label_steamcmd_path = ctk.CTkLabel(master=self.optionsframe, text="Enter SteamCMD path:")
|
|
self.label_steamcmd_path.grid(row=5, column=1, padx=20, pady=(20, 0), columnspan=3, sticky="w")
|
|
|
|
self.edit_steamcmd_path = ctk.CTkEntry(master=self.optionsframe, placeholder_text="Enter your SteamCMD path")
|
|
self.edit_steamcmd_path.grid(row=6, column=1, padx=20, pady=(0, 10), columnspan=4, sticky="ew")
|
|
|
|
self.button_steamcmd_browse = ctk.CTkButton(master=self.optionsframe, text="Select", command=self.open_steamcmd_path_browser)
|
|
self.button_steamcmd_browse.grid(row=6, column=5, padx=(0, 20), pady=(0, 10), sticky="ew")
|
|
|
|
# set default values
|
|
self.appearance_mode_optionemenu.set("Dark")
|
|
self.scaling_optionemenu.set("100%")
|
|
self.progress_bar.set(0)
|
|
self.hide_settings_widgets()
|
|
self.button_stop.configure(state="disabled")
|
|
|
|
# sidebar windows bouttons
|
|
self.sidebar_button_1.configure(command=self.main_button_event, text="Main", fg_color=("#3d3d3d"))
|
|
self.sidebar_button_2.configure(state="disabled", text="Library")
|
|
self.sidebar_button_3.configure(command=self.settings_button_event, text="Settings")
|
|
|
|
# load ui configs
|
|
self.load_configs()
|
|
|
|
if check_config("checkforupdtes") == "on":
|
|
self.withdraw()
|
|
self.check_for_updates(ignore_up_todate=True)
|
|
self.update()
|
|
self.deiconify()
|
|
|
|
try:
|
|
global console
|
|
if check_config("console") == "on":
|
|
console = True
|
|
else:
|
|
console = False
|
|
except:
|
|
pass
|
|
|
|
if not check_steamcmd():
|
|
self.show_warning_message()
|
|
|
|
|
|
def id_chnaged_handler(self, some=None, other=None ,shit=None):
|
|
self.after(1, self.label_file_size.configure(text=f"File size: 0KB"))
|
|
|
|
def check_for_updates(self, ignore_up_todate=False):
|
|
try:
|
|
latest_version = get_latest_release_version()
|
|
current_version = VERSION
|
|
|
|
if latest_version and latest_version != current_version:
|
|
msg_box = CTkMessagebox(title="Update Available", message=f"An update is available!\n\nCurrent Version: {current_version}\nLatest Version: {latest_version}", option_1="View", option_2="No", option_3="Yes")
|
|
|
|
result = msg_box.get()
|
|
|
|
if result == "View":
|
|
webbrowser.open(f"https://github.com/{GITHUB_REPO}/releases/latest")
|
|
|
|
if result == "Yes":
|
|
self.attributes('-alpha', 0)
|
|
update_window = UpdateWindow(self, LATEST_RELEASE_URL)
|
|
update_window.start_update()
|
|
|
|
if result == "No":
|
|
return
|
|
|
|
elif latest_version == current_version:
|
|
if ignore_up_todate:
|
|
return
|
|
msg_box = CTkMessagebox(title="Up to Date!", message="No Updates Available!", option_1="Ok")
|
|
result = msg_box.get()
|
|
except Exception as e:
|
|
show_message("Error", f"Error while checking for updates: \n{e}", icon="cancel")
|
|
|
|
def change_appearance_mode_event(self, new_appearance_mode: str):
|
|
ctk.set_appearance_mode(new_appearance_mode)
|
|
save_config("appearance", new_appearance_mode)
|
|
|
|
def change_scaling_event(self, new_scaling: str):
|
|
new_scaling_float = int(new_scaling.replace("%", "")) / 100
|
|
ctk.set_widget_scaling(new_scaling_float)
|
|
save_config("scaling", str(new_scaling_float))
|
|
|
|
def sidebar_button_event(self):
|
|
print("sidebar_button click")
|
|
|
|
def hide_main_widgets(self):
|
|
self.optionsframe.grid_remove()
|
|
self.slider_progressbar_frame.grid_remove()
|
|
|
|
def show_main_widgets(self):
|
|
self.title("BOIII Workshop Downloader - Main")
|
|
self.optionsframe.grid(row=0, column=1, padx=(20, 20), pady=(20, 0), sticky="nsew")
|
|
self.slider_progressbar_frame.grid(row=1, column=1, padx=(20, 20), pady=(20, 0), sticky="nsew")
|
|
|
|
def hide_settings_widgets(self):
|
|
self.settings_tab.pack_forget()
|
|
|
|
def show_settings_widgets(self):
|
|
self.title("BOIII Workshop Downloader - Settings")
|
|
self.settings_tab.grid(row=0, column=1, padx=(20, 20), pady=(20, 0), sticky="nsew")
|
|
self.settings_tab.load_on_switch_screen()
|
|
|
|
def main_button_event(self):
|
|
self.sidebar_button_1.configure(state="active", fg_color=("#3d3d3d"))
|
|
self.sidebar_button_3.configure(state="normal", fg_color=("#1f538d"))
|
|
self.hide_settings_widgets()
|
|
self.show_main_widgets()
|
|
|
|
def settings_button_event(self):
|
|
self.sidebar_button_1.configure(state="normal", fg_color=("#1f538d"))
|
|
self.sidebar_button_3.configure(state="active", fg_color=("#3d3d3d"))
|
|
self.hide_main_widgets()
|
|
self.show_settings_widgets()
|
|
|
|
def load_configs(self):
|
|
if os.path.exists(CONFIG_FILE_PATH):
|
|
destination_folder = check_config("DestinationFolder", "")
|
|
steamcmd_path = check_config("SteamCMDPath", os.getcwd())
|
|
new_appearance_mode = check_config("appearance", "Dark")
|
|
new_scaling = check_config("scaling", 1.0)
|
|
self.edit_destination_folder.delete(0, "end")
|
|
self.edit_destination_folder.insert(0, destination_folder)
|
|
self.edit_steamcmd_path.delete(0, "end")
|
|
self.edit_steamcmd_path.insert(0, steamcmd_path)
|
|
ctk.set_appearance_mode(new_appearance_mode)
|
|
ctk.set_widget_scaling(float(new_scaling))
|
|
self.appearance_mode_optionemenu.set(new_appearance_mode)
|
|
scaling_float = float(new_scaling)*100
|
|
scaling_int = math.trunc(scaling_float)
|
|
self.scaling_optionemenu.set(f"{scaling_int}%")
|
|
else:
|
|
new_appearance_mode = check_config("appearance", "Dark")
|
|
new_scaling = check_config("scaling", 1.0)
|
|
ctk.set_appearance_mode(new_appearance_mode)
|
|
ctk.set_widget_scaling(float(new_scaling))
|
|
self.appearance_mode_optionemenu.set(new_appearance_mode)
|
|
scaling_float = float(new_scaling)*100
|
|
scaling_int = math.trunc(scaling_float)
|
|
self.scaling_optionemenu.set(f"{scaling_int}%")
|
|
create_default_config()
|
|
|
|
def open_BOIII_browser(self):
|
|
selected_folder = ctk.filedialog.askdirectory(title="Select BOIII Folder")
|
|
if selected_folder:
|
|
self.edit_destination_folder.delete(0, "end")
|
|
self.edit_destination_folder.insert(0, selected_folder)
|
|
save_config("DestinationFolder" ,self.edit_destination_folder.get())
|
|
save_config("SteamCMDPath" ,self.edit_steamcmd_path.get())
|
|
|
|
def open_steamcmd_path_browser(self):
|
|
selected_folder = ctk.filedialog.askdirectory(title="Select SteamCMD Folder")
|
|
if selected_folder:
|
|
self.edit_steamcmd_path.delete(0, "end")
|
|
self.edit_steamcmd_path.insert(0, selected_folder)
|
|
save_config("DestinationFolder" ,self.edit_destination_folder.get())
|
|
save_config("SteamCMDPath" ,self.edit_steamcmd_path.get())
|
|
|
|
def show_warning_message(self):
|
|
msg = CTkMessagebox(title="Warning", message="steamcmd.exe was not found in the specified directory.\nPress Download to get it or Press Cancel and select it from there!.",
|
|
icon="warning", option_1="Cancel", option_2="Download")
|
|
|
|
response = msg.get()
|
|
if response == "Cancel":
|
|
return
|
|
elif response == "Download":
|
|
self.download_steamcmd()
|
|
|
|
def open_browser(self):
|
|
link = "https://steamcommunity.com/app/311210/workshop/"
|
|
webbrowser.open(link)
|
|
|
|
def download_steamcmd(self):
|
|
self.edit_steamcmd_path.delete(0, "end")
|
|
self.edit_steamcmd_path.insert(0, cwd())
|
|
save_config("DestinationFolder" ,self.edit_destination_folder.get())
|
|
save_config("SteamCMDPath" ,self.edit_steamcmd_path.get())
|
|
steamcmd_url = "https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip"
|
|
steamcmd_zip_path = os.path.join(cwd(), "steamcmd.zip")
|
|
|
|
try:
|
|
response = requests.get(steamcmd_url)
|
|
response.raise_for_status()
|
|
|
|
with open(steamcmd_zip_path, "wb") as zip_file:
|
|
zip_file.write(response.content)
|
|
|
|
with zipfile.ZipFile(steamcmd_zip_path, "r") as zip_ref:
|
|
zip_ref.extractall(cwd())
|
|
|
|
if check_steamcmd():
|
|
os.remove(fr"{steamcmd_zip_path}")
|
|
if not show_message("Success", "SteamCMD has been downloaded ,Press ok to initialize it.", icon="info", exit_on_close=True):
|
|
pass
|
|
else:
|
|
initialize_steam_thread = threading.Thread(target=lambda: initialize_steam(self))
|
|
initialize_steam_thread.start()
|
|
else:
|
|
show_message("Error", "Failed to find steamcmd.exe after extraction.\nMake you sure to select the correct SteamCMD path (by default current BOIIIWD path)", icon="cancel")
|
|
os.remove(fr"{steamcmd_zip_path}")
|
|
except requests.exceptions.RequestException as e:
|
|
show_message("Error", f"Failed to download SteamCMD: {e}", icon="cancel")
|
|
os.remove(fr"{steamcmd_zip_path}")
|
|
except zipfile.BadZipFile:
|
|
show_message("Error", "Failed to extract SteamCMD. The downloaded file might be corrupted.", icon="cancel")
|
|
os.remove(fr"{steamcmd_zip_path}")
|
|
|
|
def show_map_info(self):
|
|
def show_map_thread():
|
|
workshop_id = self.edit_workshop_id.get().strip()
|
|
|
|
if not workshop_id:
|
|
show_message("Warning", "Please enter a Workshop ID first.")
|
|
return
|
|
|
|
if not workshop_id.isdigit():
|
|
try:
|
|
if extract_workshop_id(workshop_id).strip().isdigit():
|
|
workshop_id = extract_workshop_id(workshop_id).strip()
|
|
else:
|
|
show_message("Warning", "Please enter a valid Workshop ID.")
|
|
except:
|
|
show_message("Warning", "Please enter a valid Workshop ID.")
|
|
return
|
|
|
|
self.after(1, lambda mid=workshop_id: self.label_file_size.configure(text=f"File size: {get_workshop_file_size(mid ,raw=True)}"))
|
|
|
|
try:
|
|
url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={workshop_id}"
|
|
response = requests.get(url)
|
|
response.raise_for_status()
|
|
content = response.text
|
|
|
|
soup = BeautifulSoup(content, "html.parser")
|
|
|
|
try:
|
|
map_mod_type = soup.find("div", class_="rightDetailsBlock").text.strip()
|
|
map_name = soup.find("div", class_="workshopItemTitle").text.strip()
|
|
map_size = soup.find("div", class_="detailsStatRight").text.strip()
|
|
except:
|
|
show_message("Warning", "Please enter a valid Workshop ID.")
|
|
return
|
|
|
|
try:
|
|
preview_image_element = soup.find("img", id="previewImage")
|
|
workshop_item_image_url = preview_image_element["src"]
|
|
except:
|
|
try:
|
|
preview_image_element = soup.find("img", id="previewImageMain")
|
|
workshop_item_image_url = preview_image_element["src"]
|
|
except Exception as e:
|
|
show_message("Warning", f"Failed to get preview image ,probably wrong link/id if not please open an issue on github.\n{e}")
|
|
return
|
|
|
|
image_response = requests.get(workshop_item_image_url)
|
|
image_response.raise_for_status()
|
|
|
|
image = Image.open(io.BytesIO(image_response.content))
|
|
image = image.resize((200, 200), Image.Resampling.LANCZOS)
|
|
|
|
self.toplevel_info_window(map_name, map_mod_type, map_size, image)
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
show_message("Error", f"Failed to fetch map information.\nError: {e}", icon="cancel")
|
|
|
|
info_thread = threading.Thread(target=show_map_thread)
|
|
info_thread.start()
|
|
|
|
def toplevel_info_window(self, map_name, map_mod_type, map_size, image):
|
|
top = ctk.CTkToplevel(self)
|
|
top.after(210, lambda: top.iconbitmap("ryuk.ico"))
|
|
top.geometry("340x430")
|
|
top.title("Map/Mod Information")
|
|
top.attributes('-topmost', 'true')
|
|
|
|
label = ctk.CTkLabel(top, text="")
|
|
image = ctk.CTkImage(image, size=(260, 200))
|
|
label.configure(image=image)
|
|
label.pack()
|
|
|
|
info = (
|
|
f"Name: {map_name}\n"
|
|
f"Type: {map_mod_type}\n"
|
|
f"Size: {map_size}\n"
|
|
)
|
|
|
|
text = ctk.CTkLabel(top)
|
|
text.configure(text=info)
|
|
text.pack()
|
|
|
|
def download_map(self):
|
|
global stopped
|
|
stopped = False
|
|
|
|
save_config("DestinationFolder" ,self.edit_destination_folder.get())
|
|
save_config("SteamCMDPath" ,self.edit_steamcmd_path.get())
|
|
|
|
if not check_steamcmd():
|
|
self.show_warning_message()
|
|
return
|
|
|
|
steamcmd_path = get_steamcmd_path()
|
|
steamcmd_exe_path = os.path.join(steamcmd_path, "steamcmd.exe")
|
|
steamcmd_size = os.path.getsize(steamcmd_exe_path)
|
|
if steamcmd_size < 3 * 1024 * 1024:
|
|
if not show_message("Warning", "SteamCMD is not initialized, Press OK to do so!\nProgram may go unresponsive until SteamCMD is finished downloading.",
|
|
icon="warning" ,exit_on_close=True):
|
|
pass
|
|
else:
|
|
initialize_steam_thread = threading.Thread(target=lambda: initialize_steam(self))
|
|
initialize_steam_thread.start()
|
|
return
|
|
|
|
workshop_id = self.edit_workshop_id.get().strip()
|
|
destination_folder = self.edit_destination_folder.get()
|
|
|
|
if not workshop_id.isdigit():
|
|
try:
|
|
if extract_workshop_id(workshop_id).strip().isdigit():
|
|
workshop_id = extract_workshop_id(workshop_id).strip()
|
|
else:
|
|
show_message("Warning", "Please enter a valid Workshop ID.", icon="warning")
|
|
return
|
|
except:
|
|
show_message("Warning", "Please enter a valid Workshop ID.", icon="warning")
|
|
return
|
|
|
|
file_size = get_workshop_file_size(workshop_id)
|
|
|
|
if not valid_id(workshop_id):
|
|
show_message("Warning", "Please enter a valid Workshop ID.", icon="warning")
|
|
return
|
|
|
|
if file_size is None:
|
|
show_message("Error", "Failed to retrieve file size.", icon="cancel")
|
|
return
|
|
|
|
if not Path(destination_folder).exists():
|
|
show_message("Error", "Please select a valid destination folder.")
|
|
return
|
|
|
|
if not Path(steamcmd_path).exists():
|
|
show_message("Error", "Please enter a valid SteamCMD path.")
|
|
return
|
|
|
|
self.after(1, lambda mid=workshop_id: self.label_file_size.configure(text=f"File size: {get_workshop_file_size(mid ,raw=True)}"))
|
|
download_folder = os.path.join(get_steamcmd_path(), "steamapps", "workshop", "downloads", "311210", workshop_id)
|
|
if not os.path.exists(download_folder):
|
|
os.makedirs(download_folder)
|
|
|
|
def check_and_update_progress():
|
|
global stopped
|
|
previous_net_speed = 0
|
|
|
|
while not stopped:
|
|
current_size = sum(os.path.getsize(os.path.join(download_folder, f)) for f in os.listdir(download_folder))
|
|
|
|
progress = int(current_size / file_size * 100)
|
|
self.after(1, lambda v=progress / 100.0: self.progress_bar.set(v))
|
|
|
|
current_net_speed = psutil.net_io_counters().bytes_recv
|
|
|
|
net_speed_bytes = current_net_speed - previous_net_speed
|
|
previous_net_speed = current_net_speed
|
|
|
|
net_speed, speed_unit = convert_speed(net_speed_bytes)
|
|
|
|
self.after(1, lambda v=net_speed: self.label_speed.configure(text=f"Network Speed: {v:.2f} {speed_unit}"))
|
|
self.after(1, lambda p=progress: self.progress_text.configure(text=f"{p}%"))
|
|
time.sleep(1)
|
|
|
|
command = f"+login anonymous +workshop_download_item 311210 {workshop_id} +quit"
|
|
steamcmd_thread = threading.Thread(target=lambda: run_steamcmd_command(command, self))
|
|
steamcmd_thread.start()
|
|
|
|
def wait_for_threads():
|
|
update_ui_thread = threading.Thread(target=check_and_update_progress)
|
|
update_ui_thread.daemon = True
|
|
update_ui_thread.start()
|
|
update_ui_thread.join()
|
|
|
|
global stopped
|
|
stopped = True
|
|
|
|
self.label_speed.configure(text="Network Speed: 0 KB/s")
|
|
self.progress_text.configure(text="0%")
|
|
self.progress_bar.set(0.0)
|
|
|
|
map_folder = os.path.join(get_steamcmd_path(), "steamapps", "workshop", "content", "311210", workshop_id)
|
|
|
|
json_file_path = os.path.join(map_folder, "workshop.json")
|
|
|
|
if os.path.exists(json_file_path):
|
|
mod_type, folder_name = extract_json_data(json_file_path)
|
|
|
|
if mod_type == "mod":
|
|
mods_folder = os.path.join(destination_folder, "mods")
|
|
folder_name_path = os.path.join(mods_folder, folder_name, "zone")
|
|
elif mod_type == "map":
|
|
usermaps_folder = os.path.join(destination_folder, "usermaps")
|
|
folder_name_path = os.path.join(usermaps_folder, folder_name, "zone")
|
|
else:
|
|
show_message("Error", "Invalid map type in workshop.json.", icon="cancel")
|
|
return
|
|
|
|
os.makedirs(folder_name_path, exist_ok=True)
|
|
|
|
try:
|
|
shutil.copytree(map_folder, folder_name_path, dirs_exist_ok=True)
|
|
except Exception as E:
|
|
show_message("Error", f"Error copying files: {E}", icon="cancel")
|
|
|
|
show_message("Download Complete", f"{mod_type} files are downloaded at \n{folder_name_path}\nYou can run the game now!", icon="info")
|
|
|
|
update_wait_thread = threading.Thread(target=wait_for_threads)
|
|
update_wait_thread.start()
|
|
|
|
self.button_download.configure(state="disabled")
|
|
self.button_stop.configure(state="normal")
|
|
|
|
def stop_download(self):
|
|
global stopped
|
|
stopped = True
|
|
|
|
subprocess.run(['taskkill', '/F', '/IM', 'steamcmd.exe'], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
creationflags=subprocess.CREATE_NO_WINDOW)
|
|
|
|
self.button_download.configure(state="normal")
|
|
self.button_stop.configure(state="disabled")
|
|
self.label_speed.configure(text="Network Speed: 0 KB/s")
|
|
self.progress_text.configure(text="0%")
|
|
self.progress_bar.set(0.0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app = BOIIIWD()
|
|
app.mainloop()
|