aboutsummaryrefslogtreecommitdiff
path: root/libs/installer_fomod_plus
diff options
context:
space:
mode:
Diffstat (limited to 'libs/installer_fomod_plus')
-rw-r--r--libs/installer_fomod_plus/.clang-format4
-rw-r--r--libs/installer_fomod_plus/.clangd16
-rw-r--r--libs/installer_fomod_plus/.editorconfig16
-rw-r--r--libs/installer_fomod_plus/CMakeLists.txt36
-rw-r--r--libs/installer_fomod_plus/cmake/patch_pugixml.cmake20
-rw-r--r--libs/installer_fomod_plus/installer/CMakeLists.txt53
-rw-r--r--libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp840
-rw-r--r--libs/installer_fomod_plus/installer/FomodInstallerWindow.h214
-rw-r--r--libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp512
-rw-r--r--libs/installer_fomod_plus/installer/FomodPlusInstaller.h115
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_de.ts4
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_en.ts4
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts75
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts79
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts75
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts4
-rw-r--r--libs/installer_fomod_plus/installer/fomodplus.json4
-rw-r--r--libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp50
-rw-r--r--libs/installer_fomod_plus/installer/integration/FomodDataContent.h21
-rw-r--r--libs/installer_fomod_plus/installer/lib/ConditionTester.cpp176
-rw-r--r--libs/installer_fomod_plus/installer/lib/ConditionTester.h42
-rw-r--r--libs/installer_fomod_plus/installer/lib/CrashHandler.cpp122
-rw-r--r--libs/installer_fomod_plus/installer/lib/CrashHandler.h20
-rw-r--r--libs/installer_fomod_plus/installer/lib/FileInstaller.cpp274
-rw-r--r--libs/installer_fomod_plus/installer/lib/FileInstaller.h94
-rw-r--r--libs/installer_fomod_plus/installer/lib/FlagMap.h113
-rw-r--r--libs/installer_fomod_plus/installer/lib/Logger.h118
-rw-r--r--libs/installer_fomod_plus/installer/lib/ViewModels.h120
-rw-r--r--libs/installer_fomod_plus/installer/resources.qrc14
-rw-r--r--libs/installer_fomod_plus/installer/resources/delete.pngbin0 -> 2125 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/fomod_icon.pngbin0 -> 1782 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/image_icon.pngbin0 -> 727 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/left-chevron.pngbin0 -> 1732 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/right-chevron.pngbin0 -> 1808 bytes
-rw-r--r--libs/installer_fomod_plus/installer/ui/ClickableWidget.h24
-rw-r--r--libs/installer_fomod_plus/installer/ui/Colors.h155
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp284
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodImageViewer.h92
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp746
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodViewModel.h154
-rw-r--r--libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp120
-rw-r--r--libs/installer_fomod_plus/installer/ui/ScaleLabel.h45
-rw-r--r--libs/installer_fomod_plus/installer/ui/UIHelper.cpp95
-rw-r--r--libs/installer_fomod_plus/installer/ui/UIHelper.h80
-rw-r--r--libs/installer_fomod_plus/patchwizard/CMakeLists.txt45
-rw-r--r--libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp181
-rw-r--r--libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h54
-rw-r--r--libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts96
-rw-r--r--libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts19
-rw-r--r--libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json8
-rw-r--r--libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp86
-rw-r--r--libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h49
-rw-r--r--libs/installer_fomod_plus/patchwizard/resources.qrc6
-rw-r--r--libs/installer_fomod_plus/patchwizard/resources/fomod_icon.pngbin0 -> 1782 bytes
-rw-r--r--libs/installer_fomod_plus/patchwizard/resources/infoscroll.pngbin0 -> 3682 bytes
-rw-r--r--libs/installer_fomod_plus/scanner/CMakeLists.txt54
-rw-r--r--libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp128
-rw-r--r--libs/installer_fomod_plus/scanner/FomodPlusScanner.h64
-rw-r--r--libs/installer_fomod_plus/scanner/archiveparser.h98
-rw-r--r--libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts57
-rw-r--r--libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts57
-rw-r--r--libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts57
-rw-r--r--libs/installer_fomod_plus/scanner/fomodplusscanner.json8
-rw-r--r--libs/installer_fomod_plus/scanner/resources.qrc6
-rw-r--r--libs/installer_fomod_plus/scanner/resources/fomod_icon.pngbin0 -> 1782 bytes
-rw-r--r--libs/installer_fomod_plus/scanner/resources/wizard.gifbin0 -> 5263 bytes
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h160
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodDB.h146
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h129
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/FomodRescan.h277
-rw-r--r--libs/installer_fomod_plus/share/FOMODData/PluginReader.h119
-rw-r--r--libs/installer_fomod_plus/share/stringutil.h150
-rw-r--r--libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp44
-rw-r--r--libs/installer_fomod_plus/share/xml/FomodInfoFile.h25
-rw-r--r--libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp352
-rw-r--r--libs/installer_fomod_plus/share/xml/ModuleConfiguration.h305
-rw-r--r--libs/installer_fomod_plus/share/xml/XmlHelper.h17
-rw-r--r--libs/installer_fomod_plus/share/xml/XmlParseException.h10
78 files changed, 7837 insertions, 0 deletions
diff --git a/libs/installer_fomod_plus/.clang-format b/libs/installer_fomod_plus/.clang-format
new file mode 100644
index 0000000..37702ff
--- /dev/null
+++ b/libs/installer_fomod_plus/.clang-format
@@ -0,0 +1,4 @@
+BasedOnStyle: WebKit
+AlignConsecutiveAssignments: Consecutive
+AccessModifierOffset: -2
+ColumnLimit: 120 \ No newline at end of file
diff --git a/libs/installer_fomod_plus/.clangd b/libs/installer_fomod_plus/.clangd
new file mode 100644
index 0000000..f22e5c1
--- /dev/null
+++ b/libs/installer_fomod_plus/.clangd
@@ -0,0 +1,16 @@
+CompileFlags:
+ Add:
+ - "-ID:/var/mo2-fomod-plus/installer"
+ - "-ID:/var/mo2-fomod-plus/scanner"
+ - "-ID:/var/mo2-fomod-plus/share"
+ - "-ID:/var/mo2-fomod-plus/vsbuild/_deps/pugixml-src/src"
+ - "-ID:/var/mo2-fomod-plus/vsbuild/_deps/json-src/include"
+ - "-ID:/var/vcpkg/packages/mo2-uibase_x64-windows/include"
+ - "-ID:/var/vcpkg/packages/mo2-uibase_x64-windows/include/uibase"
+ - "-ID:/var/vcpkg/packages/mo2-uibase_x64-windows/include/uibase/game_features"
+ - "-IC:/Qt/6.7.3/msvc2022_64/include"
+ - "-IC:/Qt/6.7.3/msvc2022_64/include/QtCore"
+ - "-IC:/Qt/6.7.3/msvc2022_64/include/QtGui"
+ - "-IC:/Qt/6.7.3/msvc2022_64/include/QtWidgets"
+ - "-std=c++20"
+
diff --git a/libs/installer_fomod_plus/.editorconfig b/libs/installer_fomod_plus/.editorconfig
new file mode 100644
index 0000000..a915e4f
--- /dev/null
+++ b/libs/installer_fomod_plus/.editorconfig
@@ -0,0 +1,16 @@
+root = true
+
+[*.cpp]
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+
+[*.h]
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+
+[*.ui]
+indent_style = space
+indent_size = 2
+insert_final_newline = true
diff --git a/libs/installer_fomod_plus/CMakeLists.txt b/libs/installer_fomod_plus/CMakeLists.txt
new file mode 100644
index 0000000..3367145
--- /dev/null
+++ b/libs/installer_fomod_plus/CMakeLists.txt
@@ -0,0 +1,36 @@
+# mo2-fomod-plus — vendored from github.com/aglowinthefield/mo2-fomod-plus
+# Commit: da6c07ed4bbe235759910c14c2450e1bfe8fe25e (2026-01-27)
+#
+# Pure C++20 FOMOD installer replacement (no .NET dependency).
+# 3 plugin targets: installer, scanner, patch wizard.
+
+include(FetchContent)
+
+# Force pugixml to build as a static library so plugins don't need libpugixml.so at runtime.
+# (The global BUILD_SHARED_LIBS is ON for MO2's own archive lib, which would make pugixml shared.)
+set(BUILD_SHARED_LIBS_SAVED ${BUILD_SHARED_LIBS})
+set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
+
+# Compatibility: sub-CMakeLists call mo2_install_target but Fluorine defines mo2_install_plugin
+if (NOT COMMAND mo2_install_target AND COMMAND mo2_install_plugin)
+ function(mo2_install_target target)
+ mo2_install_plugin(${target})
+ endfunction()
+endif ()
+
+# Compute include dirs from existing targets for sub-CMakeLists that reference these variables
+get_target_property(MO2_UIBASE_INCLUDE_DIRS mo2::uibase INTERFACE_INCLUDE_DIRECTORIES)
+foreach(dir ${MO2_UIBASE_INCLUDE_DIRS})
+ list(APPEND MO2_UIBASE_INCLUDE_DIRS "${dir}/uibase" "${dir}/uibase/game_features")
+endforeach()
+get_target_property(MO2_ARCHIVE_INCLUDE_DIRS mo2::archive INTERFACE_INCLUDE_DIRECTORIES)
+foreach(dir ${MO2_ARCHIVE_INCLUDE_DIRS})
+ list(APPEND MO2_ARCHIVE_INCLUDE_DIRS "${dir}/archive")
+endforeach()
+
+add_subdirectory(installer)
+add_subdirectory(scanner)
+add_subdirectory(patchwizard)
+
+# Restore BUILD_SHARED_LIBS so subsequent targets aren't affected.
+set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_SAVED} CACHE BOOL "" FORCE)
diff --git a/libs/installer_fomod_plus/cmake/patch_pugixml.cmake b/libs/installer_fomod_plus/cmake/patch_pugixml.cmake
new file mode 100644
index 0000000..01f1a68
--- /dev/null
+++ b/libs/installer_fomod_plus/cmake/patch_pugixml.cmake
@@ -0,0 +1,20 @@
+if(NOT DEFINED PUGIXML_CMAKELISTS)
+ message(FATAL_ERROR "PUGIXML_CMAKELISTS not set; cannot patch pugixml.")
+endif()
+
+if(NOT EXISTS "${PUGIXML_CMAKELISTS}")
+ message(FATAL_ERROR "Pugixml CMakeLists.txt not found at '${PUGIXML_CMAKELISTS}'.")
+endif()
+
+file(READ "${PUGIXML_CMAKELISTS}" _pugixml_content)
+string(REGEX REPLACE
+ "cmake_minimum_required\\(VERSION [^)]+\\)"
+ "cmake_minimum_required(VERSION 3.10)"
+ _pugixml_patched "${_pugixml_content}")
+
+if(_pugixml_content STREQUAL _pugixml_patched)
+ message(STATUS "pugixml CMakeLists already uses a modern cmake_minimum_required.")
+else()
+ file(WRITE "${PUGIXML_CMAKELISTS}" "${_pugixml_patched}")
+ message(STATUS "Updated pugixml cmake_minimum_required to 3.10 to silence deprecation warnings.")
+endif()
diff --git a/libs/installer_fomod_plus/installer/CMakeLists.txt b/libs/installer_fomod_plus/installer/CMakeLists.txt
new file mode 100644
index 0000000..70018ff
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/CMakeLists.txt
@@ -0,0 +1,53 @@
+cmake_minimum_required(VERSION 3.16)
+include(FetchContent)
+
+file(GLOB_RECURSE INSTALLER_SOURCES CONFIGURE_DEPENDS
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.h
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.ui
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc
+)
+file(GLOB SHARE_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../share/**/*.cpp")
+
+add_library(fomod_plus_installer SHARED ${INSTALLER_SOURCES} ${SHARE_SOURCES})
+
+target_include_directories(
+ fomod_plus_installer
+ PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}/../share
+ ${MO2_UIBASE_INCLUDE_DIRS}
+ ${MO2_ARCHIVE_INCLUDE_DIRS}
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+FetchContent_Declare(
+ pugixml
+ GIT_REPOSITORY https://github.com/zeux/pugixml
+ GIT_TAG v1.14
+ PATCH_COMMAND ${CMAKE_COMMAND}
+ -DPUGIXML_CMAKELISTS=<SOURCE_DIR>/CMakeLists.txt
+ -P ${CMAKE_CURRENT_LIST_DIR}/../cmake/patch_pugixml.cmake
+)
+
+FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz)
+FetchContent_MakeAvailable(pugixml json)
+
+set(project_type plugin)
+project(fomod_plus_installer)
+
+target_link_libraries(fomod_plus_installer PRIVATE mo2::uibase pugixml nlohmann_json::nlohmann_json)
+if (WIN32)
+ target_link_libraries(fomod_plus_installer PRIVATE dbghelp)
+endif ()
+mo2_configure_plugin(fomod_plus_installer NO_SOURCES WARNINGS OFF PRIVATE_DEPENDS)
+
+if (MSVC)
+ target_compile_options(fomod_plus_installer PRIVATE $<$<CONFIG:RelWithDebInfo>:/Zi>)
+ target_link_options(fomod_plus_installer PRIVATE $<$<CONFIG:RelWithDebInfo>:/DEBUG>)
+ install(FILES $<TARGET_PDB_FILE:fomod_plus_installer>
+ DESTINATION ${CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO}
+ CONFIGURATIONS RelWithDebInfo
+ OPTIONAL)
+endif ()
+
+mo2_install_target(fomod_plus_installer)
diff --git a/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp b/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp
new file mode 100644
index 0000000..e5f2e18
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp
@@ -0,0 +1,840 @@
+#include "FomodInstallerWindow.h"
+
+#include "ui/FomodImageViewer.h"
+
+#include "ui/ScaleLabel.h"
+#include "ui/UIHelper.h"
+#include "xml/ModuleConfiguration.h"
+
+#include <QButtonGroup>
+#include <QCheckBox>
+#include <QComboBox>
+#include <QCompleter>
+#include <QGroupBox>
+#include <QLabel>
+#include <QRadioButton>
+#include <QScrollArea>
+#include <QScrollBar>
+#include <QSettings>
+#include <QSizePolicy>
+#include <QSplitter>
+#include <QVBoxLayout>
+#include <utility>
+
+#include "ui/FomodViewModel.h"
+
+#include <unordered_set>
+
+/**
+ *
+ * @param installer
+ * @param modName
+ * @param tree
+ * @param fomodPath
+ * @param viewModel
+ * @param fomodJson
+ * @param parent
+ */
+FomodInstallerWindow::FomodInstallerWindow(
+ FomodPlusInstaller* installer,
+ GuessedValue<QString>& modName,
+ const std::shared_ptr<IFileTree>& tree,
+ QString fomodPath,
+ const std::shared_ptr<FomodViewModel>& viewModel,
+ const nlohmann::json& fomodJson,
+ QWidget* parent): QDialog(parent),
+ mInstaller(installer),
+ mFomodPath(std::move(fomodPath)),
+ mModName(modName),
+ mTree(tree),
+ mViewModel(viewModel),
+ mFomodJson(fomodJson)
+{
+ setupUi();
+
+ mInstallStepStack = new QStackedWidget(this);
+
+ // Handle legacy FOMODs with no steps
+ if (mViewModel->getSteps().empty()) {
+ // Create a simple "Install" widget for legacy FOMODs
+ auto* legacyWidget = new QWidget(this);
+ auto* layout = new QVBoxLayout(legacyWidget);
+ auto* label = new QLabel("This mod will install all files automatically.", legacyWidget);
+ layout->addWidget(label);
+ mInstallStepStack->addWidget(legacyWidget);
+ } else {
+ updateInstallStepStack();
+ stylePreviouslySelectedOptions();
+ stylePreviouslyDeselectedOptions();
+ }
+
+ const auto containerLayout = createContainerLayout();
+ setLayout(containerLayout);
+
+ updateButtons();
+ restoreGeometryAndState();
+
+ if (!mViewModel->getSteps().empty()) {
+ populatePluginMap();
+ if (mInstaller->shouldAutoRestoreChoices()) {
+ onSelectPreviousClicked();
+ }
+ } else {
+ // For empty FOMODs, set default description
+ mDescriptionBox->setText("This mod will install all files automatically.");
+ }
+}
+
+void FomodInstallerWindow::closeEvent(QCloseEvent* event)
+{
+ saveGeometryAndState();
+ QDialog::closeEvent(event);
+}
+
+void FomodInstallerWindow::saveGeometryAndState() const
+{
+ const auto cwd = QDir::currentPath();
+ QSettings settings(cwd + "/fomod-plus-settings.ini", QSettings::IniFormat);
+ settings.setValue("windowGeometry", saveGeometry());
+ settings.setValue("centerSplitState", mCenterRow->saveState());
+ settings.setValue("leftSplitState", mLeftPane->saveState());
+}
+
+void FomodInstallerWindow::restoreGeometryAndState()
+{
+ const auto cwd = QDir::currentPath();
+ const QSettings settings(cwd + "/fomod-plus-settings.ini", QSettings::IniFormat);
+ restoreGeometry(settings.value("windowGeometry").toByteArray());
+ mCenterRow->restoreState(settings.value("centerSplitState").toByteArray());
+ mLeftPane->restoreState(settings.value("leftSplitState").toByteArray());
+}
+
+void FomodInstallerWindow::populatePluginMap()
+{
+ const auto checkboxes = findChildren<QCheckBox*>();
+ const auto radioButtons = findChildren<QRadioButton*>();
+
+ for (const auto& step : mViewModel->getSteps()) {
+ for (const auto& group : step->getGroups()) {
+ for (const auto& plugin : group->getPlugins()) {
+
+ const auto name = createObjectName(plugin, group);
+
+ for (auto* checkbox : checkboxes) {
+ if (checkbox->objectName() == name) {
+ mPluginMap.insert({ name, { plugin, checkbox } });
+ }
+ }
+ for (auto* radioButton : radioButtons) {
+ if (radioButton->objectName() == name) {
+ mPluginMap.insert({ name, { plugin, radioButton } });
+ }
+ }
+ }
+ }
+ }
+}
+
+void FomodInstallerWindow::onNextClicked()
+{
+ // For legacy FOMODs with no steps, always install
+ if (mViewModel->getSteps().empty()) {
+ onInstallClicked();
+ return;
+ }
+
+ if (!mViewModel->isLastVisibleStep()) {
+ mViewModel->stepForward();
+ mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex());
+ updateButtons();
+ updateDisplayForActivePlugin();
+ } else {
+ onInstallClicked();
+ }
+}
+
+void FomodInstallerWindow::updateCheckboxStates() const
+{
+ // PluginMap presumed to be populated
+ for (const auto& [objectName, pluginData] : mPluginMap) {
+ if (pluginData.plugin->isSelected() != pluginData.uiElement->isChecked()) {
+ const auto widgetType = pluginData.uiElement->metaObject()->className();
+ if (objectName != nullptr) {
+ logMessage(DEBUG,
+ "Updating " + objectName.toStdString() + " to state: " + (pluginData.plugin->isSelected()
+ ? "TRUE"
+ : "FALSE") + " because " + widgetType +
+ " selection state is " + (pluginData.uiElement->isChecked() ? "TRUE" : "FALSE"));
+ }
+ pluginData.uiElement->setChecked(pluginData.plugin->isSelected());
+ }
+
+ if (pluginData.plugin->isEnabled() != pluginData.uiElement->isEnabled()) {
+ logMessage(DEBUG, "[WINDOW] Changing enabled state of element " + objectName.toStdString() + " to " +
+ (pluginData.plugin->isEnabled() ? "TRUE" : "FALSE"));
+ pluginData.uiElement->setEnabled(pluginData.plugin->isEnabled());
+ }
+ }
+}
+
+void FomodInstallerWindow::onPluginToggled(const bool selected, const std::shared_ptr<GroupViewModel>& group,
+ const std::shared_ptr<PluginViewModel>& plugin) const
+{
+ logMessage(INFO,
+ "onPluginToggled called with " + plugin->getName() + " in " + group->getName() + ": " +
+ std::to_string(selected));
+ if (mViewModel->togglePlugin(group, plugin, selected)) {
+ updateCheckboxStates();
+ }
+ if (mNextInstallButton != nullptr) {
+ updateButtons();
+ }
+}
+
+void FomodInstallerWindow::onPluginManuallyUnchecked(const std::shared_ptr<PluginViewModel>& plugin) const
+{
+ logMessage(INFO, "onPluginManuallyUnchecked called with " + plugin->getName());
+ mViewModel->markManuallySet(plugin);
+}
+
+void FomodInstallerWindow::onPluginHovered(const std::shared_ptr<PluginViewModel>& plugin) const
+{
+ mViewModel->setActivePlugin(plugin);
+ updateDisplayForActivePlugin();
+}
+
+void FomodInstallerWindow::onBackClicked() const
+{
+ mViewModel->stepBack();
+ mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex());
+ updateButtons();
+ updateDisplayForActivePlugin();
+}
+
+void FomodInstallerWindow::onInstallClicked()
+{
+ saveGeometryAndState();
+
+ logMessage(DEBUG, "Installing mod: " + mModName->toStdString());
+ mModName.update(mModNameInput->currentText(), GUESS_USER);
+ mViewModel->preinstall(mTree, mFomodPath);
+ // now the installer is available in the outer scope
+ // the outer scope should call getFileInstaller() and install there.
+ this->accept();
+}
+
+void FomodInstallerWindow::updateButtons() const
+{
+ // For legacy FOMODs with no steps, always show Install
+ if (mViewModel->getSteps().empty()) {
+ mBackButton->setEnabled(false);
+ mNextInstallButton->setText(tr("Install"));
+ return;
+ }
+
+ if (mViewModel->isFirstVisibleStep()) {
+ mBackButton->setEnabled(false);
+ } else {
+ mBackButton->setEnabled(true);
+ }
+
+ if (mViewModel->isLastVisibleStep()) {
+ mNextInstallButton->setText(tr("Install"));
+ } else {
+ mNextInstallButton->setText(tr("Next"));
+ }
+}
+
+void FomodInstallerWindow::setupUi()
+{
+ setWindowIcon(QIcon(":/fomod/hat"));
+ setWindowFlags(Qt::Window); // Allows OS-controlled resizing, including snapping
+ setMinimumSize(UiConstants::WINDOW_MIN_WIDTH, UiConstants::WINDOW_MIN_HEIGHT);
+ setWindowTitle(mModName);
+ setWindowModality(Qt::NonModal); // To allow scrolling modlist without closing the window
+}
+
+// mInstallStepStack must be initialized before calling this
+void FomodInstallerWindow::updateInstallStepStack()
+{
+ if (!mInstallStepStack) {
+ logMessage(ERR, "updateInstallStepStack called with no initialized mInstallStepStack. tf?");
+ return;
+ }
+ // Create the widgets for each step. Not sure if we need these as member variables. Try it like this for now.
+ for (const auto& steps = mViewModel->getSteps(); const auto& installStep : steps) {
+ mInstallStepStack->addWidget(createStepWidget(installStep));
+ }
+ mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex());
+}
+
+/*
++-------------------------------------------------------------------+
+| +----------------------------------------------------------------+|
+| | ||
+| | Metadata and Name Input ||
+| | ||
+| +----------------------------------------------------------------+|
+| +------------------------------++--------------------------------+|
+| | || ||
+| | || ||
+| | Description || ||
+| | || ||
+| | || Step/Group/Plugins ||
+| | || ||
+| +------------------------------+| ||
+| +------------------------------+| ||
+| | || ||
+| | || ||
+| | Image || ||
+| | || ||
+| | || ||
+| | || ||
+| +----------------------------------------------------------------+|
+| | ||
+| | Bottom Bar ||
+| +----------------------------------------------------------------+|
++-------------------------------------------------------------------+
+*/
+QBoxLayout* FomodInstallerWindow::createContainerLayout()
+{
+ const auto layout = new QVBoxLayout(this);
+
+ mTopRow = createTopRow();
+ mCenterRow = createCenterRow();
+ mBottomRow = createBottomRow();
+
+ layout->addWidget(mTopRow);
+ layout->addWidget(mCenterRow, 1); // stretch 1 here so the others are static size
+ layout->addWidget(mBottomRow);
+
+ if (mInstaller->shouldShowNotifications()) {
+ mNotificationsPanel = createNotificationPanel();
+ layout->addWidget(mNotificationsPanel);
+ // Set a default welcome message
+ addNotification("FOMOD Plus notification panel initialized :)", INFO);
+ }
+ return layout;
+}
+
+QSplitter* FomodInstallerWindow::createCenterRow()
+{
+ mLeftPane = createLeftPane(); // Instance var to persist the geometry
+ const auto centerRow = new QSplitter(Qt::Horizontal, this);
+ const auto rightPane = createRightPane();
+ centerRow->addWidget(mLeftPane);
+ centerRow->addWidget(rightPane);
+ centerRow->setSizes({ width() / 2, width() / 2 });
+ return centerRow;
+}
+
+QWidget* FomodInstallerWindow::createTopRow()
+{
+ const auto topRow = new QWidget(this);
+
+ auto* mainHLayout = new QHBoxLayout(topRow);
+
+ // Holds the name (label), author, version, and website
+ auto* metadataLayout = new QHBoxLayout();
+
+ // left side metadata. just the titles of the metadata
+ auto* labelsColumn = new QVBoxLayout();
+ QLabel* nameLabel = UIHelper::createLabel(tr("Name:"), topRow);
+ QLabel* authorLabel = UIHelper::createLabel(tr("Author:"), topRow);
+ QLabel* versionLabel = UIHelper::createLabel(tr("Version:"), topRow);
+ QLabel* websiteLabel = UIHelper::createLabel(tr("Website:"), topRow);
+
+ labelsColumn->addWidget(nameLabel);
+ labelsColumn->addWidget(authorLabel);
+ labelsColumn->addWidget(versionLabel);
+ labelsColumn->addWidget(websiteLabel);
+
+ UIHelper::reduceLabelPadding(labelsColumn);
+ UIHelper::setGlobalAlignment(labelsColumn, Qt::AlignTop);
+
+ // the values of the metadata MINUS the search box
+ auto* valuesColumn = new QVBoxLayout();
+ QLabel* emptyLabel = UIHelper::createLabel("", topRow);
+ QLabel* authorValueLabel = UIHelper::createLabel(mViewModel->getInfoViewModel()->getAuthor().c_str(), topRow);
+ QLabel* versionValueLabel = UIHelper::createLabel(mViewModel->getInfoViewModel()->getVersion().c_str(), topRow);
+ QLabel* websiteValueLabel = UIHelper::createHyperlink(mViewModel->getInfoViewModel()->getWebsite().c_str(), topRow);
+
+ valuesColumn->addWidget(emptyLabel);
+ valuesColumn->addWidget(authorValueLabel);
+ valuesColumn->addWidget(versionValueLabel);
+ valuesColumn->addWidget(websiteValueLabel);
+
+ // We want these cleanup fns to be at the layout level directly containing the labels.
+ // Since we aren't recursing down the UI forever we can't just call it for mainHLayout.
+ UIHelper::reduceLabelPadding(valuesColumn);
+ UIHelper::setGlobalAlignment(valuesColumn, Qt::AlignTop);
+
+ metadataLayout->addLayout(labelsColumn);
+ metadataLayout->addLayout(valuesColumn, 1); // To push the right column close to the edge of the left.
+
+ mainHLayout->addLayout(metadataLayout, 1);
+
+ // Now make the search bar layout
+ auto* modNameComboBox = createModNameComboBox();
+ mainHLayout->addWidget(modNameComboBox, 4);
+ UIHelper::setGlobalAlignment(mainHLayout, Qt::AlignTop);
+
+ // Extra stuff
+ topRow->setLayout(mainHLayout);
+ topRow->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
+ return topRow;
+}
+
+QComboBox* FomodInstallerWindow::createModNameComboBox()
+{
+ mModNameInput = new QComboBox(this);
+ mModNameInput->setEditable(true);
+
+ mModNameInput->addItem(mModName);
+
+ for (const auto& variant : mModName.variants()) {
+ if (variant.toStdString() != mModName->toStdString()) {
+ mModNameInput->addItem(variant);
+ }
+ }
+ mModNameInput->completer()->setCaseSensitivity(Qt::CaseSensitive);
+ return mModNameInput;
+}
+
+QWidget* FomodInstallerWindow::createBottomRow()
+{
+ // In vanilla FOMOD installer, left has the Manual button, right has back, next/install, and cancel buttons
+ const auto bottomRow = new QWidget(this);
+ auto* layout = new QHBoxLayout(bottomRow);
+
+ // Manual on far left
+ mManualButton = UIHelper::createButton(tr("Manual"), bottomRow);
+ mSelectPreviousButton = UIHelper::createButton(tr("Restore Previous Choices"), bottomRow);
+ mResetChoicesButton = UIHelper::createButton(tr("Reset Choices"), bottomRow);
+
+ layout->addWidget(mManualButton);
+ layout->addWidget(mSelectPreviousButton);
+ layout->addWidget(mResetChoicesButton);
+
+ // Space to push remaining buttons right
+ layout->addStretch();
+
+ mBackButton = UIHelper::createButton(tr("Back"), bottomRow);
+ mNextInstallButton = UIHelper::createButton(tr("Next"), bottomRow);
+ mCancelButton = UIHelper::createButton(tr("Cancel"), bottomRow);
+
+ mNextInstallButton->setDefault(true);
+ mNextInstallButton->setAutoDefault(true);
+
+ connect(mManualButton, SIGNAL(clicked()), this, SLOT(onManualClicked()));
+ connect(mNextInstallButton, SIGNAL(clicked()), this, SLOT(onNextClicked()));
+ connect(mBackButton, SIGNAL(clicked()), this, SLOT(onBackClicked()));
+ connect(mCancelButton, SIGNAL(clicked()), this, SLOT(onCancelClicked()));
+ connect(mSelectPreviousButton, SIGNAL(clicked()), this, SLOT(onSelectPreviousClicked()));
+ connect(mResetChoicesButton, SIGNAL(clicked()), this, SLOT(onResetChoicesClicked()));
+ // connect(mHideImagesButton, SIGNAL(clicked()), this, SLOT(toggleImagesShown()));
+
+ layout->addWidget(mBackButton);
+ layout->addWidget(mNextInstallButton);
+ layout->addWidget(mCancelButton);
+
+ bottomRow->setLayout(layout);
+ return bottomRow;
+}
+
+QSplitter* FomodInstallerWindow::createLeftPane()
+{
+ const auto leftPane = new QSplitter(Qt::Vertical, this);
+
+ // Add description box
+ // Initialize with defaults (the first plugin's description (which defaults to the module image otherwise))
+ auto* scrollArea = new QScrollArea(leftPane);
+ scrollArea->setWidgetResizable(true);
+
+ mDescriptionBox = new QLabel("", leftPane);
+ mDescriptionBox->setTextFormat(Qt::RichText);
+ mDescriptionBox->setTextInteractionFlags(Qt::TextBrowserInteraction);
+ mDescriptionBox->setOpenExternalLinks(true);
+ mDescriptionBox->setWordWrap(true);
+ mDescriptionBox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+ mDescriptionBox->setAlignment(Qt::AlignTop | Qt::AlignLeft);
+
+ scrollArea->setWidget(mDescriptionBox);
+ leftPane->addWidget(scrollArea);
+
+ // Add image
+ // Initialize with defaults (the first plugin's image)
+ mImageLabel = new ScaleLabel(leftPane);
+ mImageLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+
+ connect(mImageLabel, &ScaleLabel::clicked, this, [this] {
+ if (!mImageLabel->hasResource()) {
+ return;
+ }
+ const auto viewer = new FomodImageViewer(this, mFomodPath, mViewModel->getActiveStep(),
+ mViewModel->getActivePlugin());
+ viewer->showMaximized();
+ });
+
+ if (!mInstaller->shouldShowImages()) {
+ mImageLabel->hide();
+ }
+
+ leftPane->addWidget(mImageLabel);
+ leftPane->setSizes({ height() / 2, height() / 2 });
+
+ updateDisplayForActivePlugin();
+
+ return leftPane;
+}
+
+QWidget* FomodInstallerWindow::createRightPane()
+{
+ const auto rightPane = new QWidget(this);
+ auto* layout = new QVBoxLayout(rightPane);
+
+ layout->addWidget(mInstallStepStack);
+
+ return rightPane;
+}
+
+QTextEdit* FomodInstallerWindow::createNotificationPanel()
+{
+ auto* panel = new QTextEdit(this);
+ panel->setReadOnly(true);
+ panel->setMaximumHeight(100); // Limit height
+ panel->setStyleSheet("font-family: monospace; font-size: 9pt;");
+
+ return panel;
+}
+
+QWidget* FomodInstallerWindow::createStepWidget(const std::shared_ptr<StepViewModel>& installStep)
+{
+ const auto stepBox = new QGroupBox(QString::fromStdString(installStep->getName()), this);
+ const auto stepBoxLayout = new QVBoxLayout(stepBox);
+
+ auto* scrollArea = new QScrollArea(stepBox);
+ scrollArea->setWidgetResizable(true);
+
+ const auto scrollAreaContent = new QWidget(scrollArea);
+ auto* scrollAreaLayout = new QVBoxLayout(scrollAreaContent);
+
+ for (const auto& group : installStep->getGroups()) {
+ const auto groupSection = renderGroup(group);
+ scrollAreaLayout->addWidget(groupSection);
+ }
+
+ scrollAreaContent->setLayout(scrollAreaLayout);
+ scrollArea->setWidget(scrollAreaContent);
+
+ stepBoxLayout->addWidget(scrollArea);
+ stepBox->setLayout(stepBoxLayout);
+ return stepBox;
+}
+
+QWidget* FomodInstallerWindow::renderGroup(const std::shared_ptr<GroupViewModel>& group)
+{
+ const auto groupBox = new QGroupBox(QString::fromStdString(group->getName()), this);
+ const auto groupBoxLayout = new QVBoxLayout(groupBox);
+
+ switch (group->getType()) {
+ case SelectAtLeastOne:
+ case SelectAny:
+ case SelectAll:
+ renderCheckboxGroup(groupBox, groupBoxLayout, group);
+ break;
+ case SelectExactlyOne:
+ case SelectAtMostOne:
+ renderSelectExactlyOne(groupBox, groupBoxLayout, group);
+ break;
+ default: ;
+ }
+
+ groupBox->setLayout(groupBoxLayout);
+ return groupBox;
+}
+
+QString FomodInstallerWindow::createObjectName(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group)
+{
+ const std::string objectName = std::format("[{}:{}] {}-{}",
+ group->getStepIndex(), group->getOwnIndex(),
+ group->getName(), plugin->getName());
+
+ return QString::fromStdString(objectName);
+}
+
+QRadioButton* FomodInstallerWindow::createPluginRadioButton(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group,
+ QWidget* parent)
+{
+ auto* radioButton = new QRadioButton(QString::fromStdString(plugin->getName()), parent);
+ radioButton->setObjectName(createObjectName(plugin, group));
+ auto* hoverFilter = new HoverEventFilter(plugin, this);
+ radioButton->installEventFilter(hoverFilter);
+ connect(hoverFilter, &HoverEventFilter::hovered, this, &FomodInstallerWindow::onPluginHovered);
+
+ connect(radioButton, &QRadioButton::toggled, this, [this, radioButton, group, plugin](const bool checked) {
+ logMessage(INFO,
+ "Received toggled signal for radio: " + plugin->getName() + ": " + (checked ? "TRUE" : "FALSE") +
+ " Radio is now: " + (radioButton->isChecked() ? "TRUE" : "FALSE"));
+ onPluginToggled(checked, group, plugin);
+ });
+
+ radioButton->setEnabled(plugin->isEnabled());
+ radioButton->setChecked(plugin->isSelected());
+ return radioButton;
+}
+
+QCheckBox* FomodInstallerWindow::createPluginCheckBox(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group, QWidget* parent)
+{
+ auto* checkBox = new QCheckBox(QString::fromStdString(plugin->getName()), parent);
+ checkBox->setObjectName(createObjectName(plugin, group));
+
+ // Make the hover stuff work
+ auto* hoverFilter = new HoverEventFilter(plugin, this);
+ checkBox->installEventFilter(hoverFilter);
+ connect(hoverFilter, &HoverEventFilter::hovered, this, &FomodInstallerWindow::onPluginHovered);
+
+ // Install Ctrl+click event filter
+ auto* ctrlClickFilter = new CtrlClickEventFilter(plugin, group, this);
+ checkBox->installEventFilter(ctrlClickFilter);
+
+ checkBox->setEnabled(plugin->isEnabled());
+ checkBox->setChecked(plugin->isSelected());
+ connect(checkBox, &QCheckBox::clicked, this, [this, plugin](const bool checked) {
+ // Send a message to viewModel saying the user deactivated the plugin manually.
+ // This may get overridden later by automatic checking, but we'll reconcile that at JSON serialization time.
+ if (!checked) {
+ onPluginManuallyUnchecked(plugin);
+ }
+ });
+ connect(checkBox, &QCheckBox::toggled, this, [this, checkBox, group, plugin](const bool checked) {
+ logMessage(INFO,
+ "Received toggled signal for checkbox: " + plugin->getName() + ": " + (checked ? "true" : "false") +
+ " Checkbox was previously " + (checkBox->isChecked() ? "true" : "false"));
+ onPluginToggled(checked, group, plugin);
+ });
+ return checkBox;
+}
+
+void FomodInstallerWindow::renderSelectExactlyOne(QWidget* parent, QLayout* parentLayout,
+ const std::shared_ptr<GroupViewModel>& group)
+{
+ // This is for parity with the legacy installer. Both styles are functionally equivalent
+ // for a group size of 1, but they chose checkbox.
+ if (group->getPlugins().size() == 1) {
+ renderCheckboxGroup(parent, parentLayout, group);
+ } else {
+ renderRadioGroup(parent, parentLayout, group);
+ }
+}
+
+void FomodInstallerWindow::renderCheckboxGroup(QWidget* parent, QLayout* parentLayout,
+ const std::shared_ptr<GroupViewModel>& group)
+{
+ for (const auto& plugin : group->getPlugins()) {
+ auto* checkbox = createPluginCheckBox(plugin, group, parent);
+ parentLayout->addWidget(checkbox);
+ }
+}
+
+QButtonGroup* FomodInstallerWindow::renderRadioGroup(QWidget* parent, QLayout* parentLayout,
+ const std::shared_ptr<GroupViewModel>& group)
+{
+ auto* buttonGroup = new QButtonGroup(parent);
+ buttonGroup->setExclusive(true); // Ensure only one button can be selected
+
+ for (const auto& plugin : group->getPlugins()) {
+ auto* radioButton = createPluginRadioButton(plugin, group, parent);
+ buttonGroup->addButton(radioButton);
+ parentLayout->addWidget(radioButton);
+ }
+ return buttonGroup;
+}
+
+void FomodInstallerWindow::addNotification(const QString& message, const LogLevel level) const
+{
+ if (!mNotificationsPanel) {
+ return;
+ }
+
+ const QString timestamp = QDateTime::currentDateTime().toString("hh:mm:ss");
+ const QString formattedMsg = QString("<span>[%2] [%3] %4</span>")
+ .arg(timestamp).arg(logLevelToString(level)).arg(message);
+
+ mNotificationsPanel->append(formattedMsg);
+
+ // Auto-scroll to bottom
+ QScrollBar* scrollbar = mNotificationsPanel->verticalScrollBar();
+ scrollbar->setValue(scrollbar->maximum());
+}
+
+[[deprecated]]
+void FomodInstallerWindow::toggleImagesShown() const
+{
+ logMessage(DEBUG, "Toggling image visibility");
+ mInstaller->toggleShouldShowImages();
+ if (mInstaller->shouldShowImages()) {
+ logMessage(DEBUG, "Turning images ON");
+ mImageLabel->show();
+ mHideImagesButton->setText(tr("Hide Images"));
+ } else {
+ logMessage(DEBUG, "Turning images OFF");
+ mImageLabel->hide();
+ mHideImagesButton->setText(tr("Show Images"));
+ }
+}
+
+
+// Updates the image and description field for a given plugin. Also use this on initialization of those widgets.
+void FomodInstallerWindow::updateDisplayForActivePlugin() const
+{
+ // Skip if no steps (legacy FOMOD)
+ if (mViewModel->getSteps().empty()) {
+ return;
+ }
+
+ auto plugin = mViewModel->getActivePlugin();
+ if (!plugin) {
+ const auto activeStep = mViewModel->getActiveStep();
+ if (!activeStep || activeStep->getGroups().empty() || activeStep->getGroups().front()->getPlugins().empty()) {
+ mDescriptionBox->setText(tr("Select a plugin to see its description."));
+ mImageLabel->clear();
+ return;
+ }
+ // Fall back to the first plugin in the active step when no active plugin is set.
+ plugin = activeStep->getGroups().front()->getPlugins().front();
+ mViewModel->setActivePlugin(plugin);
+ }
+
+ const QString description = formatPluginDescription(QString::fromStdString(plugin->getDescription()));
+ mDescriptionBox->setText(description);
+
+ const auto image = mViewModel->getDisplayImage();
+ if (image.empty()) {
+ mImageLabel->clear();
+ return;
+ }
+
+ const auto imagePath = UIHelper::getFullImagePath(mFomodPath, QString::fromStdString(image));
+ mImageLabel->setScalableResource(imagePath);
+}
+
+/**
+ *
+ * @param pluginSelector For now either 'plugins', or 'deselected'. The key of the member of 'groups' to iterate over.
+ * @param fn The callback for each plugin in the chosen group member.
+ */
+void FomodInstallerWindow::applyFnFromJson(const std::string& pluginSelector,
+ const std::function<void(QAbstractButton*)>& fn)
+{
+ if (mFomodJson.empty()) {
+ return;
+ }
+
+ const auto jsonSteps = mFomodJson["steps"];
+ // for each step in JSON, create a <group>-<plugin> string out of the { groups: [ { plugins... } ] } array
+ vector<std::string> selectedPlugins;
+
+ // TODO: Can groups have the same name within a step, or across steps? How do we account for that?
+ for (int stepIndex = 0; stepIndex < jsonSteps.size(); ++stepIndex) {
+ const auto& step = jsonSteps[stepIndex];
+ for (int groupIndex = 0; groupIndex < step["groups"].size(); ++groupIndex) {
+ const auto& group = step["groups"][groupIndex];
+
+ if (!group.contains(pluginSelector)) {
+ continue;
+ }
+
+ for (int pluginIndex = 0; pluginIndex < group[pluginSelector].size(); ++pluginIndex) {
+ const auto& plugin = group[pluginSelector][pluginIndex];
+
+ std::string name = std::format("[{}:{}] {}-{}",
+ stepIndex, groupIndex, group["name"].get<std::string>(), plugin.get<std::string>());
+ selectedPlugins.push_back(name);
+ }
+ }
+ }
+
+ const auto checkboxes = findChildren<QCheckBox*>();
+ const auto radioButtons = findChildren<QRadioButton*>();
+
+ for (auto* checkbox : checkboxes) {
+ for (const auto& selectedPlugin : selectedPlugins) {
+ if (checkbox->objectName().toStdString() == selectedPlugin) {
+ fn(checkbox);
+ }
+ }
+ }
+ for (auto* radio : radioButtons) {
+ for (const auto& selectedPlugin : selectedPlugins) {
+ if (radio->objectName().toStdString() == selectedPlugin) {
+ fn(radio);
+ }
+ }
+ }
+}
+
+void FomodInstallerWindow::stylePreviouslySelectedOptions()
+{
+ const auto stylesheet = getColorStyle(UiColors::ColorApplication::BACKGROUND);
+
+ const auto tooltip = "You previously selected this plugin when installing this mod.";
+
+ logMessage(INFO, "Styling previously selected choices with stylesheet " + stylesheet.toStdString(), true);
+ applyFnFromJson("plugins", [stylesheet, tooltip](QAbstractButton* button) {
+ button->setStyleSheet(stylesheet);
+ button->setToolTip(tooltip);
+ });
+}
+
+void FomodInstallerWindow::stylePreviouslyDeselectedOptions()
+{
+ const auto stylesheet = getColorStyle(UiColors::ColorApplication::BORDER);
+ const auto tooltip = "You previously unchecked this plugin when installing this mod.";
+ applyFnFromJson("deselected", [stylesheet, tooltip](QAbstractButton* button) {
+ button->setStyleSheet(stylesheet);
+ button->setToolTip(tooltip);
+ });
+}
+
+void FomodInstallerWindow::selectPreviouslySelectedOptions() const
+{
+ logMessage(INFO, "Selecting previously selected choices", true);
+ logMessage(INFO, "Existing JSON provided: " + mFomodJson.dump(4));
+ if (mFomodJson.empty()) {
+ return;
+ }
+ try {
+ mViewModel->selectFromJson(mFomodJson);
+ } catch (Exception& e) {
+ logMessage(ERR, std::string("Error selecting previously selected options: ") + e.what(), true);
+ }
+ updateCheckboxStates();
+}
+
+void FomodInstallerWindow::onResetChoicesClicked()
+{
+ logMessage(INFO, "Resetting choices to author defaults", true);
+ try {
+ mViewModel->resetToDefaults();
+ } catch (Exception& e) {
+ logMessage(ERR, std::string("Error resetting choices: ") + e.what(), true);
+ return;
+ }
+
+ // Reset the UI to show the first step
+ mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex());
+ updateCheckboxStates();
+ updateButtons();
+ updateDisplayForActivePlugin();
+}
+
+QString FomodInstallerWindow::getColorStyle(const UiColors::ColorApplication color_application) const
+{
+ const auto selectedColor = mInstaller->getSelectedColor();
+ logMessage(DEBUG, "Selected color: " + selectedColor.toStdString());
+ return getStyle(selectedColor, color_application);
+}
diff --git a/libs/installer_fomod_plus/installer/FomodInstallerWindow.h b/libs/installer_fomod_plus/installer/FomodInstallerWindow.h
new file mode 100644
index 0000000..bca2215
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/FomodInstallerWindow.h
@@ -0,0 +1,214 @@
+#ifndef FOMODINSTALLERWINDOW_H
+#define FOMODINSTALLERWINDOW_H
+
+#include <qboxlayout.h>
+#include <qbuttongroup.h>
+#include <qcheckbox.h>
+#include <qcombobox.h>
+#include <qtextedit.h>
+
+#include "FomodPlusInstaller.h"
+#include "xml/ModuleConfiguration.h"
+
+#include <QDialog>
+#include <QStackedWidget>
+#include <qradiobutton.h>
+#include <ui/ScaleLabel.h>
+
+#include "FomodInstallerWindow.h"
+#include "lib/FileInstaller.h"
+#include "ui/FomodViewModel.h"
+#include "ui/Colors.h"
+
+#include <QSplitter>
+
+using namespace MOBase;
+
+struct PluginData {
+ std::shared_ptr<PluginViewModel> plugin;
+ QAbstractButton* uiElement;
+};
+
+
+class FomodPlusInstaller;
+/**
+ * @class FomodInstallerWindow
+ * @brief This class represents a window for the FOMOD installer.
+ *
+ * The FomodInstallerWindow class is designed to handle and manage the FOMOD installation
+ * process. It integrates functionalities specific to FOMOD package installations.
+ * By inheriting from QObject, it supports signal-slot mechanisms, enabling interaction
+ * and communication with other components in the application.
+ *
+ * This class is primarily intended to provide a user interface and functionality
+ * to process and run the FOMOD installer in a structured manner.
+ */
+class FomodInstallerWindow final : public QDialog {
+ Q_OBJECT
+
+public:
+ FomodInstallerWindow(FomodPlusInstaller* installer,
+ GuessedValue<QString>& modName,
+ const std::shared_ptr<IFileTree>& tree,
+ QString fomodPath,
+ const std::shared_ptr<FomodViewModel>& viewModel,
+ const nlohmann::json& fomodJson,
+ QWidget* parent = nullptr);
+
+ void closeEvent(QCloseEvent* event) override;
+
+ void saveGeometryAndState() const;
+
+ void restoreGeometryAndState();
+
+ void populatePluginMap();
+
+
+ // So FomodPlusInstaller can check if the user wants to manually install
+ [[nodiscard]] bool isManualInstall() const
+ {
+ return mIsManualInstall;
+ }
+
+ [[nodiscard]] std::shared_ptr<FileInstaller> getFileInstaller() const { return mViewModel->getFileInstaller(); }
+
+private slots:
+ void onNextClicked();
+
+ void updateCheckboxStates() const;
+
+ void onPluginToggled(bool selected, const std::shared_ptr<GroupViewModel>& group,
+ const std::shared_ptr<PluginViewModel>& plugin) const;
+
+ void onPluginManuallyUnchecked(const std::shared_ptr<PluginViewModel>& plugin) const;
+
+ void onPluginHovered(const std::shared_ptr<PluginViewModel>& plugin) const;
+
+ void onSelectPreviousClicked() const { this->selectPreviouslySelectedOptions(); }
+
+ void onResetChoicesClicked();
+
+ void onBackClicked() const;
+
+ void onCancelClicked()
+ {
+ this->saveGeometryAndState();
+ this->reject();
+ }
+
+ void onManualClicked()
+ {
+ mIsManualInstall = true;
+ this->saveGeometryAndState();
+ this->reject();
+ }
+
+ void onInstallClicked();
+
+ [[deprecated]] void toggleImagesShown() const;
+
+private:
+ Logger& log = Logger::getInstance();
+ FomodPlusInstaller* mInstaller;
+ QString mFomodPath;
+ GuessedValue<QString>& mModName;
+ std::shared_ptr<IFileTree> mTree;
+ std::shared_ptr<FomodViewModel> mViewModel;
+ bool mInitialized{ false };
+ std::unordered_map<QString, PluginData> mPluginMap;
+
+
+ // Meta
+ bool mIsManualInstall{};
+ nlohmann::json mFomodJson;
+
+ // Buttons
+ QPushButton* mNextInstallButton{};
+ QPushButton* mBackButton{};
+ QPushButton* mCancelButton{};
+ QPushButton* mManualButton{};
+ QPushButton* mSelectPreviousButton{};
+ QPushButton* mResetChoicesButton{};
+ QPushButton* mHideImagesButton{};
+
+ // Widgets
+ QComboBox* mModNameInput{};
+ QLabel* mDescriptionBox{};
+ QSplitter* mCenterRow{};
+ QSplitter* mLeftPane{};
+ QStackedWidget* mInstallStepStack{};
+ QTextEdit* mNotificationsPanel{};
+ QWidget* mBottomRow{};
+ QWidget* mTopRow{};
+ ScaleLabel* mImageLabel{};
+
+ // Fn
+ void setupUi();
+
+ void updateButtons() const;
+
+ void updateInstallStepStack();
+
+ void updateDisplayForActivePlugin() const;
+
+ void applyFnFromJson(const std::string& pluginSelector, const std::function<void(QAbstractButton*)> &fn);
+
+ void stylePreviouslySelectedOptions();
+
+ void stylePreviouslyDeselectedOptions();
+
+ void selectPreviouslySelectedOptions() const;
+
+ [[nodiscard]] QString getColorStyle(UiColors::ColorApplication color_application) const;
+
+ [[nodiscard]] QBoxLayout* createContainerLayout();
+
+ [[nodiscard]] QSplitter* createCenterRow();
+
+ [[nodiscard]] QWidget* createTopRow();
+
+ [[nodiscard]] QComboBox* createModNameComboBox();
+
+ [[nodiscard]] QWidget* createBottomRow();
+
+ [[nodiscard]] QSplitter* createLeftPane();
+
+ [[nodiscard]] QWidget* createRightPane();
+
+ [[nodiscard]] QTextEdit* createNotificationPanel();
+
+ [[nodiscard]] QWidget* createStepWidget(const std::shared_ptr<StepViewModel>& installStep);
+
+ [[nodiscard]] QWidget* renderGroup(const std::shared_ptr<GroupViewModel>& group);
+
+ static QString createObjectName(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group);
+
+ QRadioButton* createPluginRadioButton(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group, QWidget* parent);
+
+ QCheckBox* createPluginCheckBox(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group, QWidget* parent);
+
+ void renderSelectExactlyOne(QWidget* parent, QLayout* parentLayout, const std::shared_ptr<GroupViewModel>& group);
+
+ void renderCheckboxGroup(QWidget* parent, QLayout* parentLayout,
+ const std::shared_ptr<GroupViewModel>& group);
+
+ QButtonGroup* renderRadioGroup(QWidget* parent, QLayout* parentLayout,
+ const std::shared_ptr<GroupViewModel>& group);
+
+ void addNotification(const QString& message, LogLevel level) const;
+
+ void logMessage(const LogLevel level, const std::string& message, const bool asNotification = true) const
+ {
+ log.logMessage(level, "[WINDOW] " + message);
+ if (asNotification) {
+ addNotification(QString::fromStdString(message), level);
+ }
+ }
+
+};
+
+
+#endif //FOMODINSTALLERWINDOW_H
diff --git a/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp
new file mode 100644
index 0000000..690bdbb
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp
@@ -0,0 +1,512 @@
+#include "FomodPlusInstaller.h"
+
+#include <igamefeatures.h>
+#include <iinstallationmanager.h>
+#include <iplugingame.h>
+#include <QEventLoop>
+#include <QTreeWidget>
+#include <xml/FomodInfoFile.h>
+#include <xml/ModuleConfiguration.h>
+#include <xml/XmlParseException.h>
+
+#include "FomodInstallerWindow.h"
+#include "stringutil.h"
+#include "integration/FomodDataContent.h"
+#include "ui/Colors.h"
+#include "ui/FomodViewModel.h"
+#include "lib/CrashHandler.h"
+
+#include <QMessageBox>
+#include <QSettings>
+
+using namespace Qt::Literals::StringLiterals;
+
+/*
+--------------------------------------------------------------------------------
+ Init
+--------------------------------------------------------------------------------
+*/
+#pragma region Initialization
+
+bool FomodPlusInstaller::init(IOrganizer* organizer)
+{
+ CrashHandler::initialize();
+ mOrganizer = organizer;
+ mFomodContent = make_shared<FomodDataContent>(organizer);
+ log.setLogFilePath(QDir::currentPath().toStdString() + "/logs/fomodplus.log");
+ std::cout << "QDir::currentPath(): " << QDir::currentPath().toStdString() << std::endl;
+ std::cout << "mOrganizer->basePath() : " << mOrganizer->basePath().toStdString() << std::endl;
+
+ // REMEMBER: This mFomodDB persists beyond the scope of an individual install. Do not do anything nasty to it.
+ mFomodDb = std::make_unique<FomodDB>(mOrganizer->basePath().toStdString());
+ setupUiInjection();
+ return true;
+}
+
+void FomodPlusInstaller::setupUiInjection() const
+{
+ if (shouldShowSidebarFilter()) {
+ mOrganizer->gameFeatures()->registerFeature(mFomodContent, 9999, false);
+ }
+
+ mOrganizer->onPluginSettingChanged([this](const QString& pluginName, const QString& key, const QVariant& oldValue, const QVariant& newValue) {
+ if (pluginName == name() && key == "show_fomod_filter") {
+ toggleFeature(newValue.toBool());
+ }
+ });
+}
+
+void FomodPlusInstaller::toggleFeature(const bool enabled) const
+{
+ if (enabled) {
+ mOrganizer->gameFeatures()->registerFeature(mFomodContent, 9999, false);
+ } else {
+ mOrganizer->gameFeatures()->unregisterFeature(mFomodContent);
+ }
+ mOrganizer->refresh();
+}
+
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Settings
+--------------------------------------------------------------------------------
+*/
+#pragma region Settings
+bool FomodPlusInstaller::shouldFallbackToLegacyInstaller() const
+{
+ return mOrganizer->pluginSetting(name(), "fallback_to_legacy").value<bool>();
+}
+
+bool FomodPlusInstaller::shouldShowImages() const
+{
+ return mOrganizer->pluginSetting(name(), "show_images").value<bool>();
+}
+
+bool FomodPlusInstaller::shouldShowNotifications() const
+{
+ return mOrganizer->pluginSetting(name(), "show_notifications").value<bool>();
+}
+
+bool FomodPlusInstaller::shouldShowSidebarFilter() const
+{
+ return mOrganizer->pluginSetting(name(), "show_fomod_filter").value<bool>();
+}
+
+bool FomodPlusInstaller::shouldAutoRestoreChoices() const
+{
+ return mOrganizer->pluginSetting(name(), "always_restore_choices").value<bool>();
+}
+
+bool FomodPlusInstaller::isWizardIntegrated() const
+{
+ return mOrganizer->pluginSetting(name(), "wizard_integration").value<bool>();
+}
+
+void FomodPlusInstaller::toggleShouldShowImages() const
+{
+ const bool showImages = shouldShowImages();
+ mOrganizer->setPluginSetting(name(), "show_images", !showImages);
+}
+
+QString FomodPlusInstaller::getSelectedColor() const
+{
+ const auto colorName = mOrganizer->pluginSetting(name(), "color_theme").toString();
+ const auto it = UiColors::colorStyles.find(colorName);
+ return it != UiColors::colorStyles.end() ? it->first : "Blue";
+}
+
+std::vector<std::shared_ptr<const IPluginRequirement> > FomodPlusInstaller::requirements() const
+{
+ return { Requirements::gameDependency(
+ { u"Morrowind"_s, u"Oblivion"_s, u"Fallout 3"_s, u"New Vegas"_s, u"Skyrim"_s, u"Enderal"_s,
+ u"Fallout 4"_s, u"Skyrim Special Edition"_s, u"Enderal Special Edition"_s,
+ u"Skyrim VR"_s, u"Fallout 4 VR"_s, u"Starfield"_s }) };
+}
+
+QList<PluginSetting> FomodPlusInstaller::settings() const
+{
+ return {
+ { u"fallback_to_legacy"_s, u"When hitting cancel, fall back to the legacy FOMOD installer."_s, false },
+ { u"always_restore_choices"_s, u"Restore previous choices without clicking the magic button"_s, false },
+ { u"show_images"_s, u"Show image previews and the image carousel in installer windows."_s, true },
+ { u"color_theme"_s, u"Select the color theme for the installer"_s, QString("Blue") }, // Default color name
+ { u"show_notifications"_s, u"Show the notifications panel"_s, false }, //WIP
+ { u"wizard_integration"_s, u"Integrate the installer with patch wizard."_s, true }, //WIP
+ { u"show_fomod_filter"_s, u"Show the filter in the sidebar (may break other content filters)"_s, true }
+ };
+}
+
+#pragma endregion
+
+bool FomodPlusInstaller::isArchiveSupported(std::shared_ptr<const IFileTree> tree) const
+{
+ tree = findFomodDirectory(tree);
+ if (tree != nullptr) {
+ return tree->exists(StringConstants::FomodFiles::MODULE_CONFIG.data(), FileTreeEntry::FILE);
+ }
+ return false;
+}
+
+
+std::pair<nlohmann::json, IModInterface*> FomodPlusInstaller::getExistingFomodJson(const GuessedValue<QString>& modName,
+ const int& nexusId,
+ const int& stepsInCurrentFomod) const
+{
+ logMessage(DEBUG, "FomodPlusInstaller::getExistingFomodJson - modName: " + modName->toStdString() +
+ " nexusId: " + std::to_string(nexusId));
+
+ // Need to have better mod matching based on presence of FOMOD plugin data.
+ struct ModMatch {
+ MOBase::IModInterface* mod;
+ bool hasFomodData;
+ int stepCount;
+ nlohmann::json fomodJson;
+ };
+ std::vector<ModMatch> matches;
+
+ auto parseStepCount = [](const QVariant& fomodData) -> std::pair<bool, int> {
+ try {
+ if (fomodData.isValid() && !fomodData.isNull()) {
+ if (auto json = nlohmann::json::parse(fomodData.toString().toStdString()); json.contains("steps") && json["steps"].is_array()) {
+ return { true, static_cast<int>(json["steps"].size()) };
+ }
+ }
+ } catch (...) {
+ // Ignore parsing errors
+ }
+ return { false, 0 };
+ };
+
+ // Check exact match first
+ const auto modList = mOrganizer->modList();
+ if (modList == nullptr) {
+ return {};
+ }
+
+ if (const auto exactMod = modList->getMod(modName)) {
+ const auto fomodData = exactMod->pluginSetting(name(), "fomod", 0);
+ if (auto [valid, stepCount] = parseStepCount(fomodData); valid) {
+ matches.push_back({
+ exactMod,
+ true,
+ stepCount,
+ nlohmann::json::parse(fomodData.toString().toStdString())
+ });
+ } else {
+ matches.push_back({
+ exactMod,
+ false,
+ 0,
+ nlohmann::json()
+ });
+ }
+ }
+
+ // Check all variants
+ for (const auto& variant : modName.variants()) {
+ if (const auto variantMod = modList->getMod(variant)) {
+ const auto fomodData = variantMod->pluginSetting(name(), "fomod", 0);
+ if (auto [valid, stepCount] = parseStepCount(fomodData); valid) {
+ matches.push_back({
+ variantMod,
+ true,
+ stepCount,
+ nlohmann::json::parse(fomodData.toString().toStdString())
+ });
+ } else {
+ matches.push_back({
+ variantMod,
+ false,
+ 0,
+ nlohmann::json()
+ });
+ }
+ }
+ }
+
+ // No matches found
+ if (matches.empty()) {
+ return {};
+ }
+
+ // First try to find exact step count match
+ const auto exactMatch = ranges::find_if(matches,
+ [stepsInCurrentFomod](const ModMatch& match) {
+ return match.hasFomodData && match.stepCount == stepsInCurrentFomod;
+ });
+
+ if (exactMatch != matches.end()) {
+ logMessage(DEBUG, "Found exact step count match in mod: " +
+ exactMatch->mod->name().toStdString() +
+ " with " + std::to_string(exactMatch->stepCount) + " steps");
+ return std::make_pair(exactMatch->fomodJson, exactMatch->mod);
+ }
+
+ // Find the closest step count among mods with FOMOD data
+ const auto closestMatch = ranges::min_element(matches,
+ [stepsInCurrentFomod](const ModMatch& a, const ModMatch& b) {
+ if (!a.hasFomodData || !b.hasFomodData)
+ return false;
+ // The min difference between the step counts
+ return std::abs(a.stepCount - stepsInCurrentFomod) <
+ std::abs(b.stepCount - stepsInCurrentFomod);
+ });
+
+ if (closestMatch != matches.end() && closestMatch->hasFomodData) {
+ logMessage(DEBUG, "Using closest step count match from mod: " +
+ closestMatch->mod->name().toStdString() +
+ " with " + std::to_string(closestMatch->stepCount) + " steps");
+ return std::make_pair(closestMatch->fomodJson, closestMatch->mod);
+ }
+
+ // Fallback to first mod with any FOMOD data
+ const auto anyFomod = ranges::find_if(matches,
+ [](const ModMatch& match) { return match.hasFomodData; });
+
+ if (anyFomod != matches.end()) {
+ logMessage(DEBUG, "Using first available FOMOD data from mod: " +
+ anyFomod->mod->name().toStdString());
+ return std::make_pair(anyFomod->fomodJson, anyFomod->mod);
+ }
+
+ logMessage(DEBUG, "No matching FOMOD data found");
+ return {};
+}
+
+void FomodPlusInstaller::clearPriorInstallData()
+{
+ mInstallerUsed = false;
+ mFomodJson = nullptr;
+ mFomodPath = "";
+}
+
+/**
+ *
+ * @param modName
+ * @param tree
+ * @param version
+ * @param nexusID
+ * @return
+ */
+IPluginInstaller::EInstallResult FomodPlusInstaller::install(GuessedValue<QString>& modName,
+ std::shared_ptr<IFileTree>& tree, QString& version,
+ int& nexusID)
+{
+ clearPriorInstallData();
+
+ logMessage(INFO, std::format("FomodPlusInstaller::install - modName: {}, version: {}, nexusID: {}",
+ modName->toStdString(),
+ version.toStdString(),
+ nexusID
+ ));
+ logMessage(INFO, std::format("FomodPlusInstaller::install - tree size: {}", tree->size()));
+
+ auto [infoFile, moduleConfigFile, filePaths] = parseFomodFiles(tree);
+
+ if (infoFile == nullptr || moduleConfigFile == nullptr) {
+ return RESULT_FAILED;
+ }
+
+ std::vector<QString> pluginPaths = {};
+ for (const auto& filePath : filePaths) {
+ if (isPluginFile(filePath)) {
+ pluginPaths.emplace_back(filePath);
+ }
+ }
+
+ const auto dbEntry = mFomodDb->getEntryFromFomod(moduleConfigFile.get(), pluginPaths, nexusID);
+
+ // create ui & pass xml classes to ui
+ auto [json, matchMod] = getExistingFomodJson(modName, nexusID, static_cast<int>(moduleConfigFile->installSteps.installSteps.size()));
+ if (matchMod != nullptr) {
+ modName.update(matchMod->name(), GUESS_USER);
+ }
+ auto fomodViewModel = FomodViewModel::create(mOrganizer, std::move(moduleConfigFile), std::move(infoFile));
+ const auto window = std::make_shared<FomodInstallerWindow>(this, modName, tree, mFomodPath, fomodViewModel, json);
+
+ // ReSharper disable once CppTooWideScopeInitStatement
+ const QDialog::DialogCode result = showInstallerWindow(window);
+ if (result == QDialog::Accepted) {
+ // modname was updated in window
+ mInstallerUsed = true;
+ const std::shared_ptr<IFileTree> installTree = window->getFileInstaller()->install();
+ tree = installTree;
+ mFomodJson = std::make_shared<nlohmann::json>(window->getFileInstaller()->generateFomodJson());
+
+ try {
+ mFomodDb->addEntry(dbEntry);
+ mFomodDb->saveToFile();
+ } catch ([[maybe_unused]] Exception& e) {
+ logMessage(ERR, "Failed to add FomodDB entries.");
+ logMessage(ERR, e.what());
+ }
+ return RESULT_SUCCESS;
+ }
+ if (window->isManualInstall()) {
+ return RESULT_MANUALREQUESTED;
+ }
+ if (shouldFallbackToLegacyInstaller()) {
+ return RESULT_NOTATTEMPTED;
+ }
+ return RESULT_CANCELED;
+}
+
+
+/**
+ *
+ * @param tree
+ * @return
+ */
+ParsedFilesTuple FomodPlusInstaller::parseFomodFiles(const std::shared_ptr<IFileTree>& tree)
+{
+ const auto emptyResult = std::make_tuple(nullptr, nullptr, QStringList());
+
+ const auto fomodDir = findFomodDirectory(tree);
+ if (fomodDir == nullptr) {
+ logMessage(ERR, "FomodPlusInstaller::install - fomod directory not found");
+ return emptyResult;
+ }
+
+ // This is a strange place to set this value but okay for now.
+ mFomodPath = fomodDir->parent()->path();
+
+ const auto infoXML = fomodDir->find(
+ StringConstants::FomodFiles::INFO_XML.data(),
+ FileTreeEntry::FILE
+ );
+ const auto moduleConfig = fomodDir->find(
+ StringConstants::FomodFiles::MODULE_CONFIG.data(),
+ FileTreeEntry::FILE
+ );
+
+ // Extract files first.
+ vector<std::shared_ptr<const FileTreeEntry> > toExtract = {};
+ if (moduleConfig) {
+ toExtract.push_back(moduleConfig);
+ } else {
+ logMessage(ERR, "FomodPlusInstaller::install - error parsing moduleConfig.xml: Not Present");
+ return emptyResult;
+ }
+ if (infoXML) {
+ toExtract.push_back(infoXML);
+ }
+ appendImageFiles(toExtract, tree);
+ appendPluginFiles(toExtract, tree); // For patch wizard data collection
+ auto paths = manager()->extractFiles(toExtract);
+ // Normalize backslash separators from MO2's internal file tree to forward slashes for Linux
+ for (auto& p : paths) {
+ p.replace('\\', '/');
+ }
+
+ auto moduleConfiguration = std::make_unique<ModuleConfiguration>();
+ try {
+ moduleConfiguration->deserialize(paths.at(0));
+ } catch (XmlParseException& e) {
+ logMessage(ERR, std::format("FomodPlusInstaller::install - error parsing moduleConfig.xml: {}", e.what()));
+ return emptyResult;
+ }
+
+ auto infoFile = std::make_unique<FomodInfoFile>();
+ if (infoXML) {
+ try {
+ infoFile->deserialize(paths.at(1));
+ } catch (XmlParseException& e) {
+ logMessage(ERR, std::format("FomodPlusInstaller::install - error parsing info.xml: {}", e.what()));
+ }
+ }
+
+ return std::make_tuple(std::move(infoFile), std::move(moduleConfiguration), paths);
+}
+
+// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/installerfomod.cpp#L123
+void FomodPlusInstaller::appendImageFiles(vector<shared_ptr<const FileTreeEntry> >& entries,
+ const shared_ptr<const IFileTree>& tree)
+{
+ static std::set<QString, FileNameComparator> imageSuffixes{ "png", "jpg", "jpeg", "gif", "bmp" };
+ for (auto entry : *tree) {
+ if (entry->isDir()) {
+ appendImageFiles(entries, entry->astree());
+ } else if (imageSuffixes.contains(entry->suffix())) {
+ entries.push_back(entry);
+ }
+ }
+}
+
+void FomodPlusInstaller::appendPluginFiles(vector<shared_ptr<const FileTreeEntry> >& entries,
+ const shared_ptr<const IFileTree>& tree)
+{
+ // Don't bother with this stuff if the user doesn't care about the wizard.
+ // It may slightly bloat the temp file directory if not needed.
+ if (isWizardIntegrated()) {
+ for (auto entry : *tree) {
+ if (entry->isDir()) {
+ appendPluginFiles(entries, entry->astree());
+ } else if (isPluginFile(entry->suffix())) {
+ entries.push_back(entry);
+ }
+ }
+ }
+}
+
+void FomodPlusInstaller::onInstallationStart(QString const& archive, const bool reinstallation,
+ IModInterface* currentMod)
+{
+ IPluginInstallerSimple::onInstallationStart(archive, reinstallation, currentMod);
+}
+
+void FomodPlusInstaller::onInstallationEnd(const EInstallResult result, IModInterface* newMod)
+{
+ IPluginInstallerSimple::onInstallationEnd(result, newMod);
+
+ // Update the meta.ini file with the fomod information
+ if (mFomodJson != nullptr && result == RESULT_SUCCESS && newMod != nullptr && mInstallerUsed) {
+ newMod->setPluginSetting(this->name(), "fomod", mFomodJson->dump().c_str());
+ mOrganizer->refresh();
+ }
+ clearPriorInstallData();
+}
+
+// Borrowed from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/installerfomod.cpp
+std::shared_ptr<const IFileTree> FomodPlusInstaller::findFomodDirectory(const std::shared_ptr<const IFileTree>& tree)
+{
+ // ReSharper disable once CppTooWideScopeInitStatement
+ const auto entry = tree->find(StringConstants::FomodFiles::FOMOD_DIR.data(), FileTreeEntry::DIRECTORY);
+
+ if (entry != nullptr) {
+ return entry->astree();
+ }
+
+ if (tree->size() == 1 && tree->at(0)->isDir()) {
+ return findFomodDirectory(tree->at(0)->astree());
+ }
+ return nullptr;
+}
+
+QDialog::DialogCode FomodPlusInstaller::showInstallerWindow(const std::shared_ptr<FomodInstallerWindow>& window)
+{
+ QEventLoop loop;
+ connect(window.get(), SIGNAL(accepted()), &loop, SLOT(quit()));
+ connect(window.get(), SIGNAL(rejected()), &loop, SLOT(quit()));
+ window->show();
+ loop.exec();
+ return static_cast<QDialog::DialogCode>(window->result());
+}
+
+void showError(const Exception& e)
+{
+ const QString errorText = "Mod Name: \n" "Nexus ID: " "\n" "Exception: " + QString(e.what()) + "\n";
+
+ QMessageBox msgBox;
+ msgBox.setWindowTitle("FOMOD Plus Error :(");
+ msgBox.setTextFormat(Qt::RichText);
+ msgBox.setText("Sorry this happened. Please copy the following error and report it to me on Nexus or GitHub.\n"
+ "<pre style='background-color: #f0f0f0; padding: 10px;'>"
+ + errorText +
+ "</pre>"
+ );
+ msgBox.setIcon(QMessageBox::Critical);
+ msgBox.exec();
+
+}
diff --git a/libs/installer_fomod_plus/installer/FomodPlusInstaller.h b/libs/installer_fomod_plus/installer/FomodPlusInstaller.h
new file mode 100644
index 0000000..494ad8e
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/FomodPlusInstaller.h
@@ -0,0 +1,115 @@
+#pragma once
+
+#include "stringutil.h"
+
+#include <iplugin.h>
+#include <iplugininstaller.h>
+#include <iplugininstallersimple.h>
+
+#include <nlohmann/json.hpp>
+#include "FomodInstallerWindow.h"
+#include "lib/Logger.h"
+#include "xml/FomodInfoFile.h"
+#include "xml/ModuleConfiguration.h"
+
+#include <QDialog>
+#include <integration/FomodDataContent.h>
+#include <FOMODData/FomodDB.h>
+
+class FomodInstallerWindow;
+
+using namespace MOBase;
+using namespace std;
+
+using ParsedFilesTuple = std::tuple<std::unique_ptr<FomodInfoFile>, std::unique_ptr<ModuleConfiguration>, QStringList>;
+
+class FomodPlusInstaller final : public IPluginInstallerSimple {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginInstaller)
+ Q_PLUGIN_METADATA(IID "io.clearing.FomodPlus" FILE "fomodplus.json")
+
+public:
+ bool init(IOrganizer* organizer) override;
+
+ // constant values
+ [[nodiscard]] QString name() const override { return StringConstants::Plugin::NAME.data(); }
+ [[nodiscard]] QString author() const override { return StringConstants::Plugin::AUTHOR.data(); }
+ [[nodiscard]] QString description() const override { return StringConstants::Plugin::DESCRIPTION.data(); }
+ [[nodiscard]] VersionInfo version() const override { return { 1, 0, 0, VersionInfo::RELEASE_FINAL }; }
+
+ [[nodiscard]] unsigned int priority() const override
+ {
+ return 999; /* Above installer_fomod's highest priority. */
+ }
+
+ [[nodiscard]] std::vector<std::shared_ptr<const IPluginRequirement> > requirements() const override;
+
+ [[nodiscard]] bool isManualInstaller() const override { return false; }
+
+ [[nodiscard]] bool isArchiveSupported(std::shared_ptr<const IFileTree> tree) const override;
+
+ [[nodiscard]] QList<PluginSetting> settings() const override;
+
+ std::pair<nlohmann::json, IModInterface*> getExistingFomodJson(const GuessedValue<QString>& modName,
+ const int& nexusId, const int& stepsInCurrentFomod) const;
+
+ void clearPriorInstallData();
+
+ EInstallResult install(GuessedValue<QString>& modName, std::shared_ptr<IFileTree>& tree, QString& version,
+ int& nexusID) override;
+
+ void onInstallationStart(QString const& archive, bool reinstallation, IModInterface* currentMod) override;
+
+ void onInstallationEnd(EInstallResult result, IModInterface* newMod) override;
+
+ [[nodiscard]] bool shouldShowImages() const;
+
+ [[nodiscard]] bool shouldShowNotifications() const;
+ bool shouldShowSidebarFilter() const;
+
+ [[nodiscard]] bool shouldAutoRestoreChoices() const;
+
+ [[nodiscard]] bool isWizardIntegrated() const;
+
+ void toggleShouldShowImages() const;
+
+ QString getSelectedColor() const;
+
+private:
+ Logger& log = Logger::getInstance();
+ IOrganizer* mOrganizer = nullptr;
+ QString mFomodPath{};
+ std::shared_ptr<nlohmann::json> mFomodJson{ nullptr };
+ bool mInstallerUsed{ false };
+ std::shared_ptr<FomodDataContent> mFomodContent{ nullptr };
+ std::unique_ptr<FomodDB> mFomodDb;
+
+ /**
+ * @brief Retrieve the tree entry corresponding to the fomod directory.
+ *
+ * @param tree Tree to look-up the directory in.
+ *
+ * @return the entry corresponding to the fomod directory in the tree, or a null
+ * pointer if the entry was not found.
+ */
+ [[nodiscard]] static shared_ptr<const IFileTree> findFomodDirectory(const shared_ptr<const IFileTree>& tree);
+
+ [[nodiscard]] static QDialog::DialogCode showInstallerWindow(const shared_ptr<FomodInstallerWindow>& window);
+
+ [[nodiscard]] ParsedFilesTuple parseFomodFiles(const shared_ptr<IFileTree>& tree);
+
+ static void appendImageFiles(vector<shared_ptr<const FileTreeEntry> >& entries,
+ const shared_ptr<const IFileTree>& tree);
+
+ void appendPluginFiles(vector<shared_ptr<const FileTreeEntry> >& entries, const shared_ptr<const IFileTree>& tree);
+
+ void setupUiInjection() const;
+ void toggleFeature(bool enabled) const;
+
+ [[nodiscard]] bool shouldFallbackToLegacyInstaller() const;
+
+ void logMessage(const LogLevel level, const std::string& message) const
+ {
+ log.logMessage(level, "[INSTALLER] " + message);
+ }
+};
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_de.ts b/libs/installer_fomod_plus/installer/fomod_plus_de.ts
new file mode 100644
index 0000000..1552582
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_de.ts
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="de_DE">
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_en.ts b/libs/installer_fomod_plus/installer/fomod_plus_en.ts
new file mode 100644
index 0000000..bc6d6e7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_en.ts
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="en_US">
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts
new file mode 100644
index 0000000..ad7bfed
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="de_DE">
+<context>
+ <name>FomodInstallerWindow</name>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="194"/>
+ <source>Installieren</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="196"/>
+ <location filename="FomodInstallerWindow.cpp" line="370"/>
+ <source>Weiter</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="288"/>
+ <source>Name:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="289"/>
+ <source>Autor:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="290"/>
+ <source>Version:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="291"/>
+ <source>Webseite:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="357"/>
+ <source>Manuell</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="358"/>
+ <source>Alle Vorherige Optionen Ausw&auml;hlen</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="359"/>
+ <source>Reset Choices</source>
+ <translation>Optionen Zur&uuml;cksetzen</translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="361"/>
+ <location filename="FomodInstallerWindow.cpp" line="586"/>
+ <source>Bilder Ausblenden</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="369"/>
+ <source>Zur&uuml;ck</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="371"/>
+ <source>Abbrechen</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="360"/>
+ <location filename="FomodInstallerWindow.cpp" line="590"/>
+ <source>Bilder Einblenden</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts
new file mode 100644
index 0000000..5cbe0a7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="en_US">
+<context>
+ <name>FomodInstallerWindow</name>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="231"/>
+ <location filename="FomodInstallerWindow.cpp" line="242"/>
+ <source>Install</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="244"/>
+ <location filename="FomodInstallerWindow.cpp" line="423"/>
+ <source>Next</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="342"/>
+ <source>Name:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="343"/>
+ <source>Author:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="344"/>
+ <source>Version:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="345"/>
+ <source>Website:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="411"/>
+ <source>Manual</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="412"/>
+ <source>Restore Previous Choices</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="413"/>
+ <source>Reset Choices</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="422"/>
+ <source>Back</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="424"/>
+ <source>Cancel</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="682"/>
+ <source>Hide Images</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="686"/>
+ <source>Show Images</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="703"/>
+ <source>Select a plugin to see its description.</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts
new file mode 100644
index 0000000..f2769e6
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="zh_CN">
+<context>
+ <name>FomodInstallerWindow</name>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="194"/>
+ <source>安装</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="196"/>
+ <location filename="FomodInstallerWindow.cpp" line="370"/>
+ <source>下一步</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="288"/>
+ <source>名称</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="289"/>
+ <source>作者</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="290"/>
+ <source>版本</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="291"/>
+ <source>网址</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="357"/>
+ <source>手动</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="358"/>
+ <source>选中所有上次安装的选择</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="359"/>
+ <source>Reset Choices</source>
+ <translation>重置选择</translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="361"/>
+ <location filename="FomodInstallerWindow.cpp" line="586"/>
+ <source>隐藏图片</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="369"/>
+ <source>上一步</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="371"/>
+ <source>取消</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="360"/>
+ <location filename="FomodInstallerWindow.cpp" line="590"/>
+ <source>显示图片</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts b/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts
new file mode 100644
index 0000000..e5ca8aa
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="zh_CN">
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomodplus.json b/libs/installer_fomod_plus/installer/fomodplus.json
new file mode 100644
index 0000000..dde5a05
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomodplus.json
@@ -0,0 +1,4 @@
+{
+ "Id": "FOMODPlus",
+ "Version" : "1.0.0"
+} \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp b/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp
new file mode 100644
index 0000000..ac4508d
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp
@@ -0,0 +1,50 @@
+#include "FomodDataContent.h"
+
+#include <ifiletree.h>
+
+#include "stringutil.h"
+
+#include <iplugingame.h>
+
+FomodDataContent::FomodDataContent(MOBase::IOrganizer* organizer) : mOrganizer(organizer)
+{
+}
+
+std::vector<MOBase::ModDataContent::Content> FomodDataContent::getAllContents() const
+{
+ static const std::vector<Content> contents = {
+ {FomodDataContentConstants::FOMOD_CONTENT_ID, "FOMOD", ":/fomod/hat", false}
+ };
+
+ return contents;
+}
+
+// Confirmed working, no need to update
+std::vector<int> FomodDataContent::getContentsFor(const std::shared_ptr<const MOBase::IFileTree> fileTree) const
+{
+ std::vector<int> contents;
+ if (!mOrganizer || !fileTree) {
+ return contents;
+ }
+
+ const auto modList = mOrganizer->modList();
+ if (!modList) {
+ return contents;
+ }
+
+ const auto mod = modList->getMod(fileTree->name());
+ if (modHasFomodContent(mod)) {
+ contents.emplace_back(FomodDataContentConstants::FOMOD_CONTENT_ID);
+ }
+ return contents;
+}
+
+bool FomodDataContent::modHasFomodContent(const MOBase::IModInterface* mod)
+{
+ if (!mod) {
+ return false;
+ }
+ const auto pluginName = QString::fromStdString(StringConstants::Plugin::NAME.data());
+ const auto fomodMeta = mod->pluginSetting(pluginName, "fomod", 0);
+ return fomodMeta != 0;
+}
diff --git a/libs/installer_fomod_plus/installer/integration/FomodDataContent.h b/libs/installer_fomod_plus/installer/integration/FomodDataContent.h
new file mode 100644
index 0000000..4ffa3d7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/integration/FomodDataContent.h
@@ -0,0 +1,21 @@
+#pragma once
+
+#include <imoinfo.h>
+#include <moddatacontent.h>
+
+namespace FomodDataContentConstants {
+constexpr int FOMOD_CONTENT_ID = 400400;
+}
+
+class FomodDataContent final : public MOBase::ModDataContent {
+public:
+ explicit FomodDataContent(MOBase::IOrganizer* organizer);
+
+ [[nodiscard]] std::vector<Content> getAllContents() const override;
+
+ [[nodiscard]] std::vector<int> getContentsFor(std::shared_ptr<const MOBase::IFileTree> fileTree) const override;
+
+private:
+ MOBase::IOrganizer* mOrganizer;
+ static bool modHasFomodContent(const MOBase::IModInterface* mod);
+};
diff --git a/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp b/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp
new file mode 100644
index 0000000..c35bf47
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp
@@ -0,0 +1,176 @@
+#include "ConditionTester.h"
+
+#include "ui/FomodViewModel.h"
+
+#include <iplugingame.h>
+#include <ipluginlist.h>
+
+std::string setToString(const std::set<int> &set)
+{
+ std::string str;
+ for (const auto& i : set) {
+ str += std::to_string(i) + ", ";
+ }
+ return str;
+}
+
+bool ConditionTester::isStepVisible(const std::shared_ptr<FlagMap>& flags,
+ const CompositeDependency& compositeDependency,
+ const int stepIndex,
+ const std::vector<std::shared_ptr<StepViewModel>>& steps) const
+{
+
+ // first things first: is it visible?
+ if (!testCompositeDependency(flags, compositeDependency)) {
+ return false;
+ }
+
+ const auto flagDependencies = compositeDependency.flagDependencies;
+ if (flagDependencies.empty()) {
+ return true;
+ }
+
+ std::set<int> stepsThatSetThisFlag;
+
+ for (const auto& flagDependency : flagDependencies) {
+ // for this flag, find the plugins that set it
+ for (int i = stepIndex - 1; i >= 0; --i) {
+ for (const auto& group : steps[i]->getGroups()) {
+ for (const auto& plugin : group->getPlugins()) {
+ if (std::ranges::any_of(plugin->getPlugin()->conditionFlags.flags,
+ [&flagDependency](const ConditionFlag& flag) {
+ return flag.name == flagDependency.flag && flag.value == flagDependency.value;
+ })) {
+ stepsThatSetThisFlag.insert(i);
+ }
+ }
+ }
+ }
+ }
+ const auto anyVisible = std::ranges::any_of(stepsThatSetThisFlag, [this, &steps, &flags](const int index) {
+ return isStepVisible(flags, steps[index]->getVisibilityConditions(), index, steps);
+ });
+ if (!anyVisible) {
+ log.logMessage(DEBUG, "Step " + steps[stepIndex]->getName() + " has no dependent steps that are visible.");
+ log.logMessage(DEBUG, "Steps that set this flag: " + setToString(stepsThatSetThisFlag));
+ }
+ return anyVisible;
+
+}
+
+bool ConditionTester::testCompositeDependency(const std::shared_ptr<FlagMap>& flags,
+ const CompositeDependency& compositeDependency) const
+{
+ const auto fileDependencies = compositeDependency.fileDependencies;
+ const auto flagDependencies = compositeDependency.flagDependencies;
+ const auto gameDependencies = compositeDependency.gameDependencies;
+ const auto nestedDependencies = compositeDependency.nestedDependencies;
+ const auto globalOperatorType = compositeDependency.operatorType;
+
+ // For the globalOperatorType
+ // Evaluate all conditions and store the results in a vector<bool>, then return based on operator.
+ // These aren't expensive to calculate so rather than do some fancy logic to short-circuit, just calculate all of 'em.
+ std::vector<bool> results;
+ for (const auto& fileDependency : fileDependencies) {
+ results.emplace_back(testFileDependency(fileDependency));
+ }
+ for (const auto& flagDependency : flagDependencies) {
+ results.emplace_back(testFlagDependency(flags, flagDependency));
+ }
+ for (const auto& gameDependency : gameDependencies) {
+ results.emplace_back(testGameDependency(gameDependency));
+ }
+ for (const auto& nestedDependency : nestedDependencies) {
+ results.emplace_back(testCompositeDependency(flags, nestedDependency));
+ }
+
+ if (globalOperatorType == OperatorTypeEnum::AND) {
+ return std::ranges::all_of(results, [](const bool result) { return result; });
+ }
+ return std::ranges::any_of(results, [](const bool result) { return result; });
+}
+
+
+bool ConditionTester::testFlagDependency(const std::shared_ptr<FlagMap>& flags, const FlagDependency& flagDependency)
+{
+ // Every instance of this flag being set in the map.
+ const auto flagList = flags->getFlagsByKey(flagDependency.flag);
+
+ // Find the first instance of this flag being set (in the order specified by getFlagsByKey)
+ if (flagList.empty()) {
+ // If the dependency value is an empty string, it means this flag should be unset.
+ // So if we don't have any value for this flag, the result is true.
+ return flagDependency.value.empty();
+ }
+
+ return flagList.front().second == flagDependency.value;
+}
+
+bool ConditionTester::testFileDependency(const FileDependency& fileDependency) const
+{
+ const std::string& pluginName = fileDependency.file;
+ const auto pluginState = getFileDependencyStateForPlugin(pluginName);
+ return pluginState == fileDependency.state;
+}
+
+bool ConditionTester::testGameDependency(const GameDependency& gameDependency) const
+{
+ const auto gameVersion = mOrganizer->managedGame()->gameVersion().toStdString();
+ log.logMessage(DEBUG, "Comparing condition version " + gameDependency.version + " against " + gameVersion);
+ if ( gameDependency.version <= gameVersion) {
+ log.logMessage(DEBUG, "Version matches!");
+ }
+ return gameDependency.version <= gameVersion;
+}
+
+FileDependencyTypeEnum ConditionTester::getFileDependencyStateForPlugin(const std::string& pluginName) const
+{
+ if (const auto it = pluginStateCache.find(pluginName); it != pluginStateCache.end()) {
+ return it->second;
+ }
+
+ const QFlags<MOBase::IPluginList::PluginState> pluginState = mOrganizer->pluginList()->state(
+ QString::fromStdString(pluginName));
+
+ FileDependencyTypeEnum state;
+
+ if (pluginState == MOBase::IPluginList::STATE_MISSING) {
+ state = FileDependencyTypeEnum::Missing;
+ } else if (pluginState == MOBase::IPluginList::STATE_INACTIVE) {
+ state = FileDependencyTypeEnum::Inactive;
+ } else if (pluginState == MOBase::IPluginList::STATE_ACTIVE) {
+ state = FileDependencyTypeEnum::Active;
+ } else {
+ state = FileDependencyTypeEnum::UNKNOWN_STATE;
+ }
+
+ pluginStateCache[pluginName] = state;
+ return state;
+}
+
+PluginTypeEnum ConditionTester::getPluginTypeDescriptorState(const std::shared_ptr<Plugin>& plugin,
+ const std::shared_ptr<FlagMap>& flags) const
+{
+ // NOTE: A plugin's ConditionFlags aren't the same thing as a step visibility one.
+ // A plugin's ConditionFlags are toggled based on the selection state of the plugin
+ // We only evaluate the typeDescriptor here.
+
+ // We will return the 'winning' type or the default. If multiple conditions are met,
+ // ...well, I'm not sure.
+ // ReSharper disable once CppTooWideScopeInitStatement
+ const auto& dependencyType = plugin->typeDescriptor.dependencyType;
+ for (const auto& pattern : dependencyType.patterns.patterns) {
+ if (testCompositeDependency(flags, pattern.dependencies)) {
+ return pattern.type;
+ }
+ }
+
+ // Sometimes authors do this.
+ if (plugin->typeDescriptor.type != PluginTypeEnum::Optional) {
+ return plugin->typeDescriptor.type;
+ }
+ if (plugin->typeDescriptor.dependencyType.defaultType.has_value()) {
+ return plugin->typeDescriptor.dependencyType.defaultType.value();
+ }
+ return PluginTypeEnum::Optional;
+} \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/lib/ConditionTester.h b/libs/installer_fomod_plus/installer/lib/ConditionTester.h
new file mode 100644
index 0000000..09aa26e
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/ConditionTester.h
@@ -0,0 +1,42 @@
+#pragma once
+
+#include <imoinfo.h>
+
+#include "FlagMap.h"
+#include "Logger.h"
+#include "xml/ModuleConfiguration.h"
+
+class CompositeDependency;
+class StepViewModel;
+
+class ConditionTester {
+public:
+ explicit ConditionTester(MOBase::IOrganizer* organizer) : mOrganizer(organizer) {}
+
+ bool isStepVisible(const std::shared_ptr<FlagMap>& flags, const CompositeDependency& compositeDependency,
+ int stepIndex,
+ const std::vector<std::shared_ptr<StepViewModel>>& steps) const;
+
+ bool testCompositeDependency(const std::shared_ptr<FlagMap>& flags,
+ const CompositeDependency& compositeDependency) const;
+
+ static bool testFlagDependency(const std::shared_ptr<FlagMap>& flags, const FlagDependency& flagDependency);
+
+ [[nodiscard]] bool testFileDependency(const FileDependency& fileDependency) const;
+
+ bool testGameDependency(const GameDependency& gameDependency) const;
+
+private:
+ Logger& log = Logger::getInstance();
+ MOBase::IOrganizer* mOrganizer;
+
+ friend class FomodViewModel;
+
+ [[nodiscard]] FileDependencyTypeEnum getFileDependencyStateForPlugin(const std::string& pluginName) const;
+
+ PluginTypeEnum getPluginTypeDescriptorState(const std::shared_ptr<Plugin>& plugin,
+ const std::shared_ptr<FlagMap>& flags) const;
+
+ mutable std::unordered_map<std::string, FileDependencyTypeEnum> pluginStateCache;
+
+};
diff --git a/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp b/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp
new file mode 100644
index 0000000..98ed1f3
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp
@@ -0,0 +1,122 @@
+#include "CrashHandler.h"
+#include <iostream>
+
+#ifdef _WIN32
+#include <windows.h>
+#include <dbghelp.h>
+#include <sstream>
+#include <fstream>
+
+LPTOP_LEVEL_EXCEPTION_FILTER CrashHandler::previousFilter = nullptr;
+PVOID CrashHandler::vectoredHandler = nullptr;
+#endif
+
+void CrashHandler::initialize() {
+#ifdef _WIN32
+ std::cout << "CrashHandler initializing..." << std::endl;
+ vectoredHandler = AddVectoredExceptionHandler(1, vectoredExceptionHandler);
+ previousFilter = SetUnhandledExceptionFilter(unhandledExceptionFilter);
+ std::cout << "CrashHandler initialized successfully" << std::endl;
+#else
+ // No-op on Linux — Windows crash handler not needed.
+#endif
+}
+
+void CrashHandler::cleanup() {
+#ifdef _WIN32
+ if (vectoredHandler) {
+ RemoveVectoredExceptionHandler(vectoredHandler);
+ vectoredHandler = nullptr;
+ }
+ if (previousFilter) {
+ SetUnhandledExceptionFilter(previousFilter);
+ previousFilter = nullptr;
+ }
+#endif
+}
+
+#ifdef _WIN32
+LONG WINAPI CrashHandler::vectoredExceptionHandler(EXCEPTION_POINTERS* exceptionInfo) {
+ // Log all exceptions but only create dumps for serious ones
+ std::cout << "Exception caught: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionCode << std::endl;
+
+ // Only create dumps for serious crashes, not benign exceptions
+ if (exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION ||
+ exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_STACK_OVERFLOW ||
+ exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ILLEGAL_INSTRUCTION) {
+
+ // For stack overflow, we need to handle it on a different thread with its own stack
+ if (exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_STACK_OVERFLOW) {
+ // Create a new thread to handle the crash dump since our stack is corrupted
+ HANDLE dumpThread = CreateThread(nullptr, 0, [](LPVOID param) -> DWORD {
+ auto* exceptionInfo = static_cast<EXCEPTION_POINTERS*>(param);
+ logCrashInfo(exceptionInfo);
+
+ HANDLE file = CreateFileA("fomod_plus_stack_overflow.dmp", GENERIC_WRITE, 0, nullptr,
+ CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
+
+ if (file != INVALID_HANDLE_VALUE) {
+ MINIDUMP_EXCEPTION_INFORMATION dumpInfo = {0};
+ dumpInfo.ThreadId = GetCurrentThreadId();
+ dumpInfo.ExceptionPointers = exceptionInfo;
+ dumpInfo.ClientPointers = FALSE;
+
+ MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file,
+ MiniDumpNormal, &dumpInfo, nullptr, nullptr);
+ CloseHandle(file);
+
+ std::cout << "Stack overflow crash dump written to fomod_plus_stack_overflow.dmp" << std::endl;
+ }
+ return 0;
+ }, exceptionInfo, 0, nullptr);
+
+ if (dumpThread) {
+ WaitForSingleObject(dumpThread, 5000); // Wait up to 5 seconds
+ CloseHandle(dumpThread);
+ }
+ } else {
+ logCrashInfo(exceptionInfo);
+
+ // Generate minidump
+ HANDLE file = CreateFileA("fomod_plus_crash.dmp", GENERIC_WRITE, 0, nullptr,
+ CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
+
+ if (file != INVALID_HANDLE_VALUE) {
+ MINIDUMP_EXCEPTION_INFORMATION dumpInfo = {0};
+ dumpInfo.ThreadId = GetCurrentThreadId();
+ dumpInfo.ExceptionPointers = exceptionInfo;
+ dumpInfo.ClientPointers = FALSE;
+
+ MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file,
+ MiniDumpNormal, &dumpInfo, nullptr, nullptr);
+ CloseHandle(file);
+
+ std::cout << "Crash dump written to fomod_plus_crash.dmp" << std::endl;
+ }
+ }
+ }
+
+ return EXCEPTION_CONTINUE_SEARCH; // Let other handlers process it
+}
+
+LONG WINAPI CrashHandler::unhandledExceptionFilter(EXCEPTION_POINTERS* exceptionInfo) {
+ logCrashInfo(exceptionInfo);
+ return EXCEPTION_EXECUTE_HANDLER;
+}
+
+void CrashHandler::logCrashInfo(const EXCEPTION_POINTERS* exceptionInfo) {
+ std::ostringstream oss;
+ oss << "FOMOD PLUS CRASH DETECTED!" << std::endl;
+ oss << "Exception Code: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionCode << std::endl;
+ oss << "Exception Address: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionAddress << std::endl;
+
+ // Write to stdout immediately
+ std::cout << oss.str() << std::endl;
+
+ // Also write to crash log file
+ std::ofstream crashLog("fomod_plus_crash.log", std::ios::app);
+ if (crashLog.is_open()) {
+ crashLog << oss.str() << std::endl;
+ }
+}
+#endif
diff --git a/libs/installer_fomod_plus/installer/lib/CrashHandler.h b/libs/installer_fomod_plus/installer/lib/CrashHandler.h
new file mode 100644
index 0000000..78c8552
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/CrashHandler.h
@@ -0,0 +1,20 @@
+#pragma once
+
+#ifdef _WIN32
+#include <windows.h>
+#endif
+
+class CrashHandler {
+public:
+ static void initialize();
+ static void cleanup();
+
+private:
+#ifdef _WIN32
+ static LONG WINAPI unhandledExceptionFilter(EXCEPTION_POINTERS* exceptionInfo);
+ static LONG WINAPI vectoredExceptionHandler(EXCEPTION_POINTERS* exceptionInfo);
+ static void logCrashInfo(const EXCEPTION_POINTERS* exceptionInfo);
+ static LPTOP_LEVEL_EXCEPTION_FILTER previousFilter;
+ static PVOID vectoredHandler;
+#endif
+};
diff --git a/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp b/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp
new file mode 100644
index 0000000..e47e088
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp
@@ -0,0 +1,274 @@
+#include "FileInstaller.h"
+
+#include <utility>
+
+#include "ui/FomodViewModel.h"
+
+using namespace MOBase;
+
+FileInstaller::FileInstaller(
+ IOrganizer* organizer,
+ QString fomodPath,
+ const std::shared_ptr<IFileTree>& fileTree,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ const std::shared_ptr<FlagMap>& flagMap,
+ const std::vector<std::shared_ptr<StepViewModel> >& steps) : mOrganizer(organizer),
+ mFomodPath(std::move(fomodPath)),
+ mFileTree(fileTree),
+ mFomodFile(std::move(fomodFile)),
+ mFlagMap(flagMap),
+ mConditionTester(organizer), mSteps(steps)
+{
+ const auto requiredCount =
+ (mFomodFile != nullptr) ? mFomodFile->requiredInstallFiles.files.size() : 0;
+ const auto stepCount = mSteps.size();
+ logMessage(DEBUG,
+ "FileInstaller constructed. steps=" + std::to_string(stepCount) + ", required files=" +
+ std::to_string(requiredCount) + ", fomodPath=" + mFomodPath.toStdString());
+}
+
+std::shared_ptr<IFileTree> FileInstaller::install() const
+{
+ logMessage(DEBUG, "Starting FileInstaller::install()");
+ const auto filesToInstall = collectFilesToInstall();
+ logMessage(INFO, "Installing " + std::to_string(filesToInstall.size()) + " files");
+ logMessage(INFO, "FlagMap");
+ logMessage(INFO, mFlagMap->toString());
+
+ // update the file tree with the new files
+ const std::shared_ptr<IFileTree> installTree = mFileTree->createOrphanTree();
+
+ for (const auto& file : filesToInstall) {
+
+ logMessage(DEBUG,
+ "Processing install entry source=" + file.source + ", destination=" +
+ (file.destination.has_value() ? file.destination.value() : "<default>") +
+ ", priority=" + std::to_string(file.priority));
+ const auto sourcePath = getQualifiedFilePath(file.source);
+ const auto sourceNode = mFileTree->find(QString::fromStdString(sourcePath));
+ if (sourceNode == nullptr) {
+ logMessage(ERR, "Could not find source: " + file.source);
+ continue;
+ }
+ const auto targetPath = file.destination.has_value()
+ ? QString::fromStdString(file.destination.value())
+ : QString::fromStdString(sourcePath);
+
+ // If it's a folder, copy the contents of the folder, not the folder itself.
+ if (sourceNode->isDir()) {
+ logMessage(DEBUG,
+ "Copying directory '" + file.source + "' into target '" + targetPath.toStdString() +
+ "'");
+ // TODO: Check if target path is literally undefined/null
+ const auto& tree = sourceNode->astree();
+ for (auto it = tree->begin(); it != tree->end(); ++it) {
+ const auto& entry = *it;
+ const auto path = targetPath.isEmpty() ? entry->name() : targetPath + "/" + entry->name();
+ logMessage(DEBUG,
+ " Copying entry '" + entry->name().toStdString() + "' to '" +
+ path.toStdString() + "'");
+ installTree->copy(entry, path, IFileTree::InsertPolicy::MERGE);
+ }
+ } else {
+ logMessage(DEBUG,
+ "Copying file '" + file.source + "' to '" + targetPath.toStdString() + "'");
+ installTree->copy(sourceNode, targetPath, IFileTree::InsertPolicy::MERGE);
+ }
+ }
+
+ // This file will be written by the InstallationManager later.
+ const auto jsonFilePath = "fomod.json";
+ installTree->addFile(QString::fromStdString(jsonFilePath), true);
+ logMessage(DEBUG, "Added fomod.json placeholder file to install tree.");
+
+ logMessage(DEBUG, "FileInstaller::install completed.");
+ return installTree;
+}
+
+nlohmann::json FileInstaller::generateFomodJson() const
+{
+ nlohmann::json fomodJson;
+ logMessage(DEBUG, "Generating fomod.json representation for " + std::to_string(mSteps.size()) +
+ " steps.");
+
+ fomodJson["steps"] = nlohmann::json::array();
+ for (const auto& stepViewModel : mSteps) {
+ auto stepJson = nlohmann::json::object();
+ stepJson["name"] = stepViewModel->getName();
+ stepJson["groups"] = nlohmann::json::array();
+ logMessage(DEBUG, "Serializing step '" + stepViewModel->getName() + "'");
+
+ for (const auto& groupViewModel : stepViewModel->getGroups()) {
+ auto groupJson = nlohmann::json::object();
+ groupJson["name"] = groupViewModel->getName();
+ auto pluginArray = nlohmann::json::array();
+ auto deselectedArray = nlohmann::json::array();
+ logMessage(DEBUG,
+ " Serializing group '" + groupViewModel->getName() +
+ "' with " + std::to_string(groupViewModel->getPlugins().size()) + " plugins");
+
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ logMessage(DEBUG,
+ " Plugin '" + pluginViewModel->getName() + "' selected=" +
+ (pluginViewModel->isSelected() ? "true" : "false") + ", manually-set=" +
+ (pluginViewModel->wasManuallySet() ? "true" : "false"));
+ if (pluginViewModel->isSelected()) {
+ pluginArray.emplace_back(pluginViewModel->getName());
+ }
+ // Add deselected plugins here. (TODO: This will be replaced with an embedded db.)
+ if (!pluginViewModel->isSelected() && pluginViewModel->wasManuallySet()) {
+ deselectedArray.emplace_back(pluginViewModel->getName());
+ }
+ }
+ groupJson["plugins"] = pluginArray;
+ groupJson["deselected"] = deselectedArray;
+ stepJson["groups"].emplace_back(groupJson);
+ }
+ fomodJson["steps"].emplace_back(stepJson);
+ }
+ return fomodJson;
+}
+
+std::string FileInstaller::getQualifiedFilePath(const std::string& treePath) const
+{
+ // We need to prepend the fomod path to whatever source we reference. Guess we're passing that path around.
+ const auto qualifiedPath = mFomodPath.toStdString() + "/" + treePath;
+ logMessage(DEBUG, "Qualifying tree path '" + treePath + "' to '" + qualifiedPath + "'");
+ return qualifiedPath;
+}
+
+std::vector<std::string> FileInstaller::collectPositiveFileNamesFromDependencyPatterns(
+ const std::vector<DependencyPattern>& patterns)
+{
+ std::vector<std::string> usableFileDependencyPluginNames = {};
+ logMessage(DEBUG,
+ "Collecting positive file names from " + std::to_string(patterns.size()) + " patterns.");
+
+ for (const auto& pattern : patterns) {
+ if (pattern.type == PluginTypeEnum::NotUsable) {
+ logMessage(DEBUG, "Skipping pattern marked as NotUsable.");
+ continue;
+ }
+
+ if (pattern.dependencies.fileDependencies.empty() && pattern.dependencies.nestedDependencies.empty()) {
+ logMessage(DEBUG, "Skipping pattern with no dependencies.");
+ continue;
+ }
+
+ const auto fileDependencies = pattern.dependencies.fileDependencies;
+ const auto nestedDependencies = pattern.dependencies.nestedDependencies;
+
+ for (const auto& fileDependency : fileDependencies) {
+ if (fileDependency.state != FileDependencyTypeEnum::Active) {
+ continue;
+ }
+ usableFileDependencyPluginNames.emplace_back(fileDependency.file);
+ logMessage(DEBUG, "Adding active file dependency: " + fileDependency.file);
+ }
+
+ for (const auto& nestedDependency : nestedDependencies) {
+ for (const auto& fileDependency : nestedDependency.fileDependencies) {
+ if (fileDependency.state != FileDependencyTypeEnum::Active) {
+ continue;
+ }
+ usableFileDependencyPluginNames.emplace_back(fileDependency.file);
+ logMessage(DEBUG, "Adding nested active file dependency: " + fileDependency.file);
+ }
+ }
+ // Not handling twice-nested dependencies now. IDK if that's even feasible.
+ }
+
+ logMessage(DEBUG,
+ "CollectPositiveFileNamesFromDependencyPatterns found " +
+ std::to_string(usableFileDependencyPluginNames.size()) + " entries.");
+ return usableFileDependencyPluginNames;
+}
+
+// Generic vector appender
+void FileInstaller::addFiles(std::vector<File>& main, std::vector<File> toAdd) const
+{
+ for (const auto& add : toAdd) {
+ logMessage(INFO, "Adding file with source: " + add.source);
+ }
+ main.insert(main.end(), toAdd.begin(), toAdd.end());
+ logMessage(DEBUG,
+ "addFiles merged " + std::to_string(toAdd.size()) + " files. Total is now " +
+ std::to_string(main.size()));
+}
+
+// TODO: Unclear if we're copying. oh well.
+// TODO: Rebuild flagmap and step indeces before installing
+std::vector<File> FileInstaller::collectFilesToInstall() const
+{
+ logMessage(DEBUG, "collectFilesToInstall started.");
+ std::vector<File> allFiles;
+
+ // Required files from FOMOD
+ const FileList requiredInstallFiles = mFomodFile->requiredInstallFiles;
+ logMessage(DEBUG,
+ "Adding " + std::to_string(requiredInstallFiles.files.size()) +
+ " required install files from fomod.");
+ addFiles(allFiles, requiredInstallFiles.files);
+
+ // Selected files from visible steps
+ for (const auto& stepViewModel : mSteps) {
+ if (!mConditionTester.testCompositeDependency(mFlagMap, stepViewModel->getVisibilityConditions())) {
+ logMessage(DEBUG,
+ "Skipping invisible step '" + stepViewModel->getName() + "'");
+ continue;
+ }
+ logMessage(DEBUG,
+ "Processing visible step '" + stepViewModel->getName() + "' with " +
+ std::to_string(stepViewModel->getGroups().size()) + " groups.");
+ for (const auto& groupViewModel : stepViewModel->getGroups()) {
+ logMessage(DEBUG,
+ " Processing group '" + groupViewModel->getName() + "' with " +
+ std::to_string(groupViewModel->getPlugins().size()) + " plugins.");
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ if (pluginViewModel->isSelected()) {
+ logMessage(DEBUG,
+ " Adding selected plugin '" + pluginViewModel->getName() +
+ "' with " +
+ std::to_string(pluginViewModel->getPlugin()->files.files.size()) +
+ " files.");
+ addFiles(allFiles, pluginViewModel->getPlugin()->files.files);
+ } else {
+ logMessage(DEBUG,
+ " Skipping plugin '" + pluginViewModel->getName() +
+ "' (not selected).");
+ }
+ }
+ }
+ }
+
+ // ConditionalInstall files
+ for (const auto conditionals = mFomodFile->conditionalFileInstalls; const auto& pattern : conditionals.patterns) {
+ //<folder source="CR\Dagi-Raht LL\VLrn_Custom Race - Dagi-Raht LL" destination="" priority="2" />
+
+ if (mConditionTester.testCompositeDependency(mFlagMap, pattern.dependencies)) {
+ // also check if the plugins setting these flags are visible. at least one
+
+ addFiles(allFiles, pattern.files.files);
+ logMessage(DEBUG,
+ "Conditional install pattern matched; added " +
+ std::to_string(pattern.files.files.size()) + " files.");
+ } else {
+ logMessage(DEBUG, "Conditional install pattern did not match.");
+ }
+ }
+
+ // Files will all have a default priority of 0 if not specified, so the order should also be informed by the
+ // order they appear within XML. That's why we put conditionalFileInstalls after.
+ logMessage(DEBUG, "Sorting " + std::to_string(allFiles.size()) + " files by priority.");
+ std::ranges::sort(allFiles, [](const auto& a, const auto& b) {
+ return a.priority < b.priority;
+ });
+
+ for (const auto& toInstall : allFiles) {
+ logMessage(DEBUG, "File to install: " + toInstall.source);
+ }
+ logMessage(DEBUG,
+ "collectFilesToInstall completed with " + std::to_string(allFiles.size()) + " files.");
+
+ return allFiles;
+}
diff --git a/libs/installer_fomod_plus/installer/lib/FileInstaller.h b/libs/installer_fomod_plus/installer/lib/FileInstaller.h
new file mode 100644
index 0000000..1c122a7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/FileInstaller.h
@@ -0,0 +1,94 @@
+#pragma once
+
+#include <ifiletree.h>
+#include <nlohmann/json.hpp>
+
+#include "ConditionTester.h"
+#include "FlagMap.h"
+#include "Logger.h"
+#include "xml/ModuleConfiguration.h"
+
+
+/* This is what legacy fomodInstaller does:
+ modName.update(dialog.getName(), GUESS_USER);
+ return dialog.updateTree(tree);
+
+ This is a link to the legacy updateTree method:
+ https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/fc263f2d923c704b4853c11ed4f8b8cf3920f30d/src/fomodinstallerdialog.cpp#L546
+
+*/
+
+// Things this should do:
+// - Copy all requiredInstall files
+// - Copy all conditionalInstall files
+// - Copy all selected files from steps that were visible to the user at the point of install
+
+using namespace MOBase;
+
+using FileGlobalIndex = int;
+using FileDescriptor = std::pair<File, FileGlobalIndex>;
+
+class StepViewModel;
+
+class FileInstaller {
+public:
+ FileInstaller(
+ IOrganizer* organizer,
+ QString fomodPath,
+ const std::shared_ptr<IFileTree>& fileTree,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ const std::shared_ptr<FlagMap>& flagMap,
+ const std::vector<std::shared_ptr<StepViewModel> >& steps);
+
+ std::shared_ptr<IFileTree> install() const;
+
+ /**
+ * @brief Create a 'fomod.json' file to add to the base of the installTree. Functionally similar to MO2's meta.ini.
+ *
+ * Until there's more utility in the JSON structure itself, it will simply be of this format:
+ * @code
+ * {
+ * "steps": [
+ * {
+ * "name": "Step 1",
+ * "groups": [
+ * {
+ * "name": "Group 1",
+ * "plugins: [
+ * "Plugin1.esp",
+ * "Plugin2.esp"
+ * ]
+ * }
+ * ]
+ * }
+ * ]
+ * }
+ * @endcode
+ *
+ * @return nhlohmann::json
+ */
+ nlohmann::json generateFomodJson() const;
+
+ std::string getQualifiedFilePath(const std::string& treePath) const;
+
+ std::vector<std::string> collectPositiveFileNamesFromDependencyPatterns(const std::vector<DependencyPattern> &patterns);
+
+ void addFiles(std::vector<File>& main, std::vector<File> toAdd) const;
+
+private:
+ IOrganizer* mOrganizer;
+ Logger& log = Logger::getInstance();
+ QString mFomodPath;
+ std::shared_ptr<IFileTree> mFileTree;
+ std::unique_ptr<ModuleConfiguration> mFomodFile;
+ std::shared_ptr<FlagMap> mFlagMap;
+ ConditionTester mConditionTester;
+ std::vector<std::shared_ptr<StepViewModel> > mSteps; // TODO: Maybe this is nasty. Idk.
+
+ std::vector<File> collectFilesToInstall() const;
+
+ void logMessage(LogLevel level, const std::string& message) const
+ {
+ log.logMessage(level, "[INSTALLER] " + message);
+ }
+};
diff --git a/libs/installer_fomod_plus/installer/lib/FlagMap.h b/libs/installer_fomod_plus/installer/lib/FlagMap.h
new file mode 100644
index 0000000..4959cec
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/FlagMap.h
@@ -0,0 +1,113 @@
+#pragma once
+
+#include "ViewModels.h"
+#include "stringutil.h"
+
+#include <ranges>
+#include <string>
+#include <unordered_map>
+
+using Flag = std::pair<std::string, std::string>;
+using FlagList = std::vector<Flag>;
+
+class FlagMap {
+public:
+
+ [[nodiscard]] std::vector<std::shared_ptr<PluginViewModel>> getPluginsSettingFlag(const std::string& key, const std::string& value) const
+ {
+ std::vector<std::shared_ptr<PluginViewModel>> result;
+ for (const auto& [plugin, theseFlags] : flags) {
+ for (const auto& [fst, snd] : theseFlags) {
+ if (toLower(fst) == toLower(key) && snd == value) {
+ result.emplace_back(plugin);
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ *
+ * @param key The flag key
+ * @return A list of flags currently set in this map with the given key. The list is ordered by step descending, then plugin ascending.
+ * So if steps 1, 2, and 3 set flag X in their first two plugins, it'll be ordered [3:1, 3:2, 2:1, 2:2, 1:2, 1:1]
+ */
+ [[nodiscard]] FlagList getFlagsByKey(const std::string& key) const
+ {
+ FlagList result;
+ std::vector<std::pair<int, std::shared_ptr<PluginViewModel>>> orderedPlugins;
+
+ // Collect all plugins with their stepIndex and ownIndex
+ for (const auto& plugin : flags | std::views::keys) {
+ orderedPlugins.emplace_back(plugin->getStepIndex(), plugin);
+ }
+
+ // Sort plugins by stepIndex and ownIndex, stepIndex descending and ownIndex ascending
+ // TODO: Might need group sorting too. How can I just do the natural order??
+ std::ranges::sort(orderedPlugins, [](const auto& a, const auto& b) {
+ return a.first > b.first || (a.first == b.first && a.second->getOwnIndex() < b.second->getOwnIndex());
+ });
+
+ // Collect flags in the sorted order
+ for (const auto& plugin : orderedPlugins | std::views::values) {
+ for (const auto& flag : flags.at(plugin)) {
+ if (toLower(flag.first) == toLower(key)) {
+ result.emplace_back(flag);
+ }
+ }
+ }
+ return result;
+ }
+
+ void setFlagsForPlugin(PluginRef plugin)
+ {
+ // Don't clutter the map with empty key-vals
+ if (plugin->getConditionFlags().empty()) {
+ return;
+ }
+ unsetFlagsForPlugin(plugin);
+
+ FlagList flagList;
+ for (const auto& conditionFlag : plugin->getConditionFlags()) {
+ flagList.emplace_back(toLower(conditionFlag.name), conditionFlag.value);
+ }
+ flags[plugin] = flagList;
+ }
+
+ void unsetFlagsForPlugin(PluginRef plugin)
+ {
+ if (flags.contains(plugin)) {
+ flags.erase(plugin);
+ }
+ }
+
+ std::string toString()
+ {
+ auto result = std::string();
+ result += "FlagMap:\n";
+
+ for (const auto& [plugin, theseFlags] : flags) {
+ result += plugin->getName() + " [";
+ for (const auto& [fst, snd] : theseFlags) {
+ result += fst + ": " + snd + ", ";
+ }
+ result.erase(result.size() - 2);
+ result += "]\n";
+ }
+ return result;
+ }
+
+ void clearAll()
+ {
+ flags.clear();
+ }
+
+ [[nodiscard]] size_t getFlagCount() const
+ {
+ return flags.size();
+ }
+
+
+private:
+ std::unordered_map<std::shared_ptr<PluginViewModel>, FlagList> flags;
+};
diff --git a/libs/installer_fomod_plus/installer/lib/Logger.h b/libs/installer_fomod_plus/installer/lib/Logger.h
new file mode 100644
index 0000000..fdeb1f0
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/Logger.h
@@ -0,0 +1,118 @@
+#pragma once
+#include <fstream>
+#include <iostream>
+#include <mutex>
+#include <regex>
+
+
+// Log levels
+enum LogLevel {
+ DEBUG = 0,
+ INFO = 1,
+ WARN = 2,
+ ERR = 3
+};
+
+// Convert LogLevel to string
+inline const char* logLevelToString(const LogLevel level) {
+ switch (level) {
+ case DEBUG: return "DEBUG";
+ case INFO: return "INFO";
+ case WARN: return "WARN";
+ case ERR: return "ERROR";
+ default: return "UNKNOWN";
+ }
+}
+
+
+class Logger {
+public:
+ static Logger& getInstance()
+ {
+ static Logger instance;
+ return instance;
+ }
+
+ void setLogFilePath(const std::string& filePath)
+ {
+ std::lock_guard lock(mMutex);
+ if (mLogFile.is_open()) {
+ mLogFile.close();
+ }
+ mLogFile.open(filePath, std::ios::out);
+ // std::ios::app is an option for appending but dont wanna grow it forever.
+ }
+
+ void setDebugMode(bool debug)
+ {
+ std::lock_guard lock(mMutex);
+ mDebugMode = debug;
+ }
+
+ void logMessage(const LogLevel level, const std::string& message)
+ {
+
+#if defined(__GNUC__) || defined(__clang__)
+ std::string functionName = __PRETTY_FUNCTION__;
+#elif defined(_MSC_VER)
+ std::string functionName = __FUNCSIG__;
+#else
+ std::string functionName = "UnknownFunction";
+#endif
+
+ std::regex classNameRegex(R"((\w+)::\w+\()");
+ std::smatch match;
+ std::string className = "UnknownClass";
+
+ if (std::regex_search(functionName, match, classNameRegex) && match.size() > 1) {
+ className = match.str(1);
+ }
+
+ std::lock_guard lock(mMutex);
+
+ auto writeLog = [&](std::ostream& stream) {
+ switch (level) {
+ case DEBUG:
+ stream << "[DEBUG] " << message << std::endl;
+ break;
+ case INFO:
+ stream << "[INFO] " << message << std::endl;
+ break;
+ case WARN:
+ stream << "[WARN] " << message << std::endl;
+ break;
+ case ERR:
+ stream << "[ERROR] " << message << std::endl;
+ break;
+ }
+ };
+
+ if (mLogFile.is_open()) {
+ writeLog(mLogFile);
+ }
+
+ writeLog(std::cout);
+ }
+
+ Logger& operator=(const Logger&) = delete;
+
+private:
+ Logger() = default;
+
+ ~Logger()
+ {
+ if (mLogFile.is_open()) {
+ mLogFile.close();
+ }
+ }
+
+ Logger(const Logger&) = delete;
+
+ std::ofstream mLogFile;
+ std::mutex mMutex;
+#if !defined(NDEBUG) || defined(CMAKE_BUILD_TYPE_RELWITHDEBINFO)
+ bool mDebugMode = true; // Auto-enable in debug/RelWithDebInfo builds
+#else
+ bool mDebugMode = false; // Disable in release builds
+#endif
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/lib/ViewModels.h b/libs/installer_fomod_plus/installer/lib/ViewModels.h
new file mode 100644
index 0000000..062445a
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/ViewModels.h
@@ -0,0 +1,120 @@
+#pragma once
+
+#include <memory>
+#include <vector>
+
+#include "xml/ModuleConfiguration.h"
+
+
+template <typename T>
+using shared_ptr_list = std::vector<std::shared_ptr<T> >;
+
+
+/*
+--------------------------------------------------------------------------------
+ Plugins
+--------------------------------------------------------------------------------
+*/
+class PluginViewModel {
+public:
+ PluginViewModel(const std::shared_ptr<Plugin>& plugin_, const bool selected, bool, const int index)
+ : ownIndex(index), selected(selected), enabled(true), manuallySet(false), plugin(plugin_) {}
+
+ void setSelected(const bool selected) { this->selected = selected; }
+ void setEnabled(const bool enabled) { this->enabled = enabled; }
+ [[nodiscard]] std::string getName() const { return plugin ? plugin->name : std::string(); }
+ [[nodiscard]] std::string getDescription() const { return plugin ? plugin->description : std::string(); }
+ [[nodiscard]] std::string getImagePath() const { return plugin ? plugin->image.path : std::string(); }
+ [[nodiscard]] bool isSelected() const { return selected; }
+ [[nodiscard]] bool isEnabled() const { return enabled; }
+ [[nodiscard]] int getOwnIndex() const { return ownIndex; }
+ [[nodiscard]] std::vector<ConditionFlag> getConditionFlags() const { return plugin->conditionFlags.flags; }
+ [[nodiscard]] PluginTypeEnum getCurrentPluginType() const { return currentPluginType; }
+ [[nodiscard]] bool wasManuallySet() const { return manuallySet; }
+
+ void setCurrentPluginType(const PluginTypeEnum type) { currentPluginType = type; }
+ void setStepIndex(const int stepIndex) { this->stepIndex = stepIndex; }
+ void setGroupIndex(const int groupIndex) { this->groupIndex = groupIndex; }
+
+ [[nodiscard]] int getStepIndex() const { return stepIndex; }
+ [[nodiscard]] int getGroupIndex() const { return groupIndex; }
+
+ friend class FomodViewModel;
+ friend class FileInstaller;
+ friend class ConditionTester;
+
+protected:
+ [[nodiscard]] std::shared_ptr<Plugin> getPlugin() const { return plugin; }
+
+private:
+ int ownIndex;
+ bool selected;
+ bool enabled;
+ bool manuallySet;
+ PluginTypeEnum currentPluginType = PluginTypeEnum::UNKNOWN;
+ std::shared_ptr<Plugin> plugin;
+
+ int stepIndex{ -1 };
+ int groupIndex{ -1 };
+};
+
+/*
+--------------------------------------------------------------------------------
+ Groups
+--------------------------------------------------------------------------------
+*/
+class GroupViewModel {
+public:
+ GroupViewModel(const std::shared_ptr<Group>& group_, const shared_ptr_list<PluginViewModel>& plugins,
+ const int index, const int stepIndex)
+ : plugins(plugins), group(group_), ownIndex(index), stepIndex(stepIndex) {}
+
+ void addPlugin(const std::shared_ptr<PluginViewModel>& plugin) { plugins.emplace_back(plugin); }
+
+ [[nodiscard]] std::string getName() const { return group->name; }
+ [[nodiscard]] GroupTypeEnum getType() const { return group->type; }
+ [[nodiscard]] const shared_ptr_list<PluginViewModel>& getPlugins() const { return plugins; }
+ [[nodiscard]] int getOwnIndex() const { return ownIndex; }
+ [[nodiscard]] int getStepIndex() const { return stepIndex; }
+
+private:
+ shared_ptr_list<PluginViewModel> plugins;
+ std::shared_ptr<Group> group;
+ int ownIndex;
+ int stepIndex;
+};
+
+/*
+--------------------------------------------------------------------------------
+ Steps
+--------------------------------------------------------------------------------
+*/
+class StepViewModel {
+public:
+ StepViewModel(const std::shared_ptr<InstallStep>& installStep_, const shared_ptr_list<GroupViewModel>& groups,
+ const int index)
+ : installStep(installStep_), groups(groups), ownIndex(index) {}
+
+ [[nodiscard]] CompositeDependency& getVisibilityConditions() const { return installStep->visible; }
+ [[nodiscard]] std::string getName() const { return installStep->name; }
+ [[nodiscard]] const shared_ptr_list<GroupViewModel>& getGroups() const { return groups; }
+ [[nodiscard]] int getOwnIndex() const { return ownIndex; }
+ [[nodiscard]] bool getHasVisited() const { return visited; }
+ void setVisited(const bool visited) { this->visited = visited; }
+
+private:
+ bool visited{ false };
+ std::shared_ptr<InstallStep> installStep;
+ shared_ptr_list<GroupViewModel> groups;
+ int ownIndex;
+};
+
+
+/*
+--------------------------------------------------------------------------------
+ Outbound Types
+--------------------------------------------------------------------------------
+*/
+using StepRef = const std::shared_ptr<StepViewModel>&;
+using GroupRef = const std::shared_ptr<GroupViewModel>&;
+using PluginRef = const std::shared_ptr<PluginViewModel>&;
diff --git a/libs/installer_fomod_plus/installer/resources.qrc b/libs/installer_fomod_plus/installer/resources.qrc
new file mode 100644
index 0000000..6c583a7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources.qrc
@@ -0,0 +1,14 @@
+<RCC>
+ <qresource prefix="/fomod">
+ <file alias="hat">resources/fomod_icon.png</file>
+ </qresource>
+ <qresource prefix="/fomod">
+ <file alias="previous">resources/left-chevron.png</file>
+ </qresource>
+ <qresource prefix="/fomod">
+ <file alias="next">resources/right-chevron.png</file>
+ </qresource>
+ <qresource prefix="/fomod">
+ <file alias="close">resources/delete.png</file>
+ </qresource>
+</RCC> \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/resources/delete.png b/libs/installer_fomod_plus/installer/resources/delete.png
new file mode 100644
index 0000000..f6502b1
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/delete.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/fomod_icon.png b/libs/installer_fomod_plus/installer/resources/fomod_icon.png
new file mode 100644
index 0000000..e044052
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/fomod_icon.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/image_icon.png b/libs/installer_fomod_plus/installer/resources/image_icon.png
new file mode 100644
index 0000000..b300751
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/image_icon.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/left-chevron.png b/libs/installer_fomod_plus/installer/resources/left-chevron.png
new file mode 100644
index 0000000..28222a1
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/left-chevron.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/right-chevron.png b/libs/installer_fomod_plus/installer/resources/right-chevron.png
new file mode 100644
index 0000000..1a0af49
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/right-chevron.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/ui/ClickableWidget.h b/libs/installer_fomod_plus/installer/ui/ClickableWidget.h
new file mode 100644
index 0000000..881600f
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/ClickableWidget.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#include <QMouseEvent>
+#include <QWidget>
+
+class ClickableWidget final : public QWidget {
+ Q_OBJECT
+
+public:
+ explicit ClickableWidget(QWidget* parent = nullptr) : QWidget(parent) {
+ setCursor(Qt::PointingHandCursor);
+ }
+
+ signals:
+ void clicked();
+
+protected:
+ void mousePressEvent(QMouseEvent* event) override {
+ if (event->button() == Qt::LeftButton) {
+ emit clicked();
+ }
+ QWidget::mousePressEvent(event);
+ }
+};
diff --git a/libs/installer_fomod_plus/installer/ui/Colors.h b/libs/installer_fomod_plus/installer/ui/Colors.h
new file mode 100644
index 0000000..c3262eb
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/Colors.h
@@ -0,0 +1,155 @@
+#pragma once
+
+#include <QString>
+#include <map>
+
+namespace UiColors {
+
+enum class ColorApplication {
+ BACKGROUND,
+ BORDER,
+ TEXT,
+ ALL
+};
+
+// Color values
+namespace Colors {
+ // Light
+ const QString Light0 = "251, 241, 199";
+ const QString Light1 = "235, 219, 178";
+ const QString Light2 = "213, 196, 161";
+ const QString Light3 = "189, 174, 147";
+
+ // Dark
+ const QString Dark0 = "40, 40, 40";
+ const QString Dark1 = "60, 56, 54";
+ const QString Dark2 = "80, 73, 69";
+ const QString Dark3 = "102, 92, 84";
+
+ // Red
+ const QString Red = "204, 36, 29";
+ const QString RedBright = "251, 73, 52";
+
+ // Green
+ const QString Green = "152, 151, 26";
+ const QString GreenBright = "184, 187, 38";
+
+ // Yellow
+ const QString Yellow = "215, 153, 33";
+ const QString YellowBright = "250, 189, 47";
+
+ // Blue
+ const QString Blue = "69, 133, 136";
+ const QString BlueBright = "131, 165, 152";
+
+ // Purple
+ const QString Purple = "177, 98, 134";
+ const QString PurpleBright = "211, 134, 155";
+
+ // Aqua
+ const QString Aqua = "104, 157, 106";
+ const QString AquaBright = "142, 192, 124";
+
+ // Orange
+ const QString Orange = "214, 93, 14";
+ const QString OrangeBright = "254, 128, 25";
+}
+
+// Helper function to generate style strings based on color and application
+inline QString generateStyle(const QString& color, const ColorApplication application, const float opacity = 0.4,
+ const int borderWidth = 1)
+{
+ QString style;
+
+ switch (application) {
+ case ColorApplication::BACKGROUND:
+ style = QString("QCheckBox { background-color: rgba(%1, %2); } "
+ "QRadioButton { background-color: rgba(%1, %2); }")
+ .arg(color).arg(opacity);
+ break;
+
+ case ColorApplication::BORDER:
+ style = QString("QCheckBox { border: %1px dashed rgb(%2); } "
+ "QRadioButton { border: %1px dashed rgb(%2); }")
+ .arg(borderWidth).arg(color);
+ break;
+
+ case ColorApplication::TEXT:
+ style = QString("QCheckBox { color: rgb(%1); } "
+ "QRadioButton { color: rgb(%1); }")
+ .arg(color);
+ break;
+
+ case ColorApplication::ALL:
+ style = QString("QCheckBox { background-color: rgba(%1, %2); border: %3px solid rgb(%1); color: rgb(%1); } "
+ "QRadioButton { background-color: rgba(%1, %2); border: %3px solid rgb(%1); color: rgb(%1); }")
+ .arg(color).arg(opacity).arg(borderWidth);
+ break;
+ }
+
+ return style;
+}
+
+// Main function to get style for a color name and application
+inline QString getStyle(const QString& colorName, const ColorApplication application = ColorApplication::BACKGROUND,
+ const float opacity = 0.4, const int borderWidth = 1)
+{
+ static const std::map<QString, QString> colorValues = {
+ { "Light0", Colors::Light0 },
+ { "Light1", Colors::Light1 },
+ { "Light2", Colors::Light2 },
+ { "Light3", Colors::Light3 },
+ { "Dark0", Colors::Dark0 },
+ { "Dark1", Colors::Dark1 },
+ { "Dark2", Colors::Dark2 },
+ { "Dark3", Colors::Dark3 },
+ { "Red", Colors::Red },
+ { "Red Bright", Colors::RedBright },
+ { "Green", Colors::Green },
+ { "Green Bright", Colors::GreenBright },
+ { "Yellow", Colors::Yellow },
+ { "Yellow Bright", Colors::YellowBright },
+ { "Blue", Colors::Blue },
+ { "Blue Bright", Colors::BlueBright },
+ { "Purple", Colors::Purple },
+ { "Purple Bright", Colors::PurpleBright },
+ { "Aqua", Colors::Aqua },
+ { "Aqua Bright", Colors::AquaBright },
+ { "Orange", Colors::Orange },
+ { "Orange Bright", Colors::OrangeBright }
+ };
+
+ if (const auto it = colorValues.find(colorName); it != colorValues.end()) {
+ return generateStyle(it->second, application, opacity, borderWidth);
+ }
+
+ return {};
+}
+
+// For backward compatibility
+const static std::map<QString, QString> colorStyles = {
+ { "Light0", getStyle("Light0", ColorApplication::BACKGROUND) },
+ { "Light1", getStyle("Light1", ColorApplication::BACKGROUND) },
+ { "Light2", getStyle("Light2", ColorApplication::BACKGROUND) },
+ { "Light3", getStyle("Light3", ColorApplication::BACKGROUND) },
+ { "Dark0", getStyle("Dark0", ColorApplication::BACKGROUND) },
+ { "Dark1", getStyle("Dark1", ColorApplication::BACKGROUND) },
+ { "Dark2", getStyle("Dark2", ColorApplication::BACKGROUND) },
+ { "Dark3", getStyle("Dark3", ColorApplication::BACKGROUND) },
+ { "Red", getStyle("Red", ColorApplication::BACKGROUND) },
+ { "Red Bright", getStyle("Red Bright", ColorApplication::BACKGROUND) },
+ { "Green", getStyle("Green", ColorApplication::BACKGROUND) },
+ { "Green Bright", getStyle("Green Bright", ColorApplication::BACKGROUND) },
+ { "Yellow", getStyle("Yellow", ColorApplication::BACKGROUND) },
+ { "Yellow Bright", getStyle("Yellow Bright", ColorApplication::BACKGROUND) },
+ { "Blue", getStyle("Blue", ColorApplication::BACKGROUND) },
+ { "Blue Bright", getStyle("Blue Bright", ColorApplication::BACKGROUND) },
+ { "Purple", getStyle("Purple", ColorApplication::BACKGROUND) },
+ { "Purple Bright", getStyle("Purple Bright", ColorApplication::BACKGROUND) },
+ { "Aqua", getStyle("Aqua", ColorApplication::BACKGROUND) },
+ { "Aqua Bright", getStyle("Aqua Bright", ColorApplication::BACKGROUND) },
+ { "Orange", getStyle("Orange", ColorApplication::BACKGROUND) },
+ { "Orange Bright", getStyle("Orange Bright", ColorApplication::BACKGROUND) }
+};
+
+}
diff --git a/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp
new file mode 100644
index 0000000..cb99d27
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp
@@ -0,0 +1,284 @@
+#include "FomodImageViewer.h"
+
+#include "ScaleLabel.h"
+#include "UIHelper.h"
+
+#include <QLabel>
+#include <QScrollArea>
+#include <QtConcurrent/QtConcurrent>
+
+constexpr int PREVIEW_IMAGE_WIDTH = 160;
+constexpr int PREVIEW_IMAGE_HEIGHT = 90;
+
+/*
++----------------------------------------------------------+
+|n/N X |
++---+--------------------------------------------------+---+
+| | | |
+| | | |
+| | | |
+| | | |
+| | | |
+| | | |
+| < | Image | > |
+| | | |
+| | | |
+| | | |
+| | | |
+| | label | |
++------+------+------+---------------------------------+---+
+| | | | ...previews |
+| | | | |
++------+------+------+-------------------------------------+
+*/
+constexpr auto BUTTON_STYLE =
+ "font-size: 16px; font-weight: bold; color: white; background-color: black; padding: 5px; border-radius: 1px solid black;";
+
+FomodImageViewer::FomodImageViewer(QWidget* parent,
+ const QString& fomodPath,
+ const std::shared_ptr<StepViewModel>& activeStep,
+ const std::shared_ptr<PluginViewModel>& activePlugin) : QDialog(parent), mFomodPath(fomodPath),
+ mActiveStep(activeStep),
+ mActivePlugin(activePlugin)
+{
+
+ setWindowFlags(Qt::FramelessWindowHint | Qt::Window);
+ setAttribute(Qt::WA_TranslucentBackground);
+ setStyleSheet("background-color: rgba(0, 0, 0, 150);");
+
+ const QScreen* screen = this->screen();
+ const QRect availableGeometry = screen->availableGeometry();
+ setFixedSize(availableGeometry.width(), availableGeometry.height());
+ move(availableGeometry.x(), availableGeometry.y());
+
+ collectImages();
+ mMainImageWrapper = createSinglePhotoPane(this);
+ mTopBar = createTopBar(this);
+ mPreviewImages = createPreviewImages(this);
+ mCenterRow = createCenterRow(this);
+
+ const auto layout = new QVBoxLayout(this);
+ layout->setContentsMargins(0, 0, 0, 0); // Remove margins
+ layout->setSpacing(0); // Remove spacing between widgets
+ layout->addWidget(mTopBar);
+ layout->addWidget(mCenterRow, 1);
+ layout->addWidget(mPreviewImages);
+ setLayout(layout);
+
+ select(mCurrentIndex);
+
+ setFocusPolicy(Qt::StrongFocus); // so we can receive key events
+}
+
+void FomodImageViewer::collectImages()
+{
+ mLabelsAndImages.clear();
+ for (const auto& groupViewModel : mActiveStep->getGroups()) {
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ if (pluginViewModel->getImagePath().empty()) {
+ continue;
+ }
+ QString imagePath = UIHelper::getFullImagePath(mFomodPath,
+ QString::fromStdString(pluginViewModel->getImagePath()));
+ mLabelsAndImages.emplace_back(QString::fromStdString(pluginViewModel->getName()), imagePath);
+
+ if (pluginViewModel == mActivePlugin) {
+ mCurrentIndex = static_cast<int>(mLabelsAndImages.size()) - 1;
+ }
+ }
+ }
+}
+
+QWidget* FomodImageViewer::createCenterRow(QWidget* parent)
+{
+ const auto centerRow = new QWidget(parent);
+ const auto layout = new QHBoxLayout(centerRow);
+
+ mBackButton = createBackButton(centerRow);
+ mForwardButton = createForwardButton(centerRow);
+
+ mBackButton->setFocusPolicy(Qt::NoFocus);
+ mForwardButton->setFocusPolicy(Qt::NoFocus);
+
+ layout->addWidget(mBackButton);
+ layout->addWidget(mMainImageWrapper, 1);
+ layout->addWidget(mForwardButton);
+
+ return centerRow;
+}
+
+// ReSharper disable once CppMemberFunctionMayBeStatic
+QWidget* FomodImageViewer::createSinglePhotoPane(QWidget* parent)
+{
+ const auto singlePhotoPane = new QWidget(parent);
+ const auto layout = new QVBoxLayout(singlePhotoPane);
+
+ // const auto [labelText, imagePath] = pair;
+
+ mMainImage = new ScaleLabel(singlePhotoPane);
+ mMainImage->setAlignment(Qt::AlignCenter);
+ layout->addWidget(mMainImage, 1);
+
+ mLabel = new QLabel(singlePhotoPane);
+ // mLabel->setText(labelText);
+ mLabel->setAlignment(Qt::AlignCenter);
+ mLabel->setStyleSheet("color: white; font-size: 20px;");
+ layout->addWidget(mLabel);
+
+ return singlePhotoPane;
+}
+
+QScrollArea* FomodImageViewer::createPreviewImages(QWidget* parent)
+{
+ mImagePanes.clear();
+ const auto previewImages = new QScrollArea(parent);
+ const auto widget = new QWidget(previewImages);
+ const auto layout = new QHBoxLayout(previewImages);
+
+ layout->setContentsMargins(0, 0, 0, 0);
+ layout->setSpacing(0);
+
+ for (int i = 0; i < mLabelsAndImages.size(); i++) {
+ const auto imageLabel = new ScaleLabel(previewImages);
+ imageLabel->setAlignment(Qt::AlignCenter);
+ imageLabel->setFixedSize(PREVIEW_IMAGE_WIDTH, PREVIEW_IMAGE_HEIGHT);
+ imageLabel->setFocusPolicy(Qt::NoFocus);
+ connect(imageLabel, &ScaleLabel::clicked, this, [this, i] {
+ select(i);
+ });
+ layout->addWidget(imageLabel);
+ mImagePanes.emplace_back(imageLabel);
+
+ // imageLabel->setScalableResource(mLabelsAndImages[i].second);
+ const auto imagePath = mLabelsAndImages[i].second;
+
+ QThreadPool::globalInstance()->start([imageLabel, imagePath]() {
+ QMetaObject::invokeMethod(imageLabel, [imageLabel, imagePath]() {
+ imageLabel->setScalableResource(imagePath);
+ }, Qt::QueuedConnection);
+ });
+ }
+
+ widget->setLayout(layout);
+ previewImages->setFixedHeight(PREVIEW_IMAGE_HEIGHT + 10);
+ previewImages->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ previewImages->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
+ previewImages->setFocusPolicy(Qt::NoFocus);
+
+ previewImages->setWidget(widget);
+ previewImages->setStyleSheet("QScrollArea { border: none; }");
+
+ return previewImages;
+}
+
+QPushButton* FomodImageViewer::createBackButton(QWidget* parent) const
+{
+ const auto backButton = new QPushButton(parent);
+ backButton->setText("<");
+ backButton->setStyleSheet(BUTTON_STYLE);
+ connect(backButton, &QPushButton::clicked, this, &FomodImageViewer::goBack);
+ return backButton;
+}
+
+QPushButton* FomodImageViewer::createForwardButton(QWidget* parent) const
+{
+ const auto forwardButton = new QPushButton(parent);
+ forwardButton->setText(">");
+ forwardButton->
+ setStyleSheet(BUTTON_STYLE);
+ connect(forwardButton, &QPushButton::clicked, this, &FomodImageViewer::goForward);
+ return forwardButton;
+}
+
+QWidget* FomodImageViewer::createTopBar(QWidget* parent)
+{
+ const auto topBar = new QWidget(parent);
+ const auto layout = new QHBoxLayout(topBar);
+
+ // counter, spacer, close button
+ mCounter = new QLabel(topBar);
+ mCounter->setStyleSheet(BUTTON_STYLE);
+ layout->addWidget(mCounter);
+
+ layout->addStretch();
+
+ mCloseButton = createCloseButton(topBar);
+ layout->addWidget(mCloseButton);
+ return topBar;
+}
+
+QPushButton* FomodImageViewer::createCloseButton(QWidget* parent)
+{
+ const auto closeButton = new QPushButton(parent);
+ // const QIcon icon(":/fomod/close");
+ // closeButton->setIcon(icon);
+ closeButton->setText("X");
+ closeButton->setStyleSheet("color: white; background-color: black; padding: 5px; border-radius: 1px solid black;");
+ connect(closeButton, &QPushButton::clicked, this, &FomodImageViewer::close);
+ return closeButton;
+}
+
+void FomodImageViewer::updateCounterText() const
+{
+ mCounter->setText(QString::number(mCurrentIndex + 1) + "/" + QString::number(mLabelsAndImages.size()));
+}
+
+void FomodImageViewer::goBack()
+{
+ if (mCurrentIndex == 0) {
+ return;
+ }
+ select(--mCurrentIndex);
+}
+
+void FomodImageViewer::goForward()
+{
+ if (mCurrentIndex == mLabelsAndImages.size() - 1) {
+ return;
+ }
+ select(++mCurrentIndex);
+}
+
+void FomodImageViewer::select(const int index)
+{
+ if (index < 0 || index >= mLabelsAndImages.size()) {
+ return;
+ }
+
+ // Remove border from previously selected image
+ mCurrentIndex = index; // check for bounds?
+ updateCounterText();
+
+ for (int i = 0; i < mImagePanes.size(); i++) {
+ if (i == mCurrentIndex) {
+ mImagePanes[i]->setStyleSheet("border: 2px solid white;");
+ } else {
+ mImagePanes[i]->setStyleSheet("");
+ }
+ }
+
+ const auto& imagePath = mLabelsAndImages[index].second;
+ const auto& labelText = mLabelsAndImages[index].first;
+ mLabel->setText(labelText);
+ mMainImage->setScalableResource(imagePath);
+}
+
+void FomodImageViewer::keyPressEvent(QKeyEvent* event)
+{
+ switch (event->key()) {
+ case Qt::Key_Left:
+ goBack();
+ break;
+ case Qt::Key_Right:
+ goForward();
+ break;
+ default:
+ QDialog::keyPressEvent(event);
+ }
+}
+
+void FomodImageViewer::showEvent(QShowEvent* event)
+{
+ QDialog::showEvent(event);
+ setFocus();
+} \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h
new file mode 100644
index 0000000..73a06f7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h
@@ -0,0 +1,92 @@
+#pragma once
+
+#include "FomodViewModel.h"
+
+#include <QDialog>
+#include <QKeyEvent>
+#include <QLabel>
+#include <QScrollArea>
+
+class ScaleLabel;
+using LabelImagePair = std::pair<QString, QString>;
+
+
+/*
++----------------------------------------------------------+
+|n/N X |
++---+--------------------------------------------------+---+
+| | | |
+| | | |
+| | | |
+| | | |
+| | | |
+| | | |
+| < | Image | > |
+| | | |
+| | | |
+| | | |
+| | | |
+| | label | |
++------+------+------+---------------------------------+---+
+| | | | ...previews |
+| | | | |
++------+------+------+-------------------------------------+
+*/
+
+class FomodImageViewer final : public QDialog {
+ Q_OBJECT
+
+public:
+ explicit FomodImageViewer(QWidget* parent,
+ const QString& fomodPath,
+ const std::shared_ptr<StepViewModel>& activeStep,
+ const std::shared_ptr<PluginViewModel>& activePlugin);
+
+private:
+ void collectImages();
+
+ QWidget* createCenterRow(QWidget* parent);
+
+ QWidget* createSinglePhotoPane(QWidget* parent);
+
+ QScrollArea* createPreviewImages(QWidget* parent);
+
+ QPushButton* createBackButton(QWidget* parent) const;
+
+ QPushButton* createForwardButton(QWidget* parent) const;
+
+ QWidget* createTopBar(QWidget* parent);
+
+ QPushButton* createCloseButton(QWidget* parent);
+
+ void updateCounterText() const;
+
+ void goBack();
+
+ void goForward();
+
+ void select(int index);
+
+ void keyPressEvent(QKeyEvent* event) override;
+
+ void showEvent(QShowEvent* event) override;
+
+ std::vector<LabelImagePair> mLabelsAndImages;
+ std::vector<QWidget*> mImagePanes{};
+ int mCurrentIndex{ 0 };
+
+ QString mFomodPath;
+ const std::shared_ptr<StepViewModel>& mActiveStep;
+ const std::shared_ptr<PluginViewModel>& mActivePlugin;
+
+ QWidget* mCenterRow{ nullptr };
+ QWidget* mTopBar{ nullptr };
+ QWidget* mCloseButton{ nullptr };
+ QPushButton* mBackButton{ nullptr };
+ QPushButton* mForwardButton{ nullptr };
+ QLabel* mCounter{ nullptr };
+ QWidget* mMainImageWrapper{ nullptr };
+ ScaleLabel* mMainImage{ nullptr };
+ QLabel* mLabel{ nullptr };
+ QScrollArea* mPreviewImages{ nullptr };
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp b/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp
new file mode 100644
index 0000000..b11674c
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp
@@ -0,0 +1,746 @@
+#include "FomodViewModel.h"
+#include "xml/ModuleConfiguration.h"
+#include "lib/Logger.h"
+
+using GroupCallback = std::function<void(GroupRef)>;
+using PluginCallback = std::function<void(GroupRef, PluginRef)>;
+
+
+/*
+--------------------------------------------------------------------------------
+ Helpers
+--------------------------------------------------------------------------------
+*/
+#pragma region Helpers
+
+bool isRadioLike(GroupRef group)
+{
+ return group->getType() == SelectExactlyOne
+ || (group->getType() == SelectAtMostOne && group->getPlugins().size() > 1);
+}
+
+bool moreThanOneSelected(GroupRef group)
+{
+ auto selectedPlugins = group->getPlugins() | std::views::filter([](const auto& plugin) {
+ return plugin->isSelected();
+ });
+ return std::ranges::distance(selectedPlugins) > 1;
+}
+
+bool anySelected(GroupRef group)
+{
+ return std::ranges::any_of(group->getPlugins(), [](const auto& plugin) { return plugin->isSelected(); });
+}
+
+std::string pluginTypeEnumToString(const PluginTypeEnum type)
+{
+ switch (type) {
+ case PluginTypeEnum::Recommended:
+ return "Recommended";
+ case PluginTypeEnum::Required:
+ return "Required";
+ case PluginTypeEnum::Optional:
+ return "Optional";
+ case PluginTypeEnum::NotUsable:
+ return "NotUsable";
+ case PluginTypeEnum::CouldBeUsable:
+ return "CouldBeUsable";
+ default:
+ return "Unknown";
+ }
+}
+
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Lifecycle
+--------------------------------------------------------------------------------
+*/
+#pragma region ViewModel Lifecycle
+
+/**
+ * @brief FomodViewModel constructor
+ *
+ * @note DO NOT USE DIRECTLY. We should only use FomodViewModel::create() to create a new instance.
+ * @see FomodViewModel::create
+ *
+ * @param organizer The organizer instance passed from the IInstaller
+ * @param fomodFile The ModuleConfiguration instance created from the raw ModuleConfiguration.xml file
+ * @param infoFile The FomodInfoFile instance created from the raw info.xml file
+ *
+ * @return A FomodViewModel instance
+ */
+FomodViewModel::FomodViewModel(MOBase::IOrganizer* organizer,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ std::unique_ptr<FomodInfoFile> infoFile)
+ : mOrganizer(organizer), mFomodFile(std::move(fomodFile)), mInfoFile(std::move(infoFile)),
+ mConditionTester(organizer),
+ mInfoViewModel(std::make_shared<InfoViewModel>(mInfoFile))
+{
+ mFlags = std::make_shared<FlagMap>();
+}
+
+/**
+ *
+ * @param organizer The organizer instance passed from the IInstaller
+ * @param fomodFile The ModuleConfiguration instance created from the raw ModuleConfiguration.xml file
+ * @param infoFile The FomodInfoFile instance created from the raw info.xml file
+ * @return A shared pointer to the FomodViewModel instance
+ */
+std::shared_ptr<FomodViewModel> FomodViewModel::create(MOBase::IOrganizer* organizer,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ std::unique_ptr<FomodInfoFile> infoFile)
+{
+ auto viewModel = std::make_shared<FomodViewModel>(organizer, std::move(fomodFile), std::move(infoFile));
+ if (viewModel->mFlags == nullptr) {
+ viewModel->mFlags = std::make_shared<FlagMap>();
+ }
+ viewModel->createStepViewModels();
+
+ // Handle FOMODs with no steps
+ if (viewModel->mSteps.empty()) {
+ viewModel->mInitialized = true;
+ viewModel->logMessage(INFO, "FOMOD with no steps - initialization complete");
+ return viewModel;
+ }
+
+ viewModel->processPluginConditions(-1); // please dont judge me. ill fix this someday.
+ viewModel->enforceGroupConstraints();
+ viewModel->updateVisibleSteps();
+ viewModel->mInitialized = true;
+ viewModel->mCurrentStepIndex = viewModel->mVisibleStepIndices.front();
+ viewModel->mActiveStep = viewModel->mSteps.at(viewModel->mVisibleStepIndices.front());
+ viewModel->mActivePlugin = viewModel->getFirstPluginForActiveStep();
+ viewModel->getActiveStep()->setVisited(true);
+ viewModel->logMessage(DEBUG, "VIEWMODEL INITIALIZED");
+ viewModel->logMessage(DEBUG, viewModel->toString());
+ return viewModel;
+}
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Traversal Functions
+--------------------------------------------------------------------------------
+*/
+#pragma region Traversal Functions
+
+void FomodViewModel::forEachGroup(const GroupCallback& callback) const
+{
+ for (const auto& stepViewModel : mSteps) {
+ for (const auto& groupViewModel : stepViewModel->getGroups()) {
+ callback(groupViewModel);
+ }
+ }
+}
+
+void FomodViewModel::forEachPlugin(const PluginCallback& callback) const
+{
+ for (const auto& stepViewModel : mSteps) {
+ for (const auto& groupViewModel : stepViewModel->getGroups()) {
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ callback(groupViewModel, pluginViewModel);
+ }
+ }
+ }
+}
+
+void FomodViewModel::forEachFuturePlugin(const int fromStepIndex, const PluginCallback& callback) const
+{
+ for (int i = fromStepIndex + 1; i < mSteps.size(); ++i) {
+ for (const auto& groupViewModel : mSteps[i]->getGroups()) {
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ callback(groupViewModel, pluginViewModel);
+ }
+ }
+ }
+}
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Initialization
+--------------------------------------------------------------------------------
+*/
+#pragma region Initializers
+void FomodViewModel::createStepViewModels()
+{
+ shared_ptr_list<StepViewModel> stepViewModels;
+
+ // Handle legacy FOMODs with no install steps
+ if (mFomodFile->installSteps.installSteps.empty()) {
+ logMessage(INFO, "No install steps found - creating default step for legacy FOMOD");
+ return;
+ }
+
+ for (int stepIndex = 0; stepIndex < mFomodFile->installSteps.installSteps.size(); ++stepIndex) {
+ const auto& installStep = mFomodFile->installSteps.installSteps[stepIndex];
+ shared_ptr_list<GroupViewModel> groupViewModels;
+
+ for (int groupIndex = 0; groupIndex < installStep.optionalFileGroups.groups.size(); ++groupIndex) {
+ const auto& group = installStep.optionalFileGroups.groups[groupIndex];
+ shared_ptr_list<PluginViewModel> pluginViewModels;
+
+ for (int pluginIndex = 0; pluginIndex < group.plugins.plugins.size(); ++pluginIndex) {
+ const auto& plugin = group.plugins.plugins[pluginIndex];
+ auto pluginViewModel = std::make_shared<PluginViewModel>(std::make_shared<Plugin>(plugin), false, true,
+ pluginIndex);
+
+ pluginViewModel->setStepIndex(stepIndex);
+ pluginViewModel->setGroupIndex(groupIndex);
+ pluginViewModels.emplace_back(pluginViewModel); // Assuming default values for selected and enabled
+ }
+ auto groupViewModel = std::make_shared<GroupViewModel>(std::make_shared<Group>(group), pluginViewModels,
+ groupIndex, stepIndex);
+ if (groupViewModel->getType() == SelectAtMostOne && groupViewModel->getPlugins().size() > 1) {
+ createNonePluginForGroup(groupViewModel);
+ }
+ groupViewModels.emplace_back(groupViewModel);
+ }
+ auto stepViewModel = std::make_shared<StepViewModel>(std::make_shared<InstallStep>(installStep),
+ std::move(groupViewModels), stepIndex);
+ stepViewModels.emplace_back(stepViewModel);
+
+ }
+ mSteps = std::move(stepViewModels);
+}
+
+void FomodViewModel::createNonePluginForGroup(GroupRef group)
+{
+ const auto nonePlugin = std::make_shared<Plugin>();
+ nonePlugin->name = "None";
+ nonePlugin->typeDescriptor.type = PluginTypeEnum::Optional;
+ const int newIndex = static_cast<int>(group->getPlugins().size());
+ const auto nonePluginViewModel = std::make_shared<PluginViewModel>(nonePlugin, true, true, newIndex);
+ group->addPlugin(nonePluginViewModel);
+}
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Group Constraints
+--------------------------------------------------------------------------------
+*/
+#pragma region Group Constraints
+
+void FomodViewModel::enforceRadioGroupConstraints(GroupRef group) const
+{
+ if (!isRadioLike(group)) {
+ return;
+ }
+
+ logMessage(INFO, "Enforcing group constraints for group " + group->getName());
+
+ if (group->getType() == SelectExactlyOne && group->getPlugins().size() == 1) {
+ logMessage(INFO,
+ "Disabling " + group->getPlugins().at(0)->getName() + " because it's the only plugin.");
+ group->getPlugins().at(0)->setEnabled(false);
+ }
+
+ if (moreThanOneSelected(group)) {
+ logMessage(ERR, "More than one plugin is selected in a SelectExactlyOne group. Deselecting all.");
+ for (const auto& plugin : group->getPlugins()) {
+ plugin->setSelected(false); // don't call toggle here, that'll do the radio stuff.
+ }
+ }
+
+ if (anySelected(group)) {
+ logMessage(INFO, "At least one plugin is selected. Nothing to enforce.");
+ return;
+ }
+
+ // First, try to select the first Recommended plugin
+ for (const auto& plugin : group->getPlugins()) {
+ if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) == PluginTypeEnum::Recommended) {
+ logMessage(INFO, "Selecting " + plugin->getName() + " because it's the first recommended plugin.");
+ togglePlugin(group, plugin, true);
+ return;
+ }
+ }
+
+ // If no Recommended plugin is found, select the first one that isn't NotUsable
+ for (const auto& plugin : group->getPlugins()) {
+ if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) != PluginTypeEnum::NotUsable) {
+ logMessage(INFO, "Selecting " + plugin->getName() + " because it's the first usable plugin.");
+ togglePlugin(group, plugin, true);
+ return;
+ }
+ }
+}
+
+void FomodViewModel::enforceSelectAllConstraint(GroupRef groupViewModel) const
+{
+ if (groupViewModel->getType() != SelectAll) {
+ return;
+ }
+
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ togglePlugin(groupViewModel, pluginViewModel, true);
+ pluginViewModel->setEnabled(false);
+ }
+
+}
+
+void FomodViewModel::enforceSelectAtLeastOneConstraint(GroupRef group) const
+{
+ if (group->getType() != SelectAtLeastOne || group->getPlugins().size() != 1) {
+ return;
+ }
+
+ const auto plugin = group->getPlugins().front();
+ if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) != PluginTypeEnum::NotUsable) {
+ logMessage(DEBUG, "Selecting " + plugin->getName() + " because it's the only plugin in a SelectAtLeastOne.");
+ togglePlugin(group, plugin, true);
+ plugin->setEnabled(false);
+ }
+}
+
+void FomodViewModel::enforceGroupConstraints() const
+{
+ forEachGroup([this](const auto& groupViewModel) {
+ enforceRadioGroupConstraints(groupViewModel);
+ enforceSelectAllConstraint(groupViewModel);
+ enforceSelectAtLeastOneConstraint(groupViewModel);
+ });
+}
+
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Plugin Constraints
+--------------------------------------------------------------------------------
+*/
+#pragma region Plugin Constraints
+
+void FomodViewModel::processPlugin(GroupRef group, PluginRef plugin) const
+{
+ if (group->getType() == SelectAll) {
+ return;
+ }
+ const auto typeDescriptor = mConditionTester.getPluginTypeDescriptorState(plugin->plugin, mFlags);
+
+ if (typeDescriptor == plugin->getCurrentPluginType()) {
+ return;
+ }
+
+ logMessage(DEBUG,
+ "Plugin " + plugin->getName() + " in group " + std::to_string(group->getOwnIndex()) + " has changed type from "
+ + pluginTypeEnumToString(plugin->getCurrentPluginType()) + " to " + pluginTypeEnumToString(typeDescriptor));
+
+ plugin->setCurrentPluginType(typeDescriptor);
+
+ const bool isOnlyPlugin = group->getPlugins().size() == 1
+ && (group->getType() == SelectExactlyOne || group->getType() == SelectAtLeastOne);
+
+ // check if step hasVisited, if it hasn't been, set it to unchecked if it's optional.
+ const auto stepNotVisitedYet = !mSteps[group->getStepIndex()]->getHasVisited();
+
+ switch (typeDescriptor) {
+ case PluginTypeEnum::Recommended:
+ plugin->setEnabled(true);
+ if (!plugin->isSelected()) {
+ togglePlugin(group, plugin, true);
+ }
+ break;
+ case PluginTypeEnum::Required:
+ plugin->setEnabled(false);
+ if (!plugin->isSelected()) {
+ togglePlugin(group, plugin, true);
+ }
+ break;
+ case PluginTypeEnum::Optional:
+ if (!isOnlyPlugin) {
+ plugin->setEnabled(true);
+ }
+ // In the case where we're changing flags to make something optional from Recommended, set it back to unchecked.
+ if (plugin->isSelected() & stepNotVisitedYet && group->getType() == SelectAny) {
+ togglePlugin(group, plugin, false);
+ }
+ break;
+ case PluginTypeEnum::NotUsable:
+ plugin->setEnabled(false);
+ if (plugin->isSelected()) {
+ togglePlugin(group, plugin, false);
+ }
+ break;
+ case PluginTypeEnum::CouldBeUsable:
+ plugin->setEnabled(true);
+ break;
+ default: ;
+ }
+}
+
+void FomodViewModel::processPluginConditions(const int fromStepIndex) const
+{
+ // We only want to update plugins that haven't been seen yet. Otherwise, we could undo manual selections by the user.
+ if (fromStepIndex >= 0) {
+ logMessage(DEBUG, "Processing plugins from step " + std::to_string(fromStepIndex));
+ forEachFuturePlugin(fromStepIndex, [this](const auto& groupViewModel, const auto& pluginViewModel) {
+ processPlugin(groupViewModel, pluginViewModel);
+ });
+ } else {
+ forEachPlugin([this](const auto& groupViewModel, const auto& pluginViewModel) {
+ processPlugin(groupViewModel, pluginViewModel);
+ });
+ }
+}
+
+void FomodViewModel::setFlagForPluginState(PluginRef plugin) const
+{
+ if (plugin->isSelected()) {
+ mFlags->setFlagsForPlugin(plugin);
+ } else {
+ mFlags->unsetFlagsForPlugin(plugin);
+ }
+}
+
+/*
+ * In an exclusive group, this gets called for the deselected plugin and then the selected plugin.
+ * So if we're unselecting modB to select modA, we will get calls like
+ * togglePlugin(group, modB, false)
+ * togglePlugin(group, modA, true)
+ */
+bool FomodViewModel::togglePlugin(GroupRef group, PluginRef plugin, const bool selected) const
+{
+ if (plugin->isSelected() == selected) {
+ logMessage(DEBUG, "Plugin " + plugin->getName() + " is already " + (selected ? "selected" : "deselected"));
+ return false;
+ }
+
+ // Disable other radio options first.
+ if (selected && isRadioLike(group)) {
+ for (const auto& otherPlugin : group->getPlugins()) {
+ if (otherPlugin != plugin && plugin->isSelected()) {
+ logMessage(DEBUG,
+ "Deselecting " + otherPlugin->getName() + " because " + plugin->getName() + " was selected.");
+ otherPlugin->setSelected(false);
+ setFlagForPluginState(otherPlugin);
+ }
+ }
+ }
+
+ const auto stepIndex = group->getStepIndex();
+
+ logMessage(INFO, "Toggling " + plugin->getName() + " to " + (selected ? "true" : "false"));
+ plugin->setSelected(selected);
+ setFlagForPluginState(plugin);
+
+ if (mInitialized) {
+ mActivePlugin = plugin;
+ }
+ processPluginConditions(stepIndex);
+ updateVisibleSteps();
+ return true;
+}
+
+void FomodViewModel::markManuallySet(PluginRef plugin)
+{
+ plugin->manuallySet = true;
+}
+
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Step Constraints
+--------------------------------------------------------------------------------
+*/
+#pragma region Step Constraints
+
+void FomodViewModel::updateVisibleSteps() const
+{
+ mVisibleStepIndices.clear();
+ mFlags->clearAll();
+
+ for (int i = 0; i < mSteps.size(); ++i) {
+ if (i == 0) {
+ rebuildConditionFlagsForStep(i);
+ }
+
+ // This also depends on previous flags that may have set this particular flag.
+ if (mConditionTester.isStepVisible(mFlags, mSteps[i]->getVisibilityConditions(), i, mSteps)) {
+ mVisibleStepIndices.push_back(i);
+ rebuildConditionFlagsForStep(i);
+ }
+ }
+ if (mFlags->getFlagCount() > 0) {
+ logMessage(DEBUG, mFlags->toString());
+ }
+}
+
+void FomodViewModel::rebuildConditionFlagsForStep(const int stepIndex) const
+{
+ for (const auto& group : mSteps[stepIndex]->getGroups()) {
+ for (const auto& plugin : group->getPlugins()) {
+ setFlagForPluginState(plugin);
+ }
+ }
+}
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Navigation/UI
+--------------------------------------------------------------------------------
+*/
+#pragma region Navigation/UI
+
+void FomodViewModel::stepBack()
+{
+ if (mSteps.empty()) {
+ return; // No steps to move back to
+ }
+
+ logMessage(DEBUG, "Stepping back from step " + std::to_string(mCurrentStepIndex));
+ const auto it = std::ranges::find(mVisibleStepIndices, mCurrentStepIndex);
+ if (it != mVisibleStepIndices.end() && it != mVisibleStepIndices.begin()) {
+ mCurrentStepIndex = *std::prev(it);
+ mActiveStep = mSteps[mCurrentStepIndex];
+ mActivePlugin = getFirstPluginForActiveStep();
+ }
+ logMessage(DEBUG, "Stepped back to step " + std::to_string(mCurrentStepIndex));
+}
+
+void FomodViewModel::stepForward()
+{
+ if (mSteps.empty()) {
+ return; // No steps to move forward to
+ }
+
+ logMessage(DEBUG, "Stepping forward from step " + std::to_string(mCurrentStepIndex));
+ const auto it = std::ranges::find(mVisibleStepIndices, mCurrentStepIndex);
+ if (it != mVisibleStepIndices.end() && std::next(it) != mVisibleStepIndices.end()) {
+ mCurrentStepIndex = *std::next(it);
+ mActiveStep = mSteps[mCurrentStepIndex];
+ mActivePlugin = getFirstPluginForActiveStep();
+ }
+ mActiveStep->setVisited(true);
+ logMessage(DEBUG, "Stepped forward to step " + std::to_string(mCurrentStepIndex));
+}
+
+bool FomodViewModel::isLastVisibleStep() const
+{
+ if (mSteps.empty()) {
+ return true; // Legacy FOMODs are always "last step"
+ }
+ return !mVisibleStepIndices.empty() && mCurrentStepIndex == mVisibleStepIndices.back();
+}
+
+bool FomodViewModel::isFirstVisibleStep() const
+{
+ if (mSteps.empty()) {
+ return true; // Legacy FOMODs are always "first step"
+ }
+ return !mVisibleStepIndices.empty() && mCurrentStepIndex == mVisibleStepIndices.front();
+}
+
+void FomodViewModel::preinstall(const std::shared_ptr<MOBase::IFileTree>& tree, const QString& fomodPath)
+{
+ mFileInstaller = std::make_shared<
+ FileInstaller>(mOrganizer, fomodPath, tree, std::move(mFomodFile), mFlags, mSteps);
+}
+
+
+std::string FomodViewModel::getDisplayImage() const
+{
+ // if the active plugin has an image, return it
+ if (mActivePlugin && !mActivePlugin->getImagePath().empty()) {
+ return mActivePlugin->getImagePath();
+ }
+ return mCurrentStepIndex == 0 ? mFomodFile->moduleImage.path : "";
+}
+
+std::shared_ptr<PluginViewModel> FomodViewModel::getFirstPluginForActiveStep() const
+{
+ if (!mActiveStep) {
+ return nullptr;
+ }
+
+ const auto& groups = mActiveStep->getGroups();
+ if (groups.empty()) {
+ return nullptr;
+ }
+
+ const auto& plugins = groups.front()->getPlugins();
+ if (plugins.empty()) {
+ return nullptr;
+ }
+
+ return plugins.front();
+}
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Utility
+--------------------------------------------------------------------------------
+*/
+#pragma region Utility
+std::string FomodViewModel::toString() const
+{
+ std::string viewModel = "\n";
+ for (const auto& step : mSteps) {
+
+ const auto isVisible = std::ranges::find(mVisibleStepIndices, step->getOwnIndex()) != mVisibleStepIndices.end();
+ viewModel += "Step " + std::to_string(step->getOwnIndex()) + ": " + step->getName() + "[Visible: " +
+ std::to_string(isVisible) + "]\n";
+
+ for (const auto& group : step->getGroups()) {
+
+ viewModel += "\tGroup " + std::to_string(group->getOwnIndex()) + ": " + group->getName() + "\n";
+
+ for (const auto& plugin : group->getPlugins()) {
+ viewModel += "\t\tPlugin: " + plugin->getName() + "[Selected: " + (plugin->isSelected()
+ ? "TRUE"
+ : "FALSE") + "]\n";
+ }
+ }
+ }
+ viewModel += "\n";
+ std::ostringstream oss;
+ std::ranges::transform(mVisibleStepIndices,
+ std::ostream_iterator<std::string>(oss, ", "),
+ [](const int i) { return std::to_string(i); });
+
+ std::string stepList = oss.str();
+ stepList.erase(stepList.length() - 2);
+
+ viewModel += "Visible Steps: [" + stepList + "]\n";
+ viewModel += mFlags->toString();
+ return viewModel;
+}
+
+
+void FomodViewModel::resetToDefaults()
+{
+ logMessage(INFO, "Resetting all choices to author defaults");
+
+ // Clear all flags first
+ mFlags->clearAll();
+
+ // Reset all plugins to deselected and clear visited states
+ for (const auto& step : mSteps) {
+ step->setVisited(false);
+ for (const auto& group : step->getGroups()) {
+ for (const auto& plugin : group->getPlugins()) {
+ plugin->setSelected(false);
+ plugin->setEnabled(true);
+ plugin->manuallySet = false;
+ plugin->setCurrentPluginType(PluginTypeEnum::UNKNOWN);
+ }
+ }
+ }
+
+ // Re-run the initial constraint enforcement to restore author defaults
+ processPluginConditions(-1);
+ enforceGroupConstraints();
+ updateVisibleSteps();
+
+ // Reset to first step
+ mCurrentStepIndex = mVisibleStepIndices.empty() ? 0 : mVisibleStepIndices.front();
+ mActiveStep = mSteps.empty() ? nullptr : mSteps.at(mCurrentStepIndex);
+ mActivePlugin = getFirstPluginForActiveStep();
+ if (mActiveStep) {
+ mActiveStep->setVisited(true);
+ }
+
+ logMessage(DEBUG, "Reset complete. Current state:\n" + toString());
+}
+
+void FomodViewModel::selectFromJson(nlohmann::json json) const
+{
+ const auto jsonSteps = json["steps"];
+ const auto stepCount = jsonSteps.size();
+
+ for (int stepIndex = 0; stepIndex < stepCount; ++stepIndex) {
+
+ if (stepIndex > mSteps.size() - 1) {
+ logMessage(ERR, "Step index " + std::to_string(stepIndex) + " is out of bounds.");
+ continue;
+ }
+
+ const auto currentStep = mSteps[stepIndex];
+ const auto step = jsonSteps[stepIndex];
+ const auto groupCount = step["groups"].size();
+
+ logMessage(DEBUG, "Selecting plugins for step " + std::to_string(stepIndex));
+ logMessage(DEBUG, "There are " + std::to_string(groupCount) + " groups.");
+
+ for (int groupIndex = 0; groupIndex < groupCount; ++groupIndex) {
+ if (groupIndex > currentStep->getGroups().size() - 1) {
+ logMessage(ERR, "Group index " + std::to_string(groupIndex) + " is out of bounds.");
+ continue;
+ }
+
+ const auto group = step["groups"][groupIndex];
+ const auto currentGroup = currentStep->getGroups()[groupIndex];
+
+ for (const auto jsonPlugin : group["plugins"]) {
+
+ const auto& allPlugins = currentGroup->getPlugins();
+ const auto searchName = jsonPlugin.get<std::string>();
+
+ logMessage(DEBUG, "Looking for plugin " + searchName);
+
+ const auto currentPlugin = std::ranges::find_if(allPlugins,
+ [searchName](PluginRef p) {
+ return p->getName() == searchName;
+ });
+
+ if (currentPlugin == allPlugins.end()) {
+ logMessage(DEBUG, "Plugin " + searchName + " not found in group " + currentGroup->getName());
+ continue;
+ }
+
+ if ((*currentPlugin)->isSelected()) {
+ logMessage(DEBUG, "Plugin " + searchName + " is already selected.");
+ continue;
+ }
+ logMessage(DEBUG, "Toggle plugin " + searchName + " to selected.");
+ if (!(*currentPlugin)->isEnabled()) {
+ logMessage(DEBUG, "Plugin " + searchName + " is not enabled.");
+ continue;
+ }
+ togglePlugin(currentGroup, *currentPlugin, true);
+ }
+
+ if (!group.contains("deselected")) {
+ continue;
+ }
+
+ // Do the opposite of the above. For unchecked plugins, disable them.
+ for (const auto jsonPlugin : group["deselected"]) {
+
+ const auto& allPlugins = currentGroup->getPlugins();
+ const auto searchName = jsonPlugin.get<std::string>();
+
+ logMessage(DEBUG, "Looking for plugin to disable: " + searchName);
+
+ const auto currentPlugin = std::ranges::find_if(allPlugins,
+ [searchName](PluginRef p) {
+ return p->getName() == searchName;
+ });
+
+ if (currentPlugin == allPlugins.end()) {
+ logMessage(DEBUG, "Plugin " + searchName + " not found in group " + currentGroup->getName());
+ continue;
+ }
+
+ if (!(*currentPlugin)->isSelected()) {
+ logMessage(DEBUG, "Plugin " + searchName + " is already deselected.");
+ continue;
+ }
+ logMessage(DEBUG, "Toggle plugin " + searchName + " to deselected.");
+ if (!(*currentPlugin)->isEnabled()) {
+ logMessage(DEBUG, "Plugin " + searchName + " is not enabled.");
+ continue;
+ }
+ togglePlugin(currentGroup, *currentPlugin, false);
+ (*currentPlugin)->manuallySet = true; // To preserve this state when serializing JSON.
+ }
+ }
+ }
+}
+#pragma endregion
diff --git a/libs/installer_fomod_plus/installer/ui/FomodViewModel.h b/libs/installer_fomod_plus/installer/ui/FomodViewModel.h
new file mode 100644
index 0000000..f0ce070
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/FomodViewModel.h
@@ -0,0 +1,154 @@
+#pragma once
+
+#include <imoinfo.h>
+#include <string>
+
+#include "lib/ConditionTester.h"
+#include "lib/FlagMap.h"
+#include "lib/FileInstaller.h"
+#include "lib/ViewModels.h"
+#include "xml/FomodInfoFile.h"
+
+/*
+--------------------------------------------------------------------------------
+ Info
+--------------------------------------------------------------------------------
+*/
+class InfoViewModel {
+public:
+ explicit InfoViewModel(const std::unique_ptr<FomodInfoFile>& infoFile)
+ {
+ if (infoFile) {
+ // Copy the necessary members from FomodInfoFile to InfoViewModel
+ mName = infoFile->getName();
+ mVersion = infoFile->getVersion();
+ mAuthor = infoFile->getAuthor();
+ mWebsite = infoFile->getWebsite();
+ }
+ }
+
+ // Accessor methods
+ [[nodiscard]] std::string getName() const { return mName; }
+ [[nodiscard]] std::string getVersion() const { return mVersion; }
+ [[nodiscard]] std::string getAuthor() const { return mAuthor; }
+ [[nodiscard]] std::string getWebsite() const { return mWebsite; }
+
+private:
+ std::string mName;
+ std::string mVersion;
+ std::string mAuthor;
+ std::string mWebsite;
+};
+
+/*
+--------------------------------------------------------------------------------
+ View Model
+--------------------------------------------------------------------------------
+*/
+class FomodViewModel {
+public:
+ FomodViewModel(
+ MOBase::IOrganizer* organizer,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ std::unique_ptr<FomodInfoFile> infoFile);
+
+ static std::shared_ptr<FomodViewModel> create(
+ MOBase::IOrganizer* organizer,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ std::unique_ptr<FomodInfoFile> infoFile);
+
+ void forEachGroup(const std::function<void(GroupRef)>& callback) const;
+
+ void forEachPlugin(const std::function<void(GroupRef, PluginRef)>& callback) const;
+
+ void forEachFuturePlugin(int fromStepIndex, const std::function<void(GroupRef, PluginRef)>& callback) const;
+
+ void selectFromJson(nlohmann::json json) const;
+
+ void resetToDefaults();
+
+ [[nodiscard]] std::shared_ptr<PluginViewModel> getFirstPluginForActiveStep() const;
+
+ // Steps
+ [[nodiscard]] shared_ptr_list<StepViewModel> getSteps() const { return mSteps; }
+ [[nodiscard]] StepRef getActiveStep() const { return mActiveStep; }
+ [[nodiscard]] int getCurrentStepIndex() const { return mCurrentStepIndex; }
+ [[deprecated]] void setCurrentStepIndex(const int index) { mCurrentStepIndex = index; }
+
+ void updateVisibleSteps() const;
+
+ void rebuildConditionFlagsForStep(int stepIndex) const;
+
+ void preinstall(const std::shared_ptr<MOBase::IFileTree>& tree, const QString& fomodPath);
+
+ std::shared_ptr<FileInstaller> getFileInstaller() { return mFileInstaller; }
+
+ std::string getDisplayImage() const;
+
+ // Plugins
+ [[nodiscard]] PluginRef getActivePlugin() const { return mActivePlugin; }
+
+ // Info
+ [[nodiscard]] std::shared_ptr<InfoViewModel> getInfoViewModel() const { return mInfoViewModel; }
+
+ // Interactions
+ void stepBack();
+
+ void stepForward();
+
+ bool isLastVisibleStep() const;
+
+ bool isFirstVisibleStep() const;
+
+ bool togglePlugin(const GroupRef, const PluginRef, bool selected) const;
+
+ bool ctrlTogglePlugin(const GroupRef, const PluginRef, bool selected) const;
+
+ void setActivePlugin(const PluginRef plugin) const { mActivePlugin = plugin; }
+
+ static void markManuallySet(PluginRef plugin);
+
+private:
+ Logger& log = Logger::getInstance();
+ MOBase::IOrganizer* mOrganizer = nullptr;
+ std::unique_ptr<ModuleConfiguration> mFomodFile;
+ std::unique_ptr<FomodInfoFile> mInfoFile;
+ std::shared_ptr<FlagMap> mFlags{ nullptr };
+ ConditionTester mConditionTester;
+ std::shared_ptr<InfoViewModel> mInfoViewModel;
+ std::vector<std::shared_ptr<StepViewModel> > mSteps;
+ mutable std::shared_ptr<PluginViewModel> mActivePlugin{ nullptr };
+ mutable std::shared_ptr<StepViewModel> mActiveStep{ nullptr };
+ mutable std::vector<int> mVisibleStepIndices;
+ std::shared_ptr<FileInstaller> mFileInstaller{ nullptr };
+ bool mInitialized{ false };
+
+ void createStepViewModels();
+
+ void setFlagForPluginState(const std::shared_ptr<PluginViewModel>& plugin) const;
+
+ static void createNonePluginForGroup(const std::shared_ptr<GroupViewModel>& group);
+
+ void processPlugin(const std::shared_ptr<GroupViewModel>& group,
+ const std::shared_ptr<PluginViewModel>& plugin) const;
+
+ void enforceRadioGroupConstraints(const std::shared_ptr<GroupViewModel>& group) const;
+
+ void enforceSelectAllConstraint(const std::shared_ptr<GroupViewModel>& groupViewModel) const;
+
+ void enforceSelectAtLeastOneConstraint(const std::shared_ptr<GroupViewModel>& group) const;
+
+ void enforceGroupConstraints() const;
+
+ void processPluginConditions(int fromStepIndex) const;
+
+ // Indices
+ int mCurrentStepIndex{ 0 };
+
+ void logMessage(const LogLevel level, const std::string& message) const
+ {
+ log.logMessage(level, "[VIEWMODEL] " + message);
+ }
+
+ std::string toString() const;
+};
diff --git a/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp b/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp
new file mode 100644
index 0000000..3a33640
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp
@@ -0,0 +1,120 @@
+#include "ScaleLabel.h"
+#include <QResizeEvent>
+#include <iostream>
+
+// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/scalelabel.h
+static bool isResourceMovie(const QString& path)
+{
+ const auto formats = QMovie::supportedFormats();
+ return std::ranges::any_of(formats, [&path](const QByteArray& format) {
+ return path.endsWith("." + QString::fromUtf8(format));
+ });
+}
+
+void ScaleLabel::setScalableResource(const QString& path)
+{
+ if (const auto m = movie()) {
+ setMovie(nullptr);
+ delete m;
+ mOriginalMovieSize = QSize();
+ }
+ if (!pixmap().isNull()) {
+ setPixmap(QPixmap());
+ mUnscaledImage = QImage();
+ }
+
+ if (path.isEmpty()) {
+ return;
+ }
+
+ if (isResourceMovie(path)) {
+ setScalableMovie(path);
+ } else {
+ setScalableImage(path);
+ }
+}
+
+void ScaleLabel::setStatic(const bool isStatic)
+{
+ misStatic = isStatic;
+
+ if (const auto m = movie()) {
+ if (isStatic) {
+ m->stop();
+ } else {
+ m->start();
+ }
+ }
+}
+
+void ScaleLabel::setScalableMovie(const QString& path)
+{
+ const auto m = new QMovie(path);
+ if (!m->isValid()) {
+ qWarning(">%s< is an invalid movie. Reason: %s", qUtf8Printable(path),
+ m->lastErrorString().toStdString().c_str());
+ delete m;
+ return;
+ }
+
+ m->setParent(this);
+ setMovie(m);
+ m->start();
+ m->stop();
+ mOriginalMovieSize = m->currentImage().size();
+
+ m->setScaledSize(mOriginalMovieSize.scaled(size(), Qt::KeepAspectRatio));
+ if (!misStatic) {
+ m->start();
+ }
+ mHasResource = true;
+}
+
+void ScaleLabel::setScalableImage(const QString& path)
+{
+ if (const QImage image(path); image.isNull()) {
+ qWarning(">%s< is a null image", qUtf8Printable(path));
+ } else {
+ mUnscaledImage = image;
+ setPixmap(QPixmap::fromImage(image).scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
+ mHasResource = true;
+ }
+}
+
+void ScaleLabel::resizeEvent(QResizeEvent* event)
+{
+ if (const auto m = movie()) {
+ m->stop();
+ m->setScaledSize(mOriginalMovieSize.scaled(event->size(), Qt::KeepAspectRatio));
+ m->start();
+
+ // We can't just skip the start() above since that is what triggers the label to
+ // resize the movie The only way to resize the movie but keep it paused is to start
+ // and then re-stop it
+ if (misStatic) {
+ m->stop();
+ }
+ }
+ if (const auto p = pixmap(); !p.isNull()) {
+ setPixmap(
+ QPixmap::fromImage(mUnscaledImage).scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
+ }
+}
+
+void ScaleLabel::showEvent(QShowEvent* event)
+{
+ QLabel::showEvent(event);
+
+ if (const auto m = movie()) {
+ m->stop();
+ m->setScaledSize(mOriginalMovieSize.scaled(size(), Qt::KeepAspectRatio));
+ m->start();
+
+ if (misStatic) {
+ m->stop();
+ }
+ }
+ if (const auto p = pixmap(); !p.isNull()) {
+ setPixmap(QPixmap::fromImage(mUnscaledImage).scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
+ }
+} \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/ui/ScaleLabel.h b/libs/installer_fomod_plus/installer/ui/ScaleLabel.h
new file mode 100644
index 0000000..f4fb5f2
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/ScaleLabel.h
@@ -0,0 +1,45 @@
+#pragma once
+
+// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/scalelabel.h
+#include <QLabel>
+#include <QMouseEvent>
+#include <QMovie>
+
+
+class ScaleLabel final : public QLabel {
+ Q_OBJECT
+
+public:
+ explicit ScaleLabel(QWidget* parent = nullptr) : QLabel(parent)
+ {
+ setCursor(Qt::PointingHandCursor);
+ }
+
+ void setScalableResource(const QString& path);
+ void setStatic(bool isStatic);
+ [[nodiscard]] bool hasResource() const { return mHasResource; }
+
+signals:
+ void clicked();
+
+protected:
+ void mousePressEvent(QMouseEvent* event) override
+ {
+ if (event->button() == Qt::LeftButton) {
+ emit clicked();
+ }
+ QLabel::mousePressEvent(event);
+ }
+
+ void resizeEvent(QResizeEvent* event) override;
+ void showEvent(QShowEvent* event) override;
+
+private:
+ void setScalableMovie(const QString& path);
+ void setScalableImage(const QString& path);
+
+ QImage mUnscaledImage;
+ QSize mOriginalMovieSize;
+ bool mHasResource = false;
+ bool misStatic = false;
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/ui/UIHelper.cpp b/libs/installer_fomod_plus/installer/ui/UIHelper.cpp
new file mode 100644
index 0000000..2312fd1
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/UIHelper.cpp
@@ -0,0 +1,95 @@
+#include "UIHelper.h"
+
+#include <qcoreevent.h>
+#include <QDir>
+#include <QMouseEvent>
+
+HoverEventFilter::HoverEventFilter(const std::shared_ptr<PluginViewModel>& plugin, QObject* parent)
+ : QObject(parent), mPlugin(plugin) {}
+
+bool HoverEventFilter::eventFilter(QObject* obj, QEvent* event)
+{
+ if (event->type() == QEvent::HoverEnter) {
+ emit hovered(mPlugin);
+ return true;
+ }
+ return QObject::eventFilter(obj, event);
+}
+
+CtrlClickEventFilter::CtrlClickEventFilter(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group, QObject* parent)
+ : QObject(parent), mPlugin(plugin), mGroup(group) {}
+
+bool CtrlClickEventFilter::eventFilter(QObject* obj, QEvent* event)
+{
+ if (event->type() == QEvent::MouseButtonPress) {
+ const QMouseEvent* mouseEvent = static_cast<QMouseEvent*>(event);
+ if (mouseEvent->button() == Qt::LeftButton &&
+ mouseEvent->modifiers() & Qt::ControlModifier) {
+ // TODO: Add Ctrl+click handler logic here
+ std::cout << "Ctrl+click detected on plugin: " << mPlugin->getName() << " in group: " << mGroup->getName() << std::endl;
+ // For now, just fall through to default behavior
+ }
+ }
+ return QObject::eventFilter(obj, event);
+}
+
+QPushButton* UIHelper::createButton(const QString& text, QWidget* parent = nullptr)
+{
+ const auto button = new QPushButton(text, parent);
+ return button;
+}
+
+QLabel* UIHelper::createLabel(const QString& text, QWidget* parent = nullptr)
+{
+ const auto label = new QLabel(text, parent);
+ return label;
+}
+
+QLabel* UIHelper::createHyperlink(const QString& url, QWidget* parent = nullptr)
+{
+ if (url.isEmpty() || !QUrl(url).isValid()) {
+ return createLabel(url, parent);
+ }
+ const auto label = new QLabel(url, parent);
+ const QString hyperlink = QString("<a href=\"%1\">%2</a>").arg(url, "Link");
+ label->setText(hyperlink);
+ label->setOpenExternalLinks(true);
+ label->setTextFormat(Qt::RichText);
+ return label;
+}
+
+QString UIHelper::getFullImagePath(const QString& fomodPath, const QString& imagePath)
+{
+ return QDir::tempPath() + "/" + fomodPath + "/" + imagePath;
+}
+
+void UIHelper::setGlobalAlignment(QBoxLayout* layout, const Qt::Alignment alignment)
+{
+ for (int i = 0; i < layout->count(); ++i) {
+ if (const QLayoutItem* item = layout->itemAt(i); item->widget()) {
+ layout->setAlignment(item->widget(), alignment);
+ }
+ }
+}
+
+void UIHelper::setDebugBorders(QWidget* widget)
+{
+ widget->setStyleSheet("border: 1px solid red;");
+ for (auto* child : widget->findChildren<QWidget*>()) {
+ child->setStyleSheet("border: 1px solid red;");
+ }
+}
+
+void UIHelper::reduceLabelPadding(const QLayout* layout)
+{
+ for (int i = 0; i < layout->count(); ++i) {
+ const QLayoutItem* item = layout->itemAt(i);
+ if (QWidget* widget = item->widget()) {
+ if (const auto label = qobject_cast<QLabel*>(widget)) {
+ label->setContentsMargins(0, 0, 0, 0);
+ label->setStyleSheet("padding: 0px; margin: 0px;");
+ }
+ }
+ }
+}
diff --git a/libs/installer_fomod_plus/installer/ui/UIHelper.h b/libs/installer_fomod_plus/installer/ui/UIHelper.h
new file mode 100644
index 0000000..1007e7c
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/UIHelper.h
@@ -0,0 +1,80 @@
+#pragma once
+
+#include <QPushButton>
+#include <QVBoxLayout>
+#include <QLabel>
+
+#include "FomodViewModel.h"
+
+class HoverEventFilter final : public QObject {
+ Q_OBJECT
+
+public:
+ explicit HoverEventFilter(const std::shared_ptr<PluginViewModel>& plugin, QObject* parent = nullptr);
+
+signals:
+ void hovered(const std::shared_ptr<PluginViewModel>& plugin);
+
+protected:
+ bool eventFilter(QObject* obj, QEvent* event) override;
+
+private:
+ std::shared_ptr<PluginViewModel> mPlugin;
+};
+
+class CtrlClickEventFilter final : public QObject {
+ Q_OBJECT
+
+public:
+ explicit CtrlClickEventFilter(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group, QObject* parent = nullptr);
+
+signals:
+ void ctrlClicked(bool selected, const std::shared_ptr<GroupViewModel>& group,
+ const std::shared_ptr<PluginViewModel>& plugin);
+
+protected:
+ bool eventFilter(QObject* obj, QEvent* event) override;
+
+private:
+ std::shared_ptr<PluginViewModel> mPlugin;
+ std::shared_ptr<GroupViewModel> mGroup;
+};
+
+
+namespace UiConstants {
+constexpr int WINDOW_MIN_WIDTH = 900;
+constexpr int WINDOW_MIN_HEIGHT = 600;
+}
+
+class UIHelper {
+public:
+ /*
+ --------------------------------------------------------------------------------
+ Widgets & Events
+ --------------------------------------------------------------------------------
+ */
+ static QPushButton* createButton(const QString& text, QWidget* parent);
+
+ static QLabel* createLabel(const QString& text, QWidget* parent);
+
+ static QLabel* createHyperlink(const QString& url, QWidget* parent);
+
+ /*
+ --------------------------------------------------------------------------------
+ Helpers
+ --------------------------------------------------------------------------------
+ */
+ static QString getFullImagePath(const QString& fomodPath, const QString& imagePath);
+
+ static void setGlobalAlignment(QBoxLayout* layout, Qt::Alignment alignment);
+
+ static void reduceLabelPadding(const QLayout* layout);
+
+ /*
+ --------------------------------------------------------------------------------
+ Development
+ --------------------------------------------------------------------------------
+ */
+ static void setDebugBorders(QWidget* widget);
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/patchwizard/CMakeLists.txt b/libs/installer_fomod_plus/patchwizard/CMakeLists.txt
new file mode 100644
index 0000000..be46b32
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/CMakeLists.txt
@@ -0,0 +1,45 @@
+cmake_minimum_required(VERSION 3.16)
+project(fomod_plus_patch_wizard)
+
+include(FetchContent)
+set(project_type plugin)
+
+file(GLOB_RECURSE PATCHWIZARD_SOURCES CONFIGURE_DEPENDS
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.h
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.ui
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc
+)
+file(GLOB SHARE_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../share/**/*.cpp")
+
+add_library(fomod_plus_patch_wizard SHARED ${PATCHWIZARD_SOURCES} ${SHARE_SOURCES})
+
+FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz)
+FetchContent_Declare(pugixml GIT_REPOSITORY https://github.com/zeux/pugixml GIT_TAG v1.14)
+FetchContent_MakeAvailable(pugixml json)
+
+target_include_directories(
+ fomod_plus_patch_wizard
+ PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_CURRENT_SOURCE_DIR}/../share
+ ${CMAKE_CURRENT_SOURCE_DIR}/../share/FOMODData
+ ${CMAKE_CURRENT_SOURCE_DIR}/../share/xml
+ ${MO2_ARCHIVE_INCLUDE_DIRS}
+)
+
+if (MSVC)
+ target_compile_options(
+ fomod_plus_patch_wizard
+ PRIVATE
+ /bigobj
+ /W4
+ /WX
+ /wd4201
+ /wd4458
+ )
+endif ()
+
+target_link_libraries(fomod_plus_patch_wizard PRIVATE mo2::uibase pugixml nlohmann_json::nlohmann_json)
+mo2_configure_plugin(fomod_plus_patch_wizard NO_SOURCES WARNINGS OFF PRIVATE_DEPENDS archive)
+mo2_install_target(fomod_plus_patch_wizard)
diff --git a/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp
new file mode 100644
index 0000000..00be8d5
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp
@@ -0,0 +1,181 @@
+#include "FomodPlusPatchWizard.h"
+
+#include <QApplication>
+#include <QDialog>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QMessageBox>
+#include <QProgressDialog>
+#include <QPushButton>
+#include <QVBoxLayout>
+
+#include "lib/PatchFinder.h"
+#include <FomodRescan.h>
+
+bool FomodPlusPatchWizard::init(IOrganizer* organizer)
+{
+ mOrganizer = organizer;
+ mDialog = new QDialog();
+ mDialog->setWindowTitle(tr("Patch Wizard"));
+ mDialog->setMinimumSize(400, 200);
+ log.setLogFilePath(QDir::currentPath().toStdString() + "/logs/fomodplus-patchwizard.log");
+
+ mOrganizer->onUserInterfaceInitialized([this](QMainWindow*) {
+ logMessage(DEBUG, "patches populated.");
+ mPatchFinder = std::make_unique<PatchFinder>(mOrganizer);
+ mPatchFinder->populateInstalledPlugins();
+ mAvailablePatches = mPatchFinder->getAvailablePatchesForModList();
+ logMessage(DEBUG, "Available Patches: " + std::to_string(mAvailablePatches.size()));
+ });
+
+ return true;
+}
+
+void FomodPlusPatchWizard::display() const
+{
+ // Clear any existing layout
+ if (mDialog->layout() != nullptr) {
+ QLayoutItem* item;
+ while ((item = mDialog->layout()->takeAt(0)) != nullptr) {
+ delete item->widget();
+ delete item;
+ }
+ delete mDialog->layout();
+ }
+
+ if (mAvailablePatches.empty()) {
+ setupEmptyState();
+ } else {
+ setupPatchList();
+ }
+
+ mDialog->exec();
+}
+
+void FomodPlusPatchWizard::setupEmptyState() const
+{
+ auto* mainLayout = new QVBoxLayout(mDialog);
+ mainLayout->setAlignment(Qt::AlignCenter);
+
+ auto* contentWidget = new QWidget(mDialog);
+ auto* contentLayout = new QHBoxLayout(contentWidget);
+ contentLayout->setAlignment(Qt::AlignCenter);
+ contentLayout->setSpacing(16);
+
+ auto* imageLabel = new QLabel(contentWidget);
+ imageLabel->setPixmap(QPixmap(":/fomod/infoscroll").scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation));
+
+ auto* textLabel = new QLabel(
+ tr("Nothing of interest yet. The wizard gets wiser as you\ninstall FOMODs, so check back later!"),
+ contentWidget
+ );
+ textLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
+
+ contentLayout->addWidget(imageLabel);
+ contentLayout->addWidget(textLabel);
+
+ mainLayout->addWidget(contentWidget);
+
+ auto* rescanButton = new QPushButton(tr("Rescan Load Order"), mDialog);
+ // Use const_cast since setupEmptyState is const but onRescanClicked modifies state
+ connect(rescanButton, &QPushButton::clicked, const_cast<FomodPlusPatchWizard*>(this),
+ &FomodPlusPatchWizard::onRescanClicked);
+ mainLayout->addWidget(rescanButton, 0, Qt::AlignCenter);
+}
+
+void FomodPlusPatchWizard::onRescanClicked()
+{
+ const auto confirmResult = QMessageBox::question(
+ mDialog,
+ tr("Rescan Load Order"),
+ tr("Rescanning will populate as many existing choices and options as we can, "
+ "but if some downloads are deleted it may be missing things! "
+ "It may take a few minutes depending on the size of your load order and all that."),
+ QMessageBox::Ok | QMessageBox::Cancel,
+ QMessageBox::Cancel
+ );
+
+ if (confirmResult != QMessageBox::Ok) {
+ return;
+ }
+
+ logMessage(DEBUG, "Rescan requested by user");
+
+ // Create progress dialog
+ QProgressDialog progress(tr("Scanning mods..."), tr("Cancel"), 0, 100, mDialog);
+ progress.setWindowModality(Qt::WindowModal);
+ progress.setMinimumDuration(0);
+ progress.setValue(0);
+
+ bool cancelled = false;
+
+ // Perform the rescan
+ FomodRescan rescan(mOrganizer, mPatchFinder->mFomodDb.get());
+ auto result = rescan.scanAllModsWithChoices([&](int current, int total, const QString& modName) {
+ if (progress.wasCanceled()) {
+ cancelled = true;
+ return;
+ }
+ const int percent = total > 0 ? (current * 100 / total) : 0;
+ progress.setValue(percent);
+ progress.setLabelText(tr("Scanning: %1 (%2/%3)").arg(modName).arg(current).arg(total));
+ QApplication::processEvents();
+ });
+
+ progress.setValue(100);
+
+ if (cancelled) {
+ QMessageBox::information(
+ mDialog,
+ tr("Rescan Cancelled"),
+ tr("The rescan was cancelled. Partial results may have been saved.")
+ );
+ logMessage(INFO, "Rescan cancelled by user");
+ } else {
+ // Show result summary
+ QString summary = tr("Rescan complete!\n\n"
+ "Mods processed: %1\n"
+ "Successfully scanned: %2\n"
+ "Missing archives: %3\n"
+ "Parse errors: %4")
+ .arg(result.totalModsProcessed)
+ .arg(result.successfullyScanned)
+ .arg(result.missingArchives)
+ .arg(result.parseErrors);
+
+ if (!result.failedMods.empty() && result.failedMods.size() <= 10) {
+ summary += tr("\n\nFailed mods:");
+ for (const auto& mod : result.failedMods) {
+ summary += QString("\n- %1").arg(QString::fromStdString(mod));
+ }
+ } else if (result.failedMods.size() > 10) {
+ summary += tr("\n\n%1 mods failed (see log for details)").arg(result.failedMods.size());
+ for (const auto& mod : result.failedMods) {
+ logMessage(INFO, "Failed mod: " + mod);
+ }
+ }
+
+ QMessageBox::information(mDialog, tr("Rescan Complete"), summary);
+
+ logMessage(INFO, "Rescan complete: " + std::to_string(result.successfullyScanned) +
+ "/" + std::to_string(result.totalModsProcessed) + " successful");
+ }
+
+ // Refresh available patches
+ mPatchFinder->populateInstalledPlugins();
+ mAvailablePatches = mPatchFinder->getAvailablePatchesForModList();
+ logMessage(DEBUG, "Available Patches after rescan: " + std::to_string(mAvailablePatches.size()));
+
+ // Refresh the UI
+ display();
+}
+
+void FomodPlusPatchWizard::setupPatchList() const
+{
+ auto* mainLayout = new QVBoxLayout(mDialog);
+
+ auto* label = new QLabel(tr("Available patches: %1").arg(mAvailablePatches.size()), mDialog);
+ mainLayout->addWidget(label);
+
+ // TODO: Implement actual patch list UI
+} \ No newline at end of file
diff --git a/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h
new file mode 100644
index 0000000..2324fb0
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h
@@ -0,0 +1,54 @@
+#pragma once
+#include "../installer/lib/Logger.h"
+#include "lib/PatchFinder.h"
+
+#include <iplugintool.h>
+#include <qtmetamacros.h>
+
+using namespace MOBase;
+
+class FomodPlusPatchWizard final : public IPluginTool {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginTool)
+#if QT_VERSION >= QT_VERSION_CHECK(5, 0, 0)
+ Q_PLUGIN_METADATA(IID "io.clearing.FomodPlusPatchWizard" FILE "fomodpluspatchwizard.json")
+#endif
+
+public:
+ bool init(IOrganizer* organizer) override;
+
+ [[nodiscard]] QString name() const override { return tr("Patch Wizard"); };
+
+ [[nodiscard]] QString author() const override { return "clearing"; };
+
+ [[nodiscard]] QString description() const override { return tr("Find missing patches from FOMODs in your load order."); };
+
+ [[nodiscard]] VersionInfo version() const override { return { 1, 0, 0, VersionInfo::RELEASE_BETA }; };
+
+ [[nodiscard]] QList<PluginSetting> settings() const override { return {}; };
+
+ [[nodiscard]] QString displayName() const override { return tr("Patch Wizard"); };
+
+ [[nodiscard]] QString tooltip() const override { return tr("Find missing patches from FOMODs in your load order."); };
+
+ [[nodiscard]] QIcon icon() const override { return QIcon(":/fomod/hat"); }
+
+ void display() const override;
+
+private:
+ Logger& log = Logger::getInstance();
+ QDialog* mDialog{ nullptr };
+ IOrganizer* mOrganizer{ nullptr };
+ std::unique_ptr<PatchFinder> mPatchFinder{ nullptr };
+ std::vector<AvailablePatch> mAvailablePatches;
+
+ void setupEmptyState() const;
+ void setupPatchList() const;
+ void onRescanClicked();
+
+ void logMessage(const LogLevel level, const std::string& message) const
+ {
+ log.logMessage(level, "[PATCHFINDER] " + message);
+ }
+
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts b/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts
new file mode 100644
index 0000000..7da04f1
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="en_US">
+<context>
+ <name>FomodPlusPatchWizard</name>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="19"/>
+ <location filename="FomodPlusPatchWizard.h" line="20"/>
+ <location filename="FomodPlusPatchWizard.h" line="30"/>
+ <source>Patch Wizard</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="69"/>
+ <source>Nothing of interest yet. The wizard gets wiser as you
+install FOMODs, so check back later!</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="79"/>
+ <location filename="FomodPlusPatchWizard.cpp" line="90"/>
+ <source>Rescan Load Order</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="91"/>
+ <source>Rescanning will populate as many existing choices and options as we can, but if some downloads are deleted it may be missing things! It may take a few minutes depending on the size of your load order and all that.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="105"/>
+ <source>Scanning mods...</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="105"/>
+ <source>Cancel</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="121"/>
+ <source>Scanning: %1 (%2/%3)</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="130"/>
+ <source>Rescan Cancelled</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="131"/>
+ <source>The rescan was cancelled. Partial results may have been saved.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="136"/>
+ <source>Rescan complete!
+
+Mods processed: %1
+Successfully scanned: %2
+Missing archives: %3
+Parse errors: %4</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="147"/>
+ <source>
+
+Failed mods:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="152"/>
+ <source>
+
+%1 mods failed (see log for details)</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="158"/>
+ <source>Rescan Complete</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="177"/>
+ <source>Available patches: %1</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.h" line="24"/>
+ <location filename="FomodPlusPatchWizard.h" line="32"/>
+ <source>Find missing patches from FOMODs in your load order.</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts b/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts
new file mode 100644
index 0000000..aaa26e2
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="de_DE">
+<context>
+ <name>FomodPlusPatchWizard</name>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="26"/>
+ <location filename="FomodPlusPatchWizard.cpp" line="51"/>
+ <location filename="FomodPlusPatchWizard.cpp" line="74"/>
+ <source>Patch Wizard</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusPatchWizard.cpp" line="36"/>
+ <source>Finde Patches, die du aus den FOMODs in deiner Modliste installieren m&ouml;chtest.</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json b/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json
new file mode 100644
index 0000000..2d6025f
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json
@@ -0,0 +1,8 @@
+{
+ "author": "clearing",
+ "date": "2025/02/06",
+ "name": "FOMOD Plus - Patch Wizard",
+ "version": "1.0.0",
+ "des": "Find missing patches in your modlist",
+ "dependencies": []
+}
diff --git a/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp
new file mode 100644
index 0000000..cd06b3a
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp
@@ -0,0 +1,86 @@
+#include "PatchFinder.h"
+
+std::vector<AvailablePatch> PatchFinder::getAvailablePatchesForMod(const MOBase::IModInterface* mod)
+{
+ std::vector<AvailablePatch> available_patches = {};
+
+ // Verify that the mod has plugins in some shape or form.
+ if (const auto it = m_installedPlugins.find(mod); it == m_installedPlugins.end()) {
+ return available_patches;
+ }
+
+ // Look through the database
+ for (const auto& fomodDbEntries = mFomodDb->getEntries(); const auto& entry : fomodDbEntries) {
+ for (const auto& option : entry->getOptions()) {
+ // Skip options that are already selected/installed
+ if (option.selectionState == SelectionState::Selected) {
+ continue;
+ }
+
+ // Extract just the filename from the path (handle both / and \)
+ const auto fileName = option.fileName.substr(option.fileName.find_last_of("/\\") + 1);
+
+ // Skip if already installed in the modlist
+ if (m_installedPluginsCacheSet.contains(fileName)) {
+ continue;
+ }
+
+ // Technically without this check, we're doing a lookup for the entire modlist, which actually
+ // kinda works, but isn't the intention of this function. Might want to consider reworking
+ // it to map to mod names with one pass of the DB instead of a DB pass per mod.
+ if (!std::ranges::any_of(option.masters, [&mod](const std::string& master) {
+ return master == mod->name().toStdString();
+ })) {
+ continue;
+ }
+
+ // If we have all of this patch's masters in our modlist, and it's not installed, add it to the results.
+ if (std::ranges::all_of(option.masters, [this](const std::string& master) {
+ return m_installedPluginsCacheSet.contains(master);
+ })) {
+ AvailablePatch patch{
+ option,
+ entry->getDisplayName(),
+ mod->name().toStdString(),
+ false, // not installed
+ false, // not hidden
+ option.selectionState == SelectionState::Deselected // userDeselected
+ };
+ available_patches.push_back(patch);
+ }
+ }
+ }
+
+ return available_patches;
+}
+
+std::vector<AvailablePatch> PatchFinder::getAvailablePatchesForModList()
+{
+ std::vector<AvailablePatch> available_patches = {};
+ for (const auto& modName : m_organizer->modList()->allMods()) {
+ const auto mod = m_organizer->modList()->getMod(modName);
+ if (mod == nullptr) {
+ continue;
+ }
+ for (const auto& available_patch : getAvailablePatchesForMod(mod)) {
+ available_patches.emplace_back(available_patch);
+ }
+ }
+
+ return available_patches;
+}
+
+void PatchFinder::populateInstalledPlugins()
+{
+ for (const auto& modName : m_organizer->modList()->allMods()) {
+ const auto mod = m_organizer->modList()->getMod(modName);
+ const auto mod_tree = mod->fileTree();
+ for (auto it = mod_tree->begin(); it != mod_tree->end(); ++it) {
+ if ((*it)->isFile() && isPluginFile((*it)->name())) {
+ std::cout << "Plugin: " << (*it)->name().toStdString() << std::endl;
+ m_installedPlugins[mod].emplace_back((*it)->name().toStdString());
+ m_installedPluginsCacheSet.insert((*it)->name().toStdString());
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h
new file mode 100644
index 0000000..02327e3
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h
@@ -0,0 +1,49 @@
+#pragma once
+
+#include "../../installer/lib/Logger.h"
+
+#include <FomodDB.h>
+#include <imodinterface.h>
+#include <imoinfo.h>
+#include <ifiletree.h>
+
+struct AvailablePatch {
+ FomodOption fomod_option;
+ std::string installer_name;
+ std::string patch_for_mod;
+ bool installed = false;
+ bool hidden = false;
+ bool userDeselected = false; // True if user explicitly chose not to install
+};
+
+class PatchFinder {
+friend class FomodPlusPatchWizard;
+
+public:
+ explicit PatchFinder(MOBase::IOrganizer* m_organizer) : m_organizer(m_organizer)
+ {
+ mFomodDb = std::make_unique<FomodDB>(m_organizer->basePath().toStdString());
+ logMessage(DEBUG, "mFomodDb loaded.");
+ }
+
+ std::vector<AvailablePatch> getAvailablePatchesForMod(const MOBase::IModInterface* mod);
+ std::vector<AvailablePatch> getAvailablePatchesForModList();
+
+protected:
+ void populateInstalledPlugins();
+
+private:
+ Logger& log = Logger::getInstance();
+ MOBase::IOrganizer* m_organizer;
+ std::unique_ptr<FomodDB> mFomodDb;
+
+ // Map of { pluginPtr: [1.esp, 2.esp, 3.esp] }
+ std::unordered_map<const MOBase::IModInterface*, std::vector<std::string> > m_installedPlugins;
+ std::unordered_set<std::string> m_installedPluginsCacheSet;
+
+ void logMessage(const LogLevel level, const std::string& message) const
+ {
+ log.logMessage(level, "[PATCHFINDER] " + message);
+ }
+
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/patchwizard/resources.qrc b/libs/installer_fomod_plus/patchwizard/resources.qrc
new file mode 100644
index 0000000..fe6971e
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/resources.qrc
@@ -0,0 +1,6 @@
+<RCC>
+ <qresource prefix="/fomod">
+ <file alias="hat">resources/fomod_icon.png</file>
+ <file alias="infoscroll">resources/infoscroll.png</file>
+ </qresource>
+</RCC>
diff --git a/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png b/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png
new file mode 100644
index 0000000..e044052
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png
Binary files differ
diff --git a/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png b/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png
new file mode 100644
index 0000000..0c31777
--- /dev/null
+++ b/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png
Binary files differ
diff --git a/libs/installer_fomod_plus/scanner/CMakeLists.txt b/libs/installer_fomod_plus/scanner/CMakeLists.txt
new file mode 100644
index 0000000..daea477
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/CMakeLists.txt
@@ -0,0 +1,54 @@
+cmake_minimum_required(VERSION 3.16)
+project(fomod_plus_scanner)
+
+include(FetchContent)
+set(project_type plugin)
+
+file(GLOB_RECURSE SCANNER_SOURCES CONFIGURE_DEPENDS
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.h
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.ui
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc
+)
+
+add_library(fomod_plus_scanner SHARED ${SCANNER_SOURCES})
+
+FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz)
+FetchContent_Declare(pugixml GIT_REPOSITORY https://github.com/zeux/pugixml GIT_TAG v1.14)
+FetchContent_MakeAvailable(pugixml json)
+
+target_include_directories(
+ fomod_plus_scanner
+ PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_CURRENT_SOURCE_DIR}/../share
+ ${CMAKE_CURRENT_SOURCE_DIR}/../share/FOMODData
+ ${MO2_UIBASE_INCLUDE_DIRS}
+ ${MO2_ARCHIVE_INCLUDE_DIRS}
+)
+
+if (MSVC)
+ target_compile_options(
+ fomod_plus_scanner
+ PRIVATE
+ /bigobj
+ /W4
+ /WX
+ /wd4201
+ /wd4458
+ )
+endif ()
+
+target_link_libraries(fomod_plus_scanner PRIVATE mo2::uibase mo2::archive pugixml nlohmann_json::nlohmann_json)
+mo2_configure_plugin(fomod_plus_scanner NO_SOURCES WARNINGS OFF PRIVATE_DEPENDS archive)
+
+if (MSVC)
+ target_compile_options(fomod_plus_scanner PRIVATE $<$<CONFIG:RelWithDebInfo>:/Zi>)
+ target_link_options(fomod_plus_scanner PRIVATE $<$<CONFIG:RelWithDebInfo>:/DEBUG>)
+ install(FILES $<TARGET_PDB_FILE:fomod_plus_scanner>
+ DESTINATION ${CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO}
+ CONFIGURATIONS RelWithDebInfo
+ OPTIONAL)
+endif ()
+
+mo2_install_target(fomod_plus_scanner)
diff --git a/libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp b/libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp
new file mode 100644
index 0000000..4770178
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp
@@ -0,0 +1,128 @@
+#include "FomodPlusScanner.h"
+
+#include "archiveparser.h"
+#include "FomodDBEntry.h"
+
+#include <QDialog>
+#include <QPushButton>
+#include <QProgressBar>
+#include <QVBoxLayout>
+#include <QMessageBox>
+#include <archive.h>
+
+#include <QLabel>
+#include <QMovie>
+#include <iostream>
+
+#include "stringutil.h"
+
+using ScanCallbackFn = std::function<bool(IModInterface*, ScanResult result)>;
+
+bool FomodPlusScanner::init(IOrganizer* organizer)
+{
+ mOrganizer = organizer;
+
+ mDialog = new QDialog();
+ mDialog->setWindowTitle(tr("FOMOD Scanner"));
+
+ const auto layout = new QVBoxLayout(mDialog);
+
+ const QString description = tr("Greetings, traveler.\n"
+ "This tool will scan your load order for mods installed via FOMOD.\n"
+ "It will also fix up any erroneous FOMOD flags from previous versions of FOMOD Plus :) \n\n"
+ "Safe travels, and may your load order be free of conflicts.");
+
+ const auto descriptionLabel = new QLabel(description, mDialog);
+ descriptionLabel->setWordWrap(true); // Enable word wrap for large text
+ descriptionLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+ descriptionLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop); // Align text to the top-left corner
+
+ const auto gifLabel = new QLabel(mDialog);
+ const auto movie = new QMovie(":/fomod/wizard");
+ gifLabel->setMovie(movie);
+ movie->start();
+
+ mProgressBar = new QProgressBar(mDialog);
+ mProgressBar->setRange(0, mOrganizer->modList()->allMods().size());
+ mProgressBar->setVisible(false);
+
+ const auto scanButton = new QPushButton(tr("Scan"), mDialog);
+ const auto cancelButton = new QPushButton(tr("Cancel"), mDialog);
+
+ layout->addWidget(descriptionLabel, 1);
+ layout->addWidget(gifLabel, 1);
+ layout->addWidget(mProgressBar, 1);
+ layout->addWidget(scanButton, 1);
+ layout->addWidget(cancelButton, 1);
+
+ connect(cancelButton, &QPushButton::clicked, mDialog, &QDialog::reject);
+ connect(scanButton, &QPushButton::clicked, this, &FomodPlusScanner::onScanClicked);
+ connect(mDialog, &QDialog::finished, this, &FomodPlusScanner::cleanup);
+
+ mDialog->setLayout(layout);
+ mDialog->setMinimumSize(400, 300);
+ mDialog->adjustSize();
+ descriptionLabel->adjustSize();
+ return true;
+}
+
+void FomodPlusScanner::onScanClicked() const
+{
+ mProgressBar->setVisible(true);
+ const int added = scanLoadOrder(setFomodInfoForMod);
+ mDialog->accept();
+ QMessageBox::information(mDialog, tr("Scan Complete"),
+ tr("The load order scan is complete. Updated filter info for ") + QString::number(added) + tr(" mods."));
+ mOrganizer->refresh();
+}
+
+void FomodPlusScanner::cleanup() const
+{
+ mProgressBar->reset();
+ mProgressBar->setVisible(false);
+}
+
+void FomodPlusScanner::display() const
+{
+ mDialog->exec();
+}
+
+int FomodPlusScanner::scanLoadOrder(const ScanCallbackFn& callback) const
+{
+ int progress = 0;
+ int modified = 0;
+ for (const auto& modName : mOrganizer->modList()->allMods()) {
+ const auto mod = mOrganizer->modList()->getMod(modName);
+ if (const ScanResult result = openInstallationArchive(mod); callback(mod, result)) {
+ modified++;
+ }
+ mProgressBar->setValue(++progress);
+ }
+ return modified;
+}
+
+ScanResult FomodPlusScanner::openInstallationArchive(const IModInterface* mod) const
+{
+ const auto downloadsDir = mOrganizer->downloadsPath();
+ const auto installationFilePath = mod->installationFile();
+ return ArchiveParser::scanForFomodFiles(downloadsDir, installationFilePath, mod->name());
+}
+
+bool FomodPlusScanner::setFomodInfoForMod(IModInterface* mod, const ScanResult result)
+{
+ const auto pluginName = "FOMOD Plus";
+ const auto setting = mod->pluginSetting(pluginName, "fomod", 0);
+ if (setting == 0 && ScanResult::HAS_FOMOD == result) {
+ return mod->setPluginSetting(pluginName, "fomod", "{}");
+ }
+ if (setting != 0 && ScanResult::NO_FOMOD == result) {
+ return mod->setPluginSetting(pluginName, "fomod", 0);
+ }
+ return false;
+}
+
+bool FomodPlusScanner::removeFomodInfoFromMod(IModInterface* mod, ScanResult)
+{
+ const auto pluginName = QString::fromStdString("FOMOD Plus");
+ return mod->setPluginSetting(pluginName, "fomod", 0);
+}
diff --git a/libs/installer_fomod_plus/scanner/FomodPlusScanner.h b/libs/installer_fomod_plus/scanner/FomodPlusScanner.h
new file mode 100644
index 0000000..b7893f7
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/FomodPlusScanner.h
@@ -0,0 +1,64 @@
+#ifndef FOMODPLUSSCANNER_H
+#define FOMODPLUSSCANNER_H
+
+#include "archiveparser.h"
+
+#include <iplugin.h>
+#include <iplugintool.h>
+
+#include <QDialog>
+#include <QProgressBar>
+
+using namespace MOBase;
+
+class FomodPlusScanner final : public IPluginTool {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginTool)
+#if QT_VERSION >= QT_VERSION_CHECK(5, 0, 0)
+ Q_PLUGIN_METADATA(IID "io.clearing.FomodPlusScanner" FILE "fomodplusscanner.json")
+#endif
+
+public:
+ ~FomodPlusScanner() override
+ {
+ delete mDialog;
+ mDialog = nullptr;
+ mProgressBar = nullptr;
+ }
+
+ bool init(IOrganizer* organizer) override;
+
+ void onScanClicked() const;
+
+ void cleanup() const;
+
+ [[nodiscard]] QString name() const override { return "FOMOD Scanner"; } // This should not be translated
+ [[nodiscard]] QString author() const override { return "clearing"; }
+ [[nodiscard]] QString description() const override { return tr("Scans modlist for files installed via FOMOD"); }
+ [[nodiscard]] VersionInfo version() const override { return {1, 0, 0, VersionInfo::RELEASE_FINAL}; }
+
+ [[nodiscard]] QList<PluginSetting> settings() const override { return {}; }
+
+ [[nodiscard]] QString displayName() const override { return tr("FOMOD Scanner"); }
+
+ [[nodiscard]] QString tooltip() const override { return tr("Scan modlist for files installed via FOMOD"); }
+
+ [[nodiscard]] QIcon icon() const override { return QIcon(":/fomod/hat"); }
+
+ void display() const override;
+
+ int scanLoadOrder(const std::function<bool(IModInterface*, ScanResult result)> &callback) const;
+
+ ScanResult openInstallationArchive(const IModInterface* mod) const;
+
+ static bool setFomodInfoForMod(IModInterface *mod, ScanResult result);
+
+ static bool removeFomodInfoFromMod(IModInterface *mod, ScanResult);
+
+private:
+ QDialog* mDialog{ nullptr };
+ QProgressBar* mProgressBar{ nullptr };
+ IOrganizer* mOrganizer{ nullptr };
+};
+
+#endif // FOMODPLUSSCANNER_H \ No newline at end of file
diff --git a/libs/installer_fomod_plus/scanner/archiveparser.h b/libs/installer_fomod_plus/scanner/archiveparser.h
new file mode 100644
index 0000000..4058d43
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/archiveparser.h
@@ -0,0 +1,98 @@
+#pragma once
+#include "stringutil.h"
+
+#include <QDir>
+#include <QString>
+#include <archive.h>
+#include <iostream>
+#include <ostream>
+
+inline std::ostream& operator<<(std::ostream& os, const Archive::Error& error)
+{
+ switch (error) {
+ case Archive::Error::ERROR_NONE:
+ os << "No error";
+ break;
+ case Archive::Error::ERROR_ARCHIVE_NOT_FOUND:
+ os << "File not found";
+ break;
+ case Archive::Error::ERROR_FAILED_TO_OPEN_ARCHIVE:
+ os << "Failed to open file";
+ break;
+ case Archive::Error::ERROR_INVALID_ARCHIVE_FORMAT:
+ os << "Invalid archive format";
+ break;
+ default:
+ os << "Unknown error??";
+ }
+ return os;
+}
+
+inline bool hasFomodFiles(const std::vector<FileData*>& files)
+{
+ bool hasModuleXml = false;
+ bool hasInfoXml = false;
+
+ for (const auto* file : files) {
+ if (endsWithCaseInsensitive(file->getArchiveFilePath(), StringConstants::FomodFiles::W_MODULE_CONFIG.data())) {
+ hasModuleXml = true;
+ }
+ if (endsWithCaseInsensitive(file->getArchiveFilePath(), StringConstants::FomodFiles::W_INFO_XML.data())) {
+ hasInfoXml = true;
+ }
+ }
+ return hasModuleXml && hasInfoXml;
+}
+
+/* This class can do the following:
+ * 1.) Detects if an archive has FOMOD files in it (without extracting)
+ * - This is used by the FOMOD scanner to set content flags
+ *
+ * 2.) It may support extracting ESPs and FOMODs, but this might be better served
+ * by a separate class. Maybe it could return a ModuleConfiguration to use like
+ * the installer.
+ */
+
+enum class ScanResult {
+ HAS_FOMOD,
+ NO_FOMOD,
+ NO_ARCHIVE
+};
+
+class ArchiveParser {
+public:
+ static ScanResult scanForFomodFiles(const QString& downloadsPath, const QString& installationFilePath,
+ const QString& modName)
+ {
+ if (installationFilePath.isEmpty()) {
+ return ScanResult::NO_ARCHIVE;
+ }
+ const auto qualifiedInstallerPath = QDir(installationFilePath).isAbsolute()
+ ? installationFilePath
+ : downloadsPath + "/" + installationFilePath;
+
+ const auto archive = CreateArchive();
+
+ if (!archive->isValid()) {
+ logErrorForMod(modName, "Failed to load the archive module ", archive);
+ return ScanResult::NO_ARCHIVE;
+ }
+ if (!archive->open(qualifiedInstallerPath.toStdWString(), nullptr)) {
+ logErrorForMod(modName, "Failed to open archive [" + qualifiedInstallerPath + "]", archive);
+ return ScanResult::NO_ARCHIVE;
+ }
+
+ if (hasFomodFiles(archive->getFileList())) {
+ std::cout << "Found FOMOD files in " << qualifiedInstallerPath.toStdString() << std::endl;
+ return ScanResult::HAS_FOMOD;
+ }
+ return ScanResult::NO_FOMOD;
+ }
+
+private:
+ static void logErrorForMod(const QString& modName, const QString& message, const std::unique_ptr<Archive>& archive)
+ {
+ std::cerr << "[" << modName.toStdString() << "] " << message.toStdString() << " (" << archive->getLastError() <<
+ ")" << std::endl;
+ }
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts
new file mode 100644
index 0000000..759824f
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="de_DE">
+<context>
+ <name>FomodPlusScanner</name>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="23"/>
+ <location filename="FomodPlusScanner.h" line="47"/>
+ <source>FOMOD Scanner</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="27"/>
+ <source>Sei gegrüßt, Reisender.
+Dieses Tool scannt deine Ladereihenfolge nach Mods, die über einen FOMOD installiert wurden.
+Es wird außerdem fehlerhafte FOMOD-Selektierungen aus früheren Versionen von FOMOD Plus beheben. :)
+
+Gute Reise, und möge deine Ladereihenfolge frei von Konflikten sein.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="46"/>
+ <source>Scannen</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="47"/>
+ <source>Abbrechen</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="71"/>
+ <source>Scan Abgeschlossen</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="72"/>
+ <source>Der Scan der Ladereihenfolge ist abgeschlossen. Aktualisierte Filterinformationen für </source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="72"/>
+ <source> Mods.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.h" line="42"/>
+ <source>Scannt die Modliste nach Dateien, die über einen FOMOD installiert wurden.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.h" line="49"/>
+ <source>Modliste nach Dateien durchsuchen, die über einen FOMOD installiert wurden.</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts
new file mode 100644
index 0000000..83a310b
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="en_US">
+<context>
+ <name>FomodPlusScanner</name>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="26"/>
+ <location filename="FomodPlusScanner.h" line="42"/>
+ <source>FOMOD Scanner</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="30"/>
+ <source>Greetings, traveler.
+This tool will scan your load order for mods installed via FOMOD.
+It will also fix up any erroneous FOMOD flags from previous versions of FOMOD Plus :)
+
+Safe travels, and may your load order be free of conflicts.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="49"/>
+ <source>Scan</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="50"/>
+ <source>Cancel</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="74"/>
+ <source>Scan Complete</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="75"/>
+ <source>The load order scan is complete. Updated filter info for </source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="75"/>
+ <source> mods.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.h" line="37"/>
+ <source>Scans modlist for files installed via FOMOD</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.h" line="44"/>
+ <source>Scan modlist for files installed via FOMOD</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts
new file mode 100644
index 0000000..01f84c8
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="zh_CN">
+<context>
+ <name>FomodPlusScanner</name>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="23"/>
+ <location filename="FomodPlusScanner.h" line="47"/>
+ <source>FOMOD Scanner</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="27"/>
+ <source>你好,旅行者。
+该工具将扫描模组列表来查找通过 FOMOD 安装的模组,
+并修正旧版本错误的 FOMOD 标志(需保留安装包才能被检测到):)
+
+一路平安,祝您的 load order 没有冲突。</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="46"/>
+ <source>扫描</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="47"/>
+ <source>取消</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="71"/>
+ <source>扫描完成</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="72"/>
+ <source>模组扫描已完成。更新了 </source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.cpp" line="72"/>
+ <source> 个模组筛选框信息。</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.h" line="42"/>
+ <source>扫描模组列表查找通过 FOMOD 安装的文件</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodPlusScanner.h" line="49"/>
+ <source>扫描模组列表查找通过 FOMOD 安装的文件</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/installer_fomod_plus/scanner/fomodplusscanner.json b/libs/installer_fomod_plus/scanner/fomodplusscanner.json
new file mode 100644
index 0000000..f6643ea
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/fomodplusscanner.json
@@ -0,0 +1,8 @@
+{
+ "author" : "clearing",
+ "date" : "2025/02/06",
+ "name" : "FOMOD Plus - Scanner",
+ "version" : "1.0.0",
+ "des" : "Scans load order for FOMOD installations",
+ "dependencies" : []
+}
diff --git a/libs/installer_fomod_plus/scanner/resources.qrc b/libs/installer_fomod_plus/scanner/resources.qrc
new file mode 100644
index 0000000..df7c48c
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/resources.qrc
@@ -0,0 +1,6 @@
+<RCC>
+ <qresource prefix="/fomod">
+ <file alias="hat">resources/fomod_icon.png</file>
+ <file alias="wizard">resources/wizard.gif</file>
+ </qresource>
+</RCC> \ No newline at end of file
diff --git a/libs/installer_fomod_plus/scanner/resources/fomod_icon.png b/libs/installer_fomod_plus/scanner/resources/fomod_icon.png
new file mode 100644
index 0000000..e044052
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/resources/fomod_icon.png
Binary files differ
diff --git a/libs/installer_fomod_plus/scanner/resources/wizard.gif b/libs/installer_fomod_plus/scanner/resources/wizard.gif
new file mode 100644
index 0000000..c1f75d3
--- /dev/null
+++ b/libs/installer_fomod_plus/scanner/resources/wizard.gif
Binary files differ
diff --git a/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h b/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h
new file mode 100644
index 0000000..65cd0ae
--- /dev/null
+++ b/libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h
@@ -0,0 +1,160 @@
+#pragma once
+
+#include "stringutil.h"
+
+#include <QDir>
+#include <QFileInfo>
+#include <QString>
+#include <QTemporaryDir>
+#include <archive.h>
+#include <filesystem>
+#include <functional>
+#include <iostream>
+#include <memory>
+#include <optional>
+
+struct ExtractionResult {
+ bool success = false;
+ QString moduleConfigPath;
+ std::vector<QString> pluginPaths;
+ QString errorMessage;
+ std::unique_ptr<QTemporaryDir> tempDir; // Owns the temp directory lifetime
+};
+
+/**
+ * Utility class to extract FOMOD data from archives without being in an installer context.
+ * Used by the Patch Wizard's rescan functionality.
+ */
+class ArchiveExtractor {
+public:
+ using ProgressCallback = std::function<void(const QString& fileName)>;
+
+ /**
+ * Extract ModuleConfig.xml and plugin files from an archive.
+ * @param archiveFilePath Full path to the archive file
+ * @param progressCallback Optional callback for progress updates
+ * @return ExtractionResult containing paths to extracted files
+ */
+ static ExtractionResult extractFomodData(
+ const QString& archiveFilePath,
+ const ProgressCallback& progressCallback = nullptr)
+ {
+ ExtractionResult result;
+ result.tempDir = std::make_unique<QTemporaryDir>();
+
+ if (!result.tempDir->isValid()) {
+ result.errorMessage = "Failed to create temporary directory";
+ return result;
+ }
+
+ const auto archive = CreateArchive();
+ if (!archive->isValid()) {
+ result.errorMessage = "Failed to load archive module";
+ return result;
+ }
+
+ if (!archive->open(archiveFilePath.toStdWString(), nullptr)) {
+ result.errorMessage = QString("Failed to open archive (error %1)")
+ .arg(static_cast<int>(archive->getLastError()));
+ return result;
+ }
+
+ // Get file list and mark files for extraction
+ const auto& fileList = archive->getFileList();
+ QString moduleConfigInArchive;
+
+ for (auto* fileData : fileList) {
+ const auto entryPath = QString::fromStdWString(fileData->getArchiveFilePath());
+
+ // Check for ModuleConfig.xml
+ if (entryPath.toLower().endsWith("fomod/moduleconfig.xml") ||
+ entryPath.toLower().endsWith("fomod\\moduleconfig.xml")) {
+ moduleConfigInArchive = entryPath;
+ // Set output path relative to output directory for extract()
+ fileData->addOutputFilePath(L"ModuleConfig.xml");
+ result.moduleConfigPath = result.tempDir->filePath("ModuleConfig.xml");
+ }
+ // Check for plugin files
+ else if (isPluginFile(entryPath)) {
+ const auto fileName = QFileInfo(entryPath).fileName();
+ const auto relativePath = QString("plugins/") + fileName;
+ fileData->addOutputFilePath(relativePath.toStdWString());
+ result.pluginPaths.push_back(result.tempDir->filePath(relativePath));
+ }
+ }
+
+ if (moduleConfigInArchive.isEmpty()) {
+ result.errorMessage = "No ModuleConfig.xml found in archive";
+ return result;
+ }
+
+ // Create plugins subdirectory
+ QDir(result.tempDir->path()).mkpath("plugins");
+
+ // Extract the files
+ Archive::FileChangeCallback fileChangeCallback = [&progressCallback](
+ Archive::FileChangeType, const std::wstring& fileName) {
+ if (progressCallback) {
+ progressCallback(QString::fromStdWString(fileName));
+ }
+ };
+
+ Archive::ErrorCallback errorCallback = [&result](const std::wstring& error) {
+ result.errorMessage = QString::fromStdWString(error);
+ };
+
+ const bool extractSuccess = archive->extract(
+ result.tempDir->path().toStdWString(),
+ Archive::ProgressCallback{}, // progress callback
+ fileChangeCallback,
+ errorCallback
+ );
+
+ if (!extractSuccess) {
+ if (result.errorMessage.isEmpty()) {
+ result.errorMessage = "Extraction failed";
+ }
+ return result;
+ }
+
+ // Verify ModuleConfig.xml was extracted
+ if (!QFile::exists(result.moduleConfigPath)) {
+ result.errorMessage = "ModuleConfig.xml extraction failed";
+ return result;
+ }
+
+ // Filter to only existing plugin files
+ std::vector<QString> existingPlugins;
+ for (const auto& path : result.pluginPaths) {
+ if (QFile::exists(path)) {
+ existingPlugins.push_back(path);
+ }
+ }
+ result.pluginPaths = std::move(existingPlugins);
+
+ result.success = true;
+ return result;
+ }
+
+ /**
+ * Check if an archive contains FOMOD files without extracting.
+ * @param archiveFilePath Full path to the archive file
+ * @return true if the archive contains fomod/ModuleConfig.xml
+ */
+ static bool hasFomodFiles(const QString& archiveFilePath)
+ {
+ const auto archive = CreateArchive();
+ if (!archive->isValid() || !archive->open(archiveFilePath.toStdWString(), nullptr)) {
+ return false;
+ }
+
+ for (const auto* fileData : archive->getFileList()) {
+ const auto path = QString::fromStdWString(fileData->getArchiveFilePath());
+ if (path.toLower().endsWith("fomod/moduleconfig.xml") ||
+ path.toLower().endsWith("fomod\\moduleconfig.xml")) {
+ return true;
+ }
+ }
+ return false;
+ }
+};
diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodDB.h b/libs/installer_fomod_plus/share/FOMODData/FomodDB.h
new file mode 100644
index 0000000..d1a9aa2
--- /dev/null
+++ b/libs/installer_fomod_plus/share/FOMODData/FomodDB.h
@@ -0,0 +1,146 @@
+#pragma once
+
+#include <fstream>
+#include <stringutil.h>
+
+#include "FomodDBEntry.h"
+
+#include <xml/ModuleConfiguration.h>
+
+#include "PluginReader.h"
+
+using FOMODDBEntries = std::vector<std::shared_ptr<FomodDbEntry> >;
+
+constexpr std::string FOMOD_DB_FILE = "fomod.db";
+
+class FomodDB {
+public:
+ /**
+ *
+ * @param moBasePath The organizer instance's basePath() value
+ * @param dbName The filename of the db. Only settable for testing purposes.
+ */
+ explicit FomodDB(const std::string &moBasePath, const std::string &dbName = FOMOD_DB_FILE) {
+ dbFilePath = (std::filesystem::path(moBasePath) / dbName).string();
+ loadFromFile();
+ }
+
+ // TODO: Also pull from non install steps (requiredInstallFiles or whatever, and optional);
+ static std::shared_ptr<FomodDbEntry> getEntryFromFomod(ModuleConfiguration *fomod, std::vector<QString> pluginPaths,
+ int modId) {
+ std::vector<FomodOption> options;
+ for (const auto &installStep: fomod->installSteps.installSteps) {
+ for (const auto &group: installStep.optionalFileGroups.groups) {
+ for (const auto &plugin: group.plugins.plugins) {
+ // Create a DB entry for the given plugin if it has an ESP
+ std::cout << "\nPlugin: " << plugin.name << std::endl;
+
+ for (auto file: plugin.files.files) {
+ if (file.isFolder || !isPluginFile(file.source)) {
+ continue;
+ }
+
+ // Find the path in pluginPaths that ends with this path
+ // PluginPaths is gathered from the archive contents.
+ auto it = std::ranges::find_if(pluginPaths, [&file](const QString &path) {
+ return path.endsWith(file.source.c_str());
+ });
+ if (it == pluginPaths.end()) {
+ continue;
+ }
+ const auto &pluginPath = *it;
+ const auto masters = PluginReader::readMasters(pluginPath.toStdString(), true);
+ options.emplace_back(
+ plugin.name,
+ file.source,
+ masters,
+ installStep.name,
+ group.name
+ );
+ }
+ }
+ }
+ }
+ return std::make_shared<FomodDbEntry>(modId, fomod->moduleName, options);
+ }
+
+ void addEntry(const std::shared_ptr<FomodDbEntry> &entry, const bool upsert = true) {
+ // TODO: Test this upsert.
+ if (upsert) {
+ const auto it = std::ranges::find_if(entries, [&entry](const std::shared_ptr<FomodDbEntry> &e) {
+ return e->getModId() == entry->getModId();
+ });
+ if (it != entries.end()) {
+ *it = entry;
+ } else {
+ entries.emplace_back(entry);
+ }
+ } else {
+ entries.emplace_back(entry);
+ }
+ }
+
+ [[nodiscard]] const FOMODDBEntries &getEntries() { return entries; }
+
+ void saveToFile() const {
+ try {
+ std::ofstream file(dbFilePath);
+ if (!file.is_open()) {
+ return;
+ }
+
+ file << toJson().dump(2); // Pretty-print with 2-space indentation
+ file.close();
+ } catch ([[maybe_unused]] const std::exception &e) {
+ // Handle saving errors
+ }
+ }
+
+ [[nodiscard]] nlohmann::json toJson() const {
+ nlohmann::json jsonArray = nlohmann::json::array();
+
+ for (const auto &entry: entries) {
+ jsonArray.push_back(entry->toJson());
+ }
+
+ return jsonArray;
+ }
+
+private:
+ FOMODDBEntries entries;
+ std::string dbFilePath;
+
+ void loadFromFile() {
+ entries.clear();
+
+ // Create empty file if it doesn't exist
+ if (!std::filesystem::exists(dbFilePath)) {
+ std::ofstream file(dbFilePath);
+ file << "[]"; // Empty JSON array
+ file.close();
+ return; // No entries to load
+ }
+
+ try {
+ // Read and parse the JSON file
+ std::ifstream file(dbFilePath);
+ if (!file.is_open()) {
+ return;
+ }
+
+ nlohmann::json jsonArray = nlohmann::json::parse(file);
+
+ // Ensure it's an array
+ if (!jsonArray.is_array()) {
+ return;
+ }
+
+ // Process each entry in the array
+ for (const auto &entryJson: jsonArray) {
+ entries.push_back(std::make_unique<FomodDbEntry>(entryJson));
+ }
+ } catch ([[maybe_unused]] const std::exception &e) {
+ // Handle parsing errors (leave entries empty)
+ }
+ }
+};
diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h b/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h
new file mode 100644
index 0000000..5d39f95
--- /dev/null
+++ b/libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h
@@ -0,0 +1,129 @@
+#pragma once
+
+#include <string>
+#include <utility>
+#include <vector>
+#include <nlohmann/json.hpp>
+
+/*
+The following JSON will be part of an array of similar objects in the root level "JSON DB" for FOMOD Plus.
+It contains information to resolve the identity of a given mod (names can change), and then the options
+in the FOMOD with their respective masters.
+{
+ modId: 12345,
+ displayName: "Lux (Patch Hub)",
+ options: [
+ {
+ "name" "JK's The Hag's Cure",
+ "fileName": "Lux - JK's The Hag's Cure patch.esp",
+ "masters": [
+ "Skyrim.esm",
+ "JK's The Hag's Cure.esp",
+ "Lux - Resources.esp",
+ "Lux.esp"
+ ],
+ "step": "Page One",
+ "group": "Group One",
+ "selectionState": "Available"
+ }
+ ]
+}
+*/
+
+enum class SelectionState {
+ Unknown, // Not yet matched to choices
+ Selected, // User selected this option
+ Deselected, // User manually deselected
+ Available // Present but user didn't interact (or choices not recorded)
+};
+
+inline std::string selectionStateToString(SelectionState state) {
+ switch (state) {
+ case SelectionState::Unknown: return "Unknown";
+ case SelectionState::Selected: return "Selected";
+ case SelectionState::Deselected: return "Deselected";
+ case SelectionState::Available: return "Available";
+ default: return "Unknown";
+ }
+}
+
+inline SelectionState stringToSelectionState(const std::string& str) {
+ if (str == "Selected") return SelectionState::Selected;
+ if (str == "Deselected") return SelectionState::Deselected;
+ if (str == "Available") return SelectionState::Available;
+ return SelectionState::Unknown;
+}
+
+struct FomodOption {
+ std::string name;
+ std::string fileName;
+ std::vector<std::string> masters;
+ std::string step;
+ std::string group;
+ SelectionState selectionState = SelectionState::Unknown;
+
+ FomodOption(std::string n, std::string fn, std::vector<std::string> m, std::string s, std::string g,
+ SelectionState state = SelectionState::Unknown)
+ : name(std::move(n)), fileName(std::move(fn)), masters(std::move(m)), step(std::move(s)), group(std::move(g)),
+ selectionState(state) {}
+};
+
+class FomodDbEntry {
+public:
+ explicit FomodDbEntry(nlohmann::json json) {
+ modId = json["modId"];
+ displayName = json["displayName"];
+ for (auto &option: json["options"]) {
+ // create an option from this object
+ SelectionState state = SelectionState::Unknown;
+ if (option.contains("selectionState")) {
+ state = stringToSelectionState(option["selectionState"]);
+ }
+ FomodOption fomodOption(
+ option["name"],
+ option["fileName"],
+ option["masters"],
+ option["step"],
+ option["group"],
+ state
+ );
+ options.push_back(fomodOption);
+ }
+ }
+
+ explicit FomodDbEntry(const int modId, std::string displayName, const std::vector<FomodOption> &options)
+ : modId(modId), displayName(std::move(displayName)), options(options) {
+ }
+
+
+ [[nodiscard]] int getModId() const { return modId; }
+ [[nodiscard]] std::string getDisplayName() const { return displayName; }
+ [[nodiscard]] const std::vector<FomodOption>& getOptions() const { return options; }
+ [[nodiscard]] std::vector<FomodOption>& getOptionsMutable() { return options; }
+
+ [[nodiscard]] nlohmann::json toJson() const {
+ nlohmann::json result;
+ result["modId"] = modId;
+ result["displayName"] = displayName;
+
+ nlohmann::json optionsArray = nlohmann::json::array();
+ for (const auto &[name, fileName, masters, step, group, selectionState]: options) {
+ nlohmann::json optionJson;
+ optionJson["name"] = name;
+ optionJson["fileName"] = fileName;
+ optionJson["masters"] = masters;
+ optionJson["step"] = step;
+ optionJson["group"] = group;
+ optionJson["selectionState"] = selectionStateToString(selectionState);
+ optionsArray.push_back(optionJson);
+ }
+
+ result["options"] = optionsArray;
+ return result;
+ }
+
+private:
+ int modId;
+ std::string displayName;
+ std::vector<FomodOption> options;
+};
diff --git a/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h b/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h
new file mode 100644
index 0000000..c7d53a2
--- /dev/null
+++ b/libs/installer_fomod_plus/share/FOMODData/FomodRescan.h
@@ -0,0 +1,277 @@
+#pragma once
+
+#include "ArchiveExtractor.h"
+#include "FomodDB.h"
+#include "stringutil.h"
+#include "xml/ModuleConfiguration.h"
+
+#include <QDir>
+#include <QString>
+#include <functional>
+#include <imodinterface.h>
+#include <imoinfo.h>
+#include <nlohmann/json.hpp>
+
+struct RescanResult {
+ int totalModsProcessed = 0;
+ int successfullyScanned = 0;
+ int missingArchives = 0;
+ int parseErrors = 0;
+ std::vector<std::string> failedMods;
+};
+
+/**
+ * Orchestrates rescanning of all mods with stored FOMOD choices to repopulate the database.
+ * Used when fomod.db is missing or needs to be regenerated from existing installations.
+ */
+class FomodRescan {
+public:
+ using ProgressCallback = std::function<void(int current, int total, const QString& modName)>;
+
+ FomodRescan(MOBase::IOrganizer* organizer, FomodDB* db)
+ : mOrganizer(organizer), mFomodDb(db) {}
+
+ /**
+ * Scan all mods that have stored FOMOD Plus choices and repopulate the database.
+ * @param progressCallback Optional callback for progress updates
+ * @return RescanResult with statistics about the scan
+ */
+ RescanResult scanAllModsWithChoices(const ProgressCallback& progressCallback = nullptr)
+ {
+ RescanResult result;
+
+ const auto modList = mOrganizer->modList();
+ if (!modList) {
+ return result;
+ }
+
+ // First pass: gather all mods with stored choices
+ std::vector<MOBase::IModInterface*> modsWithChoices;
+ for (const auto& modName : modList->allMods()) {
+ auto* mod = modList->getMod(modName);
+ if (mod && hasStoredChoices(mod)) {
+ modsWithChoices.push_back(mod);
+ }
+ }
+
+ result.totalModsProcessed = static_cast<int>(modsWithChoices.size());
+
+ // Second pass: process each mod
+ int current = 0;
+ for (auto* mod : modsWithChoices) {
+ current++;
+ if (progressCallback) {
+ progressCallback(current, result.totalModsProcessed, mod->name());
+ }
+
+ const auto scanResult = processMod(mod);
+ switch (scanResult) {
+ case ScanOutcome::Success:
+ result.successfullyScanned++;
+ break;
+ case ScanOutcome::MissingArchive:
+ result.missingArchives++;
+ result.failedMods.push_back(mod->name().toStdString() + " (missing archive)");
+ break;
+ case ScanOutcome::ParseError:
+ result.parseErrors++;
+ result.failedMods.push_back(mod->name().toStdString() + " (parse error)");
+ break;
+ case ScanOutcome::NoFomod:
+ result.failedMods.push_back(mod->name().toStdString() + " (no FOMOD)");
+ break;
+ }
+ }
+
+ // Save the database
+ mFomodDb->saveToFile();
+
+ return result;
+ }
+
+private:
+ MOBase::IOrganizer* mOrganizer;
+ FomodDB* mFomodDb;
+
+ enum class ScanOutcome {
+ Success,
+ MissingArchive,
+ ParseError,
+ NoFomod
+ };
+
+ /**
+ * Check if a mod has stored FOMOD Plus choices (non-zero pluginSetting).
+ */
+ bool hasStoredChoices(MOBase::IModInterface* mod) const
+ {
+ const auto fomodData = mod->pluginSetting(
+ StringConstants::Plugin::NAME.data(), "fomod", 0);
+
+ if (!fomodData.isValid() || fomodData.isNull()) {
+ return false;
+ }
+
+ // Check if it's actually valid JSON with steps
+ try {
+ const auto json = nlohmann::json::parse(fomodData.toString().toStdString());
+ return json.contains("steps") && json["steps"].is_array() && !json["steps"].empty();
+ } catch (...) {
+ return false;
+ }
+ }
+
+ /**
+ * Get the stored choices JSON from a mod's pluginSetting.
+ */
+ nlohmann::json getStoredChoices(MOBase::IModInterface* mod) const
+ {
+ const auto fomodData = mod->pluginSetting(
+ StringConstants::Plugin::NAME.data(), "fomod", 0);
+
+ try {
+ return nlohmann::json::parse(fomodData.toString().toStdString());
+ } catch (...) {
+ return nlohmann::json();
+ }
+ }
+
+ /**
+ * Process a single mod: extract archive, parse FOMOD, create DB entry with selection states.
+ */
+ ScanOutcome processMod(MOBase::IModInterface* mod)
+ {
+ // Get the archive path
+ const auto installationFile = mod->installationFile();
+ if (installationFile.isEmpty()) {
+ return ScanOutcome::MissingArchive;
+ }
+
+ const auto downloadsPath = mOrganizer->downloadsPath();
+ const auto archivePath = QDir(installationFile).isAbsolute()
+ ? installationFile
+ : downloadsPath + "/" + installationFile;
+
+ if (!QFile::exists(archivePath)) {
+ return ScanOutcome::MissingArchive;
+ }
+
+ // Extract FOMOD data from archive
+ auto extractionResult = ArchiveExtractor::extractFomodData(archivePath);
+ if (!extractionResult.success) {
+ return ScanOutcome::ParseError;
+ }
+
+ // Parse ModuleConfiguration
+ auto moduleConfig = std::make_unique<ModuleConfiguration>();
+ try {
+ if (!moduleConfig->deserialize(extractionResult.moduleConfigPath)) {
+ return ScanOutcome::ParseError;
+ }
+ } catch (...) {
+ return ScanOutcome::ParseError;
+ }
+
+ // Get the mod's Nexus ID
+ const int modId = mod->nexusId();
+
+ // Create FomodDbEntry using existing logic
+ auto entry = FomodDB::getEntryFromFomod(
+ moduleConfig.get(),
+ extractionResult.pluginPaths,
+ modId
+ );
+
+ if (!entry || entry->getOptions().empty()) {
+ return ScanOutcome::NoFomod;
+ }
+
+ // Apply selection states from stored choices
+ const auto choices = getStoredChoices(mod);
+ applySelectionsToEntry(*entry, choices);
+
+ // Add to database (upsert)
+ mFomodDb->addEntry(entry, true);
+
+ return ScanOutcome::Success;
+ }
+
+ /**
+ * Apply user selection states to a FomodDbEntry based on stored choices JSON.
+ *
+ * Choices JSON format:
+ * {
+ * "steps": [{
+ * "name": "Step Name",
+ * "groups": [{
+ * "name": "Group Name",
+ * "plugins": ["Selected Plugin 1"],
+ * "deselected": ["Manually Deselected Plugin"]
+ * }]
+ * }]
+ * }
+ */
+ void applySelectionsToEntry(FomodDbEntry& entry, const nlohmann::json& choices)
+ {
+ if (!choices.contains("steps") || !choices["steps"].is_array()) {
+ // No choices data - mark all as Available
+ for (auto& option : entry.getOptionsMutable()) {
+ option.selectionState = SelectionState::Available;
+ }
+ return;
+ }
+
+ // Build a lookup map for quick matching: stepName/groupName/pluginName -> state
+ struct PluginState {
+ bool selected = false;
+ bool deselected = false;
+ };
+ std::map<std::string, PluginState> stateMap;
+
+ for (const auto& step : choices["steps"]) {
+ if (!step.contains("name") || !step.contains("groups")) continue;
+ const std::string stepName = step["name"];
+
+ for (const auto& group : step["groups"]) {
+ if (!group.contains("name")) continue;
+ const std::string groupName = group["name"];
+
+ // Process selected plugins
+ if (group.contains("plugins") && group["plugins"].is_array()) {
+ for (const auto& plugin : group["plugins"]) {
+ const std::string pluginName = plugin;
+ const auto key = stepName + "/" + groupName + "/" + pluginName;
+ stateMap[key].selected = true;
+ }
+ }
+
+ // Process deselected plugins
+ if (group.contains("deselected") && group["deselected"].is_array()) {
+ for (const auto& plugin : group["deselected"]) {
+ const std::string pluginName = plugin;
+ const auto key = stepName + "/" + groupName + "/" + pluginName;
+ stateMap[key].deselected = true;
+ }
+ }
+ }
+ }
+
+ // Apply states to options
+ for (auto& option : entry.getOptionsMutable()) {
+ const auto key = option.step + "/" + option.group + "/" + option.name;
+
+ if (auto it = stateMap.find(key); it != stateMap.end()) {
+ if (it->second.selected) {
+ option.selectionState = SelectionState::Selected;
+ } else if (it->second.deselected) {
+ option.selectionState = SelectionState::Deselected;
+ } else {
+ option.selectionState = SelectionState::Available;
+ }
+ } else {
+ // Plugin not found in choices - mark as Available
+ option.selectionState = SelectionState::Available;
+ }
+ }
+ }
+};
diff --git a/libs/installer_fomod_plus/share/FOMODData/PluginReader.h b/libs/installer_fomod_plus/share/FOMODData/PluginReader.h
new file mode 100644
index 0000000..dade7e1
--- /dev/null
+++ b/libs/installer_fomod_plus/share/FOMODData/PluginReader.h
@@ -0,0 +1,119 @@
+#pragma once
+
+#include <fstream>
+#include <string>
+#include <vector>
+#include <cstdint>
+#include <unordered_set>
+
+static const std::unordered_set<std::string> VANILLA_MASTERS = {
+ "Skyrim.esm",
+ "Update.esm",
+ "Dawnguard.esm",
+ "HearthFires.esm",
+ "Dragonborn.esm"
+};
+
+class PluginReader {
+public:
+
+ /**
+ * Reads the master files from a Bethesda plugin file (ESP/ESM/ESL)
+ * @param filePath Path to the plugin file
+ * @param trimVanilla Exclude the vanilla game masters or not. Mostly to save DB space.
+ * @return Vector of master filenames
+ */
+ static std::vector<std::string> readMasters(const std::string& filePath, const bool trimVanilla = false)
+ {
+ std::vector<std::string> masters;
+ std::ifstream file(filePath, std::ios::binary);
+
+ if (!file) {
+ return masters;
+ }
+
+ // Check TES4 record signature
+ char signature[4];
+ file.read(signature, 4);
+ if (strncmp(signature, "TES4", 4) != 0) {
+ return masters;
+ }
+
+ // Read record size
+ uint32_t recordSize;
+ file.read(reinterpret_cast<char*>(&recordSize), 4);
+
+ constexpr uint32_t skipSize = sizeof(uint32_t) // flags
+ + sizeof(uint32_t) // formId
+ + sizeof(uint16_t) // timestamp
+ + sizeof(uint16_t) // version control
+ + sizeof(uint16_t) // internal version
+ + sizeof(uint16_t); // unknown
+
+ // Skip header flags, formID, etc. (total 8 bytes)
+ file.seekg(skipSize, std::ios::cur);
+
+ // Calculate where the TES4 record ends
+ std::streampos recordEnd = file.tellg() + static_cast<std::streampos>(recordSize);
+
+ // Read subrecords until we reach the end of the TES4 record
+ while (file && file.tellg() < recordEnd) {
+ char subRecordType[4];
+ uint16_t subRecordSize;
+
+ // Read subrecord type and size
+ file.read(subRecordType, 4);
+ file.read(reinterpret_cast<char*>(&subRecordSize), 2);
+
+ if (strncmp(subRecordType, "MAST", 4) == 0) {
+ // Read master filename (null-terminated string)
+ std::string masterName;
+ masterName.resize(subRecordSize);
+ file.read(masterName.data(), subRecordSize);
+
+ // Remove null terminator if present
+ if (!masterName.empty() && masterName.back() == '\0') {
+ masterName.pop_back();
+ }
+
+ // Only add if it's not a vanilla master or if we're not trimming
+ if (!trimVanilla || !VANILLA_MASTERS.contains(masterName)) {
+ masters.push_back(masterName);
+ }
+
+ // Each MAST is followed by a DATA subrecord
+ char dataType[4];
+ uint16_t dataSize;
+ file.read(dataType, 4);
+ file.read(reinterpret_cast<char*>(&dataSize), 2);
+
+ // Skip DATA content (usually an 8-byte value)
+ file.seekg(dataSize, std::ios::cur);
+ } else {
+ // Skip other subrecord types
+ file.seekg(subRecordSize, std::ios::cur);
+ }
+ }
+
+ return masters;
+ }
+
+ /**
+ * Checks if a file is a valid Bethesda plugin (ESP/ESM/ESL)
+ * @param filePath Path to the file
+ * @return True if the file is a valid plugin
+ */
+ static bool isValidPlugin(const std::string& filePath)
+ {
+ std::ifstream file(filePath, std::ios::binary);
+
+ if (!file) {
+ return false;
+ }
+
+ char signature[4];
+ file.read(signature, 4);
+
+ return strncmp(signature, "TES4", 4) == 0;
+ }
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/share/stringutil.h b/libs/installer_fomod_plus/share/stringutil.h
new file mode 100644
index 0000000..c5d2fb8
--- /dev/null
+++ b/libs/installer_fomod_plus/share/stringutil.h
@@ -0,0 +1,150 @@
+#ifndef STRINGCONSTANTS_H
+#define STRINGCONSTANTS_H
+#include <algorithm>
+#include <regex>
+#include <string>
+#include <vector>
+#include <QString>
+
+
+namespace StringConstants
+{
+ namespace Plugin
+ {
+ constexpr std::string_view NAME = "FOMOD Plus";
+ constexpr std::string_view AUTHOR = "clearing";
+ constexpr std::string_view DESCRIPTION =
+ "Extends the capabilities of the FOMOD installer for advanced users.\n\n"
+ "Available colors (enter exactly): \n"
+ "'Light0'\t'Light1'\t'Light2'\t'Light3'\n"
+ "'Dark0'\t'Dark1'\t'Dark2'\t'Dark3'\n"
+ "'Red'\t'Red Bright'\n"
+ "'Green'\t'Green Bright'\n"
+ "'Yellow'\t'Yellow Bright'\n"
+ "'Blue'\t'Blue Bright'\n"
+ "'Purple'\t'Purple Bright'\n"
+ "'Aqua'\t'Aqua Bright'\n"
+ "'Orange'\t'Orange Bright'\n";
+ constexpr std::wstring_view W_NAME = L"FOMOD Plus";
+ constexpr std::wstring_view W_AUTHOR = L"clearing";
+ constexpr std::wstring_view W_DESCRIPTION =
+ L"Extends the capabilities of the FOMOD installer for advanced users.";
+ }
+
+ namespace FomodFiles
+ {
+ constexpr std::string_view FOMOD_DIR = "fomod";
+ constexpr std::string_view INFO_XML = "info.xml";
+ constexpr std::string_view MODULE_CONFIG = "ModuleConfig.xml";
+
+ // Wide string versions for archive API
+ constexpr std::wstring_view W_FOMOD_DIR = L"fomod";
+ constexpr std::wstring_view W_INFO_XML = L"fomod/info.xml";
+ constexpr std::wstring_view W_MODULE_CONFIG = L"fomod/ModuleConfig.xml";
+
+ constexpr std::string_view TYPE_REQUIRED = "Required";
+ constexpr std::string_view TYPE_OPTIONAL = "Optional";
+ constexpr std::string_view TYPE_RECOMMENDED = "Recommended";
+ constexpr std::string_view TYPE_NOT_USABLE = "NotUsable";
+ constexpr std::string_view TYPE_COULD_BE_USABLE = "CouldBeUsable";
+ }
+}
+
+// Convert narrow string_view to wstring (for ASCII strings only)
+inline std::wstring toWide(std::string_view sv)
+{
+ return std::wstring(sv.begin(), sv.end());
+}
+
+// trim from start (in place)
+inline void ltrim(std::string& s)
+{
+ s.erase(s.begin(), std::ranges::find_if(s, [](const unsigned char ch)
+ {
+ return !std::isspace(ch);
+ }));
+}
+
+// trim from end (in place)
+inline void rtrim(std::string& s)
+{
+ s.erase(std::find_if(s.rbegin(), s.rend(), [](const unsigned char ch)
+ {
+ return !std::isspace(ch);
+ }).base(), s.end());
+}
+
+// trim from both ends (in place)
+inline std::string& trim(std::string& s)
+{
+ ltrim(s);
+ rtrim(s);
+ return s;
+}
+
+inline void trim(const std::vector<std::string>& strings)
+{
+ for (auto s : strings) { trim(s); }
+}
+
+inline std::wstring toLower(const std::wstring& str)
+{
+ std::wstring lowerStr = str;
+ std::ranges::transform(lowerStr, lowerStr.begin(), towlower);
+ return lowerStr;
+}
+
+inline std::string toLower(const std::string& str)
+{
+ std::string lowerStr = str;
+ std::ranges::transform(lowerStr, lowerStr.begin(), tolower);
+ return lowerStr;
+}
+
+inline bool endsWithCaseInsensitive(const std::wstring& str, const std::wstring& suffix)
+{
+ const std::wstring lowerStr = toLower(str);
+ if (const std::wstring lowerSuffix = toLower(suffix); lowerStr.length() >= lowerSuffix.length())
+ {
+ return 0 == lowerStr.compare(lowerStr.length() - lowerSuffix.length(), lowerSuffix.length(), lowerSuffix);
+ }
+ return false;
+}
+
+inline QString formatPluginDescription(const QString& text)
+{
+ std::string formattedText = text.toStdString();
+ // Replace URLs with <a href> tags
+ const std::regex
+ urlRegex(R"((http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-]))");
+ formattedText = std::regex_replace(formattedText, urlRegex, R"(<a href="$&">$&</a>)");
+
+ // Replace line breaks
+ formattedText = std::regex_replace(formattedText, std::regex("&#13;&#10;"), "<br>");
+ formattedText = std::regex_replace(formattedText, std::regex("\\r\\n"), "<br>");
+ formattedText = std::regex_replace(formattedText, std::regex("\\r"), "<br>");
+ formattedText = std::regex_replace(formattedText, std::regex("\\n"), "<br>");
+
+ return QString::fromStdString(formattedText);
+}
+
+// NOTE: This isn't perfect. Sometimes we have whole filenames, sometimes we're just passing
+// the suffix. It should be fine as long as no one names a file like..."Testesl". Idk what that
+// would do anyway.
+inline bool isPluginFile(const QString& file)
+{
+ return file.toLower().endsWith("esl")
+ || file.toLower().endsWith("esp")
+ || file.toLower().endsWith("esm");
+}
+
+inline bool isPluginFile(const std::string& file)
+{
+ const auto lower = toLower(file);
+ return lower.ends_with("esl")
+ || lower.ends_with("esp")
+ || lower.ends_with("esm");
+}
+
+
+#endif
diff --git a/libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp b/libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp
new file mode 100644
index 0000000..d4fed69
--- /dev/null
+++ b/libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp
@@ -0,0 +1,44 @@
+#include "FomodInfoFile.h"
+#include "XmlParseException.h"
+#include <format>
+#include <pugixml.hpp>
+
+#include "stringutil.h"
+
+#include <QFile>
+#include <QString>
+
+bool FomodInfoFile::deserialize(const QString& filePath)
+{
+ QFile file(filePath);
+ if (!file.open(QIODevice::ReadOnly)) {
+ throw XmlParseException(std::format("Failed to open file: {}", filePath.toStdString()));
+ }
+ const QByteArray content = file.readAll();
+ file.close();
+
+ pugi::xml_document doc;
+ if (const pugi::xml_parse_result result = doc.load_buffer(content.constData(), content.size()); !result) {
+ throw XmlParseException(std::format("XML parsed with errors: {}", result.description()));
+ }
+
+ const pugi::xml_node fomodNode = doc.child("fomod");
+ if (!fomodNode) {
+ throw XmlParseException("No <config> node found");
+ }
+
+ name = fomodNode.child("Name").text().as_string();
+ author = fomodNode.child("Author").text().as_string();
+ version = fomodNode.child("Version").text().as_string();
+ website = fomodNode.child("Website").text().as_string();
+ description = fomodNode.child("Description").text().as_string();
+
+ trim({ name, author, version, website, description });
+
+ for (pugi::xml_node groupNode : fomodNode.child("Groups").children("element")) {
+ groups.emplace_back(groupNode.text().as_string());
+ }
+
+ return true;
+
+} \ No newline at end of file
diff --git a/libs/installer_fomod_plus/share/xml/FomodInfoFile.h b/libs/installer_fomod_plus/share/xml/FomodInfoFile.h
new file mode 100644
index 0000000..d2c2a23
--- /dev/null
+++ b/libs/installer_fomod_plus/share/xml/FomodInfoFile.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#include <qstring.h>
+#include <string>
+#include <vector>
+
+class FomodInfoFile {
+public:
+ bool deserialize(const QString &filePath);
+
+ [[nodiscard]] const std::string& getName() const { return name; }
+ [[nodiscard]] const std::string& getAuthor() const { return author; }
+ [[nodiscard]] const std::string& getVersion() const { return version; }
+ [[nodiscard]] const std::string& getWebsite() const { return website; }
+ [[nodiscard]] const std::string& getDescription() const { return description; }
+ [[nodiscard]] const std::vector<std::string>& getGroups() const { return groups; }
+
+private:
+ std::string name;
+ std::string author;
+ std::string version;
+ std::string website;
+ std::string description;
+ std::vector<std::string> groups;
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp
new file mode 100644
index 0000000..64d6b0b
--- /dev/null
+++ b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp
@@ -0,0 +1,352 @@
+#include "ModuleConfiguration.h"
+
+#include <format>
+
+#include "XmlHelper.h"
+#include "XmlParseException.h"
+#include "stringutil.h"
+
+#include <QFile>
+#include <QString>
+
+using namespace StringConstants::FomodFiles;
+
+static GroupTypeEnum groupTypeFromString(const std::string& groupType)
+{
+ if (groupType == "SelectAny")
+ return SelectAny;
+ if (groupType == "SelectAll")
+ return SelectAll;
+ if (groupType == "SelectExactlyOne")
+ return SelectExactlyOne;
+ if (groupType == "SelectAtMostOne")
+ return SelectAtMostOne;
+ if (groupType == "SelectAtLeastOne")
+ return SelectAtLeastOne;
+ return SelectAny; // is this a sane default? probably
+}
+
+PluginTypeEnum pluginTypeFromString(const std::string& typeStr)
+{
+ if (typeStr == TYPE_REQUIRED)
+ return PluginTypeEnum::Required;
+ if (typeStr == TYPE_OPTIONAL)
+ return PluginTypeEnum::Optional;
+ if (typeStr == TYPE_RECOMMENDED)
+ return PluginTypeEnum::Recommended;
+ if (typeStr == TYPE_NOT_USABLE)
+ return PluginTypeEnum::NotUsable;
+ if (typeStr == TYPE_COULD_BE_USABLE)
+ return PluginTypeEnum::CouldBeUsable;
+ return PluginTypeEnum::Optional;
+}
+
+template <typename T>
+bool deserializeList(pugi::xml_node& node, const char* childName, std::vector<T>& list)
+{
+ for (pugi::xml_node childNode : node.children(childName)) {
+ T item;
+ item.deserialize(childNode);
+ list.push_back(item);
+ }
+ return true;
+}
+
+/*
+ * NOTE: We call 'trim()' on all error-prone fields that rely on user-input. I'm assuming these FOMODs are created with
+ * the FOMOD creation tool, so I won't trim things like flags that the tool sets for the user.
+ */
+
+bool FileDependency::deserialize(pugi::xml_node& node)
+{
+ file = node.attribute("file").as_string();
+ trim(file);
+ // ReSharper disable once CppTooWideScopeInitStatement
+ // Do not use 'auto' for these. it will break equality checks
+ const std::string stateStr = node.attribute("state").as_string();
+
+ if (stateStr == "Missing")
+ state = FileDependencyTypeEnum::Missing;
+ else if (stateStr == "Inactive")
+ state = FileDependencyTypeEnum::Inactive;
+ else if (stateStr == "Active")
+ state = FileDependencyTypeEnum::Active;
+ return true;
+}
+
+bool FlagDependency::deserialize(pugi::xml_node& node)
+{
+ flag = node.attribute("flag").as_string();
+ value = node.attribute("value").as_string();
+ trim({ flag, value });
+ return true;
+}
+
+bool GameDependency::deserialize(pugi::xml_node& node)
+{
+ version = node.attribute("version").as_string();
+ trim(version);
+ return true;
+}
+
+bool CompositeDependency::deserialize(pugi::xml_node& node)
+{
+
+ // this could EITHER have a dependencies child or the dependencies are here.
+ // turns out they could have both.
+ pugi::xml_node possibleNode = node;
+
+ // If the dependencies are all right inside, just use the root node as the dependency base.
+ // This looks hacky but accommodates both _nested_ dependencies for plugins, and extremely simple ones for step visibility.
+ if (node.child("dependencies") && !node.child("fileDependency") && !node.child("flagDependency") &&!node.child("gameDependency")) {
+ possibleNode = node.child("dependencies");
+ }
+
+ deserializeList(possibleNode, "fileDependency", fileDependencies);
+ deserializeList(possibleNode, "flagDependency", flagDependencies);
+ deserializeList(possibleNode, "gameDependency", gameDependencies);
+ deserializeList(possibleNode, "dependencies", nestedDependencies);
+
+ operatorType = OperatorTypeEnum::AND; // safest default.
+
+ if (const std::string operatorStr = possibleNode.attribute("operator").as_string(); operatorStr == "Or") {
+ operatorType = OperatorTypeEnum::OR;
+ }
+
+ return true;
+}
+
+bool DependencyPattern::deserialize(pugi::xml_node& node)
+{
+ if (!node)
+ return false;
+ pugi::xml_node dependenciesNode = node.child("dependencies");
+ dependencies.deserialize(dependenciesNode);
+
+ const pugi::xml_node typeNode = node.child("type");
+ type = pluginTypeFromString(typeNode.attribute("name").as_string());
+ return true;
+}
+
+bool DependencyPatternList::deserialize(pugi::xml_node& node)
+{
+ return deserializeList(node, "pattern", patterns);
+}
+
+bool DependencyPluginType::deserialize(pugi::xml_node& node)
+{
+ pugi::xml_node patternsNode = node.child("patterns");
+ const pugi::xml_node defaultTypeNode = node.child("defaultType");
+ defaultType = pluginTypeFromString(defaultTypeNode.attribute("name").as_string());
+ patterns.deserialize(patternsNode);
+ return true;
+}
+
+bool TypeDescriptor::deserialize(pugi::xml_node& node)
+{
+ pugi::xml_node dependencyTypeNode = node.child("dependencyType");
+ dependencyType.deserialize(dependencyTypeNode);
+ const pugi::xml_node typeNode = node.child("type");
+ type = pluginTypeFromString(typeNode.attribute("name").as_string());
+ return true;
+}
+
+bool Image::deserialize(pugi::xml_node& node)
+{
+ path = node.attribute("path").as_string();
+ return true;
+}
+
+bool HeaderImage::deserialize(pugi::xml_node& node)
+{
+ path = node.attribute("path").as_string();
+ showImage = node.attribute("showImage").as_bool();
+ showFade = node.attribute("showFade").as_bool();
+ height = node.attribute("height").as_int();
+ return true;
+}
+
+bool FileList::deserialize(pugi::xml_node& node)
+{
+ for (pugi::xml_node childNode : node.children()) {
+ if (std::string(childNode.name()) == "folder"
+ || std::string(childNode.name()) == "file") {
+ File file;
+ file.deserialize(childNode);
+ files.emplace_back(file);
+ }
+ }
+ return true;
+}
+
+bool ConditionalFileInstallPattern::deserialize(pugi::xml_node& node)
+{
+ pugi::xml_node dependenciesNode = node.child("dependencies");
+ pugi::xml_node filesNode = node.child("files");
+
+ dependencies.deserialize(dependenciesNode);
+ files.deserialize(filesNode);
+
+ return true;
+}
+
+// <flag name="2">On</flag>
+bool ConditionFlag::deserialize(pugi::xml_node& node)
+{
+ name = node.attribute("name").as_string();
+ value = node.child_value(); //
+ return true;
+}
+
+bool ConditionFlagList::deserialize(pugi::xml_node& node)
+{
+ return deserializeList(node, "flag", flags);
+}
+
+bool File::deserialize(pugi::xml_node& node)
+{
+ source = node.attribute("source").as_string();
+ // destination = node.attribute("destination").as_string();
+ priority = node.attribute("priority").as_int();
+ isFolder = strcmp(node.name(), "folder") == 0;
+ if (auto attr = node.attribute("destination"); attr) {
+ destination = attr.as_string();
+ } else {
+ destination = std::nullopt;
+ }
+ return true;
+}
+
+
+bool Plugin::deserialize(pugi::xml_node& node)
+{
+ pugi::xml_node imageNode = node.child("image");
+ pugi::xml_node typeDescriptorNode = node.child("typeDescriptor");
+ pugi::xml_node conditionFlagsNode = node.child("conditionFlags");
+ pugi::xml_node filesNode = node.child("files");
+
+ // Description is optional in the schema; guard against null C strings from pugixml.
+ if (const pugi::xml_node descNode = node.child("description")) {
+ if (const char* rawDesc = descNode.text().as_string()) {
+ description = rawDesc;
+ }
+ }
+ description = trim(description); // Find a better way to do this eventually.
+ image.deserialize(imageNode);
+ typeDescriptor.deserialize(typeDescriptorNode);
+ name = node.attribute("name").as_string();
+ name = trim(name);
+ conditionFlags.deserialize(conditionFlagsNode);
+ files.deserialize(filesNode);
+ return true;
+}
+
+bool PluginList::deserialize(pugi::xml_node& node)
+{
+ deserializeList(node, "plugin", plugins);
+ order = XmlHelper::getOrderType(node.attribute("order").as_string(), OrderTypeEnum::Ascending);
+
+ // Sort the plugins based on the specified order
+ std::ranges::sort(plugins, [this](const Plugin& a, const Plugin& b) {
+ if (order == OrderTypeEnum::Ascending) {
+ return a.name < b.name;
+ }
+ if (order == OrderTypeEnum::Descending) {
+ return a.name > b.name;
+ }
+ return false; // Default case, no sorting
+ });
+
+ return true;
+}
+
+bool Group::deserialize(pugi::xml_node& node)
+{
+ pugi::xml_node pluginsNode = node.child("plugins");
+ plugins.deserialize(pluginsNode);
+ name = node.attribute("name").as_string();
+ type = groupTypeFromString(node.attribute("type").as_string());
+ return true;
+}
+
+bool GroupList::deserialize(pugi::xml_node& node)
+{
+ deserializeList(node, "group", groups);
+ order = XmlHelper::getOrderType(node.attribute("order").as_string());
+
+ // Sort the groups based on the specified order
+ std::ranges::sort(groups, [this](const Group& a, const Group& b) {
+ if (order == OrderTypeEnum::Ascending) {
+ return a.name < b.name;
+ }
+ if (order == OrderTypeEnum::Descending) {
+ return a.name > b.name;
+ }
+ return false; // Default case, no sorting
+ });
+ return true;
+}
+
+bool InstallStep::deserialize(pugi::xml_node& node)
+{
+ pugi::xml_node visibleNode = node.child("visible");
+ pugi::xml_node optionalFileGroupsNode = node.child("optionalFileGroups");
+ visible.deserialize(visibleNode);
+ optionalFileGroups.deserialize(optionalFileGroupsNode);
+ name = node.attribute("name").as_string();
+ return true;
+}
+
+bool ConditionalFileInstall::deserialize(pugi::xml_node& node)
+{
+ pugi::xml_node patternsNode = node.child("patterns");
+ deserializeList(patternsNode, "pattern", patterns);
+ return true;
+}
+
+bool StepList::deserialize(pugi::xml_node& node)
+{
+ deserializeList(node, "installStep", installSteps);
+ order = XmlHelper::getOrderType(node.attribute("order").as_string());
+ return true;
+}
+
+bool ModuleConfiguration::deserialize(const QString& filePath)
+{
+ QFile file(filePath);
+ if (!file.open(QIODevice::ReadOnly)) {
+ throw XmlParseException(std::format("Failed to open file: {}", filePath.toStdString()));
+ }
+ const QByteArray content = file.readAll();
+ file.close();
+
+ pugi::xml_document doc;
+ if (const pugi::xml_parse_result result = doc.load_buffer(content.constData(), content.size()); !result) {
+ throw XmlParseException(std::format("XML parsed with errors: {}", result.description()));
+ }
+
+ const pugi::xml_node configNode = doc.child("config");
+ if (!configNode) {
+ throw XmlParseException("No <config> node found");
+ }
+
+ moduleName = configNode.child("moduleName").text().as_string();
+
+ moduleImage = HeaderImage();
+ pugi::xml_node moduleImageNode = configNode.child("moduleImage");
+ moduleImage.deserialize(moduleImageNode);
+
+ pugi::xml_node moduleDependenciesNode = configNode.child("moduleDependencies");
+ moduleDependencies.deserialize(moduleDependenciesNode);
+
+ pugi::xml_node requiredInstallFilesNode = configNode.child("requiredInstallFiles");
+ requiredInstallFiles.deserialize(requiredInstallFilesNode);
+
+ pugi::xml_node installStepsNode = configNode.child("installSteps");
+ installSteps.deserialize(installStepsNode);
+
+ pugi::xml_node conditionalFileInstallsNode = configNode.child("conditionalFileInstalls");
+ conditionalFileInstalls.deserialize(conditionalFileInstallsNode);
+
+ return true;
+}
diff --git a/libs/installer_fomod_plus/share/xml/ModuleConfiguration.h b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.h
new file mode 100644
index 0000000..a0a017b
--- /dev/null
+++ b/libs/installer_fomod_plus/share/xml/ModuleConfiguration.h
@@ -0,0 +1,305 @@
+#pragma once
+
+#include <iostream>
+#include <optional>
+#include <pugixml.hpp>
+#include <qstring.h>
+#include <string>
+#include <vector>
+
+class XmlDeserializable {
+public:
+ virtual ~XmlDeserializable() = default;
+
+ virtual bool deserialize(pugi::xml_node& node) = 0;
+
+protected:
+ XmlDeserializable() = default;
+};
+
+enum GroupTypeEnum {
+ SelectAny,
+ SelectAll,
+ SelectExactlyOne,
+ SelectAtMostOne,
+ SelectAtLeastOne
+};
+
+enum class OperatorTypeEnum {
+ AND,
+ OR
+};
+
+enum class OrderTypeEnum {
+ Explicit,
+ Ascending,
+ Descending
+};
+
+enum class FileDependencyTypeEnum {
+ Missing,
+ Inactive,
+ Active,
+ UNKNOWN_STATE
+};
+
+template <typename T>
+class OrderedContents {
+public:
+ OrderTypeEnum order;
+
+ OrderedContents() : order(OrderTypeEnum::Ascending) {}
+ explicit OrderedContents(const OrderTypeEnum orderType): order(orderType) {}
+
+ template <typename Accessor>
+ bool compare(const T& a, const T& b, Accessor accessor) const
+ {
+ switch (order) {
+ case OrderTypeEnum::Ascending:
+ return accessor(a) < accessor(b);
+ case OrderTypeEnum::Descending:
+ return accessor(a) > accessor(b);
+ case OrderTypeEnum::Explicit:
+ default:
+ return false; // No sorting for explicit order
+ }
+ }
+};
+
+enum class PluginTypeEnum {
+ Recommended,
+ Required,
+ Optional,
+ NotUsable,
+ CouldBeUsable,
+ UNKNOWN
+};
+
+
+inline std::ostream& operator<<(std::ostream& os, const PluginTypeEnum& type)
+{
+ switch (type) {
+ case PluginTypeEnum::Recommended:
+ os << "Recommended";
+ break;
+ case PluginTypeEnum::Required:
+ os << "Required";
+ break;
+ case PluginTypeEnum::Optional:
+ os << "Optional";
+ break;
+ case PluginTypeEnum::NotUsable:
+ os << "NotUsable";
+ break;
+ case PluginTypeEnum::CouldBeUsable:
+ os << "CouldBeUsable";
+ break;
+ default: ;
+ }
+ return os;
+}
+
+class PluginType final : public XmlDeserializable {
+public:
+ PluginTypeEnum name = PluginTypeEnum::Optional; // sane default
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class FileDependency final : public XmlDeserializable {
+public:
+ std::string file;
+ FileDependencyTypeEnum state = FileDependencyTypeEnum::UNKNOWN_STATE;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class FlagDependency final : public XmlDeserializable {
+public:
+ std::string flag;
+ std::string value;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class GameDependency final : public XmlDeserializable {
+public:
+ std::string version;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class CompositeDependency final : public XmlDeserializable {
+public:
+ std::vector<FileDependency> fileDependencies;
+ std::vector<FlagDependency> flagDependencies;
+ std::vector<GameDependency> gameDependencies;
+ std::vector<CompositeDependency> nestedDependencies;
+ OperatorTypeEnum operatorType = OperatorTypeEnum::AND; // safest default.
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class DependencyPattern final : public XmlDeserializable {
+public:
+ CompositeDependency dependencies;
+ PluginTypeEnum type;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+
+class DependencyPatternList final : public XmlDeserializable {
+public:
+ std::vector<DependencyPattern> patterns;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class DependencyPluginType final : public XmlDeserializable {
+public:
+ std::optional<PluginTypeEnum> defaultType;
+ DependencyPatternList patterns;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class TypeDescriptor final : public XmlDeserializable {
+public:
+ DependencyPluginType dependencyType;
+ PluginTypeEnum type;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class Image final : public XmlDeserializable {
+public:
+ std::string path;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class HeaderImage final : public XmlDeserializable {
+public:
+ std::string path;
+ bool showImage;
+ bool showFade;
+ int height;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class File final : public XmlDeserializable {
+public:
+ std::string source;
+ std::optional<std::string> destination;
+ int priority{ 0 };
+ bool isFolder;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+
+class FileList final : public XmlDeserializable {
+public:
+ std::vector<File> files;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class ConditionalFileInstallPattern final : public XmlDeserializable {
+public:
+ CompositeDependency dependencies;
+ FileList files;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+// <flag name="2">On</flag>
+class ConditionFlag final : public XmlDeserializable {
+public:
+ std::string name;
+ std::string value;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class ConditionFlagList final : public XmlDeserializable {
+public:
+ std::vector<ConditionFlag> flags;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class Plugin final : public XmlDeserializable {
+public:
+ std::string description;
+ Image image;
+ TypeDescriptor typeDescriptor;
+ std::string name;
+ ConditionFlagList conditionFlags;
+ FileList files;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class PluginList final : public XmlDeserializable, public OrderedContents<Plugin> {
+public:
+ std::vector<Plugin> plugins;
+ OrderTypeEnum order;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class Group final : public XmlDeserializable {
+public:
+ PluginList plugins;
+ std::string name;
+ GroupTypeEnum type;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class GroupList final : public XmlDeserializable, public OrderedContents<Group> {
+public:
+ std::vector<Group> groups;
+ OrderTypeEnum order;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class InstallStep final : public XmlDeserializable {
+public:
+ CompositeDependency visible;
+ GroupList optionalFileGroups;
+ std::string name;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class ConditionalFileInstall final : public XmlDeserializable {
+public:
+ std::vector<ConditionalFileInstallPattern> patterns;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class StepList final : public XmlDeserializable, public OrderedContents<InstallStep> {
+public:
+ std::vector<InstallStep> installSteps;
+ OrderTypeEnum order;
+
+ bool deserialize(pugi::xml_node& node) override;
+};
+
+class ModuleConfiguration {
+public:
+ std::string moduleName;
+ HeaderImage moduleImage;
+ CompositeDependency moduleDependencies;
+ FileList requiredInstallFiles;
+ StepList installSteps;
+ ConditionalFileInstall conditionalFileInstalls;
+
+ bool deserialize(const QString& filePath);
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/share/xml/XmlHelper.h b/libs/installer_fomod_plus/share/xml/XmlHelper.h
new file mode 100644
index 0000000..6934a86
--- /dev/null
+++ b/libs/installer_fomod_plus/share/xml/XmlHelper.h
@@ -0,0 +1,17 @@
+#pragma once
+
+#include "ModuleConfiguration.h"
+
+class XmlHelper {
+public:
+ static OrderTypeEnum getOrderType(const std::string& orderType, OrderTypeEnum defaultOrder = OrderTypeEnum::Explicit)
+ {
+ if (orderType == "Explicit")
+ return OrderTypeEnum::Explicit;
+ if (orderType == "Ascending")
+ return OrderTypeEnum::Ascending;
+ if (orderType == "Descending")
+ return OrderTypeEnum::Descending;
+ return defaultOrder; // Ascending for plugins, Explicit for groups
+ }
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/share/xml/XmlParseException.h b/libs/installer_fomod_plus/share/xml/XmlParseException.h
new file mode 100644
index 0000000..6406f3b
--- /dev/null
+++ b/libs/installer_fomod_plus/share/xml/XmlParseException.h
@@ -0,0 +1,10 @@
+#pragma once
+
+#include <stdexcept>
+#include <string>
+
+class XmlParseException final : public std::runtime_error {
+public:
+ explicit XmlParseException(const std::string& message)
+ : std::runtime_error(message) {}
+};