aboutsummaryrefslogtreecommitdiff
path: root/libs/installer_fomod_plus
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-14 02:45:12 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-14 02:45:25 -0600
commit817e8f5cd26739c69d930d21cd9dc4c0b6e4984e (patch)
tree52aed11d6751bb7d20b06acf197d56fac113203d /libs/installer_fomod_plus
parent51a9f8f197727f00896e5de44569b098923527dd (diff)
Add FUSE external mapping support, BG3/Oblivion Remastered fixes, fomod-plus and NaK integration
FUSE VFS now deploys non-data-dir mod mappings (Paks, OBSE, UE4SS, etc.) via real symlinks and injects file-level data-dir mappings (plugins.txt, loadorder.txt) into the VFS tree. Fixes game launches for Oblivion Remastered (Root Builder path resolution, script extender support) and BG3 (Wine prefix documents directory, file mapper symlinks on Linux). Vendors mo2-fomod-plus plugin and NaK crate for FOMOD installer and game finder/runtime support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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) {}
+};