diff --git a/.gitignore b/.gitignore index 95cd5bd..44e6e43 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ *.jar data/ temp/ +config/ +data/ +resourcepacks/ # tests/ %USERPROFILE% logs/ diff --git a/poetry.lock b/poetry.lock index 85a5771..0504898 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. [[package]] name = "anyio" @@ -1063,7 +1063,7 @@ cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\ cffi = ["cffi (>=1.11)"] [extras] -gui = ["pyside6", "darkdetect"] +gui = ["darkdetect", "pyside6"] [metadata] lock-version = "2.0" diff --git a/pyproject.toml b/pyproject.toml index 0ec5e07..3bfcab3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "An installer of Fabulously Optimized for the vanilla launcher." authors = [ "osfanbuff63 ", "nsde ", - "Kichura " + "Kichura " ] documentation = "https://fabulously-optimized.gitbook.io/vanilla-installer" homepage = "https://fabulously-optimized.gitbook.io/vanilla-installer" diff --git a/vanilla_installer/assets/lang/en_us.json b/vanilla_installer/assets/lang/en_us.json index 420c467..e11ece2 100644 --- a/vanilla_installer/assets/lang/en_us.json +++ b/vanilla_installer/assets/lang/en_us.json @@ -5,5 +5,10 @@ "vanilla_installer.gui.location": "Location:", "vanilla_installer.gui.issues_button": "Report bugs", "vanilla_installer.gui.theme_toggle": "Toggle theme", - "vanilla_installer.gui.settings": "Settings" + "vanilla_installer.gui.settings": "Settings", + "vanilla_installer.gui.downgrade": "Downgrade Warning", + "vanilla_installer.gui.downgrade.text": "You are attempting to downgrade the Minecraft version. This is NOT SUPPORTED by Mojang or Fabulously Optimized and it may cause world corruption or crashes. \nIf you want to do this safely, you should backup mods, config and saves folders to a different location and delete them from your .minecraft folder.", + "vanilla_installer.gui.downgrade.install_checkbox": "I agree that downgrades are unsupported by Mojang and Fabulously Optimized, but I want to do this anyway.", + "vanilla_installer.gui.warnings.open_folder": "Open .minecraft", + "vanilla_installer.gui.warnings.install_anyway": "Install anyway" } \ No newline at end of file diff --git a/vanilla_installer/assets/versions.json b/vanilla_installer/assets/versions.json index 83e8e8e..b2f6769 100644 --- a/vanilla_installer/assets/versions.json +++ b/vanilla_installer/assets/versions.json @@ -1,6 +1,69 @@ { - "1.19.4": "https://raw.githubusercontent.com/Fabulously-Optimized/fabulously-optimized/main/Packwiz/1.19.4/pack.toml", - "1.18.2": "https://raw.githubusercontent.com/Fabulously-Optimized/fabulously-optimized/main/Packwiz/1.18.2/pack.toml", - "1.17.1": "https://raw.githubusercontent.com/Fabulously-Optimized/fabulously-optimized/main/Packwiz/1.17.1/pack.toml", - "1.16.5": "https://raw.githubusercontent.com/Fabulously-Optimized/fabulously-optimized/main/Packwiz/1.16.5/pack.toml" -} + "format_version": 2, + "versions": { + "1.19.4": { + "packwiz": "https://raw.githubusercontent.com/Fabulously-Optimized/fabulously-optimized/main/Packwiz/1.19.4/pack.toml", + "major_version": 4, + "enabled": true + }, + "1.19.3": { + "major_version": 4, + "enabled": false + }, + "1.19.2": { + "major_version": 4, + "enabled": false + }, + "1.19.1": { + "major_version": 4, + "enabled": false + }, + "1.19": { + "major_version": 4, + "enabled": false + }, + "1.18.2": { + "packwiz": "https://raw.githubusercontent.com/Fabulously-Optimized/fabulously-optimized/main/Packwiz/1.18.2/pack.toml", + "major_version": 3, + "enabled": true + }, + "1.18.1": { + "major_version": 3, + "enabled": false + }, + "1.18": { + "major_version": 3, + "enabled": false + }, + "1.17.1": { + "packwiz": "https://raw.githubusercontent.com/Fabulously-Optimized/fabulously-optimized/main/Packwiz/1.17.1/pack.toml", + "major_version": 2, + "enabled": true + }, + "1.17": { + "major_version": 2, + "enabled": false + }, + "1.16.5": { + "packwiz": "https://raw.githubusercontent.com/Fabulously-Optimized/fabulously-optimized/main/Packwiz/1.16.5/pack.toml", + "major_version": 1, + "enabled": true + }, + "1.16.4": { + "major_version": 1, + "enabled": false + }, + "1.16.3": { + "major_version": 1, + "enabled": false + }, + "1.16.2": { + "major_version": 1, + "enabled": false + }, + "1.16.1": { + "major_version": 1, + "enabled": false + } + } +} \ No newline at end of file diff --git a/vanilla_installer/gui.py b/vanilla_installer/gui.py index 0a4e15b..1b1c488 100644 --- a/vanilla_installer/gui.py +++ b/vanilla_installer/gui.py @@ -5,7 +5,6 @@ """ # IMPORTS -import logging import pathlib import platform import sys @@ -26,9 +25,11 @@ QGraphicsColorizeEffect, QLabel, QMainWindow, + QMessageBox, QPushButton, QTextEdit, QWidget, + QAbstractButton, ) # LOCAL @@ -68,12 +69,14 @@ def run() -> None: def setFont(opendyslexic: bool): global global_font if opendyslexic: + logger.debug("Set font to OpenDyslexic") global_font = "OpenDyslexic" else: # For some reason the Inter font on Linux is called `Inter` and on Windows it's called `Inter Regular` # And thus, this is a janky solution # I'm not sure what it's called on MacOS so hopefully it's the same as linux cause i can't test it # Either way it would be a better idea to move to a font that doesn't have this issue + logger.debug("Set font to Inter") inter_name = "Inter" if platform.system() == "Windows": inter_name = "Inter Regular" @@ -205,7 +208,6 @@ def setupUi(self, MainWindow: QMainWindow) -> None: Ui_MainWindow.getAsset("settings.svg"), self.settingsButton ) self.settingsButtonIcon.setGeometry(70, 0, 24, 24) - self.windowIcon = Ui_MainWindow.getAsset("icon.png") MainWindow.setWindowIcon(QIcon(self.windowIcon)) @@ -420,6 +422,18 @@ def startInstall(self) -> None: ) version = self.versionSelector.itemText(self.versionSelector.currentIndex()) location = self.selectedLocation.toPlainText() + if main.downgrade_check(version, location) is True: + logger.warning("Downgrade detected, opening downgrade warning.") + continue_on_downgrade = False + response = DowngradeWarning(self).exec() + if response == DowngradeWarning.cancelButton: + pass + if continue_on_downgrade is False: + return + else: + logger.warning( + "User chose to continue on a version downgrade, anything past this is UNSUPPORTED." + ) self.installing = True if version.startswith("1.16"): java_ver = 8 @@ -442,9 +456,6 @@ def startInstall(self) -> None: class SettingsDialog(QDialog): """ The settings dialog. - - Args: - QDialog (QDialog): The dialog. """ parentWindow: Ui_MainWindow @@ -541,6 +552,93 @@ def changeFont(self, state) -> None: self.parentWindow.reloadTheme() +class DowngradeWarning(QMessageBox): + parentWindow: Ui_MainWindow + + def __init__(self, parent) -> None: + self.parentWindow = parent + super().__init__(self.parentWindow.centralwidget) + self.setupUi() + + def setupUi(self) -> None: + """Setup the UI for the downgrade warning.""" + if not self.objectName(): + self.setObjectName("Warning") + self.setIcon(QMessageBox.Icon.Warning) + self.openFolderButton = self.addButton( + "Open .minecraft", + QMessageBox.ButtonRole.ActionRole, + ) + self.installAnywayButton = self.addButton( + "Install anyway", + QMessageBox.ButtonRole.ActionRole, + ) + self.installAnywayButton.setDisabled(True) + self.cancelButton = self.addButton(QMessageBox.Cancel) + self.installCheckbox = QCheckBox() + self.setCheckBox(self.installCheckbox) + self.setDefaultButton(QMessageBox.Cancel) + self.retranslateUi(self) + self.reloadTheme() + + def retranslateUi(self, Warning) -> None: + """ + Retranslate UI for the set dialog. + + Args: + Warning: The dialog. + """ + i18n_strings = i18n.get_i18n_values("en_us") + Warning.setWindowTitle( + QCoreApplication.translate( + "Warning", i18n_strings["vanilla_installer.gui.downgrade"], None + ) + ) + self.openFolderButton.setText( + QCoreApplication.translate( + "Warning", + i18n_strings["vanilla_installer.gui.warnings.open_folder"], + None, + ) + ) + self.installAnywayButton.setText( + QCoreApplication.translate( + "Warning", + i18n_strings["vanilla_installer.gui.warnings.install_anyway"], + None, + ) + ) + self.installCheckbox.setText( + QCoreApplication.translate( + "Warning", + i18n_strings["vanilla_installer.gui.downgrade.install_checkbox"], + None, + ) + ) + + def reloadTheme(self) -> None: + """ + Reload the theme. + """ + loaded_theme = theme.load() + self.setStyleSheet( + f'[objectName^="Warning"] {{ background-color: {loaded_theme.get("base")}}}' + ) + self.openFolderButton.setStyleSheet( + f'QPushButton {{ border: none; background-color: { loaded_theme.get("button") } ; color: {loaded_theme.get("text")}; padding: 8px; border-radius: 5px; font-family: "{global_font}"}}' + f'QPushButton:hover {{ background-color: { loaded_theme.get("buttonhovered") }}}' + f'QPushButton:hover {{ background-color: { loaded_theme.get("buttonpressed") }}}' + ) + self.installAnywayButton.setStyleSheet( + f'QPushButton {{ border: none; background-color: { loaded_theme.get("button") } ; color: {loaded_theme.get("text")}; padding: 8px; border-radius: 5px; font-family: "{global_font}"}}' + f'QPushButton:hover {{ background-color: { loaded_theme.get("buttonhovered") }}}' + f'QPushButton:hover {{ background-color: { loaded_theme.get("buttonpressed") }}}' + ) + self.installCheckbox.setStyleSheet( + f'color: {loaded_theme.get("label")}; font-family: "{global_font}"' + ) + + class Worker(QRunnable): def __init__(self, fn) -> None: super(Worker, self).__init__() diff --git a/vanilla_installer/log.py b/vanilla_installer/log.py index 8970749..2789ec5 100644 --- a/vanilla_installer/log.py +++ b/vanilla_installer/log.py @@ -27,8 +27,24 @@ def flush(self): pass -def setup_logging() -> logging.Logger: - logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) +try: + if log_setup is not True: # noqa: F821 + log_setup = False + logger.setLevel(logging.DEBUG) + logfile_path = Path("./logs").resolve() / "vanilla_installer.log" + if logfile_path.exists() is False: + Path("./logs").resolve().mkdir(exist_ok=True) + with logfile_path as file: + open(file, "x", encoding="utf-8").write("") + handler = logging.handlers.RotatingFileHandler( + filename=logfile_path, + encoding="utf-8", + maxBytes=32 * 1024 * 1024, # 32 MiB + backupCount=5, # Rotate through 5 files + ) +except UnboundLocalError: + log_setup = False logger.setLevel(logging.DEBUG) logfile_path = Path("./logs").resolve() / "vanilla_installer.log" if logfile_path.exists() is False: @@ -52,4 +68,8 @@ def setup_logging() -> logging.Logger: # To access the original stdout/stderr, use sys.__stdout__/sys.__stderr__ sys.stdout = LoggerWriter(logger.info) sys.stderr = LoggerWriter(logger.error) + log_setup = True + + +def setup_logging() -> logging.Logger: return logger diff --git a/vanilla_installer/main.py b/vanilla_installer/main.py index 47e0971..a5af1c3 100644 --- a/vanilla_installer/main.py +++ b/vanilla_installer/main.py @@ -14,7 +14,7 @@ import subprocess import zipfile from pathlib import Path -from typing import Optional +from typing import Optional, Union import click import minecraft_launcher_lib as mll @@ -25,11 +25,17 @@ from vanilla_installer import __version__, config, log logger = log.setup_logging() -logger.info("Starting Vanilla Installer") + FOLDER_LOC = "" +class UnsupportedFormatVersion(Exception): + """The format version is not supported by this program version.""" + + pass + + def set_dir(path: str = mll.utils.get_minecraft_directory()) -> str | None: """ Sets the Minecraft game directory. @@ -49,7 +55,6 @@ def get_dir() -> str: Returns: str: Path """ - path = config.read() return path["config"]["path"] @@ -270,10 +275,10 @@ def install_pack( java_ver (float): The Java version to use. Defaults to 17.3 """ logger.debug("Installing the pack now.") - os.chdir(mc_dir) - os.makedirs(f"{get_dir()}/", exist_ok=True) pack_toml = convert_version(mc_version) try: + os.chdir(mc_dir) + os.makedirs(f"{get_dir()}/", exist_ok=True) command( f"{get_java(java_ver)} -jar {packwiz_installer_bootstrap} {pack_toml} --timeout 0" ) @@ -327,34 +332,82 @@ def create_profile(mc_dir: str, version_id: str) -> None: launcher_profiles_path.write_text(profiles_json) +def log_installed_version( + version: Union[str, int, float], install_dir: Union[str, bytes, os.PathLike] +) -> None: + """Log the version of Minecraft that FO has been installed for. + This is used to find out later whether this is a downgrade. + + Args: + version (Union[str, int, float]): The version to log. + install_dir (Union[str, bytes, os.PathLike]): The directory that FO was installed to. + """ + dir_path = Path(install_dir).resolve() / ".fovi" + dir_path.mkdir(exist_ok=True) + file_path = dir_path / "mc_version.txt" + if file_path.exists() is False: + file_path.touch() + with file_path.open("w") as file: + file.write(version) + + +def read_versions(force_local: bool = False) -> dict: + """Reads the versions.json file, either over the Internet, or locally. + + Returns: + dict: The JSON file, formatted as a dictionary. + """ + if force_local is True: + response = _get_versions_local() + return dict(response) + SUPPORTED_FORMAT_VERSION = 2 + try: + response = requests.get( + "https://raw.githubusercontent.com/Fabulously-Optimized/vanilla-installer/main/vanilla_installer/assets/versions.json" + ).json() + format_version = response["format_version"] + if response["format_version"] != SUPPORTED_FORMAT_VERSION: + raise UnsupportedFormatVersion( + f"Format version {format_version} is not supported by this version." + ) + except Exception as e: + if e is UnsupportedFormatVersion: + logger.exception( + "Format version was not supported - falling back to a local file. Update Vanilla Installer to fix this." + ) + else: + logger.warning("GitHub failed, falling back to local...") + response = _get_versions_local() + return dict(response) + + +def _get_versions_local(): + try: + local_path = Path("vanilla_installer/assets").resolve() / "versions.json" + except: + local_path = Path("assets").resolve() / "versions.json" + return json.loads(local_path.read_bytes()) + + def get_pack_mc_versions() -> dict: """ Gets a list of all the versions FO currently supports. """ - return_value = dict() - try: + all_versions = dict() + raw_dict = read_versions() + for version in raw_dict["versions"]: + raw_version = raw_dict["versions"][version] + if raw_version["enabled"] is True or raw_version["enabled"] == "true": + all_versions[version] = raw_version + return_value = all_versions + for key in all_versions.keys(): try: - response = requests.get( - "https://raw.githubusercontent.com/Fabulously-Optimized/vanilla-installer/main/vanilla_installer/assets/versions.json" - ).json() - except requests.exceptions.RequestException or response.status_code != "200": - # This should never happen unless a) there's no internet connection, b) the file was deleted or is missing in a development case. - # In this case, fall back to a local file since in the latter you'll likely have the whole repo cloned. - # For this to work, you need to be in the root directory of the repository running this, otherwise the files will not be found. - logger.warning("GitHub failed, falling back to local...") - try: - local_path = ( - Path("vanilla_installer/assets").resolve() / "versions.json" - ) - except: - local_path = Path("assets").resolve() / "versions.json" - response = json.loads(local_path.read_bytes()) - - return_value = dict(response) - return return_value - except requests.exceptions.RequestException as e: - logger.exception(f"Couldn't get minecraft versions: {e}") + if all_versions[key]["packwiz"] != "": + pass + except (KeyError, TypeError): + return_value.pop(key) + return return_value def convert_version(input_mcver: str) -> str: @@ -365,16 +418,72 @@ def convert_version(input_mcver: str) -> str: input_mcver (str): The Minecraft version to find. Returns: - str: The converted version as a direct JSDelivr URL. + str: The converted version as a URL. """ versions = get_pack_mc_versions() - return_value = versions.get(input_mcver) + return_value = versions[input_mcver]["packwiz"] if return_value is None: raise TypeError("Invalid or unsupported Minecraft version.") else: return return_value +def downgrade_check( + version: Union[str, int, float], install_dir: Union[str, bytes, os.PathLike] +) -> bool: + """Checks whether the given version is a downgrade from the one currently installed. + + Args: + version (Union[str, int, float]): _description_ + + Returns: + bool: Whether this is a downgrade. + """ + downgrade = True + version_dict = read_versions() + installed_version_file = Path(install_dir).resolve() / ".fovi" / "mc_version.txt" + try: + with installed_version_file.open("r") as file: + current_version_file = file.read() + except FileNotFoundError: + logger.exception("No version file found. Assuming this is not a downgrade") + downgrade = False + if downgrade is not False: + try: + current_version = float(version_dict[current_version_file]) + if float(version) < current_version: + downgrade = True + else: + downgrade = False + except KeyError: + logger.exception( + "Invalid or unknown version installed, making extra checks." + ) + install_dir_path = Path(install_dir).resolve() + install_dir_path_mods = install_dir_path / "mods" + install_dir_path_config = install_dir_path / "config" + install_dir_path_saves = install_dir_path / "saves" + if ( + install_dir_path_mods.exists() is False + and install_dir_path_config.exists() is False + and install_dir_path_saves.exists() is False + ): + logger.info( + "No mods, config, or saves directory found, this cannot be a downgrade." + ) + downgrade = False + elif ( + not any(install_dir_path_mods.iterdir()) + and not any(install_dir_path_config.iterdir()) + and not any(install_dir_path_saves.iterdir()) + ): + logger.info( + "Mods, config, and saves directories were empty, this cannot be a downgrade." + ) + downgrade = False + return downgrade + + def run( mc_dir: str = mll.utils.get_minecraft_directory(), version: Optional[str] = None, @@ -428,3 +537,4 @@ def run( create_profile(mc_dir, fabric_version) text_update("Complete!", widget, "info", interface) logger.info("Success!") + log_installed_version(version, mc_dir)