diff --git a/boiiiwd.py b/boiiiwd.py index 84da2e9..75b3df2 100644 --- a/boiiiwd.py +++ b/boiiiwd.py @@ -1,5 +1,6 @@ import os import sys +import re import subprocess import configparser import json @@ -10,17 +11,61 @@ import requests import time import threading from bs4 import BeautifulSoup -from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QLineEdit, QPushButton, QVBoxLayout, QMessageBox, QHBoxLayout, QProgressBar, QSizePolicy, QFileDialog +from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QLineEdit, QPushButton, QDialog, \ + QVBoxLayout, QMessageBox, QHBoxLayout, QProgressBar, QSizePolicy, QFileDialog, QCheckBox, QSpacerItem from PyQt5.QtCore import Qt, QThread, pyqtSignal -from PyQt5.QtCore import QCoreApplication -from PyQt5.QtGui import QIcon, QPixmap +from PyQt5.QtCore import QCoreApplication, QSettings +from PyQt5.QtGui import QIcon, QPixmap, QCloseEvent import webbrowser import qdarktheme +VERSION = "v0.1.3" +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 +global stopped, steampid, console, up_cancelled steampid = None stopped = False +console = False +up_cancelled = False + +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): @@ -28,6 +73,18 @@ def cwd(): 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") @@ -37,6 +94,17 @@ def check_steamcmd(): return True +def initialize_steam(): + 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) + process.wait() + + show_message("Done!", "BOIIIWD is ready for action.", icon=QMessageBox.Information) + except: + show_message("Done!", "An error occurred please check your paths and try again.") + def valid_id(workshop_id): url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={workshop_id}" response = requests.get(url) @@ -68,22 +136,27 @@ def create_default_config(): config = configparser.ConfigParser() config["Settings"] = { "SteamCMDPath": cwd(), - "DestinationFolder": "" + "DestinationFolder": "", + "checkforupdtes": "on", + "console": "off" } with open(CONFIG_FILE_PATH, "w") as config_file: config.write(config_file) def run_steamcmd_command(command): 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=subprocess.PIPE, - stderr=subprocess.STDOUT, + stdout=None if console else subprocess.PIPE, + stderr=None if console else subprocess.PIPE, text=True, bufsize=1, universal_newlines=True, - creationflags=subprocess.CREATE_NO_WINDOW + creationflags=show_console ) global steampid @@ -104,6 +177,30 @@ def get_steamcmd_path(): config.read(CONFIG_FILE_PATH) return config.get("Settings", "SteamCMDPath", fallback=cwd()) +def config_check_for_updates(state=None): + if state: + config = configparser.ConfigParser() + config.read(CONFIG_FILE_PATH) + config["Settings"]["checkforupdtes"] = state + with open(CONFIG_FILE_PATH, "w") as config_file: + config.write(config_file) + return + config = configparser.ConfigParser() + config.read(CONFIG_FILE_PATH) + return config.get("Settings", "checkforupdtes", fallback="on") + +def config_console_state(state=None): + if state: + config = configparser.ConfigParser() + config.read(CONFIG_FILE_PATH) + config["Settings"]["console"] = state + with open(CONFIG_FILE_PATH, "w") as config_file: + config.write(config_file) + return + config = configparser.ConfigParser() + config.read(CONFIG_FILE_PATH) + return config.get("Settings", "console", fallback="off") + def extract_json_data(json_path): with open(json_path, "r") as json_file: data = json.load(json_file) @@ -207,16 +304,148 @@ def download_workshop_map(workshop_id, destination_folder, progress_bar, speed_l try: shutil.copytree(map_folder, folder_name_path, dirs_exist_ok=True) except Exception as E: - print(f"Error copying files: {E}") + show_message("Error", f"Error copying files: {E}") show_message("Download Complete", f"{mod_type} files are downloaded at \n{folder_name_path}\nYou can run the game now!", icon=QMessageBox.Information) -def show_message(title, message, icon=QMessageBox.Warning): +def show_message(title, message, icon=QMessageBox.Warning, exit_on_close=False): msg = QMessageBox() msg.setWindowTitle(title) + msg.setWindowIcon(QIcon('ryuk.ico')) msg.setText(message) msg.setIcon(icon) - msg.exec_() + + if exit_on_close: + msg.setStandardButtons(QMessageBox.Ok | QMessageBox.No) + msg.setDefaultButton(QMessageBox.Ok) + result = msg.exec_() + + if result == QMessageBox.No: + sys.exit(0) + else: + msg.exec_() + +class UpdatePorgressThread(QThread): + global up_cancelled + progress_update = pyqtSignal(int) + + def __init__(self, label_progress, progress_bar, label_size): + super().__init__() + self.label_progress = label_progress + self.progress_bar = progress_bar + self.label_size = label_size + self.cancelled = False + + def run(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) + + zip_path = os.path.join(update_dir, "latest_version.zip") + total_size = int(response.headers.get('content-length', 0)) + size = convert_bytes_to_readable(total_size) + self.label_size.setText(f"Size: {size}") + + with open(zip_path, "wb") as zip_file: + chunk_size = 8192 + current_size = 0 + + for chunk in response.iter_content(chunk_size=chunk_size): + if up_cancelled: + break + + if chunk: + zip_file.write(chunk) + current_size += len(chunk) + progress = int(current_size / total_size * 100) + self.progress_update.emit(progress) + QCoreApplication.processEvents() + + if not up_cancelled: + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(update_dir) + + self.label_progress.setText("Update installed successfully!") + time.sleep(1) + 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_progress.setText("Update cancelled.") + + except Exception as e: + self.label_progress.setText("Error installing the update.") + show_message("Warning", f"Error installing the update: {e}") + +class UpdateProgressWindow(QDialog): + def __init__(self): + super().__init__() + self.setWindowTitle("Updating...") + self.setWindowIcon(QIcon('ryuk.ico')) + + layout = QVBoxLayout() + + info_layout = QHBoxLayout() + self.label_progress = QLabel("Downloading latest update from Github...") + info_layout.addWidget(self.label_progress, 3) + + self.label_size = QLabel("File size: 0KB") + info_layout.addWidget(self.label_size, 1) + + layout.addLayout(info_layout) + + self.progress_bar = QProgressBar() + layout.addWidget(self.progress_bar) + + spacer = QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + layout.addSpacerItem(spacer) + + button_layout = QHBoxLayout() + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.cancel_update) + button_layout.addItem(QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)) + button_layout.addWidget(self.cancel_button) + layout.addLayout(button_layout) + + self.setLayout(layout) + + global up_cancelled + self.thread = None + up_cancelled = False + + + def update_progress(self, value): + self.progress_bar.setValue(value) + + def start_update(self): + self.thread = UpdatePorgressThread(self.label_progress, self.progress_bar, self.label_size) + self.thread.progress_update.connect(self.update_progress) + self.thread.finished.connect(self.on_update_finished) + self.thread.start() + + def on_update_finished(self): + """code""" + # self.accept() + + def cancel_update(self): + global up_cancelled + up_cancelled = True + self.label_progress.setText("Update cancelled.") + + def closeEvent(self, event: QCloseEvent): + global up_cancelled + if not up_cancelled: + self.cancel_update() + super().closeEvent(event) class DownloadThread(QThread): finished = pyqtSignal() @@ -253,12 +482,15 @@ class WorkshopDownloaderApp(QWidget): download_button = msg_box.addButton("Download", QMessageBox.AcceptRole) download_button.clicked.connect(self.download_steamcmd) + msg_box.setDefaultButton(download_button) result = msg_box.exec_() if result == QMessageBox.Cancel: sys.exit(0) def download_steamcmd(self): + self.edit_steamcmd_path.setText(cwd()) + self.save_config(self.edit_destination_folder.text(), self.edit_steamcmd_path.text()) steamcmd_url = "https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip" steamcmd_zip_path = os.path.join(cwd(), "steamcmd.zip") @@ -273,28 +505,68 @@ class WorkshopDownloaderApp(QWidget): zip_ref.extractall(cwd()) if check_steamcmd(): - show_message("Success", "SteamCMD has been downloaded and extracted.", icon=QMessageBox.Information) - os.remove(steamcmd_zip_path) + os.remove(fr"{steamcmd_zip_path}") + show_message("Success", "SteamCMD has been downloaded ,Press ok to initialize it.", icon=QMessageBox.Information, exit_on_close=True) + initialize_steam() + else: - show_message("Error", "Failed to find steamcmd.exe after extraction.") - os.remove(steamcmd_zip_path) + show_message("Error", "Failed to find steamcmd.exe after extraction.\nMake you sure to select the correct SteamCMD path (which is the current BOIIIWD path)") + os.remove(fr"{steamcmd_zip_path}") except requests.exceptions.RequestException as e: show_message("Error", f"Failed to download SteamCMD: {e}") - os.remove(steamcmd_zip_path) + os.remove(fr"{steamcmd_zip_path}") except zipfile.BadZipFile: show_message("Error", "Failed to extract SteamCMD. The downloaded file might be corrupted.") - os.remove(steamcmd_zip_path) + os.remove(fr"{steamcmd_zip_path}") + + 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 = QMessageBox() + msg_box.setWindowTitle("Update Available") + msg_box.setWindowIcon(QIcon('ryuk.ico')) + msg_box.setText(f"An update is available!, Do you want to install it?\n\nCurrent Version: {current_version}\nLatest Version: {latest_version}") + msg_box.setIcon(QMessageBox.Information) + msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No | QMessageBox.Open) + msg_box.setDefaultButton(QMessageBox.Yes) + result = msg_box.exec_() + + if result == QMessageBox.Open: + webbrowser.open(f"https://github.com/{GITHUB_REPO}/releases/latest") + + if result == QMessageBox.Yes: + update_progress_window = UpdateProgressWindow() + update_progress_window.start_update() + update_progress_window.exec_() + elif latest_version == current_version: + if ignore_up_todate: + return + msg_box = QMessageBox() + msg_box.setWindowTitle("Up to Date!") + msg_box.setWindowIcon(QIcon('ryuk.ico')) + msg_box.setText(f"No Updates Available!") + msg_box.setIcon(QMessageBox.Information) + msg_box.setStandardButtons(QMessageBox.Ok) + msg_box.setDefaultButton(QMessageBox.Ok) + result = msg_box.exec_() + except Exception as e: + show_message("Error", f"Error while checking for updates: \n{e}") def initUI(self): - self.setWindowTitle('BOIII Workshop Downloader v0.1.2-beta') + self.setWindowTitle(f'BOIII Workshop Downloader {VERSION}-beta') self.setWindowIcon(QIcon('ryuk.ico')) self.setGeometry(100, 100, 400, 200) + self.settings = QSettings("MyApp", "MyWindow") + self.restore_geometry() layout = QVBoxLayout() browse_layout = QHBoxLayout() - self.label_workshop_id = QLabel("Enter the Workshop ID of the map/mod you want to download:") + self.label_workshop_id = QLabel("Enter the Workshop ID or Link of the map/mod you want to download:") browse_layout.addWidget(self.label_workshop_id, 3) self.button_browse = QPushButton("Browse") @@ -307,7 +579,7 @@ class WorkshopDownloaderApp(QWidget): info_workshop_layout = QHBoxLayout() self.edit_workshop_id = QLineEdit() - self.edit_workshop_id.setPlaceholderText("Workshop ID => Press info to see map/mod info") + self.edit_workshop_id.setPlaceholderText("Workshop ID/Link => Press info to see map/mod info") self.edit_workshop_id.textChanged.connect(self.reset_file_size) info_workshop_layout.addWidget(self.edit_workshop_id, 3) @@ -352,7 +624,7 @@ class WorkshopDownloaderApp(QWidget): self.button_download = QPushButton("Download") self.button_download.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.button_download.clicked.connect(self.download_map) - buttons_layout.addWidget(self.button_download, 75) + buttons_layout.addWidget(self.button_download, 70) self.button_stop = QPushButton("Stop") self.button_stop.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) @@ -377,32 +649,86 @@ class WorkshopDownloaderApp(QWidget): self.progress_bar = QProgressBar() layout.addWidget(self.progress_bar, 75) + spacer = QSpacerItem(10, 10, QSizePolicy.Expanding, QSizePolicy.Minimum) + layout.addSpacerItem(spacer) + + check_for_update_layout = QHBoxLayout() + check_update_button = QPushButton("Check for Updates") + check_update_button.clicked.connect(self.check_for_updates) + check_update_button.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) + + self.check_for_update_layout = QVBoxLayout() + self.check_for_update_layout.addWidget(check_update_button) + + self.show_more_button = QPushButton("Launch boiii") + self.show_more_button.clicked.connect(self.launch_boiii) + + check_for_update_layout = QHBoxLayout() + check_for_update_layout.addWidget(check_update_button) + + self.check_for_updates_checkbox = QPushButton("Settings") + self.check_for_updates_checkbox.clicked.connect(self.open_settings_dialog) + + + check_for_update_layout = QHBoxLayout() + check_for_update_layout.addWidget(check_update_button) + check_for_update_layout.addWidget(self.check_for_updates_checkbox) + check_for_update_layout.addWidget(self.show_more_button) + + layout.addLayout(check_for_update_layout) + + layout.addLayout(check_for_update_layout) + self.setLayout(layout) self.load_config() + if config_check_for_updates() == "on": + self.check_for_updates(ignore_up_todate=True) + + try: + global console + if config_console_state() == "on": + console = True + return 1 + else: + console = False + return 0 + except: + pass + def download_map(self): global stopped stopped = False + self.save_config(self.edit_destination_folder.text(), self.edit_steamcmd_path.text()) if not check_steamcmd(): self.show_warning_message() return - workshop_id = self.edit_workshop_id.text() - if not workshop_id.isdigit(): - QMessageBox.warning(self, "Warning", "Please enter a valid Workshop ID.") - return - - if not valid_id(workshop_id): - QMessageBox.warning(self, "Warning", "Please enter a valid Workshop ID.") - 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: - show_message("Warning", "Please wait a bit until SteamCMD downloads and initializes. It might take some time, but it will only happen once.", icon=QMessageBox.Warning) + show_message("Warning", "SteamCMD is not initialized, Press OK to do so!\nProgram may go unresponsive until SteamCMD is finished downloading.", icon=QMessageBox.Warning, exit_on_close=True) + initialize_steam() + return + + workshop_id = self.edit_workshop_id.text().strip() + if not workshop_id.isdigit(): + try: + if extract_workshop_id(workshop_id).strip().isdigit(): + workshop_id = extract_workshop_id(workshop_id).strip() + else: + QMessageBox.warning(self, "Warning", "Please enter a valid Workshop ID.") + return + except: + QMessageBox.warning(self, "Warning", "Please enter a valid Workshop ID.") + return + + if not valid_id(workshop_id): + QMessageBox.warning(self, "Warning", "Please enter a valid Workshop ID.") + return destination_folder = self.edit_destination_folder.text() steamcmd_path = self.edit_steamcmd_path.text() @@ -443,11 +769,13 @@ class WorkshopDownloaderApp(QWidget): selected_folder = QFileDialog.getExistingDirectory(self, "Select BOIII Folder", "") if selected_folder: self.edit_destination_folder.setText(selected_folder) + self.save_config(self.edit_destination_folder.text(), self.edit_steamcmd_path.text()) def open_steamcmd_path_browser(self): selected_folder = QFileDialog.getExistingDirectory(self, "Select SteamCMD Folder", "") if selected_folder: self.edit_steamcmd_path.setText(selected_folder) + self.save_config(self.edit_destination_folder.text(), self.edit_steamcmd_path.text()) def on_download_finished(self): self.button_download.setEnabled(True) @@ -484,15 +812,22 @@ class WorkshopDownloaderApp(QWidget): self.label_file_size.setText(f"File size: 0KB") def show_map_info(self): - workshop_id = self.edit_workshop_id.text() + workshop_id = self.edit_workshop_id.text().strip() if not workshop_id: QMessageBox.warning(self, "Warning", "Please enter a Workshop ID first.") return if not workshop_id.isdigit(): - QMessageBox.warning(self, "Warning", "Please enter a valid Workshop ID.") - return + try: + if extract_workshop_id(workshop_id).strip().isdigit(): + workshop_id = extract_workshop_id(workshop_id).strip() + else: + QMessageBox.warning(self, "Warning", "Please enter a valid Workshop ID.") + return + except: + QMessageBox.warning(self, "Warning", "Please enter a valid Workshop ID.") + return self.label_file_size.setText(f"File size: {get_workshop_file_size(workshop_id, raw=True)}") try: @@ -542,6 +877,7 @@ class WorkshopDownloaderApp(QWidget): msg_box = QMessageBox(self) msg_box.setWindowTitle("Map/Mod Information") + msg_box.setWindowIcon(QIcon('ryuk.ico')) msg_box.setIconPixmap(pixmap) msg_box.setText(f"Name: {map_name}\nType: {map_mod_type}\nSize: {map_size}") @@ -556,7 +892,100 @@ class WorkshopDownloaderApp(QWidget): msg_box.exec_() except requests.exceptions.RequestException as e: - QMessageBox.warning(self, "Error", f"Failed to fetch map information.\nError: {e}") + show_message("Error", f"Failed to fetch map information.\nError: {e}") + + def launch_boiii(self): + try: + boiii_path = os.path.join(self.edit_destination_folder.text(), "boiii.exe") + subprocess.Popen([boiii_path], cwd=self.edit_destination_folder.text()) + except Exception as e: + show_message("Error: Failed to launch BOIII", f"Failed to launch boiii.exe\nMake sure to put in your correct boiii path\n{e}") + + def open_settings_dialog(self): + settings_dialog = SettingsDialog() + settings_dialog.exec_() + + def closeEvent(self, event): + self.settings.setValue("geometry", self.saveGeometry()) + super().closeEvent(event) + + def restore_geometry(self): + geometry = self.settings.value("geometry", None) + if geometry is not None: + self.restoreGeometry(geometry) + +class SettingsDialog(QDialog): + def __init__(self): + super().__init__() + self.setWindowTitle("Settings") + self.setWindowIcon(QIcon('ryuk.ico')) + self.setGeometry(50, 50, 250, 120) + self.settings = QSettings("MyApp2", "MyWindow2") + self.restore_geometry() + self.initUI() + + def initUI(self): + layout = QVBoxLayout() + + self.check_updates_checkbox = QCheckBox("Check for updates on launch") + self.check_updates_checkbox.setChecked(self.load_settings(updates=True)) + layout.addWidget(self.check_updates_checkbox) + + buttons_layout = QHBoxLayout() + self.checkbox_show_console = QCheckBox("Console (On Download)", self) + self.checkbox_show_console.setChecked(self.load_settings(console=True)) + tooltip_text = "Toggle SteamCMD console\nPlease don't close the Console If you want to stop press the Stop boutton." + self.checkbox_show_console.setToolTip(tooltip_text) + + buttons_layout.addWidget(self.checkbox_show_console, 5) + + layout.addLayout(buttons_layout) + + save_button = QPushButton("Save") + save_button.setFixedWidth(60) + save_button.clicked.connect(self.save_settings) + layout.addWidget(save_button, alignment=Qt.AlignLeft) + + self.setLayout(layout) + + def save_settings(self): + global console + if self.check_updates_checkbox.isChecked(): + config_check_for_updates(state="on") + else: + config_check_for_updates(state="off") + + if self.checkbox_show_console.isChecked(): + config_console_state(state="on") + console = True + else: + config_console_state(state="off") + console = False + + self.accept() + + def load_settings(self, console=None, updates=None): + if updates: + if config_check_for_updates() == "on": + return 1 + else: + return 0 + if console: + if config_console_state() == "on": + console = True + return 1 + else: + console = False + return 0 + + def closeEvent(self, event): + self.settings.setValue("geometry", self.saveGeometry()) + super().closeEvent(event) + + def restore_geometry(self): + geometry = self.settings.value("geometry", None) + if geometry is not None: + self.restoreGeometry(geometry) if __name__ == "__main__": app = QApplication(sys.argv)