From 7ee008e150bc5bcf76082d726f719ee0fdfda982 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Wed, 11 Feb 2026 02:37:39 -0600 Subject: Fluorine Manager: full Linux port of Mod Organizer 2 Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 --- libs/installer_wizard/src/CMakeLists.txt | 4 + libs/installer_wizard/src/__init__.py | 17 + libs/installer_wizard/src/dialog.py | 752 +++++++++++++++++++++ libs/installer_wizard/src/installer.py | 397 +++++++++++ libs/installer_wizard/src/installer_wizard_en.ts | 156 +++++ libs/installer_wizard/src/runner.py | 189 ++++++ .../src/ui/wizardinstallercomplete.ui | 271 ++++++++ .../src/ui/wizardinstallerdialog.ui | 128 ++++ .../src/ui/wizardinstallererror.ui | 103 +++ .../installer_wizard/src/ui/wizardinstallerpage.ui | 171 +++++ .../src/ui/wizardinstallerrequires.ui | 220 ++++++ libs/installer_wizard/src/utils.py | 170 +++++ 12 files changed, 2578 insertions(+) create mode 100644 libs/installer_wizard/src/CMakeLists.txt create mode 100644 libs/installer_wizard/src/__init__.py create mode 100644 libs/installer_wizard/src/dialog.py create mode 100644 libs/installer_wizard/src/installer.py create mode 100644 libs/installer_wizard/src/installer_wizard_en.ts create mode 100644 libs/installer_wizard/src/runner.py create mode 100644 libs/installer_wizard/src/ui/wizardinstallercomplete.ui create mode 100644 libs/installer_wizard/src/ui/wizardinstallerdialog.ui create mode 100644 libs/installer_wizard/src/ui/wizardinstallererror.ui create mode 100644 libs/installer_wizard/src/ui/wizardinstallerpage.ui create mode 100644 libs/installer_wizard/src/ui/wizardinstallerrequires.ui create mode 100644 libs/installer_wizard/src/utils.py (limited to 'libs/installer_wizard/src') diff --git a/libs/installer_wizard/src/CMakeLists.txt b/libs/installer_wizard/src/CMakeLists.txt new file mode 100644 index 0000000..b772dea --- /dev/null +++ b/libs/installer_wizard/src/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.16) + +add_custom_target(installer_wizard ALL) +mo2_configure_python(installer_wizard MODULE) diff --git a/libs/installer_wizard/src/__init__.py b/libs/installer_wizard/src/__init__.py new file mode 100644 index 0000000..2b3b391 --- /dev/null +++ b/libs/installer_wizard/src/__init__.py @@ -0,0 +1,17 @@ +""" +This file is the entry point of the module and must contain a createPlugin() +or createPlugins() function. +""" + +from __future__ import annotations + +import os +import site + +site.addsitedir(os.path.join(os.path.dirname(__file__), "lib")) + +from .installer import WizardInstaller # noqa: E402 + + +def createPlugin() -> WizardInstaller: + return WizardInstaller() diff --git a/libs/installer_wizard/src/dialog.py b/libs/installer_wizard/src/dialog.py new file mode 100644 index 0000000..76d10bb --- /dev/null +++ b/libs/installer_wizard/src/dialog.py @@ -0,0 +1,752 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any, cast + +from antlr4 import ParserRuleContext +from PyQt6 import QtWidgets +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QFontDatabase, QKeySequence, QPixmap, QResizeEvent, QShortcut +from PyQt6.QtWidgets import QApplication + +from wizard.contexts import ( + WizardInterpreterContext, + WizardRequireVersionsContext, + WizardSelectContext, + WizardSelectManyContext, + WizardSelectOneContext, + WizardTerminationContext, + WizardTopLevelContext, +) +from wizard.errors import WizardError +from wizard.interpreter import WizardInterpreter +from wizard.manager import SelectOption +from wizard.runner import WizardRunnerKeywordVisitor, WizardRunnerState +from wizard.tweaks import WizardINISetting +from wizard.value import Plugin + +import mobase + +from .ui.wizardinstallercomplete import Ui_WizardInstallerComplete +from .ui.wizardinstallerdialog import Ui_WizardInstallerDialog +from .ui.wizardinstallererror import Ui_WizardInstallerError +from .ui.wizardinstallerpage import Ui_WizardInstallerPage +from .ui.wizardinstallerrequires import Ui_WizardInstallerRequires +from .utils import make_ini_tweaks + +WizardRunnerContext = WizardInterpreterContext[WizardRunnerState, Any] + + +def check_version( + context: WizardRequireVersionsContext[WizardRunnerState], + organizer: mobase.IOrganizer, +) -> tuple[bool, bool, bool, bool]: + """ + Check if the requirements are ok. + + Args: + context: The requires version context to check. + organizer: The organizer to fetch actual versions from. + + Returns: + A 4-tuple of boolean values, where each value is True if the installed + version is ok, False otherwise. In order, checks are game, script extender + graphics extender (True if there is no requirements, False otherwise since + we cannot check in MO2), and wrye bash (always True). + """ + game = organizer.managedGame() + + game_ok = True + if context.game_version: + game_ok = mobase.VersionInfo(context.game_version) <= game.version() + + # script extender + se_ok = True + if context.script_extender_version: + se = organizer.gameFeatures().gameFeature(mobase.ScriptExtender) + if not se or not se.isInstalled(): + se_ok = False + else: + if mobase.VersionInfo( + context.script_extender_version + ) <= mobase.VersionInfo(se.getExtenderVersion()): + se_ok = True + else: + se_ok = False + + # cannot check these so... + ge_ok = not context.graphics_extender_version + + return (game_ok, se_ok, ge_ok, True) + + +class WizardInstallerRequiresVersionPage(QtWidgets.QWidget): + context: WizardRequireVersionsContext[WizardRunnerState] + + def __init__( + self, + context: WizardRequireVersionsContext[WizardRunnerState], + organizer: mobase.IOrganizer, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + self.context = context + + # set the ui file + self.ui = Ui_WizardInstallerRequires() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.groupBox.setStyleSheet( + 'QLabel[headercell="true"] { font-weight: bold; }' + ) + + game = organizer.managedGame() + + okIcon = QPixmap(":/MO/gui/checked-checkbox").scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + noIcon = QPixmap(":/MO/gui/unchecked-checkbox").scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + koIcon = QPixmap(":/MO/gui/indeterminate-checkbox").scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + self.ui.labelGame.setText(game.gameName()) + + # set the required version + self.ui.labelGameNeed.setText(context.game_version) + self.ui.labelScriptExtenderNeed.setText(context.script_extender_version) + self.ui.labelGraphicsExtenderNeed.setText(context.graphics_extender_version) + self.ui.labelWryeBashNeed.setText(context.wrye_bash_version) + + # set the current version + self.ui.labelGameHave.setText(game.version().canonicalString()) + se = organizer.gameFeatures().gameFeature(mobase.ScriptExtender) + if se and se.isInstalled(): + self.ui.labelScriptExtenderHave.setText(se.getExtenderVersion()) + + # cannot check these so... + game_ok, se_ok, _, _ = check_version(context, organizer) + self.ui.labelGameIcon.setPixmap(okIcon if game_ok else koIcon) + self.ui.labelScriptExtenderIcon.setPixmap(okIcon if se_ok else koIcon) + self.ui.labelGraphicsExtenderIcon.setPixmap(noIcon) + self.ui.labelWryeBashIcon.setPixmap(noIcon) + + +class WizardInstallerSelectPage(QtWidgets.QWidget): + # signal emitted when an item is double-clicked, only for SelectOne context + itemDoubleClicked = pyqtSignal() + + context: WizardSelectContext[WizardRunnerState, Any] + _images: dict[Path, Path] + _currentImage: QPixmap + + def __init__( + self, + context: WizardSelectContext[WizardRunnerState, Any], + images: dict[Path, Path], + options: Sequence[str] | None, + parent: QtWidgets.QWidget, + ): + """ + Args: + context: The context for this page. + images: A mapping from path (in the archive) to extracted path. + options: Potential list of options to select. Might not exactly match. + parent: The parent widget. + """ + super().__init__(parent) + + self._images = images + + # set the ui file + self.ui = Ui_WizardInstallerPage() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.optionList.currentItemChanged.connect( # pyright: ignore[reportUnknownMemberType] + self.onCurrentItemChanged + ) + + # create list item widgets + for _ in context.options: + item = QtWidgets.QListWidgetItem() + self.ui.optionList.addItem(item) + + self.update_context(context) + + # extract previous select options + previous_options = [] + if options: + previous_options = [ + option for option in context.options if option.name in options + ] + else: + if isinstance(context, WizardSelectManyContext): + previous_options = context.defaults + elif isinstance(context, WizardSelectOneContext): + previous_options = [context.default] + + # set default values + for i, option in enumerate(context.options): + item = self.ui.optionList.item(i) + assert item is not None + if isinstance(context, WizardSelectManyContext): + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + if option in previous_options: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + elif ( + isinstance(context, WizardSelectOneContext) + and option in previous_options + ): + item.setSelected(True) + self.ui.optionList.setCurrentItem(item) + + if isinstance(context, WizardSelectOneContext): + self.ui.optionList.doubleClicked.connect( # pyright: ignore[reportUnknownMemberType] + self.itemDoubleClicked.emit + ) + + def update_context(self, context: WizardSelectContext[WizardRunnerState, Any]): + self.context = context + + options = self.context.options + assert len(options) == self.ui.optionList.count() + + self.ui.selectDescriptionLabel.setText(context.description) + self.ui.selectDescriptionLabel.setMargin(4) + + # update the content of the items + for i, option in enumerate(options): + item = self.ui.optionList.item(i) + assert item is not None + item.setText(option.name) + item.setData(Qt.ItemDataRole.UserRole, option) + + # no item selected, select the first one + if not self.ui.optionList.currentItem(): + self.ui.optionList.setCurrentRow(0) + + def onCurrentItemChanged( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + option: SelectOption = current.data(Qt.ItemDataRole.UserRole) + self.ui.descriptionTextEdit.setText(option.description) + image = option.image + if image and Path(image) in self._images: + target = self._images[Path(image)] + self._currentImage = QPixmap(target.as_posix()) + else: + self._currentImage = QPixmap() + + self.ui.imageLabel.setPixmap(self.getResizedImage()) + + def getResizedImage(self) -> QPixmap: + if self._currentImage.isNull(): + return self._currentImage + return self._currentImage.scaled( + self.ui.imageLabel.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + def resizeEvent(self, a0: QResizeEvent | None) -> None: + super().resizeEvent(a0) + self.ui.imageLabel.setPixmap(self.getResizedImage()) + + def selectedOptions(self) -> list[SelectOption]: + options: list[SelectOption] = [] + if isinstance(self.context, WizardSelectOneContext): + item = self.ui.optionList.currentItem() + assert item is not None + options.append(item.data(Qt.ItemDataRole.UserRole)) + else: + for i in range(self.ui.optionList.count()): + item = self.ui.optionList.item(i) + assert item is not None + if item.checkState() == Qt.CheckState.Checked: + options.append(item.data(Qt.ItemDataRole.UserRole)) + return options + + def selected(self) -> WizardSelectContext[WizardRunnerState, Any]: + if isinstance(self.context, WizardSelectOneContext): + return self.context.select(self.selectedOptions()[0]) + elif isinstance(self.context, WizardSelectManyContext): + return self.context.select(self.selectedOptions()) + else: + return self.context + + +class WizardInstallerCompletePage(QtWidgets.QWidget): + def __init__( + self, + context: WizardTerminationContext[WizardRunnerState], + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + # set the ui file + self.ui = Ui_WizardInstallerComplete() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.setStyleSheet('QLabel[heading="true"] { font-weight: bold; }') + + self.context = context + self.state = context.state + + # retrieve the keyword visitor + kvisitor = cast(WizardRunnerKeywordVisitor, context.factory.kvisitor) + + # the list of plugins in selected sub-packages + plugins: set[Plugin] = set() + + # sub-packages + for sp in kvisitor.subpackages: + item = QtWidgets.QListWidgetItem() + item.setText(sp.name) + if sp.name in self.state.subpackages: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + plugins.update(kvisitor.plugins_for(sp)) + self.ui.subpackagesList.addItem(item) + + # switch the renamed plugins + for plugin in list(plugins): + if plugin in self.state.renames: + plugins.remove(plugin) + plugins.add(Plugin(self.state.renames[plugin])) + + # lugins + for plugin in sorted(plugins): + item = QtWidgets.QListWidgetItem() + item.setText(plugin.name) + if plugin in self.state.plugins: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + self.ui.pluginsList.addItem(item) + + # INI Tweaks + self.ui.tweaksWidget.setVisible(bool(self.state.tweaks)) + self.ui.tweaksList.currentItemChanged.connect( # pyright: ignore[reportUnknownMemberType] + self.onCurrentTweakItemChanged + ) + if self.state.tweaks: + # group the tweaks per file + tweaks = { + file: self.state.tweaks.tweaks(file) + for file in self.state.tweaks.files() + } + + for file, ftweaks in tweaks.items(): + item = QtWidgets.QListWidgetItem() + item.setText(file.replace("\\", "/")) + item.setData(Qt.ItemDataRole.UserRole, ftweaks) + self.ui.tweaksList.addItem(item) + + self.ui.tweaksTextEdit.setFont( + QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + ) + self.ui.tweaksList.setCurrentRow(0) + + # notes + md = "" + for note in self.state.notes: + md += f"- {note}\n" + document = self.ui.notesTextEdit.document() + assert document is not None + document.setIndentWidth(10) + self.ui.notesTextEdit.setMarkdown(md) + + def onCurrentTweakItemChanged( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + # clear text area and create the tweaks + self.ui.tweaksTextEdit.clear() + self.ui.tweaksTextEdit.appendPlainText( + make_ini_tweaks(current.data(Qt.ItemDataRole.UserRole)) + ) + + def subpackages(self) -> list[str]: + """ + Returns: + The list of subpackages selected in the UI (either automatically by the + interpreter or by the user). + """ + sp: list[str] = [] + for i in range(self.ui.subpackagesList.count()): + item = self.ui.subpackagesList.item(i) + assert item is not None + if item.checkState() == Qt.CheckState.Checked: + sp.append(item.text()) + return sp + + def plugins(self) -> dict[str, bool]: + """ + Returns: + The list of plugins selected in the UI (either automatically by the + interpreter or by the user). + """ + sp: dict[str, bool] = {} + for i in range(self.ui.pluginsList.count()): + item = self.ui.pluginsList.item(i) + assert item is not None + sp[item.text()] = item.checkState() == Qt.CheckState.Checked + return sp + + def tweaks(self) -> dict[str, list[WizardINISetting]]: + """ + Returns: + The list of tweaks created by the wizard. The returned value maps filenames + to INI tweaks. + """ + rets: dict[str, list[WizardINISetting]] = {} + for i in range(self.ui.tweaksList.count()): + item = self.ui.tweaksList.item(i) + assert item is not None + rets[item.text()] = item.data(Qt.ItemDataRole.UserRole) + return rets + + +class WizardInstallerCancelPage(QtWidgets.QWidget): + def __init__( + self, + context: WizardTerminationContext[WizardRunnerState], + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + # set the ui file (same UI file for both cancel and error) + self.ui = Ui_WizardInstallerError() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.titleLabel.setText( + "The installation was cancelled by the installer with the following reason." + ) + style = self.style() + assert style is not None + self.ui.iconLabel.setPixmap( + style.standardIcon( + QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning + ).pixmap(24, 24) + ) + self.ui.messageEdit.setText(context.message()) + + +class WizardInstallerErrorPage(QtWidgets.QWidget): + def __init__( + self, + error: WizardError, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + # set the ui file (same UI file for both cancel and error) + self.ui = Ui_WizardInstallerError() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.titleLabel.setText( + "An error occurred during the installation of the script, " + "this is probably due to an incorrect script file (wizard.txt) in the " + "archive." + ) + style = self.style() + assert style is not None + self.ui.iconLabel.setPixmap( + style.standardIcon( + QtWidgets.QStyle.StandardPixmap.SP_MessageBoxCritical + ).pixmap(24, 24) + ) + self.ui.messageEdit.setText(str(error)) + + +class WizardInstallerDialog(QtWidgets.QDialog): + # flag to indicate if the user chose to do a manual installation + _manual: bool = False + + # organizer + _organizer: mobase.IOrganizer + + # interpreter + _interpreter: WizardInterpreter[WizardRunnerState] + _images: dict[Path, Path] + _options: dict[str, list[str]] + + # the Wizard MO2 interface + _start_context: WizardTopLevelContext[WizardRunnerState] + + # dict from context to selected options + _pages: dict[ParserRuleContext, WizardInstallerSelectPage] + + def __init__( + self, + organizer: mobase.IOrganizer, + interpreter: WizardInterpreter[WizardRunnerState], + context: WizardTopLevelContext[WizardRunnerState], + name: mobase.GuessedString, + images: dict[Path, Path], + options: dict[str, list[str]], + parent: QtWidgets.QWidget, + ): + """ + Args: + interpreter: The interpreter to use. + context: The initial context of the script. + name: The name of the mod. + images: A mapping from path (in the archive) to extracted path. + options: The previously selected options. + parent: The parent widget. + """ + super().__init__(parent) + + self._organizer = organizer + self._interpreter = interpreter + self._images = images + self._options = options + self._start_context = context + self._pages = {} + + # set the ui file + self.ui = Ui_WizardInstallerDialog() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False) + self.setWindowFlag(Qt.WindowType.WindowMaximizeButtonHint, True) + + # mobase.GuessedString contains multiple names with various level of + # "guess", using.variants() returns the list of names, and doing str(name) + # will return the most-likely value + for value in name.variants(): + self.ui.nameCombo.addItem(value) + completer = self.ui.nameCombo.completer() + assert completer is not None + completer.setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive) + self.ui.nameCombo.setCurrentIndex(self.ui.nameCombo.findText(str(name))) + + # we need to connect the Cancel / Manual buttons. We can of course use + # PyQt6 signal/slot syntax + self.ui.cancelBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + self.reject + ) + + def manualClicked(): + self._manual = True + self.reject() + + self.ui.manualBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + manualClicked + ) + + self.ui.prevBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + self.previousClicked + ) + self.ui.nextBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + self.nextClicked + ) + + backShortcut = QShortcut(QKeySequence(Qt.Key.Key_Backspace), self) + backShortcut.activated.connect( # pyright: ignore[reportUnknownMemberType] + self.previousClicked + ) + + @property + def scriptButtonClicked(self) -> pyqtSignal: + return self.ui.scriptBtn.clicked # pyright: ignore[reportReturnType] + + def name(self): + return self.ui.nameCombo.currentText() + + def subpackages(self): + """ + Returns: + The list of subpackages to install. Only valid if exec() returned + Accepted. + """ + # we cannot fetch it from the state since the user can modify it in the UI + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return widget.subpackages() + + def plugins(self) -> dict[str, bool]: + """ + Returns: + The list of plugins to install and enable. Only valid if exec() returned + Accepted. + """ + # we cannot fetch it from the state since the user can modify it in the UI + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return widget.plugins() + + def renames(self) -> dict[str, str]: + """ + Returns: + The mapping of renames for plugins. Only valid if exec() returned Accepted. + """ + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return { + plugin.name: new_name for plugin, new_name in widget.state.renames.items() + } + + def tweaks(self) -> dict[str, list[WizardINISetting]]: + """ + Returns: + The list of tweaks per file. Only valid if exec() returned Accepted. + """ + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return widget.tweaks() + + def selectedOptions(self) -> dict[str, list[str]]: + """ + Returns: + The list of all currently selected options. + """ + result: dict[str, list[str]] = {} + for i in range(self.ui.stackedWidget.count()): + page = self.ui.stackedWidget.widget(i) + if isinstance(page, WizardInstallerSelectPage): + result[page.context.description] = [ + option.name for option in page.selectedOptions() + ] + return result + + def isManualRequested(self): + return self._manual + + def previousClicked(self): + index = self.ui.stackedWidget.currentIndex() + if index > 0: + self.ui.stackedWidget.removeWidget(self.ui.stackedWidget.widget(index)) + + self._update_prev_button() + self._update_next_button() + self._update_focus() + + def nextClicked(self): + widget = self.ui.stackedWidget.currentWidget() + + try: + if isinstance(widget, WizardInstallerSelectPage): + context = widget.selected().exec() + elif isinstance(widget, WizardInstallerRequiresVersionPage): + context = widget.context.exec() + else: + self.accept() + return + + context = self._exec_until(context) + + if context.context in self._pages: + page = self._pages[context.context] + assert isinstance(context, WizardSelectContext) + page.update_context(context) + else: + page = self._make_page(context) + + except WizardError as ex: + page = WizardInstallerErrorPage(ex, self) + + index = self.ui.stackedWidget.addWidget(page) + self.ui.stackedWidget.setCurrentIndex(index) + self._update_prev_button() + self._update_next_button() + self._update_focus() + + def _update_focus(self): + widget = self.ui.stackedWidget.currentWidget() + if isinstance(widget, WizardInstallerSelectPage): + widget.ui.optionList.setFocus() + + def _update_prev_button(self): + self.ui.prevBtn.setDisabled(self.ui.stackedWidget.currentIndex() <= 0) + + def _update_next_button(self): + widget = self.ui.stackedWidget.currentWidget() + + self.ui.nextBtn.setDisabled(False) + + name: str = self.ui.nextBtn.text() + if isinstance(widget, WizardInstallerSelectPage): + name = self.tr("Next") + elif isinstance(widget, WizardInstallerRequiresVersionPage): + name = self.tr("Install anyway") + elif isinstance(widget, (WizardInstallerCancelPage, WizardInstallerErrorPage)): + self.ui.nextBtn.setDisabled(True) + else: + name = self.tr("Install") + + self.ui.nextBtn.setText(name) + + def _exec_until(self, context: WizardRunnerContext) -> WizardRunnerContext: + context = self._interpreter.exec_until( + context, + ( + WizardSelectContext, + WizardRequireVersionsContext, + ), + ) + + # if all requirements are ok, skip the context + if isinstance(context, WizardRequireVersionsContext): + if all(check_version(context, self._organizer)): + return self._exec_until(context.exec()) + + return context + + def _make_page(self, context: WizardRunnerContext) -> QtWidgets.QWidget: + page: QtWidgets.QWidget + if isinstance(context, WizardSelectContext): + page = WizardInstallerSelectPage( + context, + self._images, + self._options.get(context.description, None), + self, + ) + page.itemDoubleClicked.connect( # pyright: ignore[reportUnknownMemberType] + self.nextClicked + ) + self._pages[context.context] = page # type: ignore + elif isinstance(context, WizardRequireVersionsContext): + page = WizardInstallerRequiresVersionPage(context, self._organizer, self) + elif isinstance(context, WizardTerminationContext): + if context.is_cancel(): + page = WizardInstallerCancelPage(context, self) + else: + page = WizardInstallerCompletePage(context, self) + else: + raise NotImplementedError() # for typing purpose + + return page + + def exec(self): + try: + context = self._exec_until(self._start_context) + page = self._make_page(context) + except WizardError as ex: + page = WizardInstallerErrorPage(ex, self) + self.ui.stackedWidget.addWidget(page) + self._update_prev_button() + self._update_next_button() + self._update_focus() + return super().exec() + + def tr(self, value: str): # pyright: ignore[reportIncompatibleMethodOverride] + return QApplication.translate("WizardInstallerDialog", value) 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) diff --git a/libs/installer_wizard/src/installer_wizard_en.ts b/libs/installer_wizard/src/installer_wizard_en.ts new file mode 100644 index 0000000..0b1bd68 --- /dev/null +++ b/libs/installer_wizard/src/installer_wizard_en.ts @@ -0,0 +1,156 @@ + + + + + WizardInstaller + + + BAIN Wizard Installer + + + + + Installer for BAIN archive containing wizard scripts. + + + + + WizardInstallerComplete + + + The installer script has finished. The following sub-packages and plugins will be installed. + + + + + Sub-Packages: + + + + + Plugins: + + + + + INI Tweaks: + + + + + Notes: + + + + + WizardInstallerDialog + + + + Next + + + + + Install anyway + + + + + Install + + + + + BAIN Wizard Installer + + + + + Name + + + + + Manual + + + + + Script + + + + + Back + + + + + Cancel + + + + + WizardInstallerPage + + + Options: + + + + + Description: + + + + + WizardInstallerRequires + + + <html><head/><body><p><span style=" font-weight:600;">Warning:</span> The following version requirements are not met for using this installer.</p></body></html> + + + + + Requirements + + + + + Graphics Extender + + + + + Wrye Bash + + + + + Need + + + + + Game + + + + + + N/A + + + + + Have + + + + + Script Extender + + + + diff --git a/libs/installer_wizard/src/runner.py b/libs/installer_wizard/src/runner.py new file mode 100644 index 0000000..e098e11 --- /dev/null +++ b/libs/installer_wizard/src/runner.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Iterable, List, Optional + +from wizard.interpreter import WizardInterpreter +from wizard.manager import ManagerModInterface +from wizard.severity import SeverityContext +from wizard.utils import make_runner_context_factory +from wizard.value import SubPackage, SubPackages + +import mobase + + +class MO2SubPackage(SubPackage): + _tree: mobase.IFileTree + _files: List[str] + + def __init__(self, tree: mobase.IFileTree): + super().__init__(tree.name()) + self._tree = tree + + # we cannot perform lazy iteration on the tree in a Python way so we + # have to list the files + self._files = [] + + def fn(folder: str, entry: mobase.FileTreeEntry) -> mobase.IFileTree.WalkReturn: + self._files.append(entry.path()) + return mobase.IFileTree.CONTINUE + + self._tree.walk(fn) + + @property + def files(self) -> Iterable[str]: + return self._files + + +class MO2SeverityContext(SeverityContext): + _organizer: mobase.IOrganizer + + def __init__(self, organizer: mobase.IOrganizer): + super().__init__() + self._organizer = organizer + + def warning(self, text: str): + print(text, file=sys.stderr) + + +class MO2ManagerModInterface(ManagerModInterface): + _organizer: mobase.IOrganizer + _game: mobase.IPluginGame + _subpackages: SubPackages + + def __init__(self, tree: mobase.IFileTree, organizer: mobase.IOrganizer): + self._organizer = organizer + self._game = organizer.managedGame() + + checker = self._organizer.gameFeatures().gameFeature(mobase.ModDataChecker) + + # read the sub-packages + self._subpackages = SubPackages() + for entry in tree: + if isinstance(entry, mobase.IFileTree): + if checker: + if checker.dataLooksValid(entry) == mobase.ModDataChecker.VALID: + self._subpackages.append(MO2SubPackage(entry)) + continue + + # add entry with INI tweaks + if entry.exists("INI Tweaks") or entry.exists("INI"): + self._subpackages.append(MO2SubPackage(entry)) + continue + + # we add folder with format "XXX Docs" where "XXX" is a number + parts = entry.name().split() + if ( + len(parts) >= 2 + and parts[0].isdigit() + and parts[1].lower().startswith("doc") + ): + self._subpackages.append(MO2SubPackage(entry)) + + @property + def subpackages(self) -> SubPackages: + return self._subpackages + + def compareGameVersion(self, version: str) -> int: + v1 = mobase.VersionInfo(version) + v2 = mobase.VersionInfo(self._game.gameVersion()) + if v1 < v2: + return 1 + elif v1 > v2: + return -1 + else: + return 0 + + def compareSEVersion(self, version: str) -> int: + se = self._organizer.gameFeatures().gameFeature(mobase.ScriptExtender) + if not se: + return 1 + v1 = mobase.VersionInfo(version) + v2 = mobase.VersionInfo(se.getExtenderVersion()) + if v1 < v2: + return 1 + elif v1 > v2: + return -1 + else: + return 0 + + def compareGEVersion(self, version: str) -> int: + # cannot do th is in MO2 + return 1 + + def compareWBVersion(self, version: str) -> int: + # cannot do this in MO2 + return 1 + + def _resolve(self, filepath: str) -> Optional[Path]: + """ + Resolve the given filepath. + + Args: + filepath: The path to resolve. + + Returns: + The path to the given file on the disk, or one of the file mapping + to it in the VFS, or None if the file does not exists. + """ + # TODO: This does not handle weird path that go back (..) and then in data + # again, e.g. ../data/xxx.esp. + path: Optional[Path] + if filepath.startswith(".."): + path = Path(self._game.dataDirectory().absoluteFilePath(filepath)) + if not path.exists(): + path = None + else: + path = Path(filepath) + parent = path.parent.as_posix() + if parent == ".": + parent = "" + + files = self._organizer.findFiles(parent, "*" + path.name) + if files: + path = Path(files[0]) + else: + path = None + + return path + + def dataFileExists(self, *filepaths: str) -> bool: + return all(self._resolve(path) for path in filepaths) + + def getPluginLoadOrder(self, filename: str, fallback: int = -1) -> int: + return self._organizer.pluginList().loadOrder(filename) + + def getPluginStatus(self, filename: str) -> int: + state = self._organizer.pluginList().state(filename) + + if state == mobase.PluginState.ACTIVE: + return 2 + if state == mobase.PluginState.INACTIVE: + return 0 # Or 1? + return -1 + + def getFilename(self, path: str) -> str: + path_ = self._resolve(path) + if path_: + if path_.is_file(): + return path_.name + return "" + + def getFolder(self, path: str) -> str: + path_ = self._resolve(path) + if path_: + if path_.is_dir(): + return path_.name + return "" + + +def make_interpreter( + base: mobase.IFileTree, organizer: mobase.IOrganizer +) -> WizardInterpreter[Any]: + manager = MO2ManagerModInterface(base, organizer) + severity = MO2SeverityContext(organizer) + + factory = make_runner_context_factory(manager.subpackages, manager, severity) + + return WizardInterpreter(factory) diff --git a/libs/installer_wizard/src/ui/wizardinstallercomplete.ui b/libs/installer_wizard/src/ui/wizardinstallercomplete.ui new file mode 100644 index 0000000..e5357dd --- /dev/null +++ b/libs/installer_wizard/src/ui/wizardinstallercomplete.ui @@ -0,0 +1,271 @@ + + + WizardInstallerComplete + + + + 0 + 0 + 652 + 476 + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + 0 + 0 + + + + The installer script has finished. The following sub-packages and plugins will be installed. + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Vertical + + + + + 0 + 15 + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + Sub-Packages: + + + true + + + + + + + + + + + + + + Plugins: + + + true + + + + + + + + + + + + + + + + + + 0 + 10 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + 0 + 0 + + + + 0 + + + INI Tweaks: + + + true + + + + + + + + + + + + true + + + + + + + + + + + + + 0 + 10 + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + Notes: + + + true + + + + + + + + 0 + 0 + + + + true + + + + + + + + + + + + + + + + + + + diff --git a/libs/installer_wizard/src/ui/wizardinstallerdialog.ui b/libs/installer_wizard/src/ui/wizardinstallerdialog.ui new file mode 100644 index 0000000..4ae2565 --- /dev/null +++ b/libs/installer_wizard/src/ui/wizardinstallerdialog.ui @@ -0,0 +1,128 @@ + + + WizardInstallerDialog + + + + 0 + 0 + 769 + 477 + + + + BAIN Wizard Installer + + + + + + 0 + + + + + Name + + + + + + + + 0 + 0 + + + + true + + + + + + + + + -1 + + + + + + + + + Manual + + + false + + + + + + + Script + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + false + + + Back + + + false + + + + + + + Next + + + + + + + Cancel + + + false + + + false + + + false + + + + + + + + + + diff --git a/libs/installer_wizard/src/ui/wizardinstallererror.ui b/libs/installer_wizard/src/ui/wizardinstallererror.ui new file mode 100644 index 0000000..d364001 --- /dev/null +++ b/libs/installer_wizard/src/ui/wizardinstallererror.ui @@ -0,0 +1,103 @@ + + + WizardInstallerError + + + + 0 + 0 + 652 + 476 + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 20 + + + + + + + + + + + true + + + + + + + + + true + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html> + + + + + + + + + + diff --git a/libs/installer_wizard/src/ui/wizardinstallerpage.ui b/libs/installer_wizard/src/ui/wizardinstallerpage.ui new file mode 100644 index 0000000..2e31c83 --- /dev/null +++ b/libs/installer_wizard/src/ui/wizardinstallerpage.ui @@ -0,0 +1,171 @@ + + + WizardInstallerPage + + + + 0 + 0 + 652 + 476 + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 6 + + + + + true + + + + 0 + 0 + + + + + 0 + 0 + + + + QFrame::NoFrame + + + QFrame::Plain + + + 1 + + + 0 + + + + 0 + + + 2 + + + 0 + + + 2 + + + + + 6 + + + 0 + + + 0 + + + + + QFrame::StyledPanel + + + QFrame::Sunken + + + + + + true + + + 0 + + + + + + + + + + + + Options: + + + + + + + + + QListView::Adjust + + + + + + + + 0 + 0 + + + + + + + false + + + Qt::AlignCenter + + + + + + + + + Description: + + + + + + + QFrame::WinPanel + + + true + + + + + + + + + + diff --git a/libs/installer_wizard/src/ui/wizardinstallerrequires.ui b/libs/installer_wizard/src/ui/wizardinstallerrequires.ui new file mode 100644 index 0000000..63bd607 --- /dev/null +++ b/libs/installer_wizard/src/ui/wizardinstallerrequires.ui @@ -0,0 +1,220 @@ + + + WizardInstallerRequires + + + + 0 + 0 + 652 + 476 + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + <html><head/><body><p><span style=" font-weight:600;">Warning:</span> The following version requirements are not met for using this installer.</p></body></html> + + + 10 + + + + + + + Requirements + + + + + + + + Graphics Extender + + + true + + + + + + + + + + + + + + + + + + + + + Wrye Bash + + + true + + + + + + + Need + + + true + + + + + + + + + + + + + + Game + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + N/A + + + + + + + Have + + + true + + + + + + + N/A + + + + + + + + + + + + + + Script Extender + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Preferred + + + + 20 + 40 + + + + + + + + + + + diff --git a/libs/installer_wizard/src/utils.py b/libs/installer_wizard/src/utils.py new file mode 100644 index 0000000..da85c48 --- /dev/null +++ b/libs/installer_wizard/src/utils.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import re +from collections.abc import Sequence +from pathlib import Path +from typing import cast + +from wizard.tweaks import WizardINISetting, WizardINISettingEdit + + +def make_obscript_ini_tweaks(tweaks: Sequence[WizardINISetting]) -> str: + lines: list[str] = [] + + for tweak in tweaks: + if not isinstance(tweak, WizardINISettingEdit): + continue + line = f"{tweak.section} {tweak.setting} to {tweak.value}" + if tweak.comment: + line += f" ; {tweak.comment}" + + lines.append(line) + + return "\n".join(["; Generated by Mod Organizer 2 via Wizard"] + sorted(lines)) + + +def make_standard_ini_tweaks(tweaks: Sequence[WizardINISetting]) -> str: + # group Tweaks per section + sections: dict[str, list[WizardINISetting]] = {m.section: [] for m in tweaks} + for m in tweaks: + sections[m.section].append(m) + + lines: list[str] = [] + + for k in sorted(sections): + s_tweaks = sorted(sections[k], key=lambda m: m.setting) + lines.append(f"[{k}]") + for tw in s_tweaks: + if isinstance(tw, WizardINISettingEdit): + line = f"{tw.setting} = {tw.value}" + if tw.comment: + line += f" # {tw.comment}" + else: + line = f"# {tw.setting} - disabled" + lines.append(line) + lines.append("\n") + + return "\n".join(lines[:-1]) + + +def merge_standard_ini_tweaks(tweaks: Sequence[WizardINISetting], file: Path) -> str: + import sys + + print(f"Cannot merge INI Tweaks for {file.name}.", file=sys.stderr) + return make_standard_ini_tweaks(tweaks) + + +def merge_obscript_ini_tweaks(tweaks: Sequence[WizardINISetting], file: Path) -> str: + # this is from Wrye Bash (and the function is inspired from Wrye Bash) + reComment = re.compile(";.*", re.U) + reDeleted = re.compile("" r";-(\w.*?)$", re.U) + reSet = re.compile("" r"\s*set\s+(.+?)\s+to\s+(.*)", re.I | re.U) + reSetGS = re.compile("" r"\s*setGS\s+(.+?)\s+(.*)", re.I | re.U) + reSetNGS = re.compile("" r"\s*SetNumericGameSetting\s+(.+?)\s+(.*)", re.I | re.U) + + _regex_tuples = ( + (reSet, "set", "set {} to {}"), + (reSetGS, "setgs", "setGS {} {}"), + (reSetNGS, "setnumericgamesetting", "SetNumericGameSetting {} {}"), + ) + + def _parse_obse_line(line: str): + for regex, sectionKey, format_string in _regex_tuples: + match = regex.match(line) + if match: + return match, sectionKey, format_string + return None, None, None + + # read the original file + with open(file, "r") as fp: + olines = fp.readlines() + + # map setting name to value + settings: dict[str, dict[str, WizardINISettingEdit]] = {} + deleted: dict[str, dict[str, WizardINISetting]] = {} + for tweak in tweaks: + if isinstance(tweak, WizardINISettingEdit): + if tweak.section not in settings: + settings[tweak.section.lower()] = {} + settings[tweak.section.lower()][tweak.setting] = tweak + else: + if tweak.section not in deleted: + deleted[tweak.section.lower()] = {} + deleted[tweak.section.lower()][tweak.setting] = tweak + + # create the lines + lines: list[str] = [] + for line in olines: + line = line.rstrip() + maDeleted = reDeleted.match(line) + if maDeleted: + stripped = maDeleted.group(1) + else: + stripped = line + stripped = reComment.sub("", stripped).strip() + + match, section_key, format_string = _parse_obse_line(stripped) + + if match: + assert format_string is not None + setting = match.group(1) + if section_key in settings and setting in settings[section_key]: + value = settings[section_key][setting].value + line = format_string.format(setting, value) + comment = "" + if settings[section_key][setting].comment: + comment = cast(str, settings[section_key][setting].comment) + comment += " " + comment += f"(set by MO2 via Wizard, was {match.group(2)})" + line = f"{line} ; {comment}" + del settings[section_key][setting] + elif ( + not maDeleted + and section_key in deleted + and setting in deleted[section_key] + ): + line = f";-{line}" + + lines.append(line) + + for section in settings.values(): + line = "" + for setting in section.values(): + if setting.section.lower() == "set": + line = f"{setting.section} {setting.setting} to {setting.value}" + else: + line = f"{setting.section} {setting.setting} {setting.value}" + + if setting.comment: + comment = setting.comment + " (set by MO2 via Wizard)" + else: + comment = "(set by MO2 via Wizard)" + line = f"{line} ; {comment}" + + lines.append(line) + + return "\n".join(lines) + + +def make_ini_tweaks(tweaks: Sequence[WizardINISetting]) -> str: + is_set = [ + tw.section.lower() in ("set", "setgs", "setnumericgamesetting") for tw in tweaks + ] + + # assume there is no mix + if all(is_set): + return make_obscript_ini_tweaks(tweaks) + else: + return make_standard_ini_tweaks(tweaks) + + +def merge_ini_tweaks(tweaks: Sequence[WizardINISetting], file: Path) -> str: + is_set = [ + tw.section.lower() in ("set", "setgs", "setnumericgamesetting") for tw in tweaks + ] + + # assume there is no mix + if all(is_set): + return merge_obscript_ini_tweaks(tweaks, file) + else: + return merge_standard_ini_tweaks(tweaks, file) -- cgit v1.3.1