From 541a1b068981c208bb9477e2b12a1bf883e13722 Mon Sep 17 00:00:00 2001 From: Faraphel Date: Sat, 11 Jun 2022 18:45:11 +0200 Subject: [PATCH] selecting a mod now work, extraction now have a progress bar, clicking on the install button will start the installation --- Pack/Test/mod_config.json | 3 + source/gui/__init__.py | 17 +++++ source/gui/install.py | 137 ++++++++++++++++++++++++++++++++++++-- source/mkw/Game.py | 27 +++++--- source/wt/__init__.py | 27 ++++++-- source/wt/szs.py | 4 +- source/wt/wit.py | 38 ++++++++++- 7 files changed, 227 insertions(+), 26 deletions(-) create mode 100644 Pack/Test/mod_config.json diff --git a/Pack/Test/mod_config.json b/Pack/Test/mod_config.json new file mode 100644 index 0000000..2f37dc5 --- /dev/null +++ b/Pack/Test/mod_config.json @@ -0,0 +1,3 @@ +{ + "name": "test" +} \ No newline at end of file diff --git a/source/gui/__init__.py b/source/gui/__init__.py index e69de29..86092cb 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -0,0 +1,17 @@ +import traceback +from tkinter import messagebox +from typing import Callable + +from source.translation import translate as _ + + +def better_gui_error(func: Callable) -> Callable: + """ + Decorator to handle GUI errors. + """ + + def wrapper(*args, **kwargs): + try: return func(*args, **kwargs) + except: messagebox.showerror(_("ERROR"), traceback.format_exc()) + + return wrapper \ No newline at end of file diff --git a/source/gui/install.py b/source/gui/install.py index fa2d93c..f24baff 100644 --- a/source/gui/install.py +++ b/source/gui/install.py @@ -9,7 +9,9 @@ from tkinter import messagebox import webbrowser from typing import Generator +from source.gui import better_gui_error from source.mkw.Game import Game +from source.mkw.ModConfig import ModConfig from source.translation import translate as _ from source import event from source import * @@ -94,7 +96,32 @@ class Window(tkinter.Tk): """ # get the generator data yield by the generator function for step_data in func_gen: - if "desc" in step_data: self.progress_bar.set_description(step_data["desc"]) + if "description" in step_data: self.progress_bar.set_description(step_data["description"]) + if "maximum" in step_data: self.progress_bar.set_maximum(step_data["maximum"]) + if "step" in step_data: self.progress_bar.step(step_data["step"]) + if "value" in step_data: self.progress_bar.set_value(step_data["value"]) + if "determinate" in step_data: self.progress_bar.set_determinate(step_data["determinate"]) + + def get_mod_config(self) -> ModConfig: + """ + Get the mod configuration + :return: Get the mod configuration + """ + return self.select_pack.mod_config + + def get_source_path(self) -> Path: + """ + Get the path of the source game + :return: path of the source game + """ + return self.source_game.get_path() + + def get_destination_path(self) -> Path: + """ + Get the path of the destination game + :return: path of the destination game + """ + return self.destination_game.get_path() # Menu bar @@ -215,7 +242,6 @@ class SourceGame(ttk.LabelFrame): if not path.exists(): raise SourceGameError(path) return path - def set_state(self, state: InstallerState) -> None: """ Set the progress bar state when the installer change state @@ -286,15 +312,31 @@ class ButtonInstall(ttk.Button): super().__init__(master, text="Install", command=self.install) @threaded + @better_gui_error def install(self): try: self.master.set_state(InstallerState.INSTALLING) + + # check if the user entered a source path + source_path = self.master.get_source_path() + if str(source_path) == ".": + messagebox.showerror(_("ERROR"), _("ERROR_INVALID_SOURCE_GAME")) + return + + # check if the user entered a destination path + destination_path = self.master.get_destination_path() + if str(destination_path) == ".": + messagebox.showerror(_("ERROR"), _("ERROR_INVALID_DESTINATION_GAME")) + return + # get space remaining on the C: drive if shutil.disk_usage(".").free < minimum_space_available: - if not messagebox.askokcancel(_("WARNING"), _("WARNING_NOT_ENOUGH_SPACE_CONTINUE")): return + if not messagebox.askokcancel(_("WARNING"), _("WARNING_LOW_SPACE_CONTINUE")): + return - game = Game(self.master.source_game.get_path()) - self.master.progress_function(game.install_mod()) + game = Game(source_path) + mod_config = self.master.get_mod_config() + self.master.progress_function(game.install_mod(destination_path, mod_config)) finally: self.master.set_state(InstallerState.IDLE) @@ -321,7 +363,7 @@ class ProgressBar(ttk.LabelFrame): self.progress_bar = ttk.Progressbar(self, orient="horizontal") self.progress_bar.grid(row=1, column=1, sticky="nsew") - self.description = ttk.Label(self, text="no process running", anchor="center", font=("TkDefaultFont", 10)) + self.description = ttk.Label(self, text="", anchor="center", font=("TkDefaultFont", 10)) self.description.grid(row=2, column=1, sticky="nsew") def set_state(self, state: InstallerState) -> None: @@ -342,14 +384,95 @@ class ProgressBar(ttk.LabelFrame): """ self.description.config(text=desc) + def set_maximum(self, maximum: int) -> None: + """ + Set the progress bar maximum value + :param maximum: the maximum value + :return: + """ + self.progress_bar.configure(maximum=maximum) + + def set_value(self, value: int) -> None: + """ + Set the progress bar value + :param value: the value + :return: + """ + self.progress_bar.configure(value=value) + + def step(self, value: int = 1) -> None: + """ + Set the progress bar by the value + :param value: the step + :return: + """ + self.progress_bar.step(value) + + def set_determinate(self, value: bool) -> None: + """ + Set the progress bar determinate value + :param value: the value + :return: + """ + self.progress_bar.configure(mode="determinate" if value else "indeterminate") + # Combobox to select the pack class SelectPack(ttk.Combobox): def __init__(self, master: tkinter.Tk): super().__init__(master) + self.mod_config: ModConfig | None = None + self.packs: list[Path] = [] + + self.refresh_packs() + self.select(index=0) + + self.bind("<>", lambda _: self.select()) + + def refresh_packs(self) -> None: + """ + Refresh the list of packs + :return: + """ + self.packs = [] + for pack in Path("./Pack/").iterdir(): - self.insert(tkinter.END, pack.name) + if self.is_valid_pack(pack): + self.packs.append(pack) + + self["values"] = [pack.name for pack in self.packs] + + def select(self, index: int = None) -> None: + """ + When the selection is changed + :index: the index of the selection. If none, use the selected index + :return: + """ + index = index if index is not None else self.current() + pack = self.packs[index] + self.set_path(pack) + self.set(pack.name) + + @better_gui_error + def set_path(self, pack: Path) -> None: + """ + Set the pack to install + :param pack: the pack + :return: + """ + self.mod_config = ModConfig.from_file(pack / "mod_config.json") + + @classmethod + def is_valid_pack(cls, path: Path) -> bool: + """ + Check if the path is a valid pack + :param path: the path + :return: True if the path is a valid pack + """ + return all([ + (path / "mod_config.json").exists(), + ]) def set_state(self, state: InstallerState) -> None: """ diff --git a/source/mkw/Game.py b/source/mkw/Game.py index 7ed464f..cf98298 100644 --- a/source/mkw/Game.py +++ b/source/mkw/Game.py @@ -2,6 +2,7 @@ import time from pathlib import Path from typing import Generator +from source.mkw.ModConfig import ModConfig from source.wt.wit import WITPath, Region @@ -23,19 +24,29 @@ class Game: """ return not any(self.wit_path[f"./files/rel/lecode-{region.value}.bin"].exists() for region in Region) - def extract(self, dest: Path | str) -> Path: + def extract(self, dest: Path | str) -> Generator[str, None, Path]: """ Extract the game to the destination directory. If the game is a FST, just copy to the destination :param dest: destination directory """ - return self.wit_path.extract_all(dest) + gen = self.wit_path.progress_extract_all(dest) + for gen_data in gen: + yield { + "description": f'EXTRACTING - {gen_data["percentage"]}% - (estimated time remaining: ' + f'{gen_data["estimation"] if gen_data["estimation"] is not None else "-:--"})', - def install_mod(self) -> Generator[str, None, None]: + "maximum": 100, + "value": gen_data["percentage"], + "determinate": True + } + try: next(gen) + except StopIteration as e: + return e.value + + def install_mod(self, dest: Path, mod_config: ModConfig) -> Generator[str, None, None]: """ Patch the game with the mod + :dest: destination directory + :mod_config: mod configuration """ - i = 0 - while True: - time.sleep(1) - yield {"desc": f"step {i}"} - i += 1 \ No newline at end of file + yield from self.extract(dest / f"{mod_config.nickname} {mod_config.version}") diff --git a/source/wt/__init__.py b/source/wt/__init__.py index d79603b..fa4249e 100644 --- a/source/wt/__init__.py +++ b/source/wt/__init__.py @@ -4,10 +4,10 @@ import os class WTError(Exception): - def __init__(self, tool_path: Path | str, return_code: int): + def __init__(self, tools_path: Path | str, return_code: int): try: error = subprocess.run( - [tool_path, "ERROR", str(return_code)], + [tools_path, "ERROR", str(return_code)], stdout=subprocess.PIPE, check=True, creationflags=subprocess.CREATE_NO_WINDOW, @@ -15,7 +15,7 @@ class WTError(Exception): except subprocess.CalledProcessError as e: error = "- Can't get the error message -" - super().__init__(f"{tool_path} raised {return_code} :\n{error}\n") + super().__init__(f"{tools_path} raised {return_code} :\n{error}\n") class MissingWTError(Exception): @@ -34,10 +34,10 @@ try: tools_wit_dir = next(tools_dir.glob("./wit*/")) / system except StopIteration as e: raise MissingWTError("wit") from e -def better_error(tool_path: Path | str): +def better_wt_error(tools_path: Path | str): """ Raise a better error when the subprocess return with a non 0 value. - :param tool_path: path of the used tools + :param tools_path: path of the used tools :return: wrapper """ @@ -46,7 +46,7 @@ def better_error(tool_path: Path | str): try: return func(*args, **kwargs) except subprocess.CalledProcessError as e: - raise WTError(tool_path, e.returncode) from e + raise WTError(tools_path, e.returncode) from e return wrapper @@ -90,3 +90,18 @@ def _run_dict(tools_path: Path | str, *args) -> dict: d[key.strip()] = value return d + + +def _run_popen(tools_path: Path | str, *args) -> subprocess.Popen: + """ + Run a command and return the process + :param args: command arguments + :return: the output of the command + """ + return subprocess.Popen( + [tools_path, *args], + stdout=subprocess.PIPE, + creationflags=subprocess.CREATE_NO_WINDOW, + bufsize=1, + universal_newlines=True, + ) diff --git a/source/wt/szs.py b/source/wt/szs.py index 4ebf66b..3745bfd 100644 --- a/source/wt/szs.py +++ b/source/wt/szs.py @@ -17,7 +17,7 @@ class SZSPath: def __eq__(self, other: "SZSPath") -> bool: return self.path == other.path - @better_error(tools_path) + @better_wt_error(tools_path) def _run(self, *args) -> bytes: """ Return a command with wszst and return the output @@ -26,7 +26,7 @@ class SZSPath: """ return _run(tools_path, *args) - @better_error(tools_path) + @better_wt_error(tools_path) def _run_dict(self, *args) -> dict: """ Return a dictionary of a command that return value associated to a key with a equal sign diff --git a/source/wt/wit.py b/source/wt/wit.py index d3f4c80..764b932 100644 --- a/source/wt/wit.py +++ b/source/wt/wit.py @@ -1,8 +1,10 @@ import enum +import re import shutil +from typing import Generator from source.wt import * -from source.wt import _run, _run_dict +from source.wt import _run, _run_dict, _run_popen tools_path = tools_wit_dir / ("wit.exe" if system == "win64" else "wit") @@ -50,7 +52,7 @@ class WITPath: def __eq__(self, other: "WITPath") -> bool: return self.path == other.path - @better_error(tools_path) + @better_wt_error(tools_path) def _run(self, *args) -> bytes: """ Return a command with wit and return the output @@ -59,7 +61,16 @@ class WITPath: """ return _run(tools_path, *args) - @better_error(tools_path) + @classmethod + def _run_popen(cls, *args) -> subprocess.Popen: + """ + Return a command with wit and return the output + :param args: command arguments + :return: the output of the command + """ + return _run_popen(tools_path, *args) + + @better_wt_error(tools_path) def _run_dict(self, *args) -> dict: """ Return a dictionary of a command that return value associated to a key with a equal sign @@ -129,6 +140,27 @@ class WITPath: """ return self["./"].extract(dest, flat=False) + def progress_extract_all(self, dest: Path | str) -> Generator[dict, None, Path]: + """ + Extract all the subfiles to the destination directory, yelling the percentage and the estimated time remaining + :param dest: destination directory + :return: the extracted file path + """ + process = self._run_popen("EXTRACT", self.path, "-d", dest, "--progress") + + while process.poll() is None: + m = re.match(r'\s*(?P\d*)%(?:.*?ETA (?P\d*:\d*))?\s*', process.stdout.readline()) + if m: + yield { + "percentage": int(m.group("percentage")), + "estimation": m.group("estimation") + } + + if process.returncode != 0: + raise WTError(tools_path, process.returncode) + + return dest + @property def extension(self) -> Extension: """