aboutsummaryrefslogtreecommitdiff
path: root/libs/installer_wizard/src
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
commit7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch)
tree27fb39be241fdb5ac2734c574de678977d1856d0 /libs/installer_wizard/src
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 <noreply@anthropic.com>
Diffstat (limited to 'libs/installer_wizard/src')
-rw-r--r--libs/installer_wizard/src/CMakeLists.txt4
-rw-r--r--libs/installer_wizard/src/__init__.py17
-rw-r--r--libs/installer_wizard/src/dialog.py752
-rw-r--r--libs/installer_wizard/src/installer.py397
-rw-r--r--libs/installer_wizard/src/installer_wizard_en.ts156
-rw-r--r--libs/installer_wizard/src/runner.py189
-rw-r--r--libs/installer_wizard/src/ui/wizardinstallercomplete.ui271
-rw-r--r--libs/installer_wizard/src/ui/wizardinstallerdialog.ui128
-rw-r--r--libs/installer_wizard/src/ui/wizardinstallererror.ui103
-rw-r--r--libs/installer_wizard/src/ui/wizardinstallerpage.ui171
-rw-r--r--libs/installer_wizard/src/ui/wizardinstallerrequires.ui220
-rw-r--r--libs/installer_wizard/src/utils.py170
12 files changed, 2578 insertions, 0 deletions
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1">
+ <context>
+ <name>WizardInstaller</name>
+ <message>
+ <location filename="installer.py" line="52" />
+ <source>BAIN Wizard Installer</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="installer.py" line="58" />
+ <source>Installer for BAIN archive containing wizard scripts.</source>
+ <translation type="unfinished" />
+ </message>
+ </context>
+ <context>
+ <name>WizardInstallerComplete</name>
+ <message>
+ <location filename="ui\wizardinstallercomplete.ui" line="0" />
+ <source>The installer script has finished. The following sub-packages and plugins will be installed.</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallercomplete.ui" line="0" />
+ <source>Sub-Packages:</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallercomplete.ui" line="0" />
+ <source>Plugins:</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallercomplete.ui" line="0" />
+ <source>INI Tweaks:</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallercomplete.ui" line="0" />
+ <source>Notes:</source>
+ <translation type="unfinished" />
+ </message>
+ </context>
+ <context>
+ <name>WizardInstallerDialog</name>
+ <message>
+ <location filename="ui\wizardinstallerdialog.ui" line="0" />
+ <location filename="dialog.py" line="688" />
+ <source>Next</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="dialog.py" line="690" />
+ <source>Install anyway</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="dialog.py" line="694" />
+ <source>Install</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerdialog.ui" line="0" />
+ <source>BAIN Wizard Installer</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerdialog.ui" line="0" />
+ <source>Name</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerdialog.ui" line="0" />
+ <source>Manual</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerdialog.ui" line="0" />
+ <source>Script</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerdialog.ui" line="0" />
+ <source>Back</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerdialog.ui" line="0" />
+ <source>Cancel</source>
+ <translation type="unfinished" />
+ </message>
+ </context>
+ <context>
+ <name>WizardInstallerPage</name>
+ <message>
+ <location filename="ui\wizardinstallerpage.ui" line="0" />
+ <source>Options:</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerpage.ui" line="0" />
+ <source>Description:</source>
+ <translation type="unfinished" />
+ </message>
+ </context>
+ <context>
+ <name>WizardInstallerRequires</name>
+ <message>
+ <location filename="ui\wizardinstallerrequires.ui" line="0" />
+ <source>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=" font-weight:600;"&gt;Warning:&lt;/span&gt; The following version requirements are not met for using this installer.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerrequires.ui" line="0" />
+ <source>Requirements</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerrequires.ui" line="0" />
+ <source>Graphics Extender</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerrequires.ui" line="0" />
+ <source>Wrye Bash</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerrequires.ui" line="0" />
+ <source>Need</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerrequires.ui" line="0" />
+ <source>Game</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerrequires.ui" line="0" />
+ <location filename="ui\wizardinstallerrequires.ui" line="0" />
+ <source>N/A</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerrequires.ui" line="0" />
+ <source>Have</source>
+ <translation type="unfinished" />
+ </message>
+ <message>
+ <location filename="ui\wizardinstallerrequires.ui" line="0" />
+ <source>Script Extender</source>
+ <translation type="unfinished" />
+ </message>
+ </context>
+</TS>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>WizardInstallerComplete</class>
+ <widget class="QWidget" name="WizardInstallerComplete">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>652</width>
+ <height>476</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string notr="true"/>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QLabel" name="label">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>The installer script has finished. The following sub-packages and plugins will be installed.</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QWidget" name="widget" native="true">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>0</width>
+ <height>0</height>
+ </size>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_11">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QSplitter" name="splitter">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <widget class="QWidget" name="packagesWidget" native="true">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>15</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>0</width>
+ <height>0</height>
+ </size>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_9">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout_5">
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout_3">
+ <item>
+ <widget class="QLabel" name="label_2">
+ <property name="text">
+ <string>Sub-Packages:</string>
+ </property>
+ <property name="heading" stdset="0">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QListWidget" name="subpackagesList"/>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout_4">
+ <item>
+ <widget class="QLabel" name="label_3">
+ <property name="text">
+ <string>Plugins:</string>
+ </property>
+ <property name="heading" stdset="0">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QListWidget" name="pluginsList"/>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="tweaksWidget" native="true">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>10</verstretch>
+ </sizepolicy>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_7">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout_6">
+ <item>
+ <widget class="QLabel" name="tweaksLabel">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Minimum">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="lineWidth">
+ <number>0</number>
+ </property>
+ <property name="text">
+ <string>INI Tweaks:</string>
+ </property>
+ <property name="heading" stdset="0">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="tweaksLayout">
+ <item>
+ <widget class="QListWidget" name="tweaksList"/>
+ </item>
+ <item>
+ <widget class="QPlainTextEdit" name="tweaksTextEdit">
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="notesWidget" native="true">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>10</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>0</width>
+ <height>0</height>
+ </size>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_10">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout_8">
+ <item>
+ <widget class="QLabel" name="notesLabel">
+ <property name="text">
+ <string>Notes:</string>
+ </property>
+ <property name="heading" stdset="0">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QTextEdit" name="notesTextEdit">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>WizardInstallerDialog</class>
+ <widget class="QDialog" name="WizardInstallerDialog">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>769</width>
+ <height>477</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>BAIN Wizard Installer</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,0">
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_2" stretch="1,2">
+ <property name="spacing">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>Name</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QComboBox" name="nameCombo">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Minimum" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="editable">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QStackedWidget" name="stackedWidget">
+ <property name="currentIndex">
+ <number>-1</number>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_3">
+ <item>
+ <widget class="QPushButton" name="manualBtn">
+ <property name="text">
+ <string>Manual</string>
+ </property>
+ <property name="autoDefault">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="scriptBtn">
+ <property name="text">
+ <string>Script</string>
+ </property>
+ <property name="autoDefault">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QPushButton" name="prevBtn">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string>Back</string>
+ </property>
+ <property name="autoDefault">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="nextBtn">
+ <property name="text">
+ <string>Next</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="cancelBtn">
+ <property name="text">
+ <string>Cancel</string>
+ </property>
+ <property name="autoDefault">
+ <bool>false</bool>
+ </property>
+ <property name="default">
+ <bool>false</bool>
+ </property>
+ <property name="flat">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>WizardInstallerError</class>
+ <widget class="QWidget" name="WizardInstallerError">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>652</width>
+ <height>476</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string notr="true"/>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <widget class="QLabel" name="iconLabel">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>32</width>
+ <height>32</height>
+ </size>
+ </property>
+ <property name="text">
+ <string notr="true"/>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeType">
+ <enum>QSizePolicy::Fixed</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>10</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QLabel" name="titleLabel">
+ <property name="text">
+ <string notr="true"/>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QTextEdit" name="messageEdit">
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ <property name="markdown">
+ <string notr="true"/>
+ </property>
+ <property name="html">
+ <string notr="true">&lt;!DOCTYPE HTML PUBLIC &quot;-//W3C//DTD HTML 4.0//EN&quot; &quot;http://www.w3.org/TR/REC-html40/strict.dtd&quot;&gt;
+&lt;html&gt;&lt;head&gt;&lt;meta name=&quot;qrichtext&quot; content=&quot;1&quot; /&gt;&lt;style type=&quot;text/css&quot;&gt;
+p, li { white-space: pre-wrap; }
+&lt;/style&gt;&lt;/head&gt;&lt;body style=&quot; font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;&quot;&gt;
+&lt;p style=&quot;-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;&lt;br /&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>WizardInstallerPage</class>
+ <widget class="QWidget" name="WizardInstallerPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>652</width>
+ <height>476</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string notr="true"/>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,2,0,1">
+ <property name="spacing">
+ <number>6</number>
+ </property>
+ <item>
+ <widget class="QFrame" name="selectDescriptionFrame">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>0</width>
+ <height>0</height>
+ </size>
+ </property>
+ <property name="frameShape">
+ <enum>QFrame::NoFrame</enum>
+ </property>
+ <property name="frameShadow">
+ <enum>QFrame::Plain</enum>
+ </property>
+ <property name="lineWidth">
+ <number>1</number>
+ </property>
+ <property name="midLineWidth">
+ <number>0</number>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_5">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>2</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>2</number>
+ </property>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout_4">
+ <property name="spacing">
+ <number>6</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QLabel" name="selectDescriptionLabel">
+ <property name="frameShape">
+ <enum>QFrame::StyledPanel</enum>
+ </property>
+ <property name="frameShadow">
+ <enum>QFrame::Sunken</enum>
+ </property>
+ <property name="text">
+ <string notr="true"/>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ <property name="margin">
+ <number>0</number>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="optionLabel">
+ <property name="text">
+ <string>Options:</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_2" stretch="1,1">
+ <item>
+ <widget class="QListWidget" name="optionList">
+ <property name="resizeMode">
+ <enum>QListView::Adjust</enum>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="imageLabel">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Ignored" vsizetype="Ignored">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="scaledContents">
+ <bool>false</bool>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QLabel" name="descriptionLabel">
+ <property name="text">
+ <string>Description:</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QTextEdit" name="descriptionTextEdit">
+ <property name="frameShape">
+ <enum>QFrame::WinPanel</enum>
+ </property>
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>WizardInstallerRequires</class>
+ <widget class="QWidget" name="WizardInstallerRequires">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>652</width>
+ <height>476</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string notr="true"/>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout_2">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-weight:600;&quot;&gt;Warning:&lt;/span&gt; The following version requirements are not met for using this installer.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ <property name="margin">
+ <number>10</number>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="groupBox">
+ <property name="title">
+ <string>Requirements</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="0">
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="3" column="0">
+ <widget class="QLabel" name="labelGraphicsExtender">
+ <property name="text">
+ <string>Graphics Extender</string>
+ </property>
+ <property name="headercell" stdset="0">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="3">
+ <widget class="QLabel" name="labelWryeBashIcon">
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="2">
+ <widget class="QLabel" name="labelGameHave">
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="0">
+ <widget class="QLabel" name="labelWryeBash">
+ <property name="text">
+ <string>Wrye Bash</string>
+ </property>
+ <property name="headercell" stdset="0">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <widget class="QLabel" name="label_18">
+ <property name="text">
+ <string>Need</string>
+ </property>
+ <property name="headercell" stdset="0">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="1">
+ <widget class="QLabel" name="labelGraphicsExtenderNeed">
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="labelGame">
+ <property name="text">
+ <string>Game</string>
+ </property>
+ <property name="headercell" stdset="0">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QLabel" name="labelScriptExtenderNeed">
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="1">
+ <widget class="QLabel" name="labelWryeBashNeed">
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="3">
+ <widget class="QLabel" name="labelScriptExtenderIcon">
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="2">
+ <widget class="QLabel" name="labelWryeBashHave">
+ <property name="text">
+ <string>N/A</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="2">
+ <widget class="QLabel" name="label_19">
+ <property name="text">
+ <string>Have</string>
+ </property>
+ <property name="headercell" stdset="0">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="2">
+ <widget class="QLabel" name="labelGraphicsExtenderHave">
+ <property name="text">
+ <string>N/A</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="3">
+ <widget class="QLabel" name="labelGameIcon">
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="labelScriptExtender">
+ <property name="text">
+ <string>Script Extender</string>
+ </property>
+ <property name="headercell" stdset="0">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="2">
+ <widget class="QLabel" name="labelScriptExtenderHave">
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="3">
+ <widget class="QLabel" name="labelGraphicsExtenderIcon">
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLabel" name="labelGameNeed">
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeType">
+ <enum>QSizePolicy::Preferred</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
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)