diff options
Diffstat (limited to 'libs/installer_wizard/src/installer.py')
| -rw-r--r-- | libs/installer_wizard/src/installer.py | 397 |
1 files changed, 397 insertions, 0 deletions
diff --git a/libs/installer_wizard/src/installer.py b/libs/installer_wizard/src/installer.py new file mode 100644 index 0000000..c32956c --- /dev/null +++ b/libs/installer_wizard/src/installer.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import os +import re +import sys +from collections import defaultdict +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Union, cast + +from PyQt6 import QtWidgets +from PyQt6.QtWidgets import QApplication + +from wizard.runner import WizardRunnerState + +import mobase + +from .dialog import WizardInstallerDialog +from .runner import make_interpreter +from .utils import make_ini_tweaks, merge_ini_tweaks + + +class WizardInstaller(mobase.IPluginInstallerSimple): + """ + This is the actual plugin. MO2 has two types of installer plugin, this one is + "simple", i.e., it will work directly on the file-tree contained in the archive. + The purpose of the installer is to take the file-tree from the archive, check if + it is valid (for this installer) and then modify it if required before extraction. + """ + + # regex used to parse settings + RE_DESCRIPTION = re.compile(r"select([0-9]+)-description") + RE_OPTION = re.compile(r"select([0-9]+)-option([0-9]+)") + + _organizer: mobase.IOrganizer + + # list of selected options + _installerOptions: Dict[str, List[str]] + _installerUsed: bool + + def __init__(self): + super().__init__() + + def init(self, organizer: mobase.IOrganizer): + self._organizer = organizer + return True + + def name(self): + return "BAIN Wizard Installer" + + def localizedName(self) -> str: + return self.tr("BAIN Wizard Installer") + + def author(self): + return "Holt59" + + def description(self): + return self.tr("Installer for BAIN archive containing wizard scripts.") + + def version(self): + return mobase.VersionInfo(1, 0, 2) + + def isActive(self): + return self._organizer.pluginSetting(self.name(), "enabled") + + def settings(self): + return [ + mobase.PluginSetting("enabled", "check to enable this plugin", True), + mobase.PluginSetting( + "prefer_fomod", + "prefer FOMOD installer over this one when possible", + True, + ), + mobase.PluginSetting( + "prefer_omod", + "prefer OMOD installer over this one when possible", + False, + ), + # above FOMOD? + mobase.PluginSetting("priority", "priority of this installer", 120), + ] + + # method for IPluginInstallerSimple + + def priority(self) -> int: + return cast(int, self._organizer.pluginSetting(self.name(), "priority")) + + def isManualInstaller(self) -> bool: + return False + + def onInstallationStart( + self, + archive: str, + reinstallation: bool, + current_mod: Optional[mobase.IModInterface], + ): + self._installerUsed = False + self._installerOptions = {} + + if current_mod: + settings = current_mod.pluginSettings(self.name()) + + # first extract the description + descriptions: Dict[int, str] = {} + options: Dict[int, Dict[int, str]] = defaultdict(dict) + for setting, value in settings.items(): + mdesc = WizardInstaller.RE_DESCRIPTION.match(setting) + if mdesc: + select = int(mdesc.group(1)) + descriptions[select] = str(value) + + mopt = WizardInstaller.RE_OPTION.match(setting) + if mopt: + select = int(mopt.group(1)) + index = int(mopt.group(2)) + options[select][index] = str(value) + + for kdesc, desc in descriptions.items(): + self._installerOptions[desc] = [] + if kdesc in options: + for index in sorted(options[kdesc].keys()): + self._installerOptions[desc].append(options[kdesc][index]) + + def onInstallationEnd( + self, result: mobase.InstallResult, new_mod: Optional[mobase.IModInterface] + ): + if ( + result != mobase.InstallResult.SUCCESS + or not self._installerUsed + or not new_mod + ): + return + + new_mod.clearPluginSettings(self.name()) + for i, desc in enumerate(self._installerOptions): + new_mod.setPluginSetting(self.name(), f"select{i}-description", desc) + for iopt, opt in enumerate(self._installerOptions[desc]): + new_mod.setPluginSetting(self.name(), f"select{i}-option{iopt}", opt) + + def _hasFomodInstaller(self) -> bool: + # do not consider the NCC installer + return self._organizer.isPluginEnabled("Fomod Installer") + + def _hasOmodInstaller(self) -> bool: + return self._organizer.isPluginEnabled("Omod Installer") + + def _getWizardArchiveBase( + self, tree: mobase.IFileTree, data_name: str, checker: mobase.ModDataChecker + ) -> Optional[mobase.IFileTree]: + """ + Try to find the folder containing wizard.txt. + + Args: + tree: Tree to look the data folder in. + data_name: Name of the data folder (e.g., "data" for Bethesda games). + checker: Checker to use to check if a tree is a data folder. + + Returns: + The tree corresponding to the folder containing wizard.txt, or None. + """ + + entry = tree.find("wizard.txt", mobase.FileTreeEntry.FILE) + + if entry: + return tree + + if len(tree) == 1 and isinstance((root := tree[0]), mobase.IFileTree): + return self._getWizardArchiveBase(root, data_name, checker) + + return None + + def _getEntriesToExtract( + self, + tree: mobase.IFileTree, + extensions: Sequence[str] = ["png", "jpg", "jpeg", "gif", "bmp", "ini"], + ) -> list[mobase.FileTreeEntry]: + """ + Retrieve all the entries to extract from the given tree. + + Args: + tree: The tree. + extensions: The extensions of files. + + Returns: + A list of entries corresponding to files with the given extensions. + """ + entries: list[mobase.FileTreeEntry] = [] + + def fn(path: str, entry: mobase.FileTreeEntry): + if entry.isFile() and entry.hasSuffix(extensions): + entries.append(entry) + return mobase.IFileTree.CONTINUE + + tree.walk(fn) + + return entries + + def isArchiveSupported(self, tree: mobase.IFileTree) -> bool: + """ + Check if the given file-tree (from the archive) can be installed by this + installer. + + Args: + tree: The tree to check. + + Returns: + True if the file-tree can be installed, false otherwise. + """ + + # retrieve the name of the "data" folder + data_name = self._organizer.managedGame().dataDirectory().dirName() + + # retrieve the mod-data-checker + checker = self._organizer.gameFeatures().gameFeature(mobase.ModDataChecker) + + # retrieve the base + base = self._getWizardArchiveBase(tree, data_name, checker) + + if not base: + return False + + # check FOMOD for priority + fomod = base.exists("fomod/ModuleConfig.xml") + if ( + fomod + and self._hasFomodInstaller() + and self._organizer.pluginSetting(self.name(), "prefer_fomod") + ): + return False + + # TODO: Check OMOD? + + return True + + def install( + self, + name: mobase.GuessedString, + tree: mobase.IFileTree, + version: str, + nexus_id: int, + ) -> Union[mobase.InstallResult, mobase.IFileTree]: + """ + Perform the actual installation. + + Args: + name: The "name" of the mod. This can be updated to change the name of the + mod. + tree: The original archive tree. + version: The original version of the mod. + nexus_id: The original ID of the mod. + + Returns: We either return the modified file-tree (if the installation was + successful), or a InstallResult otherwise. + + Note: It is also possible to return a tuple (InstallResult, IFileTree, str, int) + containing where the two last members correspond to the new version and ID + of the mod, in case those were updated by the installer. + """ + + # retrieve the name of the "data" folder + data_name = self._organizer.managedGame().dataDirectory().dirName() + + # retrieve the mod-data-checker + checker = self._organizer.gameFeatures().gameFeature(mobase.ModDataChecker) + + # retrieve the "base" folder + base = self._getWizardArchiveBase(tree, data_name, checker) + if not base or not checker: + return mobase.InstallResult.NOT_ATTEMPTED + + wizard = base.find("wizard.txt") + if wizard is None: + return mobase.InstallResult.NOT_ATTEMPTED + + to_extract = self._getEntriesToExtract(tree) + + # extract the script + paths = self._manager().extractFiles([wizard] + to_extract, silent=False) + if len(paths) != len(to_extract) + 1: + return mobase.InstallResult.FAILED + + interpreter = make_interpreter(base, self._organizer) + + script = paths[0] + + dialog = WizardInstallerDialog( + self._organizer, + interpreter, + interpreter.make_top_level_context(Path(script), WizardRunnerState()), + name, + { + Path(entry.path()): Path(path) + for entry, path in zip(to_extract, paths[1:], strict=True) + if not path.endswith(".ini") + }, + self._installerOptions, + self._parentWidget(), + ) + + dialog.scriptButtonClicked.connect( # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] + lambda: os.startfile(script) + ) + + # unlike the official installer, we do not have a "silent" setting, but it is + # really simple to add it + if dialog.exec() == QtWidgets.QDialog.DialogCode.Accepted: + # we update the name with the user specified one + name.update(dialog.name(), mobase.GuessQuality.USER) + + # create the tree with all the sub-packages + new_tree = tree.createOrphanTree() + + for subpackage in dialog.subpackages(): + entry = base.find(subpackage) + + # should never happens since we fetch the subpackage for the archive + if not entry or not isinstance(entry, mobase.IFileTree): + print( + f"SubPackage {subpackage} not found in the archive.", + file=sys.stderr, + ) + continue + + new_tree.merge(entry) + + # handle renames + for original, new in dialog.renames().items(): + # entry should be at the root + entry = new_tree.find(original) + + if not entry: + print(f"Plugin {original} not found, cannot rename.") + continue + + new_tree.move(entry, new) + + # move not selected plugins to optional + for plugin, enabled in dialog.plugins().items(): + if not enabled: + entry = new_tree.find(plugin) + if not entry: + continue # silently fail since the plugin should be disabled + new_tree.addDirectory("optional").insert(entry) + + # TODO: INI Tweaks: + alltweaks = dialog.tweaks() + + for filename, tweaks in alltweaks.items(): + # find the original file (if any) + o_entry = new_tree.find(filename) + o_filename: Optional[str] = None + if o_entry: + # find the filepath from the list of extracted files + index = to_extract.index(o_entry) + + # +1 because the first one is the script + o_filename = paths[index + 1] + + # if the file existed before, we keep the new one at the same place + if o_entry or Path(filename).parts[0].lower() == "ini tweaks": + entry = new_tree.addFile(filename, replace_if_exists=True) + + # otherwise we create it in INI Tweaks + else: + entry = new_tree.addFile( + os.path.join("INI Tweaks", filename), replace_if_exists=True + ) + + filepath = self._manager().createFile(entry) + + if not o_filename: + data = make_ini_tweaks(tweaks) + else: + data = merge_ini_tweaks(tweaks, Path(o_filename)) + + with open(filepath, "w") as fp: + fp.write(data) + + # mark stuff for saving + self._installerUsed = True + self._installerOptions = dict(dialog.selectedOptions()) + + return new_tree + + # if user requested a manual installation, we update the name (to keep it + # in the manual installation dialog) and just notify the installation manager + elif dialog.isManualRequested(): + name.update(dialog.name(), mobase.GuessQuality.USER) + return mobase.InstallResult.MANUAL_REQUESTED + + # if user canceled, we simply notify the installation manager + else: + return mobase.InstallResult.CANCELED + + def tr(self, value: str) -> str: + # we need this to translate string in Python. Check the common documentation + # for more details + return QApplication.translate("WizardInstaller", value) |
