From 817e8f5cd26739c69d930d21cd9dc4c0b6e4984e Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sat, 14 Feb 2026 02:45:12 -0600 Subject: 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 --- libs/installer_fomod_plus/.clang-format | 4 + libs/installer_fomod_plus/.clangd | 16 + libs/installer_fomod_plus/.editorconfig | 16 + libs/installer_fomod_plus/CMakeLists.txt | 36 + .../installer_fomod_plus/cmake/patch_pugixml.cmake | 20 + libs/installer_fomod_plus/installer/CMakeLists.txt | 53 ++ .../installer/FomodInstallerWindow.cpp | 840 +++++++++++++++++++++ .../installer/FomodInstallerWindow.h | 214 ++++++ .../installer/FomodPlusInstaller.cpp | 512 +++++++++++++ .../installer/FomodPlusInstaller.h | 115 +++ .../installer/fomod_plus_de.ts | 4 + .../installer/fomod_plus_en.ts | 4 + .../installer/fomod_plus_installer_de.ts | 75 ++ .../installer/fomod_plus_installer_en.ts | 79 ++ .../installer/fomod_plus_installer_zh_CN.ts | 75 ++ .../installer/fomod_plus_zh_CN.ts | 4 + libs/installer_fomod_plus/installer/fomodplus.json | 4 + .../installer/integration/FomodDataContent.cpp | 50 ++ .../installer/integration/FomodDataContent.h | 21 + .../installer/lib/ConditionTester.cpp | 176 +++++ .../installer/lib/ConditionTester.h | 42 ++ .../installer/lib/CrashHandler.cpp | 122 +++ .../installer/lib/CrashHandler.h | 20 + .../installer/lib/FileInstaller.cpp | 274 +++++++ .../installer/lib/FileInstaller.h | 94 +++ libs/installer_fomod_plus/installer/lib/FlagMap.h | 113 +++ libs/installer_fomod_plus/installer/lib/Logger.h | 118 +++ .../installer/lib/ViewModels.h | 120 +++ libs/installer_fomod_plus/installer/resources.qrc | 14 + .../installer/resources/delete.png | Bin 0 -> 2125 bytes .../installer/resources/fomod_icon.png | Bin 0 -> 1782 bytes .../installer/resources/image_icon.png | Bin 0 -> 727 bytes .../installer/resources/left-chevron.png | Bin 0 -> 1732 bytes .../installer/resources/right-chevron.png | Bin 0 -> 1808 bytes .../installer/ui/ClickableWidget.h | 24 + libs/installer_fomod_plus/installer/ui/Colors.h | 155 ++++ .../installer/ui/FomodImageViewer.cpp | 284 +++++++ .../installer/ui/FomodImageViewer.h | 92 +++ .../installer/ui/FomodViewModel.cpp | 746 ++++++++++++++++++ .../installer/ui/FomodViewModel.h | 154 ++++ .../installer/ui/ScaleLabel.cpp | 120 +++ .../installer_fomod_plus/installer/ui/ScaleLabel.h | 45 ++ .../installer_fomod_plus/installer/ui/UIHelper.cpp | 95 +++ libs/installer_fomod_plus/installer/ui/UIHelper.h | 80 ++ .../patchwizard/CMakeLists.txt | 45 ++ .../patchwizard/FomodPlusPatchWizard.cpp | 181 +++++ .../patchwizard/FomodPlusPatchWizard.h | 54 ++ .../patchwizard/fomod_plus_patch_wizard_en.ts | 96 +++ .../patchwizard/fomod_plus_patchwizard_de.ts | 19 + .../patchwizard/fomodpluspatchwizard.json | 8 + .../patchwizard/lib/PatchFinder.cpp | 86 +++ .../patchwizard/lib/PatchFinder.h | 49 ++ .../installer_fomod_plus/patchwizard/resources.qrc | 6 + .../patchwizard/resources/fomod_icon.png | Bin 0 -> 1782 bytes .../patchwizard/resources/infoscroll.png | Bin 0 -> 3682 bytes libs/installer_fomod_plus/scanner/CMakeLists.txt | 54 ++ .../scanner/FomodPlusScanner.cpp | 128 ++++ .../scanner/FomodPlusScanner.h | 64 ++ libs/installer_fomod_plus/scanner/archiveparser.h | 98 +++ .../scanner/fomod_plus_scanner_de.ts | 57 ++ .../scanner/fomod_plus_scanner_en.ts | 57 ++ .../scanner/fomod_plus_scanner_zh_CN.ts | 57 ++ .../scanner/fomodplusscanner.json | 8 + libs/installer_fomod_plus/scanner/resources.qrc | 6 + .../scanner/resources/fomod_icon.png | Bin 0 -> 1782 bytes .../scanner/resources/wizard.gif | Bin 0 -> 5263 bytes .../share/FOMODData/ArchiveExtractor.h | 160 ++++ .../installer_fomod_plus/share/FOMODData/FomodDB.h | 146 ++++ .../share/FOMODData/FomodDBEntry.h | 129 ++++ .../share/FOMODData/FomodRescan.h | 277 +++++++ .../share/FOMODData/PluginReader.h | 119 +++ libs/installer_fomod_plus/share/stringutil.h | 150 ++++ .../share/xml/FomodInfoFile.cpp | 44 ++ .../installer_fomod_plus/share/xml/FomodInfoFile.h | 25 + .../share/xml/ModuleConfiguration.cpp | 352 +++++++++ .../share/xml/ModuleConfiguration.h | 305 ++++++++ libs/installer_fomod_plus/share/xml/XmlHelper.h | 17 + .../share/xml/XmlParseException.h | 10 + 78 files changed, 7837 insertions(+) create mode 100644 libs/installer_fomod_plus/.clang-format create mode 100644 libs/installer_fomod_plus/.clangd create mode 100644 libs/installer_fomod_plus/.editorconfig create mode 100644 libs/installer_fomod_plus/CMakeLists.txt create mode 100644 libs/installer_fomod_plus/cmake/patch_pugixml.cmake create mode 100644 libs/installer_fomod_plus/installer/CMakeLists.txt create mode 100644 libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp create mode 100644 libs/installer_fomod_plus/installer/FomodInstallerWindow.h create mode 100644 libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp create mode 100644 libs/installer_fomod_plus/installer/FomodPlusInstaller.h create mode 100644 libs/installer_fomod_plus/installer/fomod_plus_de.ts create mode 100644 libs/installer_fomod_plus/installer/fomod_plus_en.ts create mode 100644 libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts create mode 100644 libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts create mode 100644 libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts create mode 100644 libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts create mode 100644 libs/installer_fomod_plus/installer/fomodplus.json create mode 100644 libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp create mode 100644 libs/installer_fomod_plus/installer/integration/FomodDataContent.h create mode 100644 libs/installer_fomod_plus/installer/lib/ConditionTester.cpp create mode 100644 libs/installer_fomod_plus/installer/lib/ConditionTester.h create mode 100644 libs/installer_fomod_plus/installer/lib/CrashHandler.cpp create mode 100644 libs/installer_fomod_plus/installer/lib/CrashHandler.h create mode 100644 libs/installer_fomod_plus/installer/lib/FileInstaller.cpp create mode 100644 libs/installer_fomod_plus/installer/lib/FileInstaller.h create mode 100644 libs/installer_fomod_plus/installer/lib/FlagMap.h create mode 100644 libs/installer_fomod_plus/installer/lib/Logger.h create mode 100644 libs/installer_fomod_plus/installer/lib/ViewModels.h create mode 100644 libs/installer_fomod_plus/installer/resources.qrc create mode 100644 libs/installer_fomod_plus/installer/resources/delete.png create mode 100644 libs/installer_fomod_plus/installer/resources/fomod_icon.png create mode 100644 libs/installer_fomod_plus/installer/resources/image_icon.png create mode 100644 libs/installer_fomod_plus/installer/resources/left-chevron.png create mode 100644 libs/installer_fomod_plus/installer/resources/right-chevron.png create mode 100644 libs/installer_fomod_plus/installer/ui/ClickableWidget.h create mode 100644 libs/installer_fomod_plus/installer/ui/Colors.h create mode 100644 libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp create mode 100644 libs/installer_fomod_plus/installer/ui/FomodImageViewer.h create mode 100644 libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp create mode 100644 libs/installer_fomod_plus/installer/ui/FomodViewModel.h create mode 100644 libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp create mode 100644 libs/installer_fomod_plus/installer/ui/ScaleLabel.h create mode 100644 libs/installer_fomod_plus/installer/ui/UIHelper.cpp create mode 100644 libs/installer_fomod_plus/installer/ui/UIHelper.h create mode 100644 libs/installer_fomod_plus/patchwizard/CMakeLists.txt create mode 100644 libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.cpp create mode 100644 libs/installer_fomod_plus/patchwizard/FomodPlusPatchWizard.h create mode 100644 libs/installer_fomod_plus/patchwizard/fomod_plus_patch_wizard_en.ts create mode 100644 libs/installer_fomod_plus/patchwizard/fomod_plus_patchwizard_de.ts create mode 100644 libs/installer_fomod_plus/patchwizard/fomodpluspatchwizard.json create mode 100644 libs/installer_fomod_plus/patchwizard/lib/PatchFinder.cpp create mode 100644 libs/installer_fomod_plus/patchwizard/lib/PatchFinder.h create mode 100644 libs/installer_fomod_plus/patchwizard/resources.qrc create mode 100644 libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png create mode 100644 libs/installer_fomod_plus/patchwizard/resources/infoscroll.png create mode 100644 libs/installer_fomod_plus/scanner/CMakeLists.txt create mode 100644 libs/installer_fomod_plus/scanner/FomodPlusScanner.cpp create mode 100644 libs/installer_fomod_plus/scanner/FomodPlusScanner.h create mode 100644 libs/installer_fomod_plus/scanner/archiveparser.h create mode 100644 libs/installer_fomod_plus/scanner/fomod_plus_scanner_de.ts create mode 100644 libs/installer_fomod_plus/scanner/fomod_plus_scanner_en.ts create mode 100644 libs/installer_fomod_plus/scanner/fomod_plus_scanner_zh_CN.ts create mode 100644 libs/installer_fomod_plus/scanner/fomodplusscanner.json create mode 100644 libs/installer_fomod_plus/scanner/resources.qrc create mode 100644 libs/installer_fomod_plus/scanner/resources/fomod_icon.png create mode 100644 libs/installer_fomod_plus/scanner/resources/wizard.gif create mode 100644 libs/installer_fomod_plus/share/FOMODData/ArchiveExtractor.h create mode 100644 libs/installer_fomod_plus/share/FOMODData/FomodDB.h create mode 100644 libs/installer_fomod_plus/share/FOMODData/FomodDBEntry.h create mode 100644 libs/installer_fomod_plus/share/FOMODData/FomodRescan.h create mode 100644 libs/installer_fomod_plus/share/FOMODData/PluginReader.h create mode 100644 libs/installer_fomod_plus/share/stringutil.h create mode 100644 libs/installer_fomod_plus/share/xml/FomodInfoFile.cpp create mode 100644 libs/installer_fomod_plus/share/xml/FomodInfoFile.h create mode 100644 libs/installer_fomod_plus/share/xml/ModuleConfiguration.cpp create mode 100644 libs/installer_fomod_plus/share/xml/ModuleConfiguration.h create mode 100644 libs/installer_fomod_plus/share/xml/XmlHelper.h create mode 100644 libs/installer_fomod_plus/share/xml/XmlParseException.h (limited to 'libs/installer_fomod_plus') 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=/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 $<$:/Zi>) + target_link_options(fomod_plus_installer PRIVATE $<$:/DEBUG>) + install(FILES $ + 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/FomodViewModel.h" + +#include + +/** + * + * @param installer + * @param modName + * @param tree + * @param fomodPath + * @param viewModel + * @param fomodJson + * @param parent + */ +FomodInstallerWindow::FomodInstallerWindow( + FomodPlusInstaller* installer, + GuessedValue& modName, + const std::shared_ptr& tree, + QString fomodPath, + const std::shared_ptr& 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(); + const auto radioButtons = findChildren(); + + 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& group, + const std::shared_ptr& 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& plugin) const +{ + logMessage(INFO, "onPluginManuallyUnchecked called with " + plugin->getName()); + mViewModel->markManuallySet(plugin); +} + +void FomodInstallerWindow::onPluginHovered(const std::shared_ptr& 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& 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& 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& plugin, + const std::shared_ptr& 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& plugin, + const std::shared_ptr& 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& plugin, + const std::shared_ptr& 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& 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& 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& 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("[%2] [%3] %4") + .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& fn) +{ + if (mFomodJson.empty()) { + return; + } + + const auto jsonSteps = mFomodJson["steps"]; + // for each step in JSON, create a - string out of the { groups: [ { plugins... } ] } array + vector 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(), plugin.get()); + selectedPlugins.push_back(name); + } + } + } + + const auto checkboxes = findChildren(); + const auto radioButtons = findChildren(); + + 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 +#include +#include +#include +#include + +#include "FomodPlusInstaller.h" +#include "xml/ModuleConfiguration.h" + +#include +#include +#include +#include + +#include "FomodInstallerWindow.h" +#include "lib/FileInstaller.h" +#include "ui/FomodViewModel.h" +#include "ui/Colors.h" + +#include + +using namespace MOBase; + +struct PluginData { + std::shared_ptr 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& modName, + const std::shared_ptr& tree, + QString fomodPath, + const std::shared_ptr& 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 getFileInstaller() const { return mViewModel->getFileInstaller(); } + +private slots: + void onNextClicked(); + + void updateCheckboxStates() const; + + void onPluginToggled(bool selected, const std::shared_ptr& group, + const std::shared_ptr& plugin) const; + + void onPluginManuallyUnchecked(const std::shared_ptr& plugin) const; + + void onPluginHovered(const std::shared_ptr& 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& mModName; + std::shared_ptr mTree; + std::shared_ptr mViewModel; + bool mInitialized{ false }; + std::unordered_map 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 &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& installStep); + + [[nodiscard]] QWidget* renderGroup(const std::shared_ptr& group); + + static QString createObjectName(const std::shared_ptr& plugin, + const std::shared_ptr& group); + + QRadioButton* createPluginRadioButton(const std::shared_ptr& plugin, + const std::shared_ptr& group, QWidget* parent); + + QCheckBox* createPluginCheckBox(const std::shared_ptr& plugin, + const std::shared_ptr& group, QWidget* parent); + + void renderSelectExactlyOne(QWidget* parent, QLayout* parentLayout, const std::shared_ptr& group); + + void renderCheckboxGroup(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr& group); + + QButtonGroup* renderRadioGroup(QWidget* parent, QLayout* parentLayout, + const std::shared_ptr& 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 +#include +#include +#include +#include +#include +#include +#include + +#include "FomodInstallerWindow.h" +#include "stringutil.h" +#include "integration/FomodDataContent.h" +#include "ui/Colors.h" +#include "ui/FomodViewModel.h" +#include "lib/CrashHandler.h" + +#include +#include + +using namespace Qt::Literals::StringLiterals; + +/* +-------------------------------------------------------------------------------- + Init +-------------------------------------------------------------------------------- +*/ +#pragma region Initialization + +bool FomodPlusInstaller::init(IOrganizer* organizer) +{ + CrashHandler::initialize(); + mOrganizer = organizer; + mFomodContent = make_shared(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(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 FomodPlusInstaller::shouldShowImages() const +{ + return mOrganizer->pluginSetting(name(), "show_images").value(); +} + +bool FomodPlusInstaller::shouldShowNotifications() const +{ + return mOrganizer->pluginSetting(name(), "show_notifications").value(); +} + +bool FomodPlusInstaller::shouldShowSidebarFilter() const +{ + return mOrganizer->pluginSetting(name(), "show_fomod_filter").value(); +} + +bool FomodPlusInstaller::shouldAutoRestoreChoices() const +{ + return mOrganizer->pluginSetting(name(), "always_restore_choices").value(); +} + +bool FomodPlusInstaller::isWizardIntegrated() const +{ + return mOrganizer->pluginSetting(name(), "wizard_integration").value(); +} + +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 > 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 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 tree) const +{ + tree = findFomodDirectory(tree); + if (tree != nullptr) { + return tree->exists(StringConstants::FomodFiles::MODULE_CONFIG.data(), FileTreeEntry::FILE); + } + return false; +} + + +std::pair FomodPlusInstaller::getExistingFomodJson(const GuessedValue& 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 matches; + + auto parseStepCount = [](const QVariant& fomodData) -> std::pair { + 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(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& modName, + std::shared_ptr& 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 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(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(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 installTree = window->getFileInstaller()->install(); + tree = installTree; + mFomodJson = std::make_shared(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& 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 > 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(); + 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(); + 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 >& entries, + const shared_ptr& tree) +{ + static std::set 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 >& entries, + const shared_ptr& 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 FomodPlusInstaller::findFomodDirectory(const std::shared_ptr& 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& 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(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" + "
"
+        + errorText +
+        "
" + ); + 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 +#include +#include + +#include +#include "FomodInstallerWindow.h" +#include "lib/Logger.h" +#include "xml/FomodInfoFile.h" +#include "xml/ModuleConfiguration.h" + +#include +#include +#include + +class FomodInstallerWindow; + +using namespace MOBase; +using namespace std; + +using ParsedFilesTuple = std::tuple, std::unique_ptr, 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 > requirements() const override; + + [[nodiscard]] bool isManualInstaller() const override { return false; } + + [[nodiscard]] bool isArchiveSupported(std::shared_ptr tree) const override; + + [[nodiscard]] QList settings() const override; + + std::pair getExistingFomodJson(const GuessedValue& modName, + const int& nexusId, const int& stepsInCurrentFomod) const; + + void clearPriorInstallData(); + + EInstallResult install(GuessedValue& modName, std::shared_ptr& 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 mFomodJson{ nullptr }; + bool mInstallerUsed{ false }; + std::shared_ptr mFomodContent{ nullptr }; + std::unique_ptr 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 findFomodDirectory(const shared_ptr& tree); + + [[nodiscard]] static QDialog::DialogCode showInstallerWindow(const shared_ptr& window); + + [[nodiscard]] ParsedFilesTuple parseFomodFiles(const shared_ptr& tree); + + static void appendImageFiles(vector >& entries, + const shared_ptr& tree); + + void appendPluginFiles(vector >& entries, const shared_ptr& 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + + FomodInstallerWindow + + + Installieren + + + + + + Weiter + + + + + Name: + + + + + Autor: + + + + + Version: + + + + + Webseite: + + + + + Manuell + + + + + Alle Vorherige Optionen Auswählen + + + + + Reset Choices + Optionen Zurücksetzen + + + + + Bilder Ausblenden + + + + + Zurück + + + + + Abbrechen + + + + + + Bilder Einblenden + + + + 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 @@ + + + + + FomodInstallerWindow + + + + Install + + + + + + Next + + + + + Name: + + + + + Author: + + + + + Version: + + + + + Website: + + + + + Manual + + + + + Restore Previous Choices + + + + + Reset Choices + + + + + Back + + + + + Cancel + + + + + Hide Images + + + + + Show Images + + + + + Select a plugin to see its description. + + + + 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 @@ + + + + + FomodInstallerWindow + + + 安装 + + + + + + 下一步 + + + + + 名称 + + + + + 作者 + + + + + 版本 + + + + + 网址 + + + + + 手动 + + + + + 选中所有上次安装的选择 + + + + + Reset Choices + 重置选择 + + + + + 隐藏图片 + + + + + 上一步 + + + + + 取消 + + + + + + 显示图片 + + + + 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 @@ + + + + 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 + +#include "stringutil.h" + +#include + +FomodDataContent::FomodDataContent(MOBase::IOrganizer* organizer) : mOrganizer(organizer) +{ +} + +std::vector FomodDataContent::getAllContents() const +{ + static const std::vector contents = { + {FomodDataContentConstants::FOMOD_CONTENT_ID, "FOMOD", ":/fomod/hat", false} + }; + + return contents; +} + +// Confirmed working, no need to update +std::vector FomodDataContent::getContentsFor(const std::shared_ptr fileTree) const +{ + std::vector 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 +#include + +namespace FomodDataContentConstants { +constexpr int FOMOD_CONTENT_ID = 400400; +} + +class FomodDataContent final : public MOBase::ModDataContent { +public: + explicit FomodDataContent(MOBase::IOrganizer* organizer); + + [[nodiscard]] std::vector getAllContents() const override; + + [[nodiscard]] std::vector getContentsFor(std::shared_ptr 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 +#include + +std::string setToString(const std::set &set) +{ + std::string str; + for (const auto& i : set) { + str += std::to_string(i) + ", "; + } + return str; +} + +bool ConditionTester::isStepVisible(const std::shared_ptr& flags, + const CompositeDependency& compositeDependency, + const int stepIndex, + const std::vector>& 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 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& 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, 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 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& 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 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, + const std::shared_ptr& 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 + +#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& flags, const CompositeDependency& compositeDependency, + int stepIndex, + const std::vector>& steps) const; + + bool testCompositeDependency(const std::shared_ptr& flags, + const CompositeDependency& compositeDependency) const; + + static bool testFlagDependency(const std::shared_ptr& 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, + const std::shared_ptr& flags) const; + + mutable std::unordered_map 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 + +#ifdef _WIN32 +#include +#include +#include +#include + +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(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 +#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 + +#include "ui/FomodViewModel.h" + +using namespace MOBase; + +FileInstaller::FileInstaller( + IOrganizer* organizer, + QString fomodPath, + const std::shared_ptr& fileTree, + std::unique_ptr fomodFile, + const std::shared_ptr& flagMap, + const std::vector >& 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 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 installTree = mFileTree->createOrphanTree(); + + for (const auto& file : filesToInstall) { + + logMessage(DEBUG, + "Processing install entry source=" + file.source + ", destination=" + + (file.destination.has_value() ? file.destination.value() : "") + + ", 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 FileInstaller::collectPositiveFileNamesFromDependencyPatterns( + const std::vector& patterns) +{ + std::vector 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& main, std::vector 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 FileInstaller::collectFilesToInstall() const +{ + logMessage(DEBUG, "collectFilesToInstall started."); + std::vector 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) { + // + + 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 +#include + +#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; + +class StepViewModel; + +class FileInstaller { +public: + FileInstaller( + IOrganizer* organizer, + QString fomodPath, + const std::shared_ptr& fileTree, + std::unique_ptr fomodFile, + const std::shared_ptr& flagMap, + const std::vector >& steps); + + std::shared_ptr 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 collectPositiveFileNamesFromDependencyPatterns(const std::vector &patterns); + + void addFiles(std::vector& main, std::vector toAdd) const; + +private: + IOrganizer* mOrganizer; + Logger& log = Logger::getInstance(); + QString mFomodPath; + std::shared_ptr mFileTree; + std::unique_ptr mFomodFile; + std::shared_ptr mFlagMap; + ConditionTester mConditionTester; + std::vector > mSteps; // TODO: Maybe this is nasty. Idk. + + std::vector 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 +#include +#include + +using Flag = std::pair; +using FlagList = std::vector; + +class FlagMap { +public: + + [[nodiscard]] std::vector> getPluginsSettingFlag(const std::string& key, const std::string& value) const + { + std::vector> 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>> 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, 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 +#include +#include +#include + + +// 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 +#include + +#include "xml/ModuleConfiguration.h" + + +template +using shared_ptr_list = std::vector >; + + +/* +-------------------------------------------------------------------------------- + Plugins +-------------------------------------------------------------------------------- +*/ +class PluginViewModel { +public: + PluginViewModel(const std::shared_ptr& 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 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 getPlugin() const { return plugin; } + +private: + int ownIndex; + bool selected; + bool enabled; + bool manuallySet; + PluginTypeEnum currentPluginType = PluginTypeEnum::UNKNOWN; + std::shared_ptr plugin; + + int stepIndex{ -1 }; + int groupIndex{ -1 }; +}; + +/* +-------------------------------------------------------------------------------- + Groups +-------------------------------------------------------------------------------- +*/ +class GroupViewModel { +public: + GroupViewModel(const std::shared_ptr& group_, const shared_ptr_list& plugins, + const int index, const int stepIndex) + : plugins(plugins), group(group_), ownIndex(index), stepIndex(stepIndex) {} + + void addPlugin(const std::shared_ptr& 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& getPlugins() const { return plugins; } + [[nodiscard]] int getOwnIndex() const { return ownIndex; } + [[nodiscard]] int getStepIndex() const { return stepIndex; } + +private: + shared_ptr_list plugins; + std::shared_ptr group; + int ownIndex; + int stepIndex; +}; + +/* +-------------------------------------------------------------------------------- + Steps +-------------------------------------------------------------------------------- +*/ +class StepViewModel { +public: + StepViewModel(const std::shared_ptr& installStep_, const shared_ptr_list& 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& 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; + shared_ptr_list groups; + int ownIndex; +}; + + +/* +-------------------------------------------------------------------------------- + Outbound Types +-------------------------------------------------------------------------------- +*/ +using StepRef = const std::shared_ptr&; +using GroupRef = const std::shared_ptr&; +using PluginRef = const std::shared_ptr&; 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 @@ + + + resources/fomod_icon.png + + + resources/left-chevron.png + + + resources/right-chevron.png + + + resources/delete.png + + \ 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 Binary files /dev/null and b/libs/installer_fomod_plus/installer/resources/delete.png 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 Binary files /dev/null and b/libs/installer_fomod_plus/installer/resources/fomod_icon.png 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 Binary files /dev/null and b/libs/installer_fomod_plus/installer/resources/image_icon.png 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 Binary files /dev/null and b/libs/installer_fomod_plus/installer/resources/left-chevron.png 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 Binary files /dev/null and b/libs/installer_fomod_plus/installer/resources/right-chevron.png 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 +#include + +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 +#include + +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 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 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 +#include +#include + +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& activeStep, + const std::shared_ptr& 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(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 +#include +#include +#include + +class ScaleLabel; +using LabelImagePair = std::pair; + + +/* ++----------------------------------------------------------+ +|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& activeStep, + const std::shared_ptr& 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 mLabelsAndImages; + std::vector mImagePanes{}; + int mCurrentIndex{ 0 }; + + QString mFomodPath; + const std::shared_ptr& mActiveStep; + const std::shared_ptr& 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; +using PluginCallback = std::function; + + +/* +-------------------------------------------------------------------------------- + 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 fomodFile, + std::unique_ptr infoFile) + : mOrganizer(organizer), mFomodFile(std::move(fomodFile)), mInfoFile(std::move(infoFile)), + mConditionTester(organizer), + mInfoViewModel(std::make_shared(mInfoFile)) +{ + mFlags = std::make_shared(); +} + +/** + * + * @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::create(MOBase::IOrganizer* organizer, + std::unique_ptr fomodFile, + std::unique_ptr infoFile) +{ + auto viewModel = std::make_shared(organizer, std::move(fomodFile), std::move(infoFile)); + if (viewModel->mFlags == nullptr) { + viewModel->mFlags = std::make_shared(); + } + 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 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 groupViewModels; + + for (int groupIndex = 0; groupIndex < installStep.optionalFileGroups.groups.size(); ++groupIndex) { + const auto& group = installStep.optionalFileGroups.groups[groupIndex]; + shared_ptr_list pluginViewModels; + + for (int pluginIndex = 0; pluginIndex < group.plugins.plugins.size(); ++pluginIndex) { + const auto& plugin = group.plugins.plugins[pluginIndex]; + auto pluginViewModel = std::make_shared(std::make_shared(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(std::make_shared(group), pluginViewModels, + groupIndex, stepIndex); + if (groupViewModel->getType() == SelectAtMostOne && groupViewModel->getPlugins().size() > 1) { + createNonePluginForGroup(groupViewModel); + } + groupViewModels.emplace_back(groupViewModel); + } + auto stepViewModel = std::make_shared(std::make_shared(installStep), + std::move(groupViewModels), stepIndex); + stepViewModels.emplace_back(stepViewModel); + + } + mSteps = std::move(stepViewModels); +} + +void FomodViewModel::createNonePluginForGroup(GroupRef group) +{ + const auto nonePlugin = std::make_shared(); + nonePlugin->name = "None"; + nonePlugin->typeDescriptor.type = PluginTypeEnum::Optional; + const int newIndex = static_cast(group->getPlugins().size()); + const auto nonePluginViewModel = std::make_shared(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& 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 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(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(); + + 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(); + + 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 +#include + +#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& 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 fomodFile, + std::unique_ptr infoFile); + + static std::shared_ptr create( + MOBase::IOrganizer* organizer, + std::unique_ptr fomodFile, + std::unique_ptr infoFile); + + void forEachGroup(const std::function& callback) const; + + void forEachPlugin(const std::function& callback) const; + + void forEachFuturePlugin(int fromStepIndex, const std::function& callback) const; + + void selectFromJson(nlohmann::json json) const; + + void resetToDefaults(); + + [[nodiscard]] std::shared_ptr getFirstPluginForActiveStep() const; + + // Steps + [[nodiscard]] shared_ptr_list 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& tree, const QString& fomodPath); + + std::shared_ptr getFileInstaller() { return mFileInstaller; } + + std::string getDisplayImage() const; + + // Plugins + [[nodiscard]] PluginRef getActivePlugin() const { return mActivePlugin; } + + // Info + [[nodiscard]] std::shared_ptr 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 mFomodFile; + std::unique_ptr mInfoFile; + std::shared_ptr mFlags{ nullptr }; + ConditionTester mConditionTester; + std::shared_ptr mInfoViewModel; + std::vector > mSteps; + mutable std::shared_ptr mActivePlugin{ nullptr }; + mutable std::shared_ptr mActiveStep{ nullptr }; + mutable std::vector mVisibleStepIndices; + std::shared_ptr mFileInstaller{ nullptr }; + bool mInitialized{ false }; + + void createStepViewModels(); + + void setFlagForPluginState(const std::shared_ptr& plugin) const; + + static void createNonePluginForGroup(const std::shared_ptr& group); + + void processPlugin(const std::shared_ptr& group, + const std::shared_ptr& plugin) const; + + void enforceRadioGroupConstraints(const std::shared_ptr& group) const; + + void enforceSelectAllConstraint(const std::shared_ptr& groupViewModel) const; + + void enforceSelectAtLeastOneConstraint(const std::shared_ptr& 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 +#include + +// 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 +#include +#include + + +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 +#include +#include + +HoverEventFilter::HoverEventFilter(const std::shared_ptr& 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& plugin, + const std::shared_ptr& 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(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("%2").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()) { + 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(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 +#include +#include + +#include "FomodViewModel.h" + +class HoverEventFilter final : public QObject { + Q_OBJECT + +public: + explicit HoverEventFilter(const std::shared_ptr& plugin, QObject* parent = nullptr); + +signals: + void hovered(const std::shared_ptr& plugin); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + +private: + std::shared_ptr mPlugin; +}; + +class CtrlClickEventFilter final : public QObject { + Q_OBJECT + +public: + explicit CtrlClickEventFilter(const std::shared_ptr& plugin, + const std::shared_ptr& group, QObject* parent = nullptr); + +signals: + void ctrlClicked(bool selected, const std::shared_ptr& group, + const std::shared_ptr& plugin); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + +private: + std::shared_ptr mPlugin; + std::shared_ptr 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 +#include +#include +#include +#include +#include +#include +#include + +#include "lib/PatchFinder.h" +#include + +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(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(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 +#include + +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 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 mPatchFinder{ nullptr }; + std::vector 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 @@ + + + + + FomodPlusPatchWizard + + + + + Patch Wizard + + + + + Nothing of interest yet. The wizard gets wiser as you +install FOMODs, so check back later! + + + + + + Rescan Load Order + + + + + 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. + + + + + Scanning mods... + + + + + Cancel + + + + + Scanning: %1 (%2/%3) + + + + + Rescan Cancelled + + + + + The rescan was cancelled. Partial results may have been saved. + + + + + Rescan complete! + +Mods processed: %1 +Successfully scanned: %2 +Missing archives: %3 +Parse errors: %4 + + + + + + +Failed mods: + + + + + + +%1 mods failed (see log for details) + + + + + Rescan Complete + + + + + Available patches: %1 + + + + + + Find missing patches from FOMODs in your load order. + + + + 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 @@ + + + + + FomodPlusPatchWizard + + + + + Patch Wizard + + + + + Finde Patches, die du aus den FOMODs in deiner Modliste installieren möchtest. + + + + 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 PatchFinder::getAvailablePatchesForMod(const MOBase::IModInterface* mod) +{ + std::vector 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 PatchFinder::getAvailablePatchesForModList() +{ + std::vector 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 +#include +#include +#include + +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(m_organizer->basePath().toStdString()); + logMessage(DEBUG, "mFomodDb loaded."); + } + + std::vector getAvailablePatchesForMod(const MOBase::IModInterface* mod); + std::vector getAvailablePatchesForModList(); + +protected: + void populateInstalledPlugins(); + +private: + Logger& log = Logger::getInstance(); + MOBase::IOrganizer* m_organizer; + std::unique_ptr mFomodDb; + + // Map of { pluginPtr: [1.esp, 2.esp, 3.esp] } + std::unordered_map > m_installedPlugins; + std::unordered_set 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 @@ + + + resources/fomod_icon.png + resources/infoscroll.png + + 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 Binary files /dev/null and b/libs/installer_fomod_plus/patchwizard/resources/fomod_icon.png 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 Binary files /dev/null and b/libs/installer_fomod_plus/patchwizard/resources/infoscroll.png 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 $<$:/Zi>) + target_link_options(fomod_plus_scanner PRIVATE $<$:/DEBUG>) + install(FILES $ + 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 +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "stringutil.h" + +using ScanCallbackFn = std::function; + +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 +#include + +#include +#include + +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 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 &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 +#include +#include +#include +#include + +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& 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) + { + 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 @@ + + + + + FomodPlusScanner + + + + FOMOD Scanner + + + + + 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. + + + + + Scannen + + + + + Abbrechen + + + + + Scan Abgeschlossen + + + + + Der Scan der Ladereihenfolge ist abgeschlossen. Aktualisierte Filterinformationen für + + + + + Mods. + + + + + Scannt die Modliste nach Dateien, die über einen FOMOD installiert wurden. + + + + + Modliste nach Dateien durchsuchen, die über einen FOMOD installiert wurden. + + + + 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 @@ + + + + + FomodPlusScanner + + + + FOMOD Scanner + + + + + 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. + + + + + Scan + + + + + Cancel + + + + + Scan Complete + + + + + The load order scan is complete. Updated filter info for + + + + + mods. + + + + + Scans modlist for files installed via FOMOD + + + + + Scan modlist for files installed via FOMOD + + + + 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 @@ + + + + + FomodPlusScanner + + + + FOMOD Scanner + + + + + 你好,旅行者。 +该工具将扫描模组列表来查找通过 FOMOD 安装的模组, +并修正旧版本错误的 FOMOD 标志(需保留安装包才能被检测到):) + +一路平安,祝您的 load order 没有冲突。 + + + + + 扫描 + + + + + 取消 + + + + + 扫描完成 + + + + + 模组扫描已完成。更新了 + + + + + 个模组筛选框信息。 + + + + + 扫描模组列表查找通过 FOMOD 安装的文件 + + + + + 扫描模组列表查找通过 FOMOD 安装的文件 + + + + 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 @@ + + + resources/fomod_icon.png + resources/wizard.gif + + \ 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 Binary files /dev/null and b/libs/installer_fomod_plus/scanner/resources/fomod_icon.png 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 Binary files /dev/null and b/libs/installer_fomod_plus/scanner/resources/wizard.gif 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct ExtractionResult { + bool success = false; + QString moduleConfigPath; + std::vector pluginPaths; + QString errorMessage; + std::unique_ptr 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; + + /** + * 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(); + + 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(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 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 +#include + +#include "FomodDBEntry.h" + +#include + +#include "PluginReader.h" + +using FOMODDBEntries = std::vector >; + +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 getEntryFromFomod(ModuleConfiguration *fomod, std::vector pluginPaths, + int modId) { + std::vector 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(modId, fomod->moduleName, options); + } + + void addEntry(const std::shared_ptr &entry, const bool upsert = true) { + // TODO: Test this upsert. + if (upsert) { + const auto it = std::ranges::find_if(entries, [&entry](const std::shared_ptr &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(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 +#include +#include +#include + +/* +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 masters; + std::string step; + std::string group; + SelectionState selectionState = SelectionState::Unknown; + + FomodOption(std::string n, std::string fn, std::vector 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 &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& getOptions() const { return options; } + [[nodiscard]] std::vector& 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 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 +#include +#include +#include +#include +#include + +struct RescanResult { + int totalModsProcessed = 0; + int successfullyScanned = 0; + int missingArchives = 0; + int parseErrors = 0; + std::vector 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; + + 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 modsWithChoices; + for (const auto& modName : modList->allMods()) { + auto* mod = modList->getMod(modName); + if (mod && hasStoredChoices(mod)) { + modsWithChoices.push_back(mod); + } + } + + result.totalModsProcessed = static_cast(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(); + 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 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 +#include +#include +#include +#include + +static const std::unordered_set 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 readMasters(const std::string& filePath, const bool trimVanilla = false) + { + std::vector 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(&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(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(&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(&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 +#include +#include +#include +#include + + +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& 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 tags + const std::regex + urlRegex(R"((http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-]))"); + formattedText = std::regex_replace(formattedText, urlRegex, R"($&)"); + + // Replace line breaks + formattedText = std::regex_replace(formattedText, std::regex(" "), "
"); + formattedText = std::regex_replace(formattedText, std::regex("\\r\\n"), "
"); + formattedText = std::regex_replace(formattedText, std::regex("\\r"), "
"); + formattedText = std::regex_replace(formattedText, std::regex("\\n"), "
"); + + 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 +#include + +#include "stringutil.h" + +#include +#include + +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 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 +#include +#include + +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& getGroups() const { return groups; } + +private: + std::string name; + std::string author; + std::string version; + std::string website; + std::string description; + std::vector 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 + +#include "XmlHelper.h" +#include "XmlParseException.h" +#include "stringutil.h" + +#include +#include + +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 +bool deserializeList(pugi::xml_node& node, const char* childName, std::vector& 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; +} + +// On +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 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 +#include +#include +#include +#include +#include + +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 +class OrderedContents { +public: + OrderTypeEnum order; + + OrderedContents() : order(OrderTypeEnum::Ascending) {} + explicit OrderedContents(const OrderTypeEnum orderType): order(orderType) {} + + template + 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 fileDependencies; + std::vector flagDependencies; + std::vector gameDependencies; + std::vector 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 patterns; + + bool deserialize(pugi::xml_node& node) override; +}; + +class DependencyPluginType final : public XmlDeserializable { +public: + std::optional 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 destination; + int priority{ 0 }; + bool isFolder; + + bool deserialize(pugi::xml_node& node) override; +}; + + +class FileList final : public XmlDeserializable { +public: + std::vector 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; +}; + +// On +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 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 { +public: + std::vector 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 { +public: + std::vector 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 patterns; + + bool deserialize(pugi::xml_node& node) override; +}; + +class StepList final : public XmlDeserializable, public OrderedContents { +public: + std::vector 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 +#include + +class XmlParseException final : public std::runtime_error { +public: + explicit XmlParseException(const std::string& message) + : std::runtime_error(message) {} +}; -- cgit v1.3.1